owpengram-server/docs/message-module.md
A 75a8861ec9 protocol: expose privacy and profile photo RPCs
(cherry picked from commit 77b033c8bf8c0a76ff0d7065e2192cbe55d3a3b6)
2026-06-14 00:20:04 +08:00

34 KiB
Raw Blame History

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,并保存当前 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 的 blocklistowner_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 单事务完成:

  1. private_messages,遇到同 sender_user_id + random_id 直接返回原消息盒。
  2. Redis 分配 sender/recipient 的 box_idpts
  3. 写 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=truesender 自己始终为 false。
  4. upsert 双方 dialogs
  5. 写双方 user_update_events(new_message)
  6. 写双方 dispatch_outboxsender 侧带 exclude_session_id
  7. 提交后由 outbox worker 批量推送在线 session。

若事务失败但 Redis 已分配 ptsstore 会尽力写 noop 事件占位,避免 pts 回退PG 不可用时该补偿也可能失败,后续需要告警指标覆盖。

如果 recipient 已 block senderSendPrivateText 仍写 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 视角模型:

  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 生成 messageFwdHeaderdrop_author 会去掉 forward header。
  4. 每条转发复用 SendPrivateText 写入双端 message box / dialog / update event / dispatch outboxpts_count=1,并使用对应 random_id 保证幂等。

messages.sendMessagereply_to 支持 inputReplyToMessage 的私聊同 peer 回复和 quote 文本/entities/offsetquote_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 侧锁定当前 dialogmax_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。

Content Read Flow

messages.readMessageContents 处理 TDesktop 打开媒体/反应等“内容已读”入口,不等同于 history read 水位:

  1. 私聊 incoming media 创建 recipient message box 时置 media_unread=truesender 自己为 false。
  2. 私聊 reaction 写入时,若反应者不是原消息作者,会把原作者 owner 视角的 message box 标记 reaction_unread=true,并重算该 dialog 的 unread_reactions_count
  3. readMessageContents 在事务内锁定当前 owner 的 exact message boxes只清理 media_unread OR reaction_unread 的行;不可见 id、已删除 id、已经 read 的 id 都不生成新 pts。
  4. 实际清理时为当前 owner 分配连续 user ptsuser_update_events(read_message_contents)dispatch_outboxTL 转换为 updateReadMessagesContents{messages,pts,pts_count}。重复调用返回当前 affectedMessagespts_count=0
  5. 该事件排除当前 auth_key/session其它在线 session 走 reliable outbox离线设备通过 updates.getDifference 恢复。

Edit Flow

messages.editMessage 当前只支持私聊文本编辑:

  1. RPC 层校验当前账号、peer、message id、文本长度和 entitiesinputMediaWebPage/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

若 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 都生成自己的 updateDeleteMessagesmessage_ids 是该 owner 视角 box_idpts_count=len(message_ids),并写入 user_update_events + dispatch_outbox。如果删除后仍有可见消息dialog 的 top/unread 会按剩余消息重算;否则删除 dialog 或在 just_clear 下保留空 dialog。

revoke=true 会影响已 block 当前用户的一方RPC 层返回 DELETE_MESSAGES_FORBIDDENrevoke=false 或本地清理仍只影响当前 owner可继续执行。

全清也必须让所有被删的 owner 视角 message_id 最终进入 update/difference但不能合成一个超大 update。messages.deleteHistory 单次最多删除 MaxDeleteHistoryBatch=1000 条,按 box_id 倒序批量软删并返回 affectedHistory.offset=1 表示客户端应继续发起下一批;每一批各自产生一条有界 updateDeleteMessagesmessages.deleteMessages 单次 id 数限制为 MaxDeleteMessageIDs=1000,服务端还会丢弃 <=0 或超过 TL/PG int4 可表达范围的 id。

Query Path

  • messages.getHistoryowner_user_id + peer_user_id + box_id/date 命中 message_boxes 分区索引;offset_idoffset_datemax_idmin_id 都下推为游标条件;add_offset 只允许 [-100,100] 的小窗口偏移,避免异常客户端把超大偏移变成 SQL 跳过扫描或内存 slice capacity。
  • messages.search / messages.searchGlobal 当前只覆盖当前 owner 私聊文本搜索;查询仍限定在 owner_user_id HASH 分区内,文本条件由 pg_trgm GIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。
  • messages.getDialogsuser_id 分区定位当前账号,再按 top_message_date/top_message_id/peer_id 做 seek paginationfolder_id=0/1 直接走 dialogs.folder_idfolder_id>=2 先取当前账号 dialog_filters 后按 include/exclude/contact 规则过滤;hashcount 基于当前筛选后的完整会话集计算。
  • 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 + 当前账号持久化 filtersmessages.updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTagsfolders.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_outboxupdatePinnedDialogs.orderupdatePeerSettings.settingsupdateDialogFilter.filterupdateDialogFilterOrder.orderupdateFolderPeers.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 会移除 dialogjust_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 都透传 silentnoforwardsreply 用 inputReplyToMessageforward 在源消息没有 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 < 150ms、p99 getDifference < 100ms。已落地 internal/loadtest 真实 PG+Redis 压测 harness首版基线见下「Load Baseline」节1k 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 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 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 私聊闭环:用双账号/多设备验证 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
  6. 兼容矩阵回填:所有真实跑到的未知 RPC 自动入 trace按 TDesktop 首屏和私聊流程补 done/stub/todo 状态。

Load Baseline

压测 harnessinternal/loadtest/send_load_test.goenv-gated未设 TELESRV_TEST_POSTGRES_DSN+TELESRV_TEST_REDIS_ADDR 时 Skip对默认 go test ./... 无副作用。

