owpengram-server/docs/performance-audit.md
A 23a2b2aff7 fix: restore sticker placeholders and media history
(cherry picked from commit 488e409a1898e9c739cc0bd24cb9791636dfd6b3)
2026-06-09 15:00:19 +08:00

249 lines
No EOL
38 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# 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](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 索引 `OnlineChannelMemberUserIDs``updates.getState/getDifference` 会分页加载当前 session 已加入的 active channeljoin/leave/invite/ban/delete 路径会运行态增删索引。持久 channel updates 走在线成员索引并经 PG active membership 复核,取消 500 captyping/reaction 等瞬时事件走 viewer-only scope`messages.getDialogs` 不再标记 viewer。旧 `OnlineUserIDs` 保留兼容但已改为到 limit 即停、不排序。Redis/PG 缓存在线关系暂不引入,待多实例或压测数据证明需要再做。
### P0-3 channel fan-out 每收件人重建相同 user/chat + N+1 单查
- **位置**`internal/rpc/channels.go:3053` `pushChannelUpdates``internal/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` 热路径不必每次重查 `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。gotd `proto.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 出站写路径每条消息 46 次堆分配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`-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无 bufio** — `internal/mtprotoedge/outbound.go:473` → transport 直写裸 `net.Conn`,每条 ≈3 次 syscall`SetWriteDeadline`。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:117``Send` 同步等到写完 / 超时,队列满时阻塞 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 入站每帧分配新 buffer** — `internal/mtprotoedge/server.go:344`:连接级 `bin.Buffer` 复用被 codec `ResetN``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`:高频 RPC100 个 ID = 100 次 `Search(Limit:1)`。**状态:已新增 `MessagesService.GetMessages` / `MessageStore.GetByIDs`PG 用 `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+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_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.getFullUser``CountOnly`,只在该 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` / `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 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 | `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 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`/`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/头像直连 MinIOtelesrv 当前先做进程内元数据/小字节 LRU + 段读,**多实例共享缓存Redis仍待二阶段换对象存储时评估**。实测启动预热 `49 sets / 2313 docs / 2298 blobs`TDesktop `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-03**TDesktop 贴纸正文缓存 key 为 `dc_id + document_id`,但 installed stickers 本地索引写入依赖 set flags`installed_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 静态图慢 / 打开会话先空白** — ✅ **已修正2026-06-07撤销 2026-06-03 的过度修复)**:根因复盘 — TDesktop `history_view_sticker.cpp` 对 animated sticker 的占位渲染优先级为 `getStickerLarge()`(完整 `.tgs` 首帧,需下载)→ ~~`thumbnailInline()`stripped 内联源码显式注释禁用sticker 需 alpha 通道)~~`goodThumbnail()` → 下载的 `thumbnail()`**`paintPath()``PhotoPathSize` 矢量轮廓,内联即时,唯一不依赖下载的占位)** → 空白;且完整 `.tgs``DocumentMedia::checkStickerLarge()``automaticLoad()` 下载,**与 path/thumbnail 无关**。2026-06-03 的 a.2 误把"卡在 path 等完整 TGS"当作 path 短路(真根因是 a.3 的 document id 污染让完整文件下载/缓存失败),改为有 raster 时过滤 `PhotoPathSize`。a.3 修掉 id 后这个过滤变成净负面document 失去唯一即时占位 ⇒ 打开会话 sticker 先空白;且 `thumbnailPath()` 变空触发 `thumbnailWanted()` ⇒ 每个 sticker 多下载一次缩略图,与完整 `.tgs` 争用下载通道,几十个 sticker 排队 ⇒ "静态图特别慢、不像本地加载"。现 seed 恢复 `PhotoPathSize` 占位(与官方 sticker 一致,`internal/app/files/seed.go` 不再过滤 path`seedPreferRasterDocumentThumbs`/`documentThumbsHave{Raster,Path}`document.thumbs = path + downloadable `photoSize m`:打开即时画轮廓占位、有 path 后不再主动下载缩略图、下载通道全给完整 `.tgs`、第二次本地缓存秒显。已实测排除磁盘 IO/缓存容量blob 4330 文件 71.3MB,几乎全 ≤64KB`document.Size` 不匹配28/28 一致)。**现有库需重新 seed 一次恢复 path**`DELETE FROM sticker_sets; DELETE FROM available_reactions;` 后重启触发全量 reseed`PutDocument`/`PutFileBlob` upsert不删用户上传媒体`TestSeedMediaFromRealExport` 用真实导出验证 seed 后 document 保留 path。**待双 TDesktop 人工验证渲染体感。**
- **P1-媒体-a.3 复用导出 document id 命中 TDesktop 旧 `DocumentData` 缓存** — ✅ **已修2026-06-03**TDesktop `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 已显示 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`:分片直接进 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:129``media` 快照内联在 `message_boxes`owner 双份)/`channel_messages`,含 stripped thumb/attributes 使单行变大;叠加主线 A 的 channel 全员写扇出时,大群每条媒体消息按成员数复制整个 media JSONB。建议随主线 A 改懒算/单副本时一并评估 media 是否只存引用、下沉 `documents`/`photos`
- **P2-媒体-d `photos.getUserPhotos` N+1 + OFFSET** — `internal/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-03**TDesktop 会把 `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_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/过期 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` / `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`、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 -1`RateLimiter 正常放行 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-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/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 参考审计救场)
原计划给账号级 `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
### 本轮落地与验证
- **P0retention✅**retention worker 改为**不再删 `user_update_events`**pts 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-l`NeedTotalCount` 默认 false使 history 返回 `Count=0`(删除断言本身通过)。
- `TestChannelStoreSendMessageFansOutDialogRows` / `...ReadOutboxDoesNotRegressSenderDialogUnread` / `...SendFailureRecordsNoopPtsGap`channel fan-out / read outbox 行为随 P0-1 等 channel 优化变化,旧断言未更新(`channel.go` 本轮未改)。
**待办**更新这些测试断言对齐新行为count 语义 / channel fan-out或确认行为符合预期并考虑给集成测试套件改共享连接池以支持全量运行。属第一轮优化的测试债独立于本轮性能修复。