37 KiB
telesrv 性能审计报告
- 日期:2026-06-02
- 审计范围:全部 140 个 Go 源文件(约 4.7 万行非测试代码)——连接层
mtprotoedge、rpc、业务app/*、store(PostgreSQL / Redis / memory)、queries/*.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/tdtransport 抽象限制,禁止修改github.com/gotd/td,需另做 telesrv-owned transport writer spike。 - 严重度口径:
P0当前即瓶颈 / 开闸即炸;P1上量级会炸或高频路径浪费;P2浪费但不致命;P3轻微。 - 规模假设(用于判断后果):单机目标 20 万在线 TCP 长连接;私聊 ~200 msg/s × 双端写;在线推送 fan-out ~40 万 push/s;PostgreSQL(默认连接池 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 CONFLICT,WHERE 无 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:7958upsertChannelDialogsForMessageTx,由sendChannelMessageOnce在每条消息事务内调用。 - 问题:
FROM channel_members WHERE channel_id=$1 AND status='active'无 LIMIT,每发一条消息写 / 更新 = 成员数 行;讨论组联动再来一遍。 - 影响:唯一随频道规模线性放大且在同步关键路径的写。百万成员频道单条 post 跑数秒、长持行锁、阻塞该频道后续所有写。
- 建议:广播频道(broadcast)不维护 per-member dialog,改 Telegram 式「单一 read state + 读时按
top_message_idvsread_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 与未删除消息动态派生普通未读。仍需大频道 PGEXPLAIN与端到端压测。
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 索引OnlineChannelMemberUserIDs。updates.getState/getDifference会分页加载当前 session 已加入的 active channel;join/leave/invite/ban/delete 路径会运行态增删索引。持久 channel updates 走在线成员索引并经 PG active membership 复核,取消 500 cap;typing/reaction 等瞬时事件走 viewer-only scope;messages.getDialogs不再标记 viewer。旧OnlineUserIDs保留兼容但已改为到 limit 即停、不排序。Redis/PG 缓存在线关系暂不引入,待多实例或压测数据证明需要再做。
P0-3 channel fan-out 每收件人重建相同 user/chat + N+1 单查
- 位置:
internal/rpc/channels.go:3053pushChannelUpdates→internal/rpc/channels.go:3143tgUsersForIDs。 - 问题:收件人无关的发送者 / 频道信息被每个 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热路径不必每次重查Self(currentUserID已由 session 鉴权)。这是 P0-3 与下面一批 P1 N+1 的公共根因。 - 状态:已给
UserStore/UsersService增加ByIDs、ByPhones,UsersService.ByID去掉重复Self查询;channel/user 富集公共函数已迁到批量 users。Messages/Channels 更细粒度批量接口仍在后续项。
P0-5 MessageIDGen 进程级全局锁串行化所有连接的出站写
- 位置:
internal/mtprotoedge/outbound.go:432→ 共享单例internal/mtprotoedge/server.go:184(创建)/server.go:202(注入每个 Conn)。gotdproto.MessageIDGen.New全程持g.mux。 - 问题:全进程一个
s.msgID,20 万连接共享;每条出站消息(rpc_result / push / ack / pong)必过这把锁。40 万 push/s ⇒ ~50 万次/s 全局锁 acquire,多核下 cache-line 争用 + lock convoy,把本可并行的 N 个 outbound actor 强行串到一点。 - 建议:改 per-Conn
MessageIDGen(msg_id 只需单连接内单调,无需跨连接全局唯一)。msg_id 已只在该连接 outbound actor 单 goroutine 内调用 → per-Conn 后退化为无争用锁甚至无锁 int64。改动最小、收益最大。 - 状态:已完成 per-Conn
MessageIDGen,移除 server 级共享 generator。
P0-6 出站写路径每条消息 4–6 次堆分配,gotd 零拷贝编码被旁路
- 位置:
internal/mtprotoedge/outbound.go:419-481(body.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-basedEncodeWithoutCopy;广播 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 每帧两次裸
writesyscall,无 bufio —internal/mtprotoedge/outbound.go:473→ transport 直写裸net.Conn,每条 ≈3 次 syscall(含SetWriteDeadline)。40 万 push/s ⇒ ~120 万 write syscall/s。gotd/tdtransport 当前隐藏底层 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:117:Send同步等到写完 / 超时,队列满时阻塞 fan-out 协程最长到 ctx 超时(5s)。状态:已新增Conn.SendBestEffort与 SessionManager best-effort fan-out,updates push 默认只等TELESRV_OUTBOUND_PUSH_TIMEOUT=200ms入队;RPC result/ack/pong 仍走可靠同步发送,outbox 入队失败不删 durable 任务。 - P1-c 入站每帧分配新 buffer —
internal/mtprotoedge/server.go:344:连接级bin.Buffer复用被 codecResetN的make抵消,每帧新分配明文缓冲。建议入站缓冲走sync.Pool。
N+1(根因 = P0-4 服务层缺批量)
- P1-d
forwardSources转发逐条查源 + 富集 N+1 —internal/rpc/messages.go:4155:100 条转发 = 100 次取源 + ~100 次富集查询。 - P1-e
getMessages逐 ID 单查 —internal/rpc/messages.go:2888:高频 RPC,100 个 ID = 100 次Search(Limit:1)。状态:已新增MessagesService.GetMessages/MessageStore.GetByIDs,PG 用unnest(ids) WITH ORDINALITY一次取 owner-visible box ids;RPC 保持缺失项返回MessageEmpty。 - P1-f
getDifference富集逐 ID 单查 + 跨事件重复查 —internal/rpc/update_peer_refs.go:9:登录风暴期高频,每事件重建 map + 同 user 在多事件被重复查。 - P1-g channel 读 RPC 普遍 N+1 —
getParticipants/getAdminLog/getHistory等 24 处逐个Users.ByID(internal/rpc/channels.go:3350等)。 - P1-h
enrichChannelHistory与存储层重复解析 —internal/rpc/update_peer_refs.go:45:存储层已批量填好Users/Channels,RPC 层又逐个重查一遍后 merge 丢弃(纯浪费)。 - P1-i
importContacts逐条ByPhone+Upsert—internal/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 次 COUNT —internal/store/postgres/channel.go:5513→:6804:单次最多 300 条额外 COUNT。建议改GROUP BY批量聚合(同文件 reactions / replies 已有该范式)。 - P1-k
ListChannelDialogs逐个回查 top message —internal/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.NeedTotalCount,SQL 默认不 COUNT;私聊搜索首屏按显式 flag 计算,其余 history/翻页默认返回 0 count。 - P1-m
ListChannelRecommendations全库 COUNT —internal/store/postgres/channel.go:3824:对全系统公开广播频道计数,带NOT EXISTS反连接。建议返回近似值或不返回精确总数。 - P1-n
users.getFullUser.common_chats_count反向规划 channel 分区 — ✅ 已修(2026-06-03):TDesktop 打开私聊会触发users.getFullUser,旧实现为填common_chats_count调ListCommonChannels(limit=1),同时跑 COUNT + list,并从user_id入口反查按channel_idhash 分区的channel_members/channels,实测 Alice/BobEXPLAINplanning 约 70ms、执行约 1.6ms,UI 表现为点开 Bob B 卡一下。现新增user_channel_member_index(user_id, channel_id),由 member upsert/leave/delete channel 事务同步;users.getFullUser走CountOnly,只在该 user 维度索引上 count。实测同一 count query planning 0.873ms、execution 0.079ms,Alice 打开 Bob B 日志中users.getFullUserhandler 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 / ListActiveChannelIDsForUser 从 user_id 入口读 channel_members,但表按 channel_id 分区 |
ListChannelDialogs planning 118ms,ListInactiveChannels 114ms,ListActiveChannelIDsForUser 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_ch,channels 按 id 分区但 join key 来自消息行 |
planning 37ms,展开 128 个 channels 分区;user_update_events/message_boxes 本身已正确裁剪到 1 个 user 分区 |
update 查询只返回 channel ref id,Go 层收集后批量 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 | ResolvePublicChannelUsername 从 channel_usernames join channels,动态 channel id 让 channels 64 分区全展开 |
planning 37ms,展开 64 个 channels 分区 |
先查 channel_usernames 得到 channel_id,再 getChannelByID 单分区读取 |
| P2 | 删除频道消息时 deleteChannelUnreadMentionsTx 按 channel_id + message_id 删除 channel_unread_mentions,但该表按 user_id 分区;随后按 affected user 更新 channel_dialogs 也会动态展开 user 分区 |
mentions delete planning 16ms,affected 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 一次GetFileBlobPG 往返;sticker/reaction/thumb 虽小,但 TDesktop 重启或打开历史首次渲染时仍可能在messages.getStickerSet+upload.getFile+ 本地文件冷读上形成可感知卡顿。现BlobBackend.GetRange/LocalFS(internal/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 read(object.ReadAt)+ SSDB 两级,但其下载命中冷存储不回填、无 LRU/阈值、photo/头像直连 MinIO;telesrv 当前先做进程内元数据/小字节 LRU + 段读,多实例共享缓存(Redis)仍待二阶段换对象存储时评估。实测启动预热49 sets / 2313 docs / 2298 blobs,TDesktopmessages.getStickerSet从约 18-24ms 降至 0-1.7ms;seed document id 归一后,首次新服务端 id 会触发必要的upload.getFile主体/缩略图请求,但 server 端大多 0.5-6ms、样例最长 59ms,重复打开 Bob 会话 0.58s 截图已可见 sticker。 -
P1-媒体-a.1 system sticker set 污染 installed stickers 本地索引 — ✅ 已修(2026-06-03):TDesktop 贴纸正文缓存 key 为
dc_id + document_id,但 installed stickers 本地索引写入依赖 set flags;installed_date会使 set 进入 Installed,普通 stickers 类型的Installed + NotLoadedset 会让writeInstalledStickers()中止。此前 seed 把 animated emoji/dice/generic animations 这类 system set 也标成 installed,可能导致重启后 installed sticker 索引反复失效与 set 元数据重拉。现 migration0066_system_sticker_sets_not_installed修复既有库,seed 后续也不再把set_kind=system声明为 installed。 -
P1-媒体-a.2 sticker document 同时暴露
PhotoPathSize与 raster thumb,历史页静态图被占位短路 — ✅ 已修(2026-06-03):TDesktophistory_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 时过滤PhotoPathSize,thumb blob MIME 按魔数写入;domain 中可用 cached bytes 做修复依据,但 RPC 对 document 统一暴露 downloadablephotoSize m,避免PhotoCachedSize与旧本地 cache 叠加出不可替换状态。启动 repair 后 PG 中 document thumbs kind 仅剩cached=2313,document thumbfile_blobs.mime_type全部为image/webp。 -
P1-媒体-a.3 复用导出 document id 命中 TDesktop 旧
DocumentData缓存 — ✅ 已修(2026-06-03):TDesktopData::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 id,RPC/download/custom emoji/channel appearance 全部直接使用该服务端 id;migration0067_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 已显示 sticker,messages.getHistory约 6-12ms,upload.getFile命中新 id 成功,server/client 日志无 location/hash/API 错误。 -
P1-媒体-b
upload_parts无 GC / 无每用户配额 —internal/app/files/service.go:30+deploy/migrations/0057_media.up.sql:分片直接进 PGbytea,仅assembleUpload成功才删;未 assemble 的分片永久滞留,且不同file_id无上限 ⇒ 任意登录用户可用海量 fileID 各传几片撑爆 PG(容量 DoS)。建议每用户 in-flight 上传字节/分片配额 + 后台按created_at过期清理 worker。 -
P1-媒体-c
mediaJSONB 内联放大 fan-out 写 —deploy/migrations/0057_media.up.sql:129:media快照内联在message_boxes(owner 双份)/channel_messages,含 stripped thumb/attributes 使单行变大;叠加主线 A 的 channel 全员写扇出时,大群每条媒体消息按成员数复制整个 media JSONB。建议随主线 A 改懒算/单副本时一并评估 media 是否只存引用、下沉documents/photos。 -
P2-媒体-d
photos.getUserPhotosN+1 + OFFSET —internal/app/files/photos.go:165逐个GetPhoto+internal/store/postgres/queries/media.sql:314OFFSET 分页。头像数少、limit<=100,影响有限;建议批量GetPhotos(ids)+ keyset 分页。 -
P2-媒体-e sticker set cover 元数据声明可下载但 seed 无 raster blob — ✅ 已修(2026-06-03):TDesktop 会把
StickerSet.thumbs中的 downloadablePhotoSize变成inputStickerSetThumb下载;TELESRV_STICKER_SEED_DIR当前 set_cover 只有PhotoPathSizeSVG(40 个 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_watermarks,MaxContiguousPts 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/过期 outbox,delivered 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 / RefreshDialog 用 COUNT(*) 重算未读(应纯增量维护) |
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 已是单 Lua(
user_counter_allocator把 pts + box 合一,hash-tag 同 slot)——既定优化已落地,热路径仅 1 次 RTT。 - 私聊核心已达标:双写 / dialog upsert / event / outbox 单事务、
ON CONFLICT(sender, random_id)幂等、keyset 分页无大 OFFSET、批量转发 / 删除已unnest、outboxFOR 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 -1,RateLimiter 正常放行 1 RTT。 - SessionManager 锁:push 在锁外发送,临界区极短——既有 benchmark 结论仍成立(注意:这与 P0-2 的
OnlineUserIDs全量排序是两回事,后者是新发现)。
7. 修复优先级路线图
第一梯队 — 当前主路径就该做(与群组规模无关,直接影响 20 万连接稳态)
- P0-5
MessageIDGen改 per-Conn — ✅ 已完成。 - P0-6 + P1-a 出站去分配 + bufio 合并 syscall — 直击 250 万 alloc/s + 120 万 syscall/s。
- P0-4 服务层加批量
ByIDs+ 去掉ByID重复Self— ✅ 已完成 users 公共根因;messages/channels 专用批量接口继续推进。 - P1-i
importContacts批量化 — ✅ 已完成。 - P1-l
getHistory翻页省略 COUNT — ✅ 已完成NeedTotalCount默认 false。 - P2
MaxContiguousPtsO(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 push;P3 注册表分片待真有重连风暴尾延迟再做。
附录 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-487:make(…, len(m.byUser)) → range 全 map → sort.Slice → ids[:limit](排序在截断前) |
✅ 属实 |
| P0-3 | channels.go:3053-3082 for _, userID := range recipients { build(userID) };:3143 tgUsersForIDs 循环内 Users.ByID |
✅ 属实 |
| P0-4 | users/service.go:49-58 ByID 先 s.Self(ctx, currentUserID) 再 s.users.ByID(每次 ×2) |
✅ 属实 |
| P0-5 | server.go:147 字段、:184 建一个、:202 注入每个 Conn 共享;outbound.go:432 每条出站调 c.msgID.New |
✅ 属实 |
附录 B:复跑与验证
- 连接层:
internal/mtprotoedge的BenchmarkSessionManager*+-mutexprofile(既有 harness)。已新增BenchmarkSessionManagerOnlineCandidateFilter,用于 20 万在线下候选过滤路径复跑。 - 私聊 / 消息:
internal/loadtest+ 本机 docker PG/Redis(env-gatedTELESRV_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 blobs,migration 状态59|f;本轮新增头像编辑页messages.getEmojiProfilePhotoGroupsempty stub 属 TDesktop 兼容面补齐,不改变性能债优先级。双 TDesktop 群/频道头像 UI 回归仍待 Windows Computer Use 恢复后补实测证据。
附录 C:复核与落地轮(2026-06-02,第二轮)
对第一轮已落地的优化做正确性复核,并把剩余 P0/P1 改到位 + 加测试验证。
协议关键纠正:账号级 differenceTooLong 不可用(§2.5 参考审计救场)
原计划给账号级 getDifference 补 differenceTooLong 让落后客户端整库重置。审计 TDesktop 基线源码发现 api_updates.cpp:516 对账号级 differenceTooLong 只打一行日志、不读 pts、且漏 setRequesting(false)——收到后 _ptsWaiter.requesting() 永真,之后所有 getDifference() 在 :689 早退,永久锁死整个 update 引擎,重连/新 session 都救不回。参考实现 也都故意不发账号级 too-long。
→ 结论:账号级落后只能 differenceSlice 分批续传;绝不能裁剪 user_update_events 到客户端够不着——这直接决定了 P0 的修法(不删事件,而非加 too-long)。
本轮落地与验证
- P0(retention)✅:retention worker 改为不再删
user_update_events(pts log 永久保留,对齐参考实现),仅保留 outbox failed 清理。根除「裁剪事件→落后客户端静默丢消息」。 - P1(watermark 死锁)✅:先识破「预锁 watermark」会引入 watermark↔dialog 跨类型死锁,且 send 双向并发本就有 dialog 行 pre-existing AB-BA。终选事务级 advisory lock 按 user_id 升序串行化同一对用户的写(
lockUsersForUpdate,独立锁空间、不与行锁交叉),一举消除 watermark+dialog+box 所有 AB-BA;覆盖 send/read/edit/delete/deleteHistory,delete 另配 dialog rebuild 升序。强验证:新增TestMessageStoreBidirectionalConcurrencyNoDeadlock,临时禁用 advisory 时 116/320 操作deadlock detected (40P01)(死锁点upsert dialog),启用后 0 失败。 - P1(tgUsersForIDs)✅:批量
ByIDs失败不再静默return nil,记 Warn 日志(批量无部分结果,不降级逐个以免 DB 抖动放大)。 - P1(outbox 拥塞)✅:
pushOutboxUpdate区分retriable——best-effort 队列拥塞保留 dispatching 行靠租约(30s)重投、不计入 attempts 升级;新增TestOutboxDispatcherDefersOnPushQueueFull。
验证:go build/vet ./... 全绿;单测 16 ok / 0 FAIL;PG 集成单独/分批全绿(watermark / retention / 并发 / 双向死锁 / outbox 拥塞 / send-read-edit-delete 往返)。
发现:一批 pre-existing 集成测试失败(第一轮优化遗留,非本轮引入)
全量跑 internal/store/postgres 集成套件整体不稳定(stash 掉本轮改动的基线也大量失败,含无关的 TestAuthKeyStoreRoundTrip——根因是每测试建独立连接池不释放,全量连接耗尽)。逐个单独跑时,下列测试仍失败,根因是第一轮优化改了行为但旧断言未同步,已逐条核对非本轮 4 项修复引入:
TestMessageStoreDeleteHistoryBatchesHugeMaxID:断言history.Count==2,但 P1-l(NeedTotalCount默认 false)使 history 返回Count=0(删除断言本身通过)。TestChannelStoreSendMessageFansOutDialogRows/...ReadOutboxDoesNotRegressSenderDialogUnread/...SendFailureRecordsNoopPtsGap:channel fan-out / read outbox 行为随 P0-1 等 channel 优化变化,旧断言未更新(channel.go本轮未改)。 → 待办:更新这些测试断言对齐新行为(count 语义 / channel fan-out),或确认行为符合预期;并考虑给集成测试套件改共享连接池以支持全量运行。属第一轮优化的测试债,独立于本轮性能修复。