跳转至

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 文档做对的部分(不建议推翻)

这几条判断是本设计真正的价值,后续修订应保护它们:

  1. 提交、收件索引、Socket 投递三阶段分离(§4.1)。这是可扩展 IM 的正确骨架。
  2. 正文单存 + 邮箱插轻量引用(§10)。消除的是 N 份正文、N 次跨服务调用、N 次编码,这个边界划得准。
  3. materialized_watermark 连续物化水位(§9.2)。绝大多数自研 IM 在这里丢消息,文档提前识别了。
  4. 会话列表三层模型(§12.2:公共头 + 用户主动状态 + 可重建异步投影)。方向正确。
  5. §10.2 诚实承认 O(N) 邮箱写入无法消除。不吹牛,比大多数设计文档强。
  6. PING 携带 last_applied_mailbox_seq、PONG 回 dirty 标志(§15.1)。低成本缺口检测的思路是对的(但具体字段有错,见 C-2)。
  7. 拒绝 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 = 在线人数 × 平均在线设备数

六个必须补的机制:

  1. 成本从未量化,且没有闸门(最严重)。§21.1 只有公式没有任何数字。修正:
  2. 补"待实测参数表":entry_ondisk_bytes、lsm_write_amp、mailbox_replicas,标注"回填前不得进入容量评审结论"。
  3. 新增 §21.1.1 平台 fanout 预算:platform_fanout_entries_per_sec = Σ(msg_rate_g × N_g) + 单聊事件/s,据此推导 logical_mailbox_shard_count 下界。
  4. 把 §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,行为是发送侧拒绝或排队,绝不是静默丢邮箱引用。
  5. §2.2 L30 补一句:"10 万成员上限的前提是该群受 per_conversation_msg_rate 分档限制。"

  6. 分片级单一水位造成队头阻塞(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)。
  1. 成员边界矛盾。§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。"

  2. Bitmap 槽位映射缺失。§17.1 自己点明 RoaringBitmap"需要稳定的成员槽位映射",但全文未定义 slot 的分配域、分配/回收算法与重分片迁移。slot 复用会导致跨用户错投消息。必须补。

  3. 推送扩散只算了写次数。§21.2 的公式漏了三层:syscall 次数、TLS 加密 CPU、网卡 pps/线速。100 万在线聊天室按此规划会低估一个数量级。全文也无 writev/批量合并/GSO 机制。同时公式用"在线人数"而非"在线连接数",按人均 2 设备算低估 2 倍。

  4. 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 只作兜底、时间轮管理定时器。方向正确。

四个必须修正的问题:

  1. 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。
  1. 固定 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,节点过载时可全局拉长心跳降载。

  2. 60 秒才发现死连接,对"消息发不出去"太慢。修正:发送侧独立超时——SEND_MESSAGE 带 request_id,3 秒无 SEND_ACK 立即发 PING{probe=true},再 3 秒无 PONG 判链路失效并重连,端到端不可用检测 ≤ 7 秒;内核兜底 TCP_USER_TIMEOUT=15s + keepalive(60/10/3)。

  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 快照(当前文档全文没有这个等式)。

关于"客户端按最后一次消息时间戳重新拉取"——这个做法必须明确禁止。理由:

  1. §6.2 L159 自己承认"消息可能因分发重试而晚到"。一条 created_at 早于客户端已记录时间戳、但实际晚到的消息,在时间戳补拉模型下会被永久跳过。
  2. §6.1 L153 只承诺 message_id"大致按创建时间有序",跨 region/writer 无全序。
  3. 时间戳与本文档的 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 个会导致线上事故的问题:

  1. 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 后帧整体覆盖前帧",禁止任何累加型合并。
  1. 未读数存在双事实源(unread_count 累加 vs read_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 变化时可判定"清零 / 增量扣减 / 触发重算"三态
  1. 全局未读 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"、"不自行聚合未读"。

  2. 后台投影压缩器的驱动方式未定义——若按用户表扫描,就等于把"每次登录再计算"换成了"常驻全量扫描",成本更差(正是你担心的第二种爆炸换了个地方)。修正:

压缩器顺序消费 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 闭环
  1. 会话列表读路径缺排序索引。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 差值判丢"。