owpengram-server/docs/message-module.md
2026-06-04 01:37:39 +08:00

252 lines
No EOL
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Message Module Design
Date: 2026-05-31
## Scope
二阶段先实现私聊文本闭环:
- `messages.sendMessage`
- `messages.forwardMessages`
- `messages.getDialogs`
- `messages.getHistory`
- `messages.readHistory`
- `messages.editMessage`
- `messages.deleteMessages`
- `messages.deleteHistory`
- `updates.getState`
- `updates.getDifference`
- 在线 session update 推送
不在本阶段实现:媒体、定时消息、群组/频道、Bot API、文件 DC。
## Storage
大表从第一版开始分区:
| table | partition key | purpose |
|---|---|---|
| `private_messages` | `sender_user_id` HASH | 共享私聊消息主体;`sender_user_id + random_id` 保证发送幂等 |
| `message_boxes` | `owner_user_id` HASH | owner 视角消息盒TDesktop 看到的 message id 即 `box_id` |
| `dialogs` | `user_id` HASH | 会话摘要;`folder_id=0/1` 表示主列表/归档置顶、manual unread、action bar 隐藏均是 owner 视角 |
| `dialog_filters` / `dialog_filter_settings` | `user_id` HASH | TDesktop 自定义 dialog filter、filter 顺序与 folder tags 开关;不把自定义 filter 伪装成 dialogs.folder_id |
| `user_update_events` | `user_id` HASH | 账号级 pts durable log承载新消息、已读 inbox/outbox、文本编辑、删除消息也承载 contacts reset、dialog pinned/order/manual unread、peer settings、dialog filters 与 folder peers 等 owner 视角状态事件 |
| `dispatch_outbox` | `target_user_id` HASH | transactional outbox事务后批量推送在线 session投递成功即删除仅保留未完成任务 |
Redis 只存可恢复计数:
- `counter:pts:{user_id}`:账号级 pts。
- `counter:box_id:{user_id}`owner 视角 box_id。
- `ratelimit:messages:send:{user_id}`:发送窗口限流,当前为每用户每分钟 30 条。
Redis miss 时分别从 `MAX(user_update_events.pts)``MAX(message_boxes.box_id)` 恢复。
分配器热路径使用 Redis Lua 脚本递增;首次 miss 时先从 PG durable log 读取恢复值,再通过恢复脚本完成「初始化 + 首次自增」,避免并发 first-use 把 `pts` / `box_id` 分配出重复或回退。
## Send Flow
`messages.sendMessage` 在 RPC 层只做 TL 转换和当前 user/session 校验,业务写入由 `MessageStore.SendPrivateText` 单事务完成:
1.`private_messages`,遇到同 `sender_user_id + random_id` 直接返回原消息盒。
2. Redis 分配 sender/recipient 的 `box_id``pts`
3. 写 sender/recipient `message_boxes`,并保存 `silent/noforwards/reply_to/fwd_from` 元数据reply 会把当前 owner 的 `reply_to_msg_id` 翻译成对端 owner 视角的 box_id。
4. upsert 双方 `dialogs`
5. 写双方 `user_update_events(new_message)`
6. 写双方 `dispatch_outbox`sender 侧带 `exclude_session_id`
7. 提交后由 outbox worker 批量推送在线 session。
若事务失败但 Redis 已分配 ptsstore 会尽力写 `noop` 事件占位,避免 pts 回退PG 不可用时该补偿也可能失败,后续需要告警指标覆盖。
## Forward / Reply Flow
`messages.forwardMessages` 当前覆盖私聊文本转发,参考实现 的业务语义但保持 telesrv 的 owner 视角模型:
1. RPC 层校验 from/to peer、id/random_id 等长、单次最多 100 条scheduled/monoforum/quick reply/effect/paid/suggested 等当前阶段外能力用显式 TL 错误拒绝,目标为 channel 的 `send_as` 交由频道模块校验 self/current channel。
2. store 按当前 owner + from peer + box_id 保序读取源消息,源消息带 `noforwards` 时返回 `CHAT_FORWARDS_RESTRICTED`
3. 未设置 `drop_author` 时,若源消息已有 forward header 则沿用;否则用源消息 `from/date` 生成 `messageFwdHeader``drop_author` 会去掉 forward header。
4. 每条转发复用 `SendPrivateText` 写入双端 message box / dialog / update event / dispatch outbox`pts_count=1`,并使用对应 random_id 保证幂等。
`messages.sendMessage``reply_to` 支持 `inputReplyToMessage` 的私聊同 peer 回复和 quote 文本/entities/offset`quote_text` 按 TDesktop `quote_length_max=1024` 限制,`quote_offset` 是原消息文本内 offset不是 message id按当前文本消息上限 4096 收口。cross-peer reply、story、monoforum、todo/poll reply 仍拒绝,但返回 `REPLY_MESSAGE_ID_INVALID` / `STORY_ID_INVALID` / `REPLY_TO_MONOFORUM_PEER_INVALID` / `POLL_OPTION_INVALID` 等显式错误,不再落 `NOT_IMPLEMENTED`。服务端保存 sender 视角和 recipient 视角各自的 `reply_to_msg_id`,避免一个 owner 的 box_id 泄漏到另一个 owner。
## Read Flow
`messages.readHistory` 采用 owner 视角的双端回执模型,参考实现 的 inbox 已读链路和 参考实现 的 inbox/outbox read history event
1. reader 侧锁定当前 dialog`max_id` 与当前可见 incoming message 计算新的 `read_inbox_max_id`,清零 unread_count 并清除 manual unread。
2. 如果 reader 确实推进了已读水位,给 reader 生成 `updateReadHistoryInbox(pts_count=1)`,写入 `user_update_events + dispatch_outbox`
3. 同一事务中找出本次被读到的最新 incoming message定位原 sender 的 dialog推进 sender 侧 `read_outbox_max_id`
4. sender 水位真正推进时,给 sender 生成 `updateReadHistoryOutbox(pts_count=1)`,离线设备可通过 `updates.getDifference` 补齐。
发送消息时不会预先推进 sender 的 `read_outbox_max_id`;只有对端实际 readHistory 后才产生 outbox 已读回执,避免“刚发出就被标成已读”的假状态。
`messages.getOutboxReadDate` 复用 sender 侧 durable `read_history_outbox` 事件:先校验当前 owner 的 `msg_id` 是该 peer 下可见 outgoing message再取最早一条 `max_id >= msg_id` 的 outbox read event 日期返回 `outboxReadDate`;未被读到返回 `MESSAGE_NOT_READ_YET`。PG 上有 `(user_id, peer_type, peer_id, max_id, date) WHERE event_type='read_history_outbox'` partial index避免 TDesktop 已读详情查询扫全量 update log。
## Edit Flow
`messages.editMessage` 当前只支持私聊文本编辑:
1. RPC 层校验当前账号、peer、message id、文本长度和 entities`inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media/reply_markup/quick replies/scheduled 返回显式 TL 错误。
2. store 锁定当前 owner 的 message_box确认它是当前 user 发出的 outgoing 私聊消息。
3. 更新共享 `private_messages` 文本与 `edit_date`,并更新同一 private message 下所有未删除 owner message_box。
4. 每个受影响 owner 各自分配 `pts`,写 `updateEditMessage(pts_count=1)``dispatch_outbox`
5. 当前请求设备直接拿到 `updates` 响应;其它在线设备走 reliable outbox离线设备走 `updates.getDifference`
如果文本和 entities 完全未变化,返回 `MESSAGE_NOT_MODIFIED`;非作者编辑返回 `MESSAGE_AUTHOR_REQUIRED`
## Delete Flow
`messages.deleteMessages` / `messages.deleteHistory` 以 owner 视角软删除 `message_boxes`,不会删除共享 `private_messages` 主体。默认 deleteHistory 清空后如果该 peer 已无可见消息,则删除当前 owner 的 dialog后续任意新消息会通过正常 send/upsert 路径重建 dialog。`just_clear=true` 对齐 参考实现 语义:清空历史但保留一个空 dialog当前阶段不生成 `messageActionHistoryClear` 服务消息)。
`revoke=true` 时按 `(message_sender_id, private_message_id)` 找到同一私聊消息在其它 owner 下的 message_box 并软删除。每个受影响 owner 都生成自己的 `updateDeleteMessages``message_ids` 是该 owner 视角 box_id`pts_count=len(message_ids)`,并写入 `user_update_events + dispatch_outbox`。如果删除后仍有可见消息dialog 的 top/unread 会按剩余消息重算;否则删除 dialog 或在 `just_clear` 下保留空 dialog。
全清也必须让所有被删的 owner 视角 message_id 最终进入 update/difference但不能合成一个超大 update。`messages.deleteHistory` 单次最多删除 `MaxDeleteHistoryBatch=1000` 条,按 box_id 倒序批量软删并返回 `affectedHistory.offset=1` 表示客户端应继续发起下一批;每一批各自产生一条有界 `updateDeleteMessages``messages.deleteMessages` 单次 id 数限制为 `MaxDeleteMessageIDs=1000`,服务端还会丢弃 `<=0` 或超过 TL/PG int4 可表达范围的 id。
## Query Path
- `messages.getHistory``owner_user_id + peer_user_id + box_id/date` 命中 `message_boxes` 分区索引;`offset_id``offset_date``max_id``min_id` 都下推为游标条件;`add_offset` 只允许 `[-100,100]` 的小窗口偏移,避免异常客户端把超大偏移变成 SQL 跳过扫描或内存 slice capacity。
- `messages.search` / `messages.searchGlobal` 当前只覆盖当前 owner 私聊文本搜索;查询仍限定在 `owner_user_id` HASH 分区内,文本条件由 `pg_trgm` GIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。
- `messages.getDialogs``user_id` 分区定位当前账号,再按 `top_message_date/top_message_id/peer_id` 做 seek paginationfolder_id=0/1 直接走 `dialogs.folder_id`folder_id>=2 先取当前账号 `dialog_filters` 后按 include/exclude/contact 规则过滤;`hash``count` 基于当前筛选后的完整会话集计算。
- `messages.getDialogFilters` 返回 `dialogFilterDefault` + 当前账号持久化 filters`messages.updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags``folders.editPeerFolders` 都是 owner 视角写入,归档只允许 folder_id 0/1自定义 filters 从 ID 2 开始。
- `updates.getDifference` 只按 `user_id + pts` 顺序扫描 `user_update_events`,多设备各自的 `(auth_key_id,user_id)` state 只记录消费位置,不参与账号事件归属;离线设备通过同一条 durable log 恢复新消息、已读 inbox/outbox、文本编辑、删除消息、联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filters/order/reload 与 folder peers 变化置顶顺序、peer settings flags、filter payload 与 folder peers 会随事件负载持久化。
## 参考实现 Comparison
参考实现 的 `SyncUpdatesNotMe` 设计有两个值得借鉴的点:所有“其它端通知”收敛到 sync 服务,以及带 pts 的消息/已读类 update 会写 `user_pts_updates` 后再推 session。它的不足也很明确`updatePeerSettings` 只在线 push 不入 durable pts 队列,`markDialogUnread` / `hidePeerSettingsBar` 仍有 TODO离线设备可能依赖后续主动刷新才能看到状态变化。
telesrv 当前做得更进一步:联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filter/order/reload 与 folder peers 都写入账号级 `user_update_events`,并通过 Postgres `AppendWithDispatch` 在同一事务里写入 `dispatch_outbox``updatePinnedDialogs.order``updatePeerSettings.settings``updateDialogFilter.filter``updateDialogFilterOrder.order``updateFolderPeers.folder_peers` 都是 durable payload不依赖后续主动刷新在线投递由 outbox worker 负责,排除当前设备时同时携带 `exclude_auth_key_id + exclude_session_id`,避免同一 session_id 在不同 auth key 下误排除或串号。RPC handler 在检测到可靠 outbox 后不再额外手动 push避免其它设备收到重复在线通知。
dialog 分组借鉴 参考实现 的 `dialog_filters`/`editPeerFolders` 业务语义与 参考实现 的 `UpdateDialogFilter` / `UpdateFolderPeers` 事件模型但修正两点filter 设置不仅在线 push也有持久化真值表和 durable update归档 folder 与自定义 filter 分离,避免一个 `folder_id` 字段同时承担归档状态和任意筛选规则。
删除链路借鉴 参考实现 的 `just_clear` / dialog 删除边界:普通 deleteHistory 会移除 dialog`just_clear` 保留 dialog。借鉴 参考实现 的点是“先按 owner 计算受影响 message_id再按 owner 重建 top message 并发 `updateDeleteMessages`”,而不是只对当前请求账号返回 affectedMessages这样 revoke、多设备离线补偿和 dialog 后续重建都在同一条 owner 视角语义里。
已读和编辑链路也分别借鉴了两边的优点:参考实现 在 edit 时会校验 sender 并把编辑同步到 inbox/outbox参考实现 把 readHistory 分成 reader 的 `UpdateReadHistoryInbox` 和 sender 的 `UpdateReadHistoryOutbox`。telesrv 将这两类事件都落到账号级 durable log同事务写 reliable outbox且 edit 更新共享消息主体和所有 owner 视角 message_box避免只在线 push 或只改当前盒子导致离线设备/对端历史不一致。
reply/forward 的语义参考实现 的 handlersend/forward 都透传 `silent``noforwards`reply 用 `inputReplyToMessage`forward 在源消息没有 `fwd_from` 时构造原作者 header遇到受保护内容拒绝转发。telesrv 额外修正 owner 视角 id 翻译与 durable update 负载:对端收到的 reply header 使用对端自己的 message id离线设备通过 `updates.getDifference` 也能拿到同样的 `reply_to/fwd_from` 元数据。
## Observability
消息链路 RPC 层预留 `Metrics` 接口,当前覆盖:
- `MessageSend`记录发送成功、payload 长度与双端 pts。
- `MessageRateLimited`:记录用户级窗口限流。
- `OutboxClaimed` / `OutboxDelivered` / `OutboxFailed`:记录 outbox claim、在线推送成功与重试失败。
默认实现是 no-op生产接 Prometheus/OpenTelemetry 时只需在 `rpc.Deps` 注入实现,不污染业务服务和 store 边界。
## Next Execution Plan
连接层已具备 per-connection outbound actor、scoped session context 与 bounded inbound RPC scheduler。消息模块下一轮按以下顺序推进不再先扩 RPC 面:
1. 压测基线:单机目标 200 msg/s 私聊文本、p99 sendMessage < 150msp99 getDifference < 100ms已落地 `internal/loadtest` 真实 PG+Redis 压测 harness首版基线见下Load Baseline1k online session 的网络 fanout 属连接层本轮 harness 未覆盖binder 用零连接 SessionManager只测 outbox PG 排空)。
2. Redis 计数强一致`pts` / `box_id` 分配已收敛到 Lua 脚本路径覆盖 Redis miss PG durable log 恢复与并发 first-use 连续分配事务失败 noop gap random_id 幂等已有 store 测试覆盖后续补真实 Redis+PG 混合压测
3. PG 分区验收已有 `message_boxes` history seek `dispatch_outbox` stale retry 索引已补 explain 集成测试固定 `message_boxes` / `user_update_events` 单用户查询命中分区索引`dispatch_outbox` 全局 claim 允许跨分区但必须走分区 partial index不出现 Seq Scan
4. Outbox 背压已做到生产级见下Load Baseline」):worker batch claimlease timeoutretry/backofffailed 终态和 claim/deliver/fail 指标batch size / interval / lease timeout / **worker 数** 均配置化env `TELESRV_OUTBOX_BATCH` / `_INTERVAL` / `_LEASE_TIMEOUT` / `_WORKERS`)。**已并行化N worker 竞争 `FOR UPDATE SKIP LOCKED` claim并批量化每批一次 `BatchListDispatchEvents` unnest join + 一次 `MarkDispatchDeliveredBatch`取代逐条往返**排空上限从单 worker ~270 /s 提到 ~12k /s本机 PG~45x批量化后瓶颈转移到 PG 本身主动 push 仍只走 `auth_key_id + session_id + user_id` scoped API
5. TDesktop 私聊闭环用双账号/多设备验证 sendMessageforwardMessagesreplygetDialogsgetHistoryreadHistorygetOutboxReadDateeditMessagedeleteMessages/deleteHistoryupdates.getDifference当前设备过滤与退出换号不串号2026-05-31 已用 Alice/Bob 两个 TDesktop workdir 验证在线 read/edit/delete Bob 离线 send+edit/read/delete 差量同步删除后 dialog 可清除后续新消息仍按正常路径重建2026-06-01 已补 reply/forward 服务端路径和单测仍待重新用双 TDesktop 实机点 UI 行为通知声音reply 预览forward header)。
6. 兼容矩阵回填所有真实跑到的未知 RPC 自动入 trace TDesktop 首屏和私聊流程补 `done/stub/todo` 状态
## Load Baseline
压测 harness[`internal/loadtest/send_load_test.go`](../internal/loadtest/send_load_test.go)env-gated未设 `TELESRV_TEST_POSTGRES_DSN`+`TELESRV_TEST_REDIS_ADDR` Skip对默认 `go test ./...` 无副作用
**方法** main.go 一致装配连接池 + Redis 分配器 + PG 消息存储 + transactional outbox + 真实 `OutboxDispatcher`binder 用零连接 `SessionManager``PushToUserExceptSession` 返回 0因此 outbox 走完整 `claim→批量取事件→push→批量标记` PG 往返测排空而非网络 fanout)。closed-loop 饱和`concurrency` worker 各自不停发满 `messages` `TELESRV_LOAD_DEFER_DISPATCH=1` 时先把积压攒满发送结束才启 dispatcher用以隔离测量 outbox **纯排空上限**否则 dispatcher 实时跟上发送积压近 0 量不出天花板)。
**运行**PowerShell
```powershell
$env:TELESRV_TEST_POSTGRES_DSN = "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable"
$env:TELESRV_TEST_REDIS_ADDR = "localhost:6399"
# 稳态dispatcher 与发送并发)
go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 240s
# 纯排空上限(先攒满积压再排空)
$env:TELESRV_LOAD_MESSAGES=30000; $env:TELESRV_OUTBOX_BATCH=200; $env:TELESRV_LOAD_DEFER_DISPATCH=1
go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 300s
```
可调 env`TELESRV_LOAD_USERS`(50) / `_CONCURRENCY`(32) / `_MESSAGES`(5000) / `_POOL_CONNS`(64) / `_DEFER_DISPATCH`outbox `TELESRV_OUTBOX_WORKERS`(loadtest 默认 8运行时默认 2) / `_BATCH`(100) / `_INTERVAL` / `_LEASE_TIMEOUT``TELESRV_LOAD_ENFORCE_SLO=1` SLO 超标硬失败回归门禁)。正确性无发送错误/无意外重复/全部发出/outbox 必须排空/无终态失败始终硬断言
**生产化做了什么**2026-05-31
- **连接池配置化**`TELESRV_POSTGRES_MAX_CONNS` 默认 50`TELESRV_POSTGRES_MIN_CONNS` 默认 16 `postgres.WithMaxConns/WithMinConns`)。`postgres.Open` 会在启动时显式预热 min 连接避免 TDesktop 双端冷启动时大量 RPC 一边建 PG 连接一边排队pgx 默认 `max(4,NumCPU)` 在高并发下排队是首版 send max 2s 突刺的根因
- **dispatcher 并行**`TELESRV_OUTBOX_WORKERS`运行时默认 2loadtest 默认 8`rpc.WithOutboxWorkers`)。N worker 各跑 claim 循环 `FOR UPDATE SKIP LOCKED` 互不重叠本地 TDesktop 双端启动默认偏保守避免多个 worker 同扫 64 分区父表触发 PG lock/shared-memory 压力
- **dispatcher 批量**`store` 具备批量能力时自动启用否则逐条回退)。每批一次 `BatchListDispatchEvents` `unnest WITH ORDINALITY USING(ord)` 配对 (user_id,pts) join+ 一次 `MarkDispatchDeliveredBatch`把每条 ~2 PG 往返降到每批 ~3
**outbox 排空上限演进**本机 docker PG+Redis攒满 6 万行积压后纯排空
| 方案 | drain 速率 | 相对 |
|---|---|---|
| worker 逐条首版 | ~270 /s | 1x |
| 8 worker 逐条仅并行 | ~1711 /s | ~6x |
| **8 worker 批量(当前)** | **~12285 /s** | **~45x** |
稳态下concurrent出厂默认 users=1000/concurrency=32/8 worker/batch100**峰值积压仅 ~330 排空 ~80ms**——dispatcher 实时跟上发送~2500 /s 入队send p99 ~114msgetDifference p99 ~2ms吞吐 ~1255 msg/sSLO 全过12k /s 6k msg/s 可持续 outbox 吞吐16 worker/batch500 ~11.7k /s 8 worker 持平 瓶颈已是 PG 而非 worker )。
> send p99 对**用户池集中度**敏感harness 用 50 用户时 p99 飙到 ~220325ms32 并发挤少数用户的 dialog/message_box 行行锁争用2000 用户降到 ~58ms。这是小池假象生产 20 万用户分散后争用极低;默认已取 users=1000。
**面向 20 万在线的剩余 levers按 §8 记账,本轮未做)**
1. **PG 已是共享瓶颈**批量化后 send drain 都打在同一 PG 本机 ~12k /s 触顶生产需更强的 PG多核/NVMe/调参更高规模再上读副本 / user 分库worker/batch/池均可调以匹配硬件
2. **send 路径 Redis 分配已移出事务(已做)**原先 sender/recipient `pts`/`box_id` 分配4 Redis 往返嵌在 PG 事务内持连接空等 Redis现已移到 `BeginTx` 之前——分配本就走 Redis 不属 PG 事务前移后不再在持有 PG 连接与行锁期间空等连接周转更快池受压时收益最大收益随 Redis RTT 增大远端 Redis 更明显)。本机 dockerRedis 亚毫秒 RTT pool=24/conc=964x 超订实测 send p99 ~130ms<150ms)、~1800 msg/s0 err 0 dupSLO 全过
- **配套的 pts 连续性兜底mtproto 对齐已做**分配前移加宽了pts 已分配未提交的瞬时空洞窗口并发发送 commit 重排序本就存在 `updates` 服务改为只暴露**连续 pts**`getState` 报告最大连续已提交 pts allocator 最大已分配值`getDifference` 只返回从客户端 pts 起连续的事件遇空洞即截断`State.Pts` 取最后连续值 `limit`(100) `updates.differenceSlice`客户端永不越过在途空洞空洞由 commit/补洞在毫秒内自愈下次拉取补齐——绝不丢消息连续值由 `UpdateEventStore.MaxContiguousPts`PG 顶部 4096 窗口计算正确性已由单测空洞截断/slice 翻页/getState 连续+ 真实 PG 集成测试200 并发发送 pts 严格 1..200 无空洞无重复无丢失PG 空洞场景覆盖
- 仍可继续 4 Redis 分配 pipeline/合并成 1 每用户 pts+box 一个 Luasender/recipient 并行进一步缩短分配耗时
3. **连接层 `SessionManager` 全局锁——已实测,非吞吐瓶颈(结论被数据修正)**原假设是"pushToUser 抢全局锁 = 20 万在线最大瓶颈"。新增连接层 benchmark`session_manager_bench_test.go`20 万连接内存 Conn 不走真实 socket实测
- **push fanout**~4.6M push/s24 延迟 124 核仅 249ns328ns**几乎不随核数恶化**——临界区只是 snapshot 一个 per-user map锁极短20 万在线 @ 200 msg/s × 双端 = ~40 push/s**比上限低一个数量级push 不是瓶颈**。
- **Register/Unregister churn**全局**写锁**~2067ns/op @ 24 48 churn/s稳态 churn20 万连接 × 平均存活分钟级)≈ 千级/s余量巨大
- mutexprofile 确认两者都 100% 串行在同一把锁但因临界区极短**当前规模下不构成瓶颈**。
- **真正的风险点**分片的价值所在但非紧急重连风暴断网恢复 / 发版重启时瞬时 churn 飙升**写锁会阻塞同期所有 push 读锁**。 userID 分片可把写锁从全局 1 把拆成 N churn 风暴只影响 1/N push——这是**稳健性/尾延迟**提升不是吞吐解锁建议留待真有重连风暴尾延迟问题或单机连接数再上一个量级时再做
- 仍未覆盖真实 socket per-conn 加密/写出背压20 fd 的内核态开销——那需要真实网络压测环境非本 harness内存 Conn目标
## Multi-Account / Multi-Device
- `authorizations` auth_key user 的权威绑定
- active session 缓存 `auth_key_id + user_id`router 额外按业务 auth_key 缓存 `user_id`并用 singleflight 合并启动期并发 miss避免同一永久 auth_key 派生的多个 temp session 重复查授权表
- `update_states` 主键是 `(auth_key_id, user_id)`同设备退出登录或换账号只清 auth_key 设备状态不删除账号级 `user_update_events`
- 一个账号多设备共享 `user_update_events`各设备通过自己的 `auth_key_id + user_id` 状态和 `updates.getDifference` 补偿
- 联系人备注dialog pinned/order/manual unreadpeer settings 都是 owner 视角数据写业务表后同步写账号级 durable update event并把投递任务写入 `dispatch_outbox`避免只在线 push离线设备永远不知道的状态漂移
## ACK / Global Sequence Note
`参考实现` 的可借鉴点是`msgs_ack` 不直接生成业务事件它只确认某个已发送 server msg_id / RPC response 已被客户端收到server 通过 ack cache 找回该响应对应的 `pts` / `globalSeqNo`再推进 `(auth_key_id,user_id)` 维度的已确认水位这个设计适合频道 peer 混合更新和 server delivered watermark
当前阶段私聊文本只需要账号级 `pts` durable log客户端 `updates.getDifference` 仍以请求里的 `pts` 为准MTProto ACK 只释放出站重发缓存后续引入频道或跨 peer 全局更新流时再新增独立的 per-device `global_seq_no` / delivered watermark不把它塞进 MTProto ACK 状态本身
## 客户端 pts 重排依赖 pts_count 准确TDesktop PtsWaiter已核对源码
> 结论来自实读 TDesktop pinned baseline`9caf32dffc`)源码:
> `tdesktop/Telegram/SourceFiles/data/data_pts_waiter.cpp`、`data_pts_waiter.h`、`api/api_updates.cpp`。
**背景**outbox worker worker 并发 claim + 发送事务乱序提交所以**主动推送可能乱序到达客户端**pts=6 `UpdateNewMessage` 先于 pts=5 发出)。这不会让客户端乱序或丢消息——前提是每条 update `pts` / `pts_count` 准确
**客户端如何处理乱序**`PtsWaiter::check`data_pts_waiter.cpp:170-187客户端维护 `_good`已应用的最大连续 pts)、`_last = max(见过的 pts)``_count += 每条 update 的 pts_count`
- 收到 pts=6count=1`_last=6, _count=5` `_last > _count`中间缺 pts)→ 这条 update **不应用,先缓存进 `_updatesQueue`**`setWaitingForSkipped(1000ms)`
- 随后收到 pts=5count=1`_last=6, _count=6` 相等 `_good=6``applySkippedUpdates` 把缓存的 56 **按序应用**data_pts_waiter.cpp:48-72)。
- 1 `kWaitForSkippedTimeout`data_pts_waiter.h:24内空缺没补上 触发 `getDifference` 主动补齐
即客户端**缓存乱序等空缺按序应用1 秒补不齐才 getDifference**——"丢弃重拉"更优雅普通私聊 updates channel 复用同一个 `_ptsWaiter` 单例调用时 channel `nullptr`api_updates.cpp:603-604)。
**对 server 的硬约束**客户端这套重排完全依赖 `_count += pts_count` 累加因此 server 必须保证
1. **每条 update 的 `pts_count` 准确等于它推进的 pts 步数**私聊文本已读 inbox/outbox文本编辑恒为 1批量删除为 owner 视角删除 message_id 数量`UpdateDeleteMessages{Pts: event.Pts, PtsCount: len(message_ids)}`
2. **每个分配出去的 pts 最终都能被 getDifference 拿到**即使事务回滚也要写 `noop` 占位 `recordPtsGaps`)——否则连续水位永远卡在空缺处客户端会永久 `getDifference` 重试或永久 gap
**当前安全**私聊文本转发已读回执文本编辑 `pts_count` 恒为 1删除消息 `pts_count` 等于删除数量每个 pts 都有真实事件或 noop 占位 getState/getDifference 只暴露连续 pts见上文 Load Baseline pts 连续性兜底」)。**未来风险**引入其它一次操作产生多条 update / `pts_count ≠ 1`的场景批量 service action频道 editMessage server `pts_count` 算错客户端 `_count` 会永久错位 永久 gap新增此类 RPC 时必须同步核对 pts_count 语义
## Current Limits
- `messages.sendMessage` 仅支持私聊文本reply 仅支持同一私聊 peer `inputReplyToMessage`
- `messages.forwardMessages` 仅支持当前 owner 可见的私聊文本消息转发到 user peer单次最多 100
- `messages.editMessage` 仅支持文本消息编辑网页预览 media 降级文本编辑真实媒体reply markupquick repliesscheduled edit 留后续并返回显式 TL 错误
- `messages.deleteMessages` / `messages.deleteHistory` 仅支持私聊消息`min_date/max_date` 当前作为兼容 no-op服务消息 `messageActionHistoryClear` 留到后续消息类型扩展
- `messages.deleteHistory` 全清按 1000 条一批推进`offset>0` 续删`deleteMessages` 单次最多 1000 id避免客户端构造超大数组或超大 `max_id` 导致服务端 OOM
- `messages.getHistory/messages.search` `add_offset` 统一 clamp `[-100,100]`TDesktop 正常滚动只会使用小偏移恶意超大值不能触发无界内存分配或大 SQL OFFSET
- `reply_markup`quick replieseffectspaid sendsuggested postsstory/monoforum/todo/poll reply 均返回客户端可理解的显式 TL 错误 `REPLY_MARKUP_INVALID``SHORTCUT_INVALID``EFFECT_ID_INVALID``PAYMENT_UNSUPPORTED``SUGGESTED_POST_PEER_INVALID``STORY_ID_INVALID``REPLY_TO_MONOFORUM_PEER_INVALID``POLL_OPTION_INVALID`不再落 `NOT_IMPLEMENTED``send_as` 仅在目标为 channel 时按频道模块规则接受 self/current channel私聊目标返回 `SEND_AS_PEER_INVALID`目标为 channel 且请求未显式带 `send_as` RPC 层读取 `messages.saveDefaultSendAs` 保存的默认身份并重新校验
- 定时消息返回 `SCHEDULE_DATE_INVALID`
- `messages.search` / `messages.searchGlobal` 当前是私聊文本 `ILIKE` + `pg_trgm`后续大规模数据需要按语言/分词策略补全文索引或外部搜索服务
- 单条文本当前限制 4096 Unicode code point超限返回 `MESSAGE_TOO_LONG`触发窗口限流返回 `FLOOD_WAIT_X`
## 媒体消息2026-06-02
- `messages.sendMedia` / `uploadMedia` / `sendMultiMedia` 接入RPC `resolveInputMedia` `inputMediaUploadedPhoto/Document`组装 `upload.*` 分片 `Photo`/`Document` `inputMediaPhoto/Document`引用已存在资源含贴纸转成 `domain.MessageMedia`经抽取的 `sendOutgoing` 走与文本完全相同的 pts/box/outbox/在线推送/离线 `getDifference` 路径私聊与 channel 共用
- `private_messages` / `message_boxes` `media` JSONB 快照列发送在事务内随 body/entities 一起写双端盒子历史 `getHistory`/`getMessages`/dialog preview 读取时随消息一并解码无需 join `documents`/`photos`转发复制源消息 media同一文档引用文本编辑保留 media
- `tgMessage` media 非空时 `SetMedia(MessageMediaPhoto/Document)`客户端经 `upload.getFile` blob backend 下载Document id domain/store 中保持 telesrv-owned 正数外部 seed source id 在导入阶段归一`InputDocument` / `inputDocumentFileLocation` 入站直接按服务端 id 解析避免把第三方导出 id 当成本服资源身份
- 仅媒体消息 caption放宽 `private_messages` body 非空 CHECK `body<>'' OR media<>'{}'`
- 范围外grouped_id 相册聚合sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media `MEDIA_INVALID`