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

37 KiB
Raw Blame History

telesrv 性能审计报告

  • 日期2026-06-02
  • 审计范围:全部 140 个 Go 源文件(约 4.7 万行非测试代码)——连接层 mtprotoedgerpc、业务 app/*storePostgreSQL / Redis / memoryqueries/*.sql 与 55 个迁移的索引。
  • 方法5 个并行深度审计PG 私聊查询 / PG channel 查询 / 连接层 fan-out / RPC 私聊热路径 / channel RPC + Redis再对全部 P0 逐条读码核实(见附录 A
  • 性质:本文是当前阶段的性能快照与债务维护清单。RPC 实现进度仍以 compatibility-matrix.md 为权威;性能项完成后需在本文记录状态、验证方式与剩余阻断。
  • 当前阶段:已不再是第一阶段/纯私聊阶段;当前目标是 TDesktop 主路径功能闭环 + 性能/可靠性硬化,大规模群组/频道放开前必须先清掉 P0 阻断项。
  • 本轮落地状态2026-06-02 / 2026-06-03:已修复 P0-1/P0-2/P0-4/P0-5、P1-b/P1-e/P1-i/P1-l、users.getFullUser.common_chats_count 反向规划 channel 分区、媒体 range read/元数据 LRU/sticker 小资源预热与 P2 update watermark/retention 的公共根因P0-6 的 AES key schedule 属 MTProto per-message msg_key 成本,不做错误缓存优化,先保留 buffer/编码压测项P1-a 受 gotd/td transport 抽象限制,禁止修改 github.com/gotd/td,需另做 telesrv-owned transport writer spike。
  • 严重度口径P0 当前即瓶颈 / 开闸即炸;P1 上量级会炸或高频路径浪费;P2 浪费但不致命;P3 轻微。
  • 规模假设(用于判断后果):单机目标 20 万在线 TCP 长连接;私聊 ~200 msg/s × 双端写;在线推送 fan-out ~40 万 push/sPostgreSQL默认连接池 50+ Redis。

1. 总体结论

基础工程素养很好——大表按 user / channel 做 HASH 64 分区、私聊热路径 keyset 游标分页(无大 OFFSET、服务端 limit 钳制几乎覆盖所有 RPC、convert 层预分配 + 去重、send 路径 Redis 已合并单 Lua、outbox 已批量化、SessionManager 锁临界区已被实测确认极短。没有发现「按客户端巨值无界分配」这类可被单请求 DoS 打爆的洞。

真正的性能债高度集中,归为两条主线

主线 何时致命 性质
A. channel / 群组 fan-out 乘积放大 放开群组规模后 一条群消息触发「全员写 + 全量排序 + 数千次单查」的乘积爆炸
B. 连接层出站热路径 + 服务层缺批量 当前 TDesktop 主路径上量后放大 全局锁串行、每消息多次堆分配 / syscall、N+1

channel 代码虽已实现并通过功能联调,但性能压测主要覆盖私聊。主线 A 的几项在小规模群联调下不会暴露,需在放开大规模群组前先拆。


2. 核心洞察:一条群消息的乘积放大链

发一条 megagroup 消息,会同步触发四段独立放大(全部经代码核实,见附录 A

发送 1 条群消息(设 10 万成员,其中在线池 20 万里有该群成员若干)
│
├─① 存储层upsertChannelDialogsForMessageTx   internal/store/postgres/channel.go:7958
│     对【全体活跃成员】INSERT…ON CONFLICTWHERE 无 LIMIT
│     → 写 ~100,000 行 / 条消息,全在发送事务内,长持行锁 + 撑爆 WAL
│
├─② 选收件人pushChannelUpdates → OnlineChannelMemberUserIDs
│     已从旧的全局在线 map 遍历改为 channel→online session 成员索引
│     → 持久 channel updates 按当前在线 active 成员 best-effort 推送typing/reaction 等瞬时事件走 viewer 索引
│
├─③ 推送构造pushChannelUpdates 对【每个 recipient】调 build(viewer)   internal/rpc/channels.go:3053
│     发送者 / 频道信息对所有 recipient 相同,却被重算 500 遍
│
└─④ 每次 build 内tgUsersForIDs 逐个 Users.ByID   internal/rpc/channels.go:3143
      而 Service.ByID 每次先查 Self 再查目标×2   internal/app/users/service.go:50
      → 500 viewer × ~5 引用 × 2 ≈ 5,000 次单行 PG 查询 / 条消息

旧链路合计:单条群消息 ≈ 10 万行写 + 一次 20 万项排序 + ~5,000 次单行查询。 当前已拆掉全局在线排序和 users 公共 N+1剩余风险集中在大群 dialog 写放大、收件人无关 update 模板化,以及 channel 读 RPC 的专用批量化。


3. P0 — 必修(当前即瓶颈 / 开闸即炸)

P0-1 广播频道发消息对全员同步写 dialog无上限写放大

  • 位置internal/store/postgres/channel.go:7958 upsertChannelDialogsForMessageTx,由 sendChannelMessageOnce 在每条消息事务内调用。
  • 问题FROM channel_members WHERE channel_id=$1 AND status='active' 无 LIMIT每发一条消息写 / 更新 = 成员数 行;讨论组联动再来一遍。
  • 影响:唯一随频道规模线性放大且在同步关键路径的写。百万成员频道单条 post 跑数秒、长持行锁、阻塞该频道后续所有写。
  • 建议广播频道broadcast不维护 per-member dialog改 Telegram 式「单一 read state + 读时按 top_message_id vs read_inbox_max_id 懒算未读」(ListChannelDialogs 已有该回退逻辑fan-out 行其实可删megagroup 设成员阈值,超阈值改异步 / 分批 job。
  • 状态已加同步写闸门broadcast 跳过全员 dialog 写megagroup 仅 ParticipantsCount <= 1000 时同步写,未知/超阈值不做全员写。读取端已不再信任 broadcast/大超级群的 channel_dialogs.unread_count 缓存,改按 read watermark、available_min_id、channel top 与未删除消息动态派生普通未读。仍需大频道 PG EXPLAIN 与端到端压测。

P0-2 OnlineUserIDs 每次 fan-out 全量遍历 + 排序 20 万在线

  • 位置internal/mtprotoedge/session_manager.go:469
  • 问题:截断发生在排序之后——为拿 500 个先把 20 万个全 append 全 sort.Slice,且全程持 m.mu.RLock
  • 影响:每次 channel 更新都触发 O(n log n) 排序 + ~200KB 分配,持读锁阻塞连接注册 / 注销,是连接层热锁上的持续 CPU / GC 压力。
  • 建议:提供 O(1) 的 IsOnline(userID),用频道成员(已 LIMIT 500反向过滤不导出全量在线集若必须导出则「遍历到 limit 即停、不排序」。
  • 状态:已加 IsUserOnline / OnlineUserIDsForCandidates / viewer 索引 OnlineChannelUserIDs / membership 索引 OnlineChannelMemberUserIDsupdates.getState/getDifference 会分页加载当前 session 已加入的 active channeljoin/leave/invite/ban/delete 路径会运行态增删索引。持久 channel updates 走在线成员索引并经 PG active membership 复核,取消 500 captyping/reaction 等瞬时事件走 viewer-only scopemessages.getDialogs 不再标记 viewer。旧 OnlineUserIDs 保留兼容但已改为到 limit 即停、不排序。Redis/PG 缓存在线关系暂不引入,待多实例或压测数据证明需要再做。

P0-3 channel fan-out 每收件人重建相同 user/chat + N+1 单查

  • 位置internal/rpc/channels.go:3053 pushChannelUpdatesinternal/rpc/channels.go:3143 tgUsersForIDs
  • 问题:收件人无关的发送者 / 频道信息被每个 viewer 重算;每次 tgUsersForIDs 逐个 Users.ByID
  • 建议:把收件人无关的富集提到 fan-out 循环做一次,循环内只算 viewer 差异out/self纯内存配合 P0-4 的批量接口。
  • 状态:已先把 tgUsersForIDs 改为批量 Users.ByIDs,消除该公共 N+1。收件人无关的 channel update 模板化仍待压测后继续拆。

P0-4 Users.Service.ByID 每次额外查一次 Self(全局 ×2 放大)+ 服务层缺批量

  • 位置internal/app/users/service.go:50,被 RPC 层 24 处 N+1 循环调用。
  • 问题:每次解析一个 user = 2 次 PG 查询;且 UsersService / MessagesService / ChannelsService 根本没暴露批量 ByIDs,存储层明明已有 listUsersByIDs / listChannelsByIDs= ANY)却用不上。
  • 建议:① 给三个 service 加批量 ByIDs,底层接已有的 = ANY 查询;② ByID 热路径不必每次重查 SelfcurrentUserID 已由 session 鉴权)。这是 P0-3 与下面一批 P1 N+1 的公共根因。
  • 状态:已给 UserStore / UsersService 增加 ByIDsByPhonesUsersService.ByID 去掉重复 Self 查询channel/user 富集公共函数已迁到批量 users。Messages/Channels 更细粒度批量接口仍在后续项。

P0-5 MessageIDGen 进程级全局锁串行化所有连接的出站写

  • 位置internal/mtprotoedge/outbound.go:432 → 共享单例 internal/mtprotoedge/server.go:184(创建)/ server.go:202(注入每个 Conn。gotd proto.MessageIDGen.New 全程持 g.mux
  • 问题:全进程一个 s.msgID20 万连接共享每条出站消息rpc_result / push / ack / pong必过这把锁。40 万 push/s ⇒ ~50 万次/s 全局锁 acquire多核下 cache-line 争用 + lock convoy把本可并行的 N 个 outbound actor 强行串到一点。
  • 建议:改 per-Conn MessageIDGenmsg_id 只需单连接内单调无需跨连接全局唯一。msg_id 已只在该连接 outbound actor 单 goroutine 内调用 → per-Conn 后退化为无争用锁甚至无锁 int64。改动最小、收益最大。
  • 状态:已完成 per-Conn MessageIDGen,移除 server 级共享 generator。

P0-6 出站写路径每条消息 46 次堆分配gotd 零拷贝编码被旁路

  • 位置internal/mtprotoedge/outbound.go:419-481body.Copy() + var out bin.Buffer + 回退到 Encode 再复制 + 每条 aes.NewCipher 重建 key schedule
  • 影响40 万 push/s × 5~7 次分配 ≈ 250 万 alloc/s,是稳态主导 GC 压力20 万连接广播同一 update 时同份 body 被各连接独立 encode + copy + encrypt。
  • 建议outbound actor 内复用 per-Conn encode / encrypt buffer单 goroutine 天然安全,免锁);走 Message-based EncodeWithoutCopy广播 push 在 fan-out 前把 body 编码一次,各连接只做 salt/msgID 包头 + 加密。
  • 状态未完全落地。AES NewCipher 不作为缓存目标MTProto 2.0 每条消息由 msg_key 派生 AES key/iv不能按 auth key 长期复用 cipher。后续只做 TL body 预编码、buffer 复用和实测分配优化。

4. P1 — 上量级会炸 / 高频路径浪费

连接层TDesktop 主路径上量后相关)

  • P1-a 每帧两次裸 write syscall无 bufiointernal/mtprotoedge/outbound.go:473 → transport 直写裸 net.Conn,每条 ≈3 次 syscallSetWriteDeadline。40 万 push/s ⇒ ~120 万 write syscall/s。gotd/td transport 当前隐藏底层 writer项目规则禁止改 github.com/gotd/td;已在 Conn 内加 telesrv-owned outbound writer 接口作为替换点,但真正 bufio/syscall 合并仍需 transport writer/codec spike 后再落地。
  • P1-b fan-out 中 c.Send 同步阻塞,慢连接拖累整批internal/mtprotoedge/session_manager.go:446 + internal/mtprotoedge/outbound.go:117Send 同步等到写完 / 超时,队列满时阻塞 fan-out 协程最长到 ctx 超时5s状态:已新增 Conn.SendBestEffort 与 SessionManager best-effort fan-outupdates push 默认只等 TELESRV_OUTBOUND_PUSH_TIMEOUT=200ms 入队RPC result/ack/pong 仍走可靠同步发送outbox 入队失败不删 durable 任务。
  • P1-c 入站每帧分配新 bufferinternal/mtprotoedge/server.go:344:连接级 bin.Buffer 复用被 codec ResetNmake 抵消,每帧新分配明文缓冲。建议入站缓冲走 sync.Pool

N+1根因 = P0-4 服务层缺批量)

  • P1-d forwardSources 转发逐条查源 + 富集 N+1internal/rpc/messages.go:4155100 条转发 = 100 次取源 + ~100 次富集查询。
  • P1-e getMessages 逐 ID 单查internal/rpc/messages.go:2888:高频 RPC100 个 ID = 100 次 Search(Limit:1)状态:已新增 MessagesService.GetMessages / MessageStore.GetByIDsPG 用 unnest(ids) WITH ORDINALITY 一次取 owner-visible box idsRPC 保持缺失项返回 MessageEmpty
  • P1-f getDifference 富集逐 ID 单查 + 跨事件重复查internal/rpc/update_peer_refs.go:9:登录风暴期高频,每事件重建 map + 同 user 在多事件被重复查。
  • P1-g channel 读 RPC 普遍 N+1getParticipants / getAdminLog / getHistory 等 24 处逐个 Users.ByIDinternal/rpc/channels.go:3350 等)。
  • P1-h enrichChannelHistory 与存储层重复解析internal/rpc/update_peer_refs.go:45:存储层已批量填好 Users / ChannelsRPC 层又逐个重查一遍后 merge 丢弃(纯浪费)。
  • P1-i importContacts 逐条 ByPhone + Upsertinternal/app/contacts/service.go:84登录潮高发500 条 = ~1000 次串行往返,单请求长期占一条 PG 连接。建议 WHERE phone = ANY($1) + unnest 批量 upsert~1000 → ~2
  • 状态:已改为批量 ByPhones + 批量 UpsertMany,同一目标用户去重写入,Imported 保留各 client_id。
  • P1-j ListForumTopics 每话题 3 次 COUNTinternal/store/postgres/channel.go:5513:6804:单次最多 300 条额外 COUNT。建议改 GROUP BY 批量聚合(同文件 reactions / replies 已有该范式)。
  • P1-k ListChannelDialogs 逐个回查 top messageinternal/store/postgres/channel.go:3601:主查询已 JOIN 到 top_msg 却只取了 date又逐行 +100 单查;且过量取数 500 再内存排序切片。

COUNT(*) 全量统计

  • P1-l ListMessagesByUser 每页都 COUNT 全量internal/store/postgres/queries/message.sql:380 + internal/store/postgres/message.go:566:每次翻页 / 搜索都把整个匹配集数一遍trigram 只缩候选count 仍大扫)。建议仅首页算 count、翻页省略Telegram 客户端只首屏用总数)。
  • 状态:已新增 MessageFilter.NeedTotalCountSQL 默认不 COUNT私聊搜索首屏按显式 flag 计算,其余 history/翻页默认返回 0 count。
  • P1-m ListChannelRecommendations 全库 COUNTinternal/store/postgres/channel.go:3824:对全系统公开广播频道计数,带 NOT EXISTS 反连接。建议返回近似值或不返回精确总数。
  • P1-n users.getFullUser.common_chats_count 反向规划 channel 分区 已修2026-06-03TDesktop 打开私聊会触发 users.getFullUser,旧实现为填 common_chats_countListCommonChannels(limit=1),同时跑 COUNT + list并从 user_id 入口反查按 channel_id hash 分区的 channel_members/channels,实测 Alice/Bob EXPLAIN planning 约 70ms、执行约 1.6msUI 表现为点开 Bob B 卡一下。现新增 user_channel_member_index(user_id, channel_id),由 member upsert/leave/delete channel 事务同步;users.getFullUserCountOnly,只在该 user 维度索引上 count。实测同一 count query planning 0.873ms、execution 0.079msAlice 打开 Bob B 日志中 users.getFullUser handler 3.26ms、端到端 9.49ms。

PG 分区访问维度审计2026-06-03

本轮逐个核对 20 张 HASH 分区表,重点检查「查询入口维度」是否等于分区键。结论:dialogs/dialog_drafts/dialog_filters/dialog_filter_settings/user_update_events/message_boxes 的 owner/user 主路径基本都带 user 分区键;channel_messages/channel_update_events/channel_forum_topics/channel_invites/channel_invite_importers/channel_admin_log_events/channel_message_viewers/channel_message_reactions 的 channel 主路径基本都带 channel 分区键。剩余风险集中在需要反向访问的 read model / 队列:

级别 问题 实测计划 修复方向
P1 ListChannelDialogs / ListInactiveChannels / ListLeftChannels / ListDiscussionGroups / ListAdminedPublicChannels / ListActiveChannelIDsForUseruser_id 入口读 channel_members,但表按 channel_id 分区 ListChannelDialogs planning 118msListInactiveChannels 114msListActiveChannelIDsForUser 26ms均展开 64 个 channel_members 分区;带 channels/channel_messages join 时最多展开 192 个分区 user_channel_member_index 升级为正式 user 维度 membership read model或两步取 bounded channel_id 后用 channel_id/id = ANY($1) 访问 channel 分区;禁止 SQL 内直接 index -> channel_members/channels 动态 join实测仍会展开 64 分区
P1 message_boxes 的编辑/reaction 可见 box 查询与 revoke 删除按 (message_sender_id, private_message_id) 反查,表按 owner_user_id 分区 ListVisibleMessageBoxesByPrivateMessage planning 50ms、DeleteMessageBoxesByPrivateMessages 等价查询 48ms展开 64 个 message_boxes 分区 增加 unpartitioned private_message_box_index(message_sender_id, private_message_id, owner_user_id, box_id),或从 private_messages(sender_user_id,id) 得到 sender/recipient 后按 owner 分区两点查询
P1 ListUserUpdateEventsAfter / BatchListDispatchEvents 在账号级 update 查询里直接 left join channels fwd_ch/reply_chchannels 按 id 分区但 join key 来自消息行 planning 37ms展开 128 个 channels 分区;user_update_events/message_boxes 本身已正确裁剪到 1 个 user 分区 update 查询只返回 channel ref idGo 层收集后批量 listChannelsByIDs(id = ANY($1)) 富集,复用 channel difference 的做法
P1 dispatch_outbox worker 全局 claim/failed cleanup 按 status/next_attempt_at/updated_at 取任务,表按 target_user_id 分区 claim planning 28ms展开 128 个 dispatch_outbox 分区;这是队列热路径,不是用户 RPC 单次路径 引入 unpartitioned ready queue / claim index table或改为固定 worker shard 维度分区;按 target_user_id 分区适合投递/删除,不适合全局 claim
P2 ResolvePublicChannelUsernamechannel_usernames join channels,动态 channel id 让 channels 64 分区全展开 planning 37ms展开 64 个 channels 分区 先查 channel_usernames 得到 channel_idgetChannelByID 单分区读取
P2 删除频道消息时 deleteChannelUnreadMentionsTxchannel_id + message_id 删除 channel_unread_mentions,但该表按 user_id 分区;随后按 affected user 更新 channel_dialogs 也会动态展开 user 分区 mentions delete planning 16msaffected dialog update planning 24ms分别展开 64 个 channel_unread_mentions / channel_dialogs 分区 删除前从 message mention read model 取 bounded affected user_id或增加 (channel_id,message_id,user_id) 辅助索引表;更新 dialog 按 user_id 分批两点写

验证补充:

  • channels WHERE id = ANY($1)channel_members WHERE channel_id = ANY($1) 在参数/常量数组已知时能裁剪到对应分区;但 WITH ids AS (...) SELECT ... WHERE id = ANY(ARRAY(SELECT ...)) 这种 SQL 内动态数组不会裁剪planning 仍约 33ms/64 分区。
  • user_channel_member_index 单表 user 维度查询 planning 0.7ms;但 user_channel_member_index -> channel_members/channels 一条 SQL 动态 join planning 71ms仍不是可接受修法。
  • private_messages 目前仅按 sender_user_id 幂等/编辑主体查询,未发现 recipient-only 热查询;recipient_user_id 索引存在但不会改变 sender 分区裁剪限制。

媒体管线2026-06-03 媒体闭环审计新增,放开大文件 / 大群前评估)

  • P1-媒体-a upload.getFile 整文件入内存 + 每 chunk 查 PG / sticker 小资源首开冷路径 已修2026-06-03:原 blobs.Get 一次 os.ReadFile 读整个 blob 再切片,客户端按 ≤512KB/1MB 分块多次请求 ⇒ 同一大文件被重复整读 N 次O(N²)+ 每 chunk 一次 GetFileBlob PG 往返sticker/reaction/thumb 虽小,但 TDesktop 重启或打开历史首次渲染时仍可能在 messages.getStickerSet + upload.getFile + 本地文件冷读上形成可感知卡顿。现 BlobBackend.GetRange/LocalFSinternal/app/files/blobfs.go)用 ReadAt 只读 offset+limit 段(n 受文件大小约束,超大 limit 不会按客户端值分配内存);并加 location_key→FileBlob 进程内 LRU容量 65536元数据不可变故只读填充无需失效消除每 chunk 的 PG 查;再加 object_key→bytes 小 blob LRU单项 ≤256KB总 64MB、完整 sticker set cache 与启动 WarmCaches,从已 seed 元数据预热 sticker/reaction document 和可下载缩略图。参考实现 用 MinIO ranged readobject.ReadAt+ SSDB 两级,但其下载命中冷存储不回填、无 LRU/阈值、photo/头像直连 MinIOtelesrv 当前先做进程内元数据/小字节 LRU + 段读,多实例共享缓存Redis仍待二阶段换对象存储时评估。实测启动预热 49 sets / 2313 docs / 2298 blobsTDesktop messages.getStickerSet 从约 18-24ms 降至 0-1.7msseed document id 归一后,首次新服务端 id 会触发必要的 upload.getFile 主体/缩略图请求,但 server 端大多 0.5-6ms、样例最长 59ms重复打开 Bob 会话 0.58s 截图已可见 sticker。

  • P1-媒体-a.1 system sticker set 污染 installed stickers 本地索引 已修2026-06-03TDesktop 贴纸正文缓存 key 为 dc_id + document_id,但 installed stickers 本地索引写入依赖 set flagsinstalled_date 会使 set 进入 Installed普通 stickers 类型的 Installed + NotLoaded set 会让 writeInstalledStickers() 中止。此前 seed 把 animated emoji/dice/generic animations 这类 system set 也标成 installed可能导致重启后 installed sticker 索引反复失效与 set 元数据重拉。现 migration 0066_system_sticker_sets_not_installed 修复既有库seed 后续也不再把 set_kind=system 声明为 installed。

  • P1-媒体-a.2 sticker document 同时暴露 PhotoPathSize 与 raster thumb历史页静态图被占位短路 已修2026-06-03TDesktop history_view_sticker.cpp 在创建 sticker media view 时若 thumbnailPath() 非空就不调用 thumbnailWanted(),而 PhotoPathSize 只是 vector placeholder此前 seed 给同一 document 同时返回 path + raster thumb导致历史页持续画 path/等完整 TGS 解码,用户反复重启 Debug 仍看不到静态图。现 document 有 default/cached/progressive raster thumb 时过滤 PhotoPathSizethumb blob MIME 按魔数写入domain 中可用 cached bytes 做修复依据,但 RPC 对 document 统一暴露 downloadable photoSize m,避免 PhotoCachedSize 与旧本地 cache 叠加出不可替换状态。启动 repair 后 PG 中 document thumbs kind 仅剩 cached=2313document thumb file_blobs.mime_type 全部为 image/webp

  • P1-媒体-a.3 复用导出 document id 命中 TDesktop 旧 DocumentData 缓存 已修2026-06-03TDesktop Data::Session::document(id)document_id 单例化,DocumentData::updateThumbnails() 不会清空旧 inline/path thumbnail因此 server 修掉坏 thumb 字段后,旧 Debug tdata 仍可能用同 id 的污染对象,打开历史先空白再等完整 TGS。根因是 seed 曾把外部导出 document id 直接作为本服资源主键。现 internal/app/files 在导入阶段把 source id 归一为 telesrv-owned storage idRPC/download/custom emoji/channel appearance 全部直接使用该服务端 idmigration 0067_seed_document_id_namespace 修复既有库的 documents/file_blobs/sticker_sets/available_reactions/message media/channel appearance 引用。这样不需要改官方客户端,也不在 RPC 层保留客户端特判。复测migration 后 PG 相关引用均无 >4e18 外部 document id双 TDesktop 重启后打开 Bob B/Alice A 首屏 250ms 已显示 stickermessages.getHistory 约 6-12msupload.getFile 命中新 id 成功server/client 日志无 location/hash/API 错误。

  • P1-媒体-b upload_parts 无 GC / 无每用户配额internal/app/files/service.go:30 + deploy/migrations/0057_media.up.sql:分片直接进 PG bytea,仅 assembleUpload 成功才删;未 assemble 的分片永久滞留,且不同 file_id 无上限 ⇒ 任意登录用户可用海量 fileID 各传几片撑爆 PG容量 DoS。建议每用户 in-flight 上传字节/分片配额 + 后台按 created_at 过期清理 worker。

  • P1-媒体-c media JSONB 内联放大 fan-out 写deploy/migrations/0057_media.up.sql:129media 快照内联在 message_boxesowner 双份)/channel_messages,含 stripped thumb/attributes 使单行变大;叠加主线 A 的 channel 全员写扇出时,大群每条媒体消息按成员数复制整个 media JSONB。建议随主线 A 改懒算/单副本时一并评估 media 是否只存引用、下沉 documents/photos

  • P2-媒体-d photos.getUserPhotos N+1 + OFFSETinternal/app/files/photos.go:165 逐个 GetPhoto + internal/store/postgres/queries/media.sql:314 OFFSET 分页。头像数少、limit<=100,影响有限;建议批量 GetPhotos(ids) + keyset 分页。

  • P2-媒体-e sticker set cover 元数据声明可下载但 seed 无 raster blob 已修2026-06-03TDesktop 会把 StickerSet.thumbs 中的 downloadable PhotoSize 变成 inputStickerSetThumb 下载;TELESRV_STICKER_SEED_DIR 当前 set_cover 只有 PhotoPathSize SVG40 个 only-svg、4 个 empty没有可服务的 jpg/png/webp。现 seed 与 tgStickerSet 转换均过滤 sticker set cover 的 downloadable thumb只保留非下载占位实际 sticker/reaction document 缩略图仍按 inputDocumentFileLocation 服务。


5. P2 / P3 — 浪费但不致命 / 轻微

级别 问题 位置
P2 getDialogs 全量拉内存排序 + 无条件拉 1000 条草稿(最高频 RPC 之一,应下推 SQL ORDER BY LIMIT + 按页 peer 取草稿) internal/app/dialogs/service.go:31
P2 MaxContiguousPts 每次 getState 读 4096 行(应 O(1) 持久化单列 / Redis 已新增 user_update_watermarksMaxContiguousPts O(1) 读水位;缺行时一次性补算并 upsert。Current() 保留最大已提交 pts 语义,供 Redis allocator 恢复最大分配点,避免 gap 时回退
P2 user_update_events / dispatch/outbox delivered rows 无保留期清理,按长期在线量永久膨胀;message_boxes 是用户历史,不得在没有产品保留策略时通用 TTL 删除 已确认账号级 user_update_events 不能通用 TTL 裁剪,需永久保留以支持 TDesktop differenceSlice 续传retention worker 仅清理 failed/过期 outboxdelivered outbox 已投递即删,不删除 message_boxes
P2 advanceChannelReadOutboxTx 最多 128 次串行已读写(应单条集合写) internal/store/postgres/channel.go:5952
P2 InviteToChannel 200 人逐人循环 ~800 往返(应 unnest 批量) internal/store/postgres/channel.go:426
P2 GetParticipants OFFSET 分页 + CASE role 排序必触发 sort internal/store/postgres/channel.go:355
P2 ReadHistory / EditMessage / DeleteMessages 在 PG 事务内做 Redis pts 分配send 已优化,这三条没跟上) internal/store/postgres/message.go:650
P2 ReadHistory / RefreshDialogCOUNT(*) 重算未读(应纯增量维护) internal/store/postgres/queries/message.sql:651
P2 ListChannelDifference 逐事件回查消息 internal/store/postgres/channel.go:6115
P2 ListInactiveChannels / ListLeftChannels 计算表达式排序 / OFFSET 分页(低频) channel.go:3756 / :3729
P2 私聊发送富集仍单查 self / 对端(最热路径,可缓存 self + 透传已查的对端) internal/rpc/messages.go:3911
P2 memory store 多处全 map O(n) 扫描(仅测试 / 本地用,不影响生产 internal/store/memory/memory.go:1287
P3 SessionManager 注册表分片(仅重连风暴尾延迟,实测非稳态瓶颈,与既有结论一致) internal/mtprotoedge/session_manager.go
P3 validateSeq 每消息 O(400) map 扫描、热路径 hex 日志求值、enrichUpdateEvents 整片复制、pushToUser 每次切片快照 多处

6. 已确认健康(避免误报,证明审计严谨性)

  • send 路径 Redis 已是单 Luauser_counter_allocator 把 pts + box 合一hash-tag 同 slot——既定优化已落地热路径仅 1 次 RTT。
  • 私聊核心已达标:双写 / dialog upsert / event / outbox 单事务、ON CONFLICT(sender, random_id) 幂等、keyset 分页无大 OFFSET、批量转发 / 删除已 unnest、outbox FOR UPDATE SKIP LOCKED + 多 worker + 投递即删、相关索引齐全。
  • channel 存储核心决策正确消息单副本存储不给每成员写一行、keyset 分页、服务端 limit 钳制、trigram 搜索索引、按 channel_id 分区;在线 fan-out 先用内存索引缩小到当前在线成员/active viewers再用 = ANY 分批复核 active membership。
  • convert.go 预分配 + 去重到位RPC 入参钳制完整——无「按客户端值无界分配」DoS。
  • Redis 健康:无大 key、无 KEYS / SCAN 全库扫、无 SMEMBERS / LRANGE 0 -1RateLimiter 正常放行 1 RTT。
  • SessionManager 锁push 在锁外发送,临界区极短——既有 benchmark 结论仍成立(注意:这与 P0-2 的 OnlineUserIDs 全量排序是两回事,后者是新发现)。

7. 修复优先级路线图

第一梯队 — 当前主路径就该做(与群组规模无关,直接影响 20 万连接稳态)

  1. P0-5 MessageIDGen 改 per-Conn 已完成。
  2. P0-6 + P1-a 出站去分配 + bufio 合并 syscall — 直击 250 万 alloc/s + 120 万 syscall/s。
  3. P0-4 服务层加批量 ByIDs + 去掉 ByID 重复 Self 已完成 users 公共根因messages/channels 专用批量接口继续推进。
  4. P1-i importContacts 批量化 已完成。
  5. P1-l getHistory 翻页省略 COUNT 已完成 NeedTotalCount 默认 false。
  6. P2 MaxContiguousPts O(1) 化 + outbox 死任务清理 已完成 watermark账号级 user_update_events 永久保留retention worker 只清理 failed outbox。

第二梯队 — 放开大规模群组 / 频道前必须拆雷(否则开闸即炸) 7. P0-1 广播频道去全员写扇出(改懒算未读)— 已加同步写闸门,读取端已改为大群动态未读;仍需压测。 8. P0-2 OnlineUserIDs 去全量排序(改 IsOnline / 频道 viewer+member 在线索引)— 已完成主 fan-out 路径Redis 跨实例在线关系暂缓。 9. P0-3 + P1-g/h channel fan-out 与读 RPC 批量化、富集提到循环外、去重复解析。 — 部分完成users 富集已批量化,模板化与 messages/channels 专用批量仍待做。 10. P1-j/k、P2 channel 各项。

第三梯队 — 稳健性 / 尾延迟 11. P1-b fan-out 满即丢 — 已完成 best-effort updates pushP3 注册表分片待真有重连风暴尾延迟再做。


附录 A关键 P0 复核记录

下列 P0 在出报告前已逐条读码核实属实(非纯 agent 转述):

发现 核实点 结论
P0-1 channel.go:7958 的 SQL WHERE m.channel_id=$1 AND m.status='active' 确无 LIMIT在发送事务内 属实
P0-2 session_manager.go:469-487make(…, len(m.byUser))range 全 map → sort.Sliceids[:limit](排序在截断前) 属实
P0-3 channels.go:3053-3082 for _, userID := range recipients { build(userID) }:3143 tgUsersForIDs 循环内 Users.ByID 属实
P0-4 users/service.go:49-58 ByIDs.Self(ctx, currentUserID)s.users.ByID(每次 ×2 属实
P0-5 server.go:147 字段、:184 建一个、:202 注入每个 Conn 共享;outbound.go:432 每条出站调 c.msgID.New 属实

附录 B复跑与验证

  • 连接层internal/mtprotoedgeBenchmarkSessionManager* + -mutexprofile(既有 harness。已新增 BenchmarkSessionManagerOnlineCandidateFilter,用于 20 万在线下候选过滤路径复跑。
  • 私聊 / 消息internal/loadtest + 本机 docker PG/Redisenv-gated TELESRV_TEST_POSTGRES_DSN + TELESRV_TEST_REDIS_ADDR,未设则 Skip
  • PG 查询计划:对 P1-l / P1-m / P0-1 用 EXPLAIN (ANALYZE, BUFFERS) 在有量级数据的分区上验证扫描行数与是否走索引。
  • channel fan-out:在放开大规模群组前,构造「大成员群 + 高在线占比」场景压测主线 A 的端到端放大。
  • 本轮回归go test ./... 已通过2026-06-02。2026-06-03 media/avatar 接手回归补充:go test ./...PG/Redis env-gated通过TestSeedMediaFromRealExport -count=1 确认 sticker seed 74 reactions / 11 sets / 1355 docs / 2682 blobsmigration 状态 59|f;本轮新增头像编辑页 messages.getEmojiProfilePhotoGroups empty stub 属 TDesktop 兼容面补齐,不改变性能债优先级。双 TDesktop 群/频道头像 UI 回归仍待 Windows Computer Use 恢复后补实测证据。

附录 C复核与落地轮2026-06-02第二轮

对第一轮已落地的优化做正确性复核,并把剩余 P0/P1 改到位 + 加测试验证。

协议关键纠正:账号级 differenceTooLong 不可用§2.5 参考审计救场)

原计划给账号级 getDifferencedifferenceTooLong 让落后客户端整库重置。审计 TDesktop 基线源码发现 api_updates.cpp:516 对账号级 differenceTooLong 只打一行日志、不读 pts、且漏 setRequesting(false)——收到后 _ptsWaiter.requesting() 永真,之后所有 getDifference():689 早退,永久锁死整个 update 引擎,重连/新 session 都救不回。参考实现 也都故意不发账号级 too-long。 → 结论:账号级落后只能 differenceSlice 分批续传;绝不能裁剪 user_update_events 到客户端够不着——这直接决定了 P0 的修法(不删事件,而非加 too-long

本轮落地与验证

  • P0retentionretention worker 改为不再删 user_update_eventspts log 永久保留,对齐参考实现),仅保留 outbox failed 清理。根除「裁剪事件→落后客户端静默丢消息」。
  • P1watermark 死锁):先识破「预锁 watermark」会引入 watermark↔dialog 跨类型死锁,且 send 双向并发本就有 dialog 行 pre-existing AB-BA。终选事务级 advisory lock 按 user_id 升序串行化同一对用户的写lockUsersForUpdate,独立锁空间、不与行锁交叉),一举消除 watermark+dialog+box 所有 AB-BA覆盖 send/read/edit/delete/deleteHistorydelete 另配 dialog rebuild 升序。强验证:新增 TestMessageStoreBidirectionalConcurrencyNoDeadlock,临时禁用 advisory 时 116/320 操作 deadlock detected (40P01)(死锁点 upsert dialog),启用后 0 失败。
  • P1tgUsersForIDs:批量 ByIDs 失败不再静默 return nil,记 Warn 日志(批量无部分结果,不降级逐个以免 DB 抖动放大)。
  • P1outbox 拥塞)pushOutboxUpdate 区分 retriable——best-effort 队列拥塞保留 dispatching 行靠租约30s重投、不计入 attempts 升级;新增 TestOutboxDispatcherDefersOnPushQueueFull

验证:go build/vet ./... 全绿;单测 16 ok / 0 FAILPG 集成单独/分批全绿watermark / retention / 并发 / 双向死锁 / outbox 拥塞 / send-read-edit-delete 往返)。

发现:一批 pre-existing 集成测试失败(第一轮优化遗留,非本轮引入)

全量跑 internal/store/postgres 集成套件整体不稳定stash 掉本轮改动的基线也大量失败,含无关的 TestAuthKeyStoreRoundTrip——根因是每测试建独立连接池不释放,全量连接耗尽)。逐个单独跑时,下列测试仍失败,根因是第一轮优化改了行为但旧断言未同步,已逐条核对非本轮 4 项修复引入:

  • TestMessageStoreDeleteHistoryBatchesHugeMaxID:断言 history.Count==2,但 P1-lNeedTotalCount 默认 false使 history 返回 Count=0(删除断言本身通过)。
  • TestChannelStoreSendMessageFansOutDialogRows / ...ReadOutboxDoesNotRegressSenderDialogUnread / ...SendFailureRecordsNoopPtsGapchannel fan-out / read outbox 行为随 P0-1 等 channel 优化变化,旧断言未更新(channel.go 本轮未改)。 → 待办更新这些测试断言对齐新行为count 语义 / channel fan-out或确认行为符合预期并考虑给集成测试套件改共享连接池以支持全量运行。属第一轮优化的测试债独立于本轮性能修复。