方法:与 main.go 一致装配连接池 + Redis 分配器 + PG 消息存储 + transactional outbox + 真实 OutboxDispatcherbinder 用零连接 SessionManagerPushToUserExceptSession 返回 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

可调 envTELESRV_LOAD_USERS(50) / _CONCURRENCY(32) / _MESSAGES(5000) / _POOL_CONNS(64) / _DEFER_DISPATCHoutbox 用 TELESRV_OUTBOX_WORKERS(loadtest 默认 8运行时默认 2) / _BATCH(100) / _INTERVAL / _LEASE_TIMEOUTTELESRV_LOAD_ENFORCE_SLO=1 时 SLO 超标硬失败(回归门禁)。正确性(无发送错误/无意外重复/全部发出/outbox 必须排空/无终态失败)始终硬断言。

生产化做了什么2026-05-31

  • 连接池配置化TELESRV_POSTGRES_MAX_CONNS 默认 50TELESRV_POSTGRES_MIN_CONNS 默认 16postgres.WithMaxConns/WithMinConns)。postgres.Open 会在启动时显式预热 min 连接,避免 TDesktop 双端冷启动时大量 RPC 一边建 PG 连接一边排队pgx 默认 max(4,NumCPU) 在高并发下排队,是首版 send max 2s 突刺的根因。
  • dispatcher 并行TELESRV_OUTBOX_WORKERS,运行时默认 2loadtest 默认 8rpc.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/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/s、0 err 0 dup、SLO 全过。
    • 配套的 pts 连续性兜底mtproto 对齐,已做)分配前移加宽了「pts 已分配未提交」的瞬时空洞窗口(并发发送 commit 重排序,本就存在),故 updates 服务改为只暴露连续 ptsgetState 报告最大连续已提交 pts非 allocator 最大已分配值),getDifference 只返回从客户端 pts 起连续的事件、遇空洞即截断、State.Pts 取最后连续值、超 limit(100) 置 updates.differenceSlice。客户端永不越过在途空洞,空洞由 commit/补洞在毫秒内自愈,下次拉取补齐——绝不丢消息。连续值由 UpdateEventStore.MaxContiguousPtsPG 顶部 4096 窗口)计算。正确性已由单测(空洞截断/slice 翻页/getState 连续)+ 真实 PG 集成测试200 并发发送 pts 严格 1..200 无空洞无重复无丢失、PG 空洞场景)覆盖。
    • 仍可继续:把 4 次 Redis 分配 pipeline/合并成 1 次(每用户 pts+box 一个 Lua、sender/recipient 并行),进一步缩短分配耗时。
  3. 连接层 SessionManager 全局锁——已实测,非吞吐瓶颈(结论被数据修正):原假设是"pushToUser 抢全局锁 = 20 万在线最大瓶颈"。新增连接层 benchmarksession_manager_bench_test.go20 万连接、内存 Conn 不走真实 socket实测
    • push fanout~4.6M push/s24 核),延迟 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。稳态 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_idrouter 额外按业务 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 baseline9caf32dffc)源码: tdesktop/Telegram/SourceFiles/data/data_pts_waiter.cppdata_pts_waiter.hapi/api_updates.cpp

背景outbox worker 多 worker 并发 claim + 发送事务乱序提交,所以主动推送可能乱序到达客户端pts=6 的 UpdateNewMessage 先于 pts=5 发出)。这不会让客户端乱序或丢消息——前提是每条 update 的 pts / pts_count 准确。

客户端如何处理乱序PtsWaiter::checkdata_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 不应用,先缓存进 _updatesQueuesetWaitingForSkipped(1000ms)
  • 随后收到 pts=5count=1_last=6, _count=6 → 相等 → _good=6applySkippedUpdates 把缓存的 5、6 按序应用data_pts_waiter.cpp:48-72
  • 若 1 秒(kWaitForSkippedTimeoutdata_pts_waiter.h:24内空缺没补上 → 触发 getDifference 主动补齐。

即客户端缓存乱序、等空缺、按序应用1 秒补不齐才 getDifference——比"丢弃重拉"更优雅。普通私聊 updates 与 channel 复用同一个 _ptsWaiter 单例(调用时 channel 传 nullptrapi_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 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.searchadd_offset 统一 clamp 到 [-100,100]TDesktop 正常滚动只会使用小偏移,恶意超大值不能触发无界内存分配或大 SQL OFFSET。
  • reply_markup、quick replies、effects、paid send、suggested posts、story/monoforum/todo/poll reply 均返回客户端可理解的显式 TL 错误(如 REPLY_MARKUP_INVALIDSHORTCUT_INVALIDEFFECT_ID_INVALIDPAYMENT_UNSUPPORTEDSUGGESTED_POST_PEER_INVALIDSTORY_ID_INVALIDREPLY_TO_MONOFORUM_PEER_INVALIDPOLL_OPTION_INVALID),不再落 NOT_IMPLEMENTEDsend_as 仅在目标为 channel 时按频道模块规则接受 self/current channel私聊目标返回 SEND_AS_PEER_INVALID;目标为 channel 且请求未显式带 send_asRPC 层读取 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 层 resolveInputMediainputMediaUploadedPhoto/Document(组装 upload.* 分片→建 Photo/Document)与 inputMediaPhoto/Document(引用已存在资源,含贴纸)转成 domain.MessageMedia,经抽取的 sendOutgoing 走与文本完全相同的 pts/box/outbox/在线推送/离线 getDifference 路径;私聊与 channel 共用。
  • private_messages / message_boxesmedia 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