Q-IM 总体设计评审报告 001¶
评审对象:
docs/PLAN.md(975 行,2026-08-13 版)与CLAUDE.md评审日期:2026-08-13 结论:架构主干成立,可以作为基线继续演进;但存在 2 个阻断级缺陷、61 个重大问题、19 个次要问题、2 个需拍板的开放决策。 本报告只提问题与修正建议,不改动docs/PLAN.md;采纳哪些条目由项目方决定。
0. 总评¶
0.1 文档做对的部分(不建议推翻)¶
这几条判断是本设计真正的价值,后续修订应保护它们:
- 提交、收件索引、Socket 投递三阶段分离(§4.1)。这是可扩展 IM 的正确骨架。
- 正文单存 + 邮箱插轻量引用(§10)。消除的是 N 份正文、N 次跨服务调用、N 次编码,这个边界划得准。
materialized_watermark连续物化水位(§9.2)。绝大多数自研 IM 在这里丢消息,文档提前识别了。- 会话列表三层模型(§12.2:公共头 + 用户主动状态 + 可重建异步投影)。方向正确。
- §10.2 诚实承认
O(N)邮箱写入无法消除。不吹牛,比大多数设计文档强。 - PING 携带
last_applied_mailbox_seq、PONG 回 dirty 标志(§15.1)。低成本缺口检测的思路是对的(但具体字段有错,见 C-2)。 - 拒绝 exactly-once、拒绝 HTTP 轮询、拒绝物理节点取模(§3)。三条禁令都站得住。
0.2 主要风险:决策层合格,可实现层缺口很大¶
文档在"选什么方案"上基本正确,但在"这个方案怎么落地"上大量关键机制只有一句话:
| 机制 | 文档篇幅 | 后果 |
|---|---|---|
| 重分片(双写、游标迁移、切流) | §5.1 一句话 | 见 A-1、E-1,游标不可换算 |
| Home Region fencing | §18.2 一句话 | 脑裂窗口与切换期客户端行为无定义 |
| 在线位置目录 | §5.3 隐含、无定义 | 阻断级 B-2 |
| 离线推送 / APNs / FCM | §16 表格一行 | 移动端不可用,角标无数据源 |
| 限流 | §8 流程里两个字 | 大群成本无闸门 |
| E2EE | §19 一句话 | 与未读、@、审核、推送、新设备全面冲突 |
| 存储主键 / 分区键 | 全文 0 处 | §7 的模型无法建表 |
| SLO / 延迟目标 | 全文 0 处 | §22 的 24 条验收 21 条不可判定 |
同时全文没有任何数值:邮箱保留几天、压缩窗口多长、水位多少字节、心跳超时多少、单分区上界多大、目标 P99 是多少——全部缺失。§21 三条容量公式没有一个代入值,§21.1 的公式量纲还有错(把写放大当成了存储系数)。
一句话总结:这是一份优秀的"架构立场声明",但还不是一份可以据以开工的"设计基线"。§23 计划的 6 份专题文档在当前状态下无法遵守 §L960("不得自行重新定义序列、游标和确认语义"),因为最核心的推送帧、在线目录、排序契约都还没被定义,专题文档只能自行发明。
1. 对八个问题的直接回答¶
问题 1:单个用户消息如何排序¶
文档现状:定义了 4 种序列(§6),但关于"消息怎么排"全文只有两句话——L159 与 L508 的"客户端按 conversation_seq 排列会话内消息"。§12.3 的排序规则是会话列表排序,不是消息时间轴排序。
判定:会话内主排序键是明确的(conversation_seq),但边界情形全部缺失,三端必然各自发明规则。
修正:新增 §6.6「客户端排序契约」,写死四条:
1. 会话内已确认区: (timeline_anchor_seq ASC, event_ordinal ASC, event_id ASC)
- timeline_anchor_seq:普通消息 = 自身 conversation_seq
撤回/编辑 = target_conversation_seq(原地更新,不新增时间轴行)
- event_ordinal:uint8,同一 (user_id, mailbox_seq) 存在多条 entry 时,
由 MailboxNode 按 event_type 固定优先级表确定性分配
(MESSAGE=0, MENTION=1, MEMBERSHIP=2, CONTROL=3),保证主备一致
2. 本地 pending 区恒排在已确认区之后,内部按 (client_send_ts, client_message_id);
收到 SEND_ACK 后按 client_message_id 原位升级并局部重排
3. 跨会话统一时间轴(@我列表、通知中心): (last_activity_id DESC, conversation_id ASC)
4. 把 L143「禁止混用」细化为可执行禁令:
mailbox_seq 仅用于拉取分页、游标推进与缺口检测;room_seq 仅用于房间回放定位;
二者禁止参与任何 UI 排序
同时必须补的字段(当前数据模型不足以实现上述排序):
UserMailboxEntry缺last_activity_id→ §12.6「快照 + 邮箱增量 = 会话列表」在排序维度上不可实现(问题 8 也依赖这个字段)。UserMailboxEntry缺sender_id/client_message_id→ 多设备与断线重连时自己发的消息无法去重回显,会出现气泡重复。SEND_ACK也应回带client_message_id。UserMailboxEntry缺target_message_id/target_conversation_seq→ §12.8 要求的"预览为该消息时修复预览"在数据模型上无法判定。
必须补的规格(当前近乎为零):
message_id:位宽、纪元、region_id/writer_id分配、HLC 算法、时钟回拨阈值与拒绝策略、跨 region 可比性。§6.1 只有一行公式。last_activity_id:它是会话列表排序主键,但全文没有生成规则,与message_id的关系也未定义。建议与message_id同源 HLC,由 ConversationWriter 在分配conversation_seq的同一临界区内分配,硬约束"同一会话内二者严格同序";无conversation_seq的活动(建会话、加群)单独分配last_activity_id而不推进conversation_seq。conversation_seq分配器:单写吞吐、是否每条强一致持久化、故障切换后next_seq如何恢复、是否允许空洞。四问全无答案,而第四问直接决定客户端能否用 seq 连续性判丢消息(答案应是"允许空洞,禁止用差值判丢",见问题 2)。
问题 2:如何区分离线消息和历史消息¶
文档现状:§9.5 已给出定义——离线消息 = 设备游标之后的 UserMailboxEntry;历史消息 = 按 conversation_seq 查的 MessageRecord。这个划分本身是正确的,是文档的亮点之一。
判定:定义正确,但边界会静默丢消息 → 见阻断级缺陷 B-1。
核心问题:§17.3 允许裁剪邮箱,而 §9.3 L408 说"序列空洞可以安全跳过"。这条规则的论证只覆盖了"该用户在此区间本来就没有事件",完全没有覆盖"事件曾经存在但已被裁剪"。两种情况在范围扫描结果上不可区分。结果是:长期离线设备登录后,服务端一路把 covered_through_seq 推到最新,返回 has_more=false,系统自认为同步成功,用户整段消息消失。这直接违反 §2.3 L42 的成功标准。
修正:
1. 每个 MailboxShard 持久化 mailbox_trim_watermark(已物理删除的最大 mailbox_seq),
随 AUTH_OK 与 PONG 下发
2. §9.3 L408 改写为:
仅当 after_seq >= mailbox_trim_watermark 时,序列空洞才可安全跳过;
否则服务端必须拒绝该 PULL 并返回 CURSOR_EXPIRED
3. AUTH 增加显式判定:
cursor.last_applied_mailbox_seq < mailbox_trim_watermark
→ 必须返回 CURSOR_EXPIRED{reason, trim_watermark, rebuild_required=true}
→ 禁止退化成 SYNC_REQUIRED
4. 定义 REBUILD 流程(把"丢失的离线消息"显式降级为"按 conversation_seq 拉的历史消息"):
复用 §9.6 三步 → 保留本地已有消息、按 message_id 去重
→ 每会话 PULL_HISTORY(after_conversation_seq = 本地该会话最大连续 seq) 补齐
→ 完成后游标从 trim_watermark 起算
5. §17.3 明确:邮箱条目仅按时间窗口裁剪,不以任何单设备游标为条件
6. §22.2 增加验收项:离线超过保留期登录必须收到 CURSOR_EXPIRED,
不得出现 has_more=false 的静默成功
保留期取值需拍板(见 §4 开放问题 Q-2):建议 mailbox_retention_days 默认 30(私有化可下调 7),它是邮箱层容量的线性因子。
问题 3 + 问题 4:登录后先取离线消息,且应通知拉取而非逐条推送¶
文档现状:§9.3 已经正确实现了你要的语义——SYNC_REQUIRED(sync_to_seq) 通知 → 客户端 PULL_MAILBOX 批量拉 → SYNC_COMPLETE → ONLINE_READY,登录屏障期新消息进实时队列(§9.4)。§22.2 也有对应验收项"多条离线消息只触发一次 SYNC_REQUIRED"。
判定:机制方向完全正确,但协议有 6 处漏洞。
修正清单:
| # | 问题 | 修正 |
|---|---|---|
| 1 | covered_through_seq 规则一会在同一 mailbox_seq 中间截断(§7.2 主键含 event_id,一个 seq 下可有多条 entry),剩余事件被永久跳过 |
写死:after_seq 为开区间下界、up_to_seq 为闭区间上界;批次切分只能发生在 mailbox_seq 边界,同一 seq 的全部 entry 必须整组返回;max_items/max_bytes 降为软上限。同时在 §7.2 加不变量:单个 (user_id, mailbox_seq) 的 entry 组 ≤ 8 条且 ≤ 8 KiB,超限由 FanoutCoordinator 拆成新的 mailbox_seq |
| 2 | SYNC_REQUIRED 只给 sync_to_seq,而 mailbox_seq 是稀疏的,差值与条数无关,客户端无法决定全量拉还是分批显示进度 |
增加 pending_entry_count_hint、pending_bytes_hint(估算值即可) |
| 3 | 严格串行 request-response,1 万条积压需 50+ RTT | 允许流水线:客户端可并发 N 个 PULL_MAILBOX(N≤4),或服务端在 MAILBOX_BATCH 中带 next_after_seq 支持连续推送式拉取 |
| 4 | MAILBOX_ACK 与 §6.5"游标由客户端持有并签名"自相矛盾,服务端副作用未定义,且每批多一个 RTT |
二选一并写死:推荐保留 MAILBOX_ACK 但改为纯服务端遥测/裁剪辅助(不作为游标权威),并允许与下一个 PULL_MAILBOX 合并为一帧(PULL_MAILBOX.acked_seq),消除额外 RTT |
| 5 | SYNC_COMPLETE 的服务端校验规则未定义,客户端可提前解除屏障 |
服务端必须校验 SYNC_COMPLETE.sync_to_seq == 本次分配值 且已发出的批次全部覆盖,否则回 SYNC_INCOMPLETE |
| 6 | 登录屏障期实时队列:存引用还是存正文未定义;与 §11.3 双水位是否同一队列未定义;MAILBOX_DIRTY 后的重复/乱序去重规则未定义 |
写死不变量:设备游标只能由 MAILBOX_BATCH 连续推进,实时 PUSH 不得越位推进游标;实时队列只存引用 + 正文指针(与 §10.3 共享正文一致);客户端去重表定义容量与淘汰窗口 |
其中第 6 条的不变量最关键:它与 §9.3 L408 的跳洞规则叠加,是另一条静默丢消息路径。
问题 5:群消息如何防止写扩散和推送扩散¶
必须先澄清一个认知:本设计没有消除写扩散,也没有消除推送扩散。它消除的是"N 份正文 + N 次跨服务 RPC + N 次编码",代价仍然是 N 条邮箱引用写入 + O 次 Socket 写入。§10.2 对此是诚实的。若产品侧以为"10 万人群一条消息只写 1 次",那是误解。
当前成本结构(§10.2):
消息正文存储 O(1)
ConversationHead O(1)
中心跨节点任务 O(S) S = 目标 MailboxShard 数
邮箱轻量引用写入 O(N) ← 全系统最大成本项
最终 Socket 写入 O(O) O = 在线人数 × 平均在线设备数
六个必须补的机制:
- 成本从未量化,且没有闸门(最严重)。§21.1 只有公式没有任何数字。修正:
- 补"待实测参数表":
entry_ondisk_bytes、lsm_write_amp、mailbox_replicas,标注"回填前不得进入容量评审结论"。 - 新增 §21.1.1 平台 fanout 预算:
platform_fanout_entries_per_sec = Σ(msg_rate_g × N_g) + 单聊事件/s,据此推导logical_mailbox_shard_count下界。 - 把 §8 的"限流"两个字展开为三层准入:
per_conversation_msg_rate按群规模分档(≤1000 人 20 msg/s;1000~10000 人 5 msg/s;>10000 人 2 msg/s)、per_sender_in_conversation_rate(1 msg/3s)、tenant_fanout_quota令牌桶;超限返回FANOUT_QUOTA_EXCEEDED,行为是发送侧拒绝或排队,绝不是静默丢邮箱引用。 -
§2.2 L30 补一句:"10 万成员上限的前提是该群受
per_conversation_msg_rate分档限制。" -
分片级单一水位造成队头阻塞(
major,实际影响很大)。§9.2 要求materialized_watermark按mailbox_seq顺序推进 → 一个 10 万人大群的 dispatch 在某分片展开期间,同分片上纯单聊用户的可见水位也被卡住。§10.3 的"配额和公平调度"只分配 CPU/IO 份额,在语义上无法解决顺序可见性阻塞。修正:
水位由标量改为按通道分组的向量:
lane_id = user_bucket % K (K 默认 64,必须为 2 的幂)
materialized_watermark[K]
对某用户暴露的水位恒为 W[lane(user_bucket)]
mailbox_seq 定义不变(仍是分发日志 offset),CLAUDE.md「同一群消息在同一分片只分配一次」不变。
GroupDispatch 在分片内按 lane 拆成 K 个子任务,各自独立推进 W[j],互不等待。
min(W[0..K-1]) 保留为分片级水位,仅用于检查点与备节点接管判定,不对客户端暴露。
协议:AUTH_OK 增加 lane_id/lane_watermark;MailboxCursor 增加 lane_id(服务端签入 token)。
-
成员边界矛盾。§10.1 第 8 步"为每个本地成员批量插入引用"与 §7.4 的
joined_at_conversation_seq/membership_state相互矛盾;退群在途消息无边界。修正:UserConversationState增加left_at_conversation_seq,把可见区间定义为[max(joined_at, hidden_before), left_at ?? +∞);§10.1 第 8 步补过滤规则"跳过left_at_conversation_seq ≤ 本消息 conversation_seq的成员";UserMailboxEntry增加visibility_floor_conversation_seq,使客户端与投影层仅凭邮箱条目即可判可见性。另需明确写死:"加群不回填历史邮箱引用,只写一条JOINED_CONVERSATION引用,历史一律走PULL_HISTORY。" -
Bitmap 槽位映射缺失。§17.1 自己点明 RoaringBitmap"需要稳定的成员槽位映射",但全文未定义 slot 的分配域、分配/回收算法与重分片迁移。slot 复用会导致跨用户错投消息。必须补。
-
推送扩散只算了写次数。§21.2 的公式漏了三层:syscall 次数、TLS 加密 CPU、网卡 pps/线速。100 万在线聊天室按此规划会低估一个数量级。全文也无
writev/批量合并/GSO 机制。同时公式用"在线人数"而非"在线连接数",按人均 2 设备算低估 2 倍。 -
PushBatch 的 recipients 字段不足,无法执行 §11.1 自己规定的去重与排序规则,也无法承载 §10.3 L479 承诺的"轻量个性化帧头"(
counts_unread、mention_type是 per-recipient 的)。@全体成员的展开规则与推送风暴抑制全文未定义。
是否应该引入读扩散混合档:§3 L46-48 的禁令把"读扩散混合方案"一并误杀了,导致只剩"10 万人群全员写扩散"与"100 万聊天室无持久语义"两个极端,中间缺一整档。建议不改 §3 措辞(避免动摇已锁决策),而在 §10.2 后补"档位与降级预留":
mailbox_write_policy: always | mention_only (默认 always,不改变 §2.2 L30 承诺)
mention_only 启用条件(触发 ADR 评审,而非固定阈值):
fanout_entries_per_sec 持续超过 platform_fanout_budget 的 70%,或
大群邮箱写占 MailboxNode 总预算 > 50%
mention_only 的最小改动集:
UserConversationState 增加 delivered_conversation_seq
登录 SYNC 阶段追加 BIG_GROUP_HEADS[]{conversation_id, latest_conversation_seq,
last_activity_id, preview, unread_estimate}
未读改为 latest_conversation_seq - read_conversation_seq 估算
@提及无条件逐条物化,mention_count 保持精确
对标:微信群上限 500,Telegram 超级群走拉模式,Slack channel 走游标。本设计选了更贵的路径换"离线精确可达",这个取舍应该写进文档,否则后续文档作者会反复重开该议题。
问题 6:如何保证心跳¶
文档现状:§15.1 定义了 30 秒固定周期、连续两周期无帧则断开、业务帧可刷新活跃时间、TCP Keepalive 只作兜底、时间轮管理定时器。方向正确。
四个必须修正的问题:
- PONG 回分片级
materialized_watermark是错的(这是最值得改的一处)。个人队列是稀疏的(§6.3),用户的last_applied_mailbox_seq与分片水位天然不相等。客户端若按last_applied_mailbox_seq < materialized_watermark判缺口,每个在线用户每 30 秒都会发起一次无效PULL_MAILBOX——千万在线即 33 万次/秒的空拉。修正:
PONG { server_time, echo_client_time, ping_id,
last_pushed_user_seq, ← 替换 materialized_watermark
mailbox_dirty,
next_ping_interval_ms, idle_timeout_ms }
last_pushed_user_seq 不需要新字段:§11.1 的 recipients[].mailbox_seq 本就是该用户自己的序号,
ConnectionNode 按连接维护 max(已写入 Socket 的 mailbox_seq) 即可。
客户端规则写死:仅当 mailbox_dirty==true,或 last_applied_mailbox_seq < last_pushed_user_seq
时才发起 PULL_MAILBOX;严禁与分片级水位直接比较。
验收:某用户 24 小时无新消息、其分片水位推进 100 万,该用户任一在线设备不得发起 PULL_MAILBOX。
-
固定 30 秒不适配移动网络,且服务端无下发能力。运营商 NAT 超时差异极大(部分 60 秒,部分 5 分钟以上),固定值要么费电要么被静默断链。修正:自适应心跳,按
(网络类型, 运营商 MCC/MNC 或 WiFi BSSID 哈希)本地缓存探测结果,起始 60 秒、连续 3 次成功 +30 秒、前台上限 120 秒/后台上限 240 秒,一次 PONG 缺失即回退到上次成功值 × 0.8 并锁定 30 分钟;PONG 下发next_ping_interval_ms/idle_timeout_ms,服务端超时判定改为next_ping_interval × 2 + 10s,节点过载时可全局拉长心跳降载。 -
60 秒才发现死连接,对"消息发不出去"太慢。修正:发送侧独立超时——
SEND_MESSAGE带request_id,3 秒无SEND_ACK立即发PING{probe=true},再 3 秒无 PONG 判链路失效并重连,端到端不可用检测 ≤ 7 秒;内核兜底TCP_USER_TIMEOUT=15s+ keepalive(60/10/3)。 -
开销未量化。修正:单节点 20 万连接、tick 1 秒、槽位 512 → 每 tick 扫约 390 个连接,超时用"惰性校验
last_active_at"而非精确定时器,PONG 按 10ms 窗口批量写出;§21 增加心跳 pps = 在线连接数 / 平均心跳间隔 × 2。
另需补的(心跳之外,但同属连接层):
session_epoch由谁分配、如何保证单调、被替换连接如何被告知,全文未定义;每用户设备数上限、多设备互踢规则、切网快速重连期间的新旧连接并存窗口,全部缺失。§16 的 Auth/Session 职责列也没包含 epoch 分配。- iOS 后台无长连接的降级路径完全缺失,离线推送(APNs/FCM/VoIP)全文只有 §16 表格一行。
- 重连风暴无任何服务端准入控制:无连接建立速率限制、无
RETRY_AFTER信令、无 AUTH 前的连接超时(slowloris 面)、无接管分批放行。与"固定 ConnectionShard + 网络切换立即快速重连"叠加会自我放大。修正:接管节点按"每秒放行该分片总连接数的 5%"分批放行,AUTH_OK增加sync_delay_hint_ms(0~30000 随机)让重连用户错峰拉取。 - 流控只有"软/硬水位"四个字:无数值、无优先级(控制帧 > 单聊 > 大群 > 聊天室)、无节点级全局发送缓冲预算、无退出降级态的低水位滞回。
问题 7:刚登录用户的第一信令,以及客户端按时间戳补拉¶
关于首包信令:§9.3 的设计基本正确,两点优化建议:
AUTH_OK与SYNC_REQUIRED/SYNC_EMPTY承载的是同一件事的两半,建议合并为AUTH_OK{session_epoch, lane_id, lane_watermark, sync_to_seq, has_offline, pending_entry_count_hint, total_unread, projection_mailbox_seq, preferred_endpoint},登录风暴时每省一个 RTT × 千万连接是实打实的收益。- 明确写死
sync_to_seq := AUTH 时刻的 lane_watermark 快照(当前文档全文没有这个等式)。
关于"客户端按最后一次消息时间戳重新拉取"——这个做法必须明确禁止。理由:
- §6.2 L159 自己承认"消息可能因分发重试而晚到"。一条
created_at早于客户端已记录时间戳、但实际晚到的消息,在时间戳补拉模型下会被永久跳过。 - §6.1 L153 只承诺
message_id"大致按创建时间有序",跨 region/writer 无全序。 - 时间戳与本文档的
mailbox_seq游标模型是两套互不兼容的完整性模型,混用等于放弃 §9.2 水位提供的所有保证。
正确的兜底通道有两条,都不是时间戳:
全局兜底(下拉刷新 / 客户端定期自检):
PULL_MAILBOX(after_seq = cursor.last_applied_mailbox_seq,
up_to_seq = 最新 lane_watermark)
纯读幂等,客户端限流 >= 5s 一次
会话级兜底(进入会话 / 发现空洞):
PULL_HISTORY{conversation_id, after_conversation_seq | before_conversation_seq,
direction, limit}
→ HISTORY_BATCH{messages[], latest_conversation_seq,
earliest_available_conversation_seq, has_more}
latest/earliest 两个字段让客户端 O(1) 自检本地空洞与保留边界
建议在 §9.5 直接写一条禁令:
禁止将
created_at或客户端本地时间作为任何增量同步的下界。时间戳只用于 UI 展示与"跳转到某天"入口,服务端必须先把日期映射为conversation_seq再按 seq 取数。
另外,PULL_HISTORY 在全文只有 §9.5 一处提及、没有任何字段定义,必须补齐(它是问题 2、问题 7 两条修正路径的共同依赖)。
同时需要写明:用户视角的 conversation_seq 天然稀疏(定向系统消息、被治理删除的消息、joined_at 之前的消息都会造成中段空洞),因此禁止用 conversation_seq 差值判定丢消息。丢消息检测的唯一锚点在邮箱层。§9.3 L408 的跳洞规则不得外推到 conversation_seq。
问题 8:会话记录如何保持和更新¶
文档现状:§12 的三层模型(ConversationHead 每消息一次 + UserConversationState 只因用户动作更新 + UserSessionProjection 异步可重建)方向正确,确实同时规避了你担心的两种爆炸。§12.6 的"快照 + 邮箱增量"也是对的——它复用离线同步已经要读的数据,边际成本接近零。
但有 5 个会导致线上事故的问题:
SESSION_DELTA用增量语义承载未读,与"至少一次投递 + 可丢弃推送"根本冲突。该帧无event_id、无版本号,在MAILBOX_DIRTY重放与连接替换丢帧两条正常路径下必然双加或少算,且永久漂移。修正:
SESSION_DELTA 改为幂等绝对值帧:
{ conversation_id, driving_event_id, driving_mailbox_seq, projection_mailbox_seq,
latest_conversation_seq, last_activity_id, last_message_id, preview_or_placeholder,
unread_count, unread_exact: bool, mention_count }
版本源复用 projection_mailbox_seq(§7.5 已有),不新增计数器:
用户所有投影输入都进本人邮箱,该 seq 在用户维度天然单调。
客户端规则:projection_mailbox_seq 更大才应用,绝对值直接覆盖,否则丢弃。
合并规则:只允许"同 conversation_id 后帧整体覆盖前帧",禁止任何累加型合并。
- 未读数存在双事实源(
unread_count累加 vsread_conversation_seq水位),文档既无权威定义式也无 reconcile 机制。修正:在 §12.4 首行写入权威定义式,并声明缓存关系:
unread(u,c) = |{ m : base(u,c) < m.conversation_seq <= head.latest_conversation_seq
∧ m.sender_id ≠ u ∧ m.counts_unread ∧ ¬recalled ∧ ¬deleted }|
base(u,c) = max(read_conversation_seq, hidden_before_conversation_seq,
joined_at_conversation_seq)
unread_count 是该式的缓存,read_conversation_seq 是权威输入,冲突以定义式为准。
有界重算:latest - base <= 200 时向 MessageStore 单分区范围读精确重算;
> 200 时返回 unread_count=200, unread_exact=false(与 UI 的 99+ 闭环)
reconcile 触发点:新设备/登录、检查点回滚重放、MAILBOX_DIRTY、
read_conversation_seq 被跨设备前推到非最新位置
§7.5 增加 unread_base_seq,使 read_seq 变化时可判定"清零 / 增量扣减 / 触发重算"三态
-
全局未读
total_unread/ 角标在全文完全缺失。APNs 的aps.badge需要服务端给出绝对数字,当前架构给不出。修正:新增UserBadgeState{tenant_id, user_id, total_unread, total_mention, muted_unread, badge_projection_mailbox_seq},与UserSessionProjection同实例、同 WriteBatch、同 checkpoint;AUTH_OK带初值,新增BADGE_UPDATE帧共用 100~200ms 合并窗口;§16 的 Notification Service 补"读UserBadgeState填 badge"、"不自行聚合未读"。 -
后台投影压缩器的驱动方式未定义——若按用户表扫描,就等于把"每次登录再计算"换成了"常驻全量扫描",成本更差(正是你担心的第二种爆炸换了个地方)。修正:
压缩器顺序消费 MailboxNode 本地邮箱写入流(按 mailbox_seq 递增的 tail),
在内存按 (user_id, conversation_id) 聚合,每 T 秒或每 N 条 flush 一次 per-user WriteBatch。
明令禁止按用户主键全表扫描。
维护 per-node dirty_users RoaringBitmap,flush 后清位 —— 成本正比于"有事件的用户数"。
据此可删去"冷用户"这一分类:日志尾驱动下冷热同路径,冷用户只是恰好无事件。
SLO:projection_lag_seq = materialized_watermark - min(projection_mailbox_seq),p99 < 5 分钟
裁剪水位 = min(所有有效设备游标, 投影检查点 seq) - 安全余量,使 L565 与 L792 闭环
- 会话列表读路径缺排序索引。
UserSessionProjection以conversation_id为主键末段,last_activity_id只是值列 → §12.7 要求的(pin_rank, last_activity_id DESC, conversation_id)keyset 分页在存储层无法实现,只能读该用户全部会话再内存排序,成本O(会话数)而非O(limit)。修正:明确"排序发生在 Session Projection 服务的内存快照层,快照按snapshot_revision版本化,冷用户按需一次分区读全量载入(≤5000 行)";同时在 §2.2 补每用户会话数上限(建议 5000)——文档给了群上限和聊天室上限,独缺这一条,客服号/机器人号会 OOM。
其他需补:snapshot_revision 的生成规则、单调性、首次取值全文未定义;退群/删除会话缺 left_at / deleted_before 上界水位,导致重放与主备切换时会话"复活"结果不可重现;§12.8 表格缺"消息删除、消息 TTL 过期、标记未读、清空聊天记录、被踢、归档"六行(archived 字段在 §7.4 存在但表中无对应行);mention_count 只有计数无位置,无法支撑"跳到第一条 @我"。
2. 阻断级缺陷(P0,必须先解决)¶
B-1 邮箱裁剪与"空洞可安全跳过"冲突 → 静默丢消息¶
见问题 2。这是全文最严重的问题:系统会在自认为同步成功的情况下丢掉整段离线消息,且没有任何错误码暴露。
B-2 在线目录缺失:PushBatch 需要的字段推导不出来¶
矛盾链条:
§5.3 L137 MessageNode 根据固定映射计算 ConnectionShard,不逐条查询远程目录
§11.1 L498 PushBatch.recipients[] 需要 connection_id、session_epoch
§10.1 L453 与"在线用户 Bitmap"求交 ← 用户级位图,承载不了 per-device 明细
§16 L749 Shard Registry 不负责每条消息路由查询
稳定哈希只能算出 ConnectionShard,算不出在线状态、per-device 的 connection_id 与 session_epoch。全文没有任何一处定义在线目录的权威存储、写入方、发布通道、传播延迟与丢事件收敛策略。结果是投递链路最后一跳无法实现:要么 MailboxNode 每条消息回查目录(大群一条消息查 10 万次,违背 §3 L54),要么 ConnectionNode 自己本地查表(此时 PushBatch 里的 connection_id/session_epoch 是假的,§11.1 的 epoch 校验形同虚设)。
修正:新增 §5.4「在线目录与发布通道」:
PresenceEntry { tenant_id, user_id, device_id, connection_id, node_id,
connection_shard, session_epoch, capabilities, lease_expire_at }
写入方唯一:持有该 ConnectionShard 租约的 ConnectionNode,AUTH_OK 后写入
租约续期 10 秒、过期 30 秒
发布通道:compacted topic,key=(tenant_id, user_id, device_id),
分区键使用与 MailboxShard 同源的 user_bucket
→ MailboxNode 只订阅覆盖本地分片的分区,推送路径 0 次同步远程调用
(千万连接、平均在线 30 分钟 ≈ 1.1 万 events/s,可忽略)
收敛机制:ConnectionNode 收到 epoch 不匹配的推送 → 丢弃并回 PRESENCE_STALE
→ MailboxNode 失效本地缓存并按需回源
每 MailboxNode 每 60 秒对本分片全量对账
在线用户 Bitmap 降级为"该用户至少一个设备在线"的快速过滤器,
per-device 明细一律取自 presence 缓存。
替代方案(若坚持"完全不维护目录"):PushBatch 改为按 user_id 寻址,由 ConnectionNode 本地展开并自行做 epoch 过滤。可以,但代价是 MailboxNode 感知不到在线状态、会向 0 在线的 ConnectionShard 发空批次,且离线推送触发判断失去依据。两者取其一,但不能同时声称"有 connection_id"和"不查目录"。
3. 重大问题速查表¶
完整的 84 条 findings(含证据行号、影响与逐条修正)见评审工作流原始输出。此处按主题归并列出,编号 A~H 对应主题分组。
A. 序列与排序¶
| 编号 | 问题 | 关键修正 |
|---|---|---|
| A-1 | mailbox_seq 复用日志 offset,epoch 变更后静默回退,游标换算全文未定义 |
定义为复合 u64 (shard_epoch:16 << 48) \| offset:48,整体无符号比较;Shard Registry 持久化 epoch_boundary;新增 CURSOR_REBASED 帧回放旧 epoch 尾部;mailbox_seq_regression_count 指标恒应为 0 |
| A-2 | 撤回/编辑的 conversation_seq 语义未定;§12.3 把排序/未读/预览三种副作用合并为一个 AND 门控 |
门控拆为两条:排序键更新条件 last_activity_id >;内容更新条件 conversation_seq >。补 target_message_id/target_conversation_seq |
| A-3 | event_id 生成算法未定义 |
规定为确定性哈希 blake3(tenant_id, dispatch_id, user_id, event_type)[0:16],明令禁止随机 UUID(否则主备物化结果不一致) |
| A-4 | 聊天室序列与主模型脱节:房间消息是否有 message_id、room_seq 重启是否回退、"提升为持久群会话"如何衔接,全未定义 |
补齐三问 |
B. 大群分发¶
| 编号 | 问题 | 关键修正 |
|---|---|---|
| B-3 | membership_version 的产生粒度、频率、生命周期未定义(全文仅 2 处提及),保留期约束互相引用形成循环定义 |
定义为 dispatch 发起时的惰性快照,并量化保留期 |
| B-4 | dispatch 分块进度存放位置与原子性、主备接管 RTO、旧成员版本与日志保留期的数值关系,三者全部未定义 |
补齐;§10.4 当前无法验收 |
| B-5 | 逻辑 MailboxShard 总数与 virtual_bucket_count 全文无量级,且单分区吞吐从未作为容量维度出现 |
在 §21 增加"每 MailboxShard 分发事件/s"维度,据此推导分片数下界 |
| B-6 | 主备双消费同一日志物化"相同索引",但未排除非确定性输入(created_at、event_id) |
与 A-3 一并解决:所有物化输入必须确定性 |
| B-7 | §9.2"达到复制要求"全文未定义(副本数?fsync?谁 ack?) | 定义持久性契约 |
C. 连接与协议¶
| 编号 | 问题 | 关键修正 |
|---|---|---|
| C-1 | 协议选型零对比论证(全文 0 次出现 QUIC/HTTP3/gRPC/MQTT/443/NAT/防火墙/代理/握手) | 见 §4 的接入决策建议 |
| C-2 | PONG 回分片级水位导致全网无效轮询 | 见问题 6 修正 1 |
| C-3 | 固定 ConnectionShard 与 §5.2 自身的"L4 按源地址一致性分发"矛盾——源地址哈希几乎必然落错分片 | L123 改为"L4 只做四层直通(PROXY protocol v2 透传源地址),分发键为最小连接数;分片亲和完全由应用层路由令牌达成";单次连接最多重定向 1 次,令牌 TTL ≤ 60s 且单次使用;分片到节点映射按 region 分组以保就近接入 |
| C-4 | 协议帧格式只有一句话;"整帧完整性校验"若覆盖整帧,会与 §10.3"共享正文一次编码"优化互相抵消(10 万人群退化为 10 万次全帧扫描) | 明确校验覆盖范围仅限帧头 + 长度;定义序列化选型(建议信令用 Protobuf、正文透传用零拷贝格式);定义单流内 MAILBOX_BATCH 是否会阻塞控制帧与 PONG |
| C-5 | 客户端帧集合严重不完备:服务端推消息的帧从未命名,PULL_HISTORY 无定义,无错误帧/重定向帧/踢下线帧/重认证帧,无 opcode 表 |
在 §9 补"帧与错误码基线"最小自洽集:PUSH_EVENTS、统一 ERROR{code, retry_after_ms, detail}、REDIRECT、PULL_HISTORY/HISTORY_BATCH、上行控制帧名录;其余授权给 docs/01 |
D. 存储与容量¶
| 编号 | 问题 | 关键修正 |
|---|---|---|
| D-1 | §7 只列字段,全文 0 处分区键/聚簇键/查询模式;大群按 conversation_id 单分区必然超限 |
MessageRecord: ((tenant_id, conversation_id, seq_bucket), conversation_seq DESC),seq_bucket = conversation_seq / 4096(确定性、无空洞、向上翻页 bucket--);读最新页先读 ConversationHead.latest_conversation_seq 定位当前 bucket。ClientDedup ((tenant_id, sender_id, client_message_id)) TTL 86400。UserConversationState / UserSessionProjection ((tenant_id, user_id), conversation_id) |
| D-2 | 自研 MailboxNode(Pebble + 自建复制/接管/重分片)等于自研一套分布式有状态存储,成本被严重低估 | 定义 MailboxStore 抽象(AppendBatch/RangeScan/TruncateBefore/Watermark),阶段一用 ScyllaDB 实现:PRIMARY KEY ((tenant_id,user_id), mailbox_seq, event_id),TWCS window=1d,LOCAL_QUORUM;写死切换到自研的四条判据(峰值邮箱写 > 150 万条/秒、写 P99 > 20ms、邮箱层成本 > 全系统 25%、大群展开扇出成本 > 本地展开 3 倍)。落 ADR-0001 |
| D-3 | §21.1 量纲错误:把写放大当成了存储系数,高估存储 10~30 倍 | 拆两式:驻留容量用空间放大(leveled 1.1~1.3),设备寿命用写放大并给 DWPD 约束式;§21.2 补乘"峰值消息速率 × 平均在线设备数" |
| D-4 | §18.3 检查点无任何数值(大小/频率/重放速率/追平判据/分级 RTO) | 补齐五个参数,L818 才能从口号变成可验收的不等式 |
| D-5 | 数据删除与合规全文缺失:至少一次投递 + 多副本 + 不可变 S3 检查点使逐行物理删除不可完成 | 必须在基线阶段确定加密擦除路径(它约束存储格式,不能后补);注意 preview_or_placeholder 在 ConversationHead 与 UserSessionProjection 两处会残留正文片段 |
| D-6 | 消息搜索 / 导出 / 合规 WORM 留存三项全文零提及,且会反向决定存储模型 | 至少要在基线声明取舍与 V2 边界 |
| D-7 | 聊天室 100 万在线目标无房间消息速率上限、无单连接出向帧率上限 | §11.3 的水位是事后被动降级,不是事前限速;补硬约束,否则聊天室广播会挤占共享网关并触发全局重连风暴 |
E. 一致性、容灾与安全¶
| 编号 | 问题 | 关键修正 |
|---|---|---|
| E-1 | 重分片只有一句话,而 MailboxCursor 是单分片结构、mailbox_seq 绑定单分片 offset → 用户逻辑归属变更后游标不可换算 |
定义 ShardSplitBoundary{old_shard, old_epoch, split_at_seq, new_shard, new_epoch},游标迁移是换发签名令牌而非数值映射;定义迁移窗口内 seq 归属方与缺口检测口径 |
| E-2 | Home Region fencing 只有名词没有机制:颁发方、载体、强制校验点、租约参数全无;切换期客户端行为无处安放(全文无错误/NACK 帧) | 补齐;同时定义"用户邮箱所在 region"与"会话 Home Region"的关系——否则无法判断地域切换是否波及 mailbox_seq 与设备游标 |
| E-3 | E2EE 被当成一个开关:无密钥体系、无适用范围声明;与 @提醒(mention_type 来源)、内容审核、推送内容、新设备历史、合规导出的关系全部悬空;10 万人群 E2EE 工程上不成立 |
补"E2EE 功能降级矩阵",明确大群/聊天室是否支持。注意:未读计数与排序标志属类型级契约不需解析明文,预览已有占位方案——这三项不构成冲突,不要过度设计 |
| E-4 | 幂等键覆盖不全:client_message_id 幂等记录的介质与 TTL 未定义;已读/撤回/编辑控制事件的 event_id 生成规则未定义 |
与 A-3 一并解决 |
| E-5 | 签名游标与协议明文自相矛盾:§6.5 L205 声明游标是服务端签名的不透明 token,但 §9.3/§15.1 的帧里传的是明文 after_seq/last_applied_mailbox_seq |
二选一并写死。建议:token 承载 (shard, lane, epoch) 等不可伪造部分,seq 保持明文(服务端校验其 ≤ 已发放上界) |
| E-6 | 长连接安全缺口:token 无续期与吊销、无远程登出、限流维度只有两个字、内部服务无鉴权 | 长连接活 7 天而 token 过期时的续期路径必须定义 |
| E-7 | 多租户只有一个字段:无隔离档位、无配额对象、无数据驻留、无私有化差异;且 §5.1 均匀打散 + §9.2 分片级水位构成"单租户慢任务阻塞同分片其他租户"的跨租户级联故障路径(lane 水位可同时缓解此项) | 补租户隔离档位与配额 |
| E-8 | 全文无任何 SLO;§20.1 八组指标无阈值;§22 的 24 条验收 21 条无量化判据 | SLO 属产品目标,与 §21"容量常数必须实测"的立场不冲突,应当先定 |
F. 文档自洽性(机械可修,但必须修)¶
| 编号 | 问题 |
|---|---|
| F-1 | MessageNode / MailboxNode / Mailbox Node / Message/Mailbox 节点 四种写法指同一实体(共 18 处),且与真实独立服务 Message Store/MessageStore 撞车;MessageNode 从未在 §4 架构图与 §16 服务表中定义。L46 的"MessageNode 公共日志"实指可靠分发日志,是第三种含义 |
| F-2 | 会话投影归属三处矛盾:§16 L754 归 Mailbox Node、§16 L755 另有独立 Session Projection 服务、§17.1 L771 归节点本地 Pebble。导致 PULL_SESSION_LIST 的服务落点与投影权威副本位置无法确定 |
| F-3 | 孤儿字段:media_metadata、retention_class、head_version、state_version、projection_version 各只出现 1 次、无语义定义。其中三个 *_version 是并发控制原语,被当装饰字段忽略会导致多设备置顶/静音互相覆盖 |
| F-4 | §4 架构图 Message Store → Fanout Coordinator 的读箭头与 §8/§10.3 实际数据流不符(读正文的是 MailboxNode),会误导读者在中心层引入不必要的正文读取 |
| F-5 | connection_epoch(§5.3 L134,1 次)与 session_epoch(7 次)术语不统一 |
| F-6 | §12.8 控制事件表缺 6 行;archived 字段有定义但表中无对应行 |
建议:在 §5 前新增「实体与命名表」,列出每个实体的唯一英文写法与禁止别名,并在 L960 后追加"后续文档只能使用该表写法";修复后加一条 CI grep 断言防回归。
4. 需要项目方拍板的开放决策¶
Q-1 接入协议:是否统一到 WSS/443¶
CLAUDE.md 已锁定"TCP/TLS 主链路 + WebSocket 承载同一协议"。评审意见分歧点在于是否值得维护两套接入路径。建议保留已锁定决策,但补齐工程约束:
1. 端口固定 443,TLS 1.3 ALPN 协商 qim/1(自定义帧)与 http/1.1(WSS 升级)
2. 强制回落顺序:ALPN qim/1 → WSS 443 → 经系统 HTTP 代理 CONNECT 的 WSS 443
每级超时 5 秒;客户端按 (网络类型, 运营商/BSSID 哈希) 缓存上次成功方式,TTL 7 天
3. Transport{open, sendFrame, onFrame, close} 抽象层,
TCP 与 WS 两条路径必须复用同一 codec 库,禁止各自演化帧方言
4. QUIC / WebTransport 明确列为二期,落 ADR
5. §5.2 补接入协议决策矩阵(七维:中间设备穿透、企业代理、切网迁移、
握手 RTT、帧头开销、四端实现成本、LB/可观测性支持)
并写明拒绝理由:gRPC 需 grpc-web 代理且帧头与流控不可控;
MQTT 的 QoS 语义与本文"至少一次 + mailbox_seq 幂等"重复冲突
待拍板:TLS 终止位置(建议终止在 ConnectionNode 以保留 ALPN 快路径与真实源 IP,L4 走四层直通 + PROXY protocol v2)、是否 mTLS、是否证书 pinning、是否启用 0-RTT(建议关闭;若开启则白名单仅限 RESUME/PING)。
Q-2 邮箱保留窗口与降级体验¶
它是邮箱层容量的线性因子(7 天 vs 30 天 = 4 倍成本差),必须先定才能算容量。
建议默认:mailbox_retention_days = 30(私有化可下调至 7)
chatroom_room_log_retention_minutes = 30
device_inactive_gc_days = 60
同时必须写死一条隐藏约束(当前 L565 只保护投影、不保护设备游标):
保留窗口 >= 投影压缩器最坏滞后 P99.9 × 3
§20.1 增加"投影压缩滞后超过保留窗口 1/3"告警
CURSOR_EXPIRED 后不可精确重建的只有"窗口外的未读数与提及计数"
(撤回/编辑通过每会话最近一页的 MessageRecord 当前状态自然收敛)
→ 协议需返回 mention_precise=false 由 UI 展示为不精确态
待拍板:是否给主设备更长窗口(主 30 天 / 副 3 天)?若是则需新增 device_class 字段
Q-2 应与"是否启用大群惰性物化"(问题 5 的 mailbox_write_policy)一并决策:若启用惰性物化,沉默成员只有会话级游标,延长保留窗口的边际成本显著下降,30 天窗口的总成本反而可能低于当前设计的 7 天。
5. 建议的修订顺序¶
第一批(阻断,先改)
1. B-1 mailbox_trim_watermark + CURSOR_EXPIRED + REBUILD 流程
2. B-2 新增 §5.4 在线目录与发布通道
3. F-1/F-2/F-5 术语统一 + 新增「实体与命名表」(机械修改,成本最低,收益立竿见影)
第二批(正确性,开工前必须有)
4. 新增 §6.6 客户端排序契约 + 补 UserMailboxEntry 的 5 个字段
5. §9.3 批次切分对齐 mailbox_seq 边界 + 复合事件组上限
6. §9.2 水位改为 lane 向量(消除大群队头阻塞)
7. SESSION_DELTA 改绝对值 + 未读权威定义式 + UserBadgeState
8. A-1 mailbox_seq 复合序号 + epoch 边界表
9. §7 补每张表的分区键/聚簇键与单分区上界
第三批(可实现性,专题文档开工前)
10. §9 帧与错误码基线(PUSH_EVENTS / ERROR / REDIRECT / PULL_HISTORY)
11. §15 自适应心跳 + PONG 字段修正 + 发送侧超时
12. §8 限流三层准入 + §21.1.1 平台 fanout 预算
13. §21 容量公式量纲修正 + 三档基线参数表
14. 定 SLO(§20 指标阈值 + §22 验收量化判据)
第四批(缺失章节,可标 V2 但必须显式声明边界)
15. 离线推送与角标全链路(触发点、去重、静音生效层、频控、device token 生命周期)
16. 数据删除与合规(加密擦除路径 —— 它约束存储格式,不能后补)
17. E2EE 功能降级矩阵
18. 重分片流程 / Home Region 切换流程
19. 客户端本地存储与端上一致性;协议与存储演进规则;群角色与权限
20. 搜索、导出、机器人开放平台、成本模型 —— 可显式标 V2
6. 评审方法与可信度说明¶
本报告由 7 个独立维度的评审(序列排序 / 登录同步 / 大群分发 / 会话列表 / 连接心跳 / 存储容量 / 一致性安全)产出 84 条 findings,每条再经对抗性验证(逐条核对文档原文,尝试反驳)。
- 43 条判定
CONFIRMED(确为文档缺陷) - 40 条判定
PARTIAL(缺陷成立但原描述夸大,已按核对结果下调严重度或收窄范围) - 0 条被完全反驳
PARTIAL 的典型修正例:
- "大群写扩散代价失控"被下调——§L861/L775 已声明常数必须实测,刻意留白不是遗漏;真正缺的是 fanout 预算闸门。
- "ConversationHead 是平台级单分区热点"不成立——写按
conversation_id分散;真实问题是head_version的并发语义未定义。 - "E2EE 下未读计数不可能"不成立——
counts_unread属类型级契约,不需要解析明文。 - "客户端无法检测丢消息"不成立——检测锚点在邮箱层,与
conversation_seq连续性无关;真实缺陷是文档没写明"禁止用conversation_seq差值判丢"。