34 KiB
Message Module Design
Date: 2026-05-31
Scope
二阶段先实现私聊文本闭环:
messages.sendMessagemessages.forwardMessagesmessages.getDialogsmessages.getHistorymessages.readHistorymessages.editMessagemessages.deleteMessagesmessages.deleteHistoryupdates.getStateupdates.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,并保存当前 owner 的 media_unread/reaction_unread 内容已读状态 |
dialogs |
user_id HASH |
会话摘要;folder_id=0/1 表示主列表/归档,置顶、manual unread、action bar 隐藏均是 owner 视角 |
contact_blocks |
owner_user_id HASH |
当前 owner 的 blocklist;owner_user_id + blocked_user_id 唯一,作为本阶段私聊 privacy gate |
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 单事务完成:
- 写
private_messages,遇到同sender_user_id + random_id直接返回原消息盒。 - Redis 分配 sender/recipient 的
box_id与pts。 - 写 sender/recipient
message_boxes,并保存silent/noforwards/reply_to/fwd_from元数据;reply 会把当前 owner 的reply_to_msg_id翻译成对端 owner 视角的 box_id。incoming media 在 recipient box 上置media_unread=true,sender 自己始终为 false。 - upsert 双方
dialogs。 - 写双方
user_update_events(new_message)。 - 写双方
dispatch_outbox,sender 侧带exclude_session_id。 - 提交后由 outbox worker 批量推送在线 session。
若事务失败但 Redis 已分配 pts,store 会尽力写 noop 事件占位,避免 pts 回退;PG 不可用时该补偿也可能失败,后续需要告警指标覆盖。
如果 recipient 已 block sender,SendPrivateText 仍写 sender outbox/dialog/update,保证当前用户能看到自己发出的消息;但不创建 recipient message box、不推进 recipient pts、不写 recipient dispatch_outbox,也不会进入 recipient 离线 updates.getDifference。该规则同样适用于 messages.sendMedia/sendMultiMedia 和私聊转发,因为它们共用 sendOutgoing/SendPrivateText。
Forward / Reply Flow
messages.forwardMessages 当前覆盖私聊文本转发,参考实现 的业务语义但保持 telesrv 的 owner 视角模型:
- RPC 层校验 from/to peer、id/random_id 等长、单次最多 100 条;scheduled/monoforum/quick reply/effect/paid/suggested 等当前阶段外能力用显式 TL 错误拒绝,目标为 channel 的
send_as交由频道模块校验 self/current channel。 - store 按当前 owner + from peer + box_id 保序读取源消息,源消息带
noforwards时返回CHAT_FORWARDS_RESTRICTED。 - 未设置
drop_author时,若源消息已有 forward header 则沿用;否则用源消息from/date生成messageFwdHeader。drop_author会去掉 forward header。 - 每条转发复用
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:
- reader 侧锁定当前 dialog,按
max_id与当前可见 incoming message 计算新的read_inbox_max_id,清零 unread_count 并清除 manual unread。 - 如果 reader 确实推进了已读水位,给 reader 生成
updateReadHistoryInbox(pts_count=1),写入user_update_events + dispatch_outbox。 - 同一事务中找出本次被读到的最新 incoming message,定位原 sender 的 dialog,推进 sender 侧
read_outbox_max_id。 - 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。
Content Read Flow
messages.readMessageContents 处理 TDesktop 打开媒体/反应等“内容已读”入口,不等同于 history read 水位:
- 私聊 incoming media 创建 recipient message box 时置
media_unread=true;sender 自己为 false。 - 私聊 reaction 写入时,若反应者不是原消息作者,会把原作者 owner 视角的 message box 标记
reaction_unread=true,并重算该 dialog 的unread_reactions_count。 readMessageContents在事务内锁定当前 owner 的 exact message boxes,只清理media_unread OR reaction_unread的行;不可见 id、已删除 id、已经 read 的 id 都不生成新 pts。- 实际清理时为当前 owner 分配连续 user pts,写
user_update_events(read_message_contents)与dispatch_outbox,TL 转换为updateReadMessagesContents{messages,pts,pts_count}。重复调用返回当前 affectedMessages,pts_count=0。 - 该事件排除当前 auth_key/session,其它在线 session 走 reliable outbox,离线设备通过
updates.getDifference恢复。
Edit Flow
messages.editMessage 当前只支持私聊文本编辑:
- RPC 层校验当前账号、peer、message id、文本长度和 entities;
inputMediaWebPage/inputMediaEmpty降级为文本编辑,真实 media/reply_markup/quick replies/scheduled 返回显式 TL 错误。 - store 锁定当前 owner 的 message_box,确认它是当前 user 发出的 outgoing 私聊消息。
- 更新共享
private_messages文本与edit_date,并更新同一 private message 下所有未删除 owner message_box。 - 每个受影响 owner 各自分配
pts,写updateEditMessage(pts_count=1)与dispatch_outbox。 - 当前请求设备直接拿到
updates响应;其它在线设备走 reliable outbox,离线设备走updates.getDifference。
如果文本和 entities 完全未变化,返回 MESSAGE_NOT_MODIFIED;非作者编辑返回 MESSAGE_AUTHOR_REQUIRED。
若 peer 已 block 当前用户,私聊 edit 会返回 EDIT_MESSAGES_FORBIDDEN,避免修改对方侧已存在的 message box。该 gate 只来自 contact_blocks;完整 Telegram privacy keys 暂不扩展。
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。
若 revoke=true 会影响已 block 当前用户的一方,RPC 层返回 DELETE_MESSAGES_FORBIDDEN;revoke=false 或本地清理仍只影响当前 owner,可继续执行。
全清也必须让所有被删的 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_idHASH 分区内,文本条件由pg_trgmGIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。messages.getDialogs以user_id分区定位当前账号,再按top_message_date/top_message_id/peer_id做 seek pagination;folder_id=0/1 直接走dialogs.folder_id,folder_id>=2 先取当前账号dialog_filters后按 include/exclude/contact 规则过滤;hash与count基于当前筛选后的完整会话集计算。messages.getDialogs/getPeerDialogs/getHistory/getMessages/search返回的 users 统一经过internal/app/userprojection:批量读取 viewer personal photo、profile_photos(kind=profile|fallback)、当前 viewer contacts 与 account privacy,按 personal > allowed profile > fallback > empty 选择头像,并按联系人备注/PhoneNumber/StatusTimestamp privacy 裁剪姓名、手机号与状态。该路径是 TDesktop dialog 标题、头像、history 标题与 action bar 用户状态的公共入口,禁止在各 handler 内重复拼用户资料。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 的语义参考实现 的 handler:send/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 面:
- 压测基线:单机目标 200 msg/s 私聊文本、p99 sendMessage < 150ms、p99 getDifference < 100ms。已落地
internal/loadtest真实 PG+Redis 压测 harness,首版基线见下「Load Baseline」节;1k online session 的网络 fanout 属连接层,本轮 harness 未覆盖(binder 用零连接 SessionManager,只测 outbox 的 PG 排空)。 - Redis 计数强一致:
pts/box_id分配已收敛到 Lua 脚本路径,覆盖 Redis miss 从 PG durable log 恢复与并发 first-use 连续分配;事务失败 noop gap 与 random_id 幂等已有 store 测试覆盖,后续补真实 Redis+PG 混合压测。 - PG 分区验收:已有
message_boxeshistory seek 与dispatch_outboxstale retry 索引;已补 explain 集成测试,固定message_boxes/user_update_events单用户查询命中分区索引,dispatch_outbox全局 claim 允许跨分区但必须走分区 partial index、不出现 Seq Scan。 - Outbox 背压(已做到生产级,见下「Load Baseline」):worker 有 batch claim、lease timeout、retry/backoff、failed 终态和 claim/deliver/fail 指标;batch size / interval / lease timeout / worker 数 均配置化(env
TELESRV_OUTBOX_BATCH/_INTERVAL/_LEASE_TIMEOUT/_WORKERS)。已并行化(N worker 竞争FOR UPDATE SKIP LOCKEDclaim)并批量化(每批一次BatchListDispatchEventsunnest join + 一次MarkDispatchDeliveredBatch,取代逐条往返),排空上限从单 worker ~270 行/s 提到 ~12k 行/s(本机 PG,~45x);批量化后瓶颈转移到 PG 本身。主动 push 仍只走auth_key_id + session_id + user_idscoped API。 - TDesktop 私聊闭环:用双账号/多设备验证 sendMessage、forwardMessages、reply、getDialogs、getHistory、readHistory、getOutboxReadDate、editMessage、deleteMessages/deleteHistory、updates.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)。
- 兼容矩阵回填:所有真实跑到的未知 RPC 自动入 trace,按 TDesktop 首屏和私聊流程补
done/stub/todo状态。
Load Baseline
压测 harness: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):
$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,运行时默认 2,loadtest 默认 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 ~114ms、getDifference p99 ~2ms、吞吐 ~1255 msg/s,SLO 全过。12k 行/s ≈ 6k msg/s 可持续 outbox 吞吐;16 worker/batch500 仍 ~11.7k 行/s(与 8 worker 持平 → 瓶颈已是 PG 而非 worker 数)。
send p99 对用户池集中度敏感:harness 用 50 用户时 p99 飙到 ~220–325ms(32 并发挤少数用户的 dialog/message_box 行,行锁争用),2000 用户降到 ~58ms。这是小池假象,生产 20 万用户分散后争用极低;默认已取 users=1000。
面向 20 万在线的剩余 levers(按 §8 记账,本轮未做):
- PG 已是共享瓶颈:批量化后 send 与 drain 都打在同一 PG 上,本机 ~12k 行/s 触顶。生产需更强的 PG(多核/NVMe/调参),更高规模再上读副本 / 按 user 分库。worker/batch/池均可调以匹配硬件。
- send 路径 Redis 分配已移出事务(已做):原先 sender/recipient 的
pts/box_id分配(4 次 Redis 往返)嵌在 PG 事务内,持连接空等 Redis。现已移到BeginTx之前——分配本就走 Redis 不属 PG 事务,前移后不再在持有 PG 连接(与行锁)期间空等,连接周转更快(池受压时收益最大;收益随 Redis RTT 增大,远端 Redis 更明显)。本机 docker(Redis 亚毫秒 RTT)下 pool=24/conc=96(4x 超订)实测 send p99 ~130ms(<150ms)、~1800 msg/s、0 err 0 dup、SLO 全过。- 配套的 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 一个 Lua、sender/recipient 并行),进一步缩短分配耗时。
- 配套的 pts 连续性兜底(mtproto 对齐,已做):分配前移加宽了「pts 已分配未提交」的瞬时空洞窗口(并发发送 commit 重排序,本就存在),故
- 连接层
SessionManager全局锁——已实测,非吞吐瓶颈(结论被数据修正):原假设是"pushToUser 抢全局锁 = 20 万在线最大瓶颈"。新增连接层 benchmark(session_manager_bench_test.go,20 万连接、内存 Conn 不走真实 socket)实测:- push fanout:~4.6M push/s(24 核),延迟 1→24 核仅 249ns→328ns,几乎不随核数恶化——临界区只是 snapshot 一个 per-user 小 map,锁极短。20 万在线 @ 200 msg/s × 双端 = ~40 万 push/s,比上限低一个数量级,push 不是瓶颈。
- Register/Unregister churn:全局写锁,~2067ns/op @ 24 核 ≈ 48 万 churn/s。稳态 churn(20 万连接 × 平均存活分钟级)≈ 千级/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补偿。 - 联系人备注、联系人 mutual/shareContact 状态、dialog pinned/order/manual unread、peer 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=6(count=1):
_last=6, _count=5→_last > _count(中间缺 pts)→ 这条 update 不应用,先缓存进_updatesQueue,setWaitingForSkipped(1000ms)。 - 随后收到 pts=5(count=1):
_last=6, _count=6→ 相等 →_good=6,applySkippedUpdates把缓存的 5、6 按序应用(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 必须保证:
- 每条 update 的
pts_count准确等于它推进的 pts 步数。私聊文本、已读 inbox/outbox、文本编辑恒为 1;批量删除为 owner 视角删除 message_id 数量:UpdateDeleteMessages{Pts: event.Pts, PtsCount: len(message_ids)}。 - 每个分配出去的 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 markup、quick replies、scheduled 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 replies、effects、paid send、suggested posts、story/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增mediaJSONB 快照列:发送在事务内随 body/entities 一起写双端盒子,历史getHistory/getMessages/dialog preview 读取时随消息一并解码,无需 joindocuments/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_messagesbody 非空 CHECK 为body<>'' OR media<>'{}'。 - 范围外:grouped_id 相册聚合(sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media 仍
MEDIA_INVALID。