跳转至

Q-IM 现代化即时通信系统总体设计计划

状态:架构基线 v2 + 当前实现覆盖说明 更新日期:2026-09-26 适用范围:单聊、群聊、聊天室、控制消息、自定义消息、RTC 信令及多端同步 上一版评审:docs/REVIEW-001-plan-baseline.md(84 条 findings,本版逐条闭环)

0. 阅读指引

本文是 Q-IM 的总体架构基线。以下三处是契约核心,任何专题文档、任何实现都不得重新定义:

  • §5.1 实体与命名表:系统里有哪些实体、每个实体的唯一写法与禁止别名。
  • §6 标识、序列与游标 与 §7 核心数据模型:五种序列的位级格式、所有持久结构的字段与主键。
  • 附录 A 协议帧与错误码总表 与 附录 B 默认参数表:客户端可见的全部帧、错误码与所有默认数值。

专题文档可以细化编码、布局与实现,但改变上述内容必须先修改本文或新增 ADR。

当前已进入实现与验证阶段;实现完成度、发布阻断与短压证据以 CLAUDE.md 和 docs/designs/arch_20260901_current_log_driven_summary.md 为准。本文仍是目标契约权威, 但旧章节若与后续已接受 ADR 或下述实现覆盖说明冲突,以后者为准。

0.1 本版相对 v1 的重大变更

变更 原因
新增 mailbox_trim_watermark,裁剪与"空洞跳过"规则闭环 v1 会在长期离线设备上静默丢消息
新增 §5.4 在线目录 PresenceDirectory v1 的 PushBatch 需要 connection_id/session_epoch,但声称不查目录,投递最后一跳无法实现
materialized_watermark 由标量改为 lane 向量 v1 中一个大群 dispatch 会卡住同分片全部用户(含纯单聊用户)的可见水位
mailbox_seq 改为 (shard_epoch, log_offset) 复合序号 v1 直接用日志 offset,epoch 变更后会静默回退
SESSION_DELTA 由增量语义改为幂等绝对值帧 v1 的 unread_delta 与"至少一次投递"冲突,未读必然永久漂移
新增 §6.9 客户端排序契约、§6.10 完整性判定规则 v1 未定义"单用户视角如何排序",也未禁止按时间戳补拉
§7 每张表补齐分区键、聚簇键与单分区上界 v1 只列字段,无法建表
新增 §16 离线推送与角标、§21 数据删除与合规、§22 E2EE 能力边界 v1 完全缺失
新增 §23 投递模型与 Akka 对照 明确本设计与成熟 Actor/Sharding/Reliable-Delivery 模型的同构处与有意偏离处
全文统一实体命名,删除消息节点的四种英文别名 v1 用四种写法指同一实体,且与消息存储服务撞车
新增 §2.4 SLO、附录 B 默认参数表 v1 全文无任何数值与目标值,§26 的验收项不可判定

0.2 当前实现覆盖说明(2026-09-26)

  • ADR-0012 已取代 Fanout Kafka transaction:当前顺序是 dispatch 全部 durable 后同步提交 source offset;TransactionalFanout 等仅为历史命名,QIM_FANOUT_TRANSACTIONAL_ID 是待删配置残留。 ADR-0017:dispatch 生产者默认关闭幂等(acks=all 不变),逻辑唯一性由 dispatch_id + payload_digest 保证。
  • MessageStore 为分层形态(ADR-0018),§17.1 已按此修订:提交状态、ClientDedup、序号分配与 history_hot_window(7 天)内的近期历史在 Redis,提交热路径不写 ScyllaDB;HistoryArchiver (qim-archiver,§5.1 新增实体)独立消费 Outbox 批量普通 INSERT 归档到 ScyllaDB,全批确认后 提交位点并发布归档水位;只有「到期 ∧ 已被归档水位覆盖」的历史才从 Redis 裁剪,读路径按每会话 热层下界拼接两层。§7.1、§18.2 中「正文/历史用 ScyllaDB」指永久权威,不再指同步写入。 ADR-0007 的「Redis 承载正文与历史」否决项据此修订为否决永久承载。
  • 后端选择拆为 QIM_MESSAGE_STORE_BACKEND(writer/session,tiered 默认 / redis 仅开发 / scylla 仅对照)与 QIM_MAILBOX_STORE_BACKEND(mailbox,redis 默认 / scylla);旧全局 QIM_STORE_BACKEND 出现即拒绝启动。acceptance.sh 完整门禁认证 hybrid(tiered + Redis 邮箱)。
  • 提交辅助状态(commit hash)在进入 COMMITTED 时设 client_dedup_ttl_seconds(2 h)绝对过期, 由 Redis 自身回收;此前依赖每分钟 256 条的后台回收,持续写入超过约 4 条/秒即无界累积。
  • 当前 mailbox_seq 来自 dispatch partition offset,W 是持久 64-lane 连续覆盖状态; W = min(在途)-1 与登记/销账只属于旧直写模型。当前实现对每条 record 等全部 lane/chunk/entry durable 后把 64 lane 一起推进;§6.5.1 的跨 lane 独立推进尚未落地, 用例 26.3.6/M-1 当前应失败,不得宣称已具备 lane 可见性隔离。
  • 一期会话投影由 qsession 独立消费 dispatch 并持久 per-partition checkpoint;§12.3 中 MailboxNode mailbox-tail 投影压缩器、per-user projection_mailbox_seq 与同批 UserBadgeState 是后续目标形态,不得描述成当前实现。
  • 群成员可见区间与会话集合权威(ADR-0021,2026-09-26 实现):GroupMembership 全部键随群会话的 提交桶 {bNNN} 落在 MessageStore 同一实例;群发送在分配 conversation_seq 的同一 Lua 内校验冻结的 membership_version(不符即重冻结重试,不消耗序号),效果等价于 §10.1 第 3 步的“分配之后冻结”。 入群(管理面 GroupAdd)、退群(LEAVE_GROUP,附录 A.3)、移除(管理面 GroupRemove)各提交一条 CONTROL 消息并在同一原子区改写成员集合、版本快照与区间 (joined_at, left_at)(§7.5 visible(u,c)); 历史翻页、定义式未读与会话头都按区间裁剪,退群者仍可读退群点之前的历史。§7.5 UserConversationState 一期只实现会话集合子集(ScyllaDB 表 user_conversation_state,会话首次 进入集合时登记一次),成员状态与区间的权威仍是 GroupMembership 区间、已读水位仍是 read:{user}; qsession 的 checkpoint 越过 dispatch 保留窗口或投影整体丢失时递增投影纪元、按用户惰性从该表重建 会话集合,不再失败闭合。已知缺口:越窗期间首次出现且之后无消息的会话无法恢复(ADR-0021)。
  • GroupDispatch.fencing_epoch(§19.2)在一期形态不适用(ADR-0020):writer 不持有序号窗口, conversation_seq 由 Redis Lua 原子分配,旧 owner 复用序号的脑裂路径不存在;一旦引入本地序号 预留窗口或会话写入租约,必须先实现 fencing_epoch 与两个校验点。
  • 实测状态:开发机(14C/28T、62 GB、同机 Docker)180 秒 + T-HEAVY 门禁在 10,000 msg/s 连续 3/3、15,000 msg/s 连续 3/3、分层形态 10,000 msg/s 通过(详见 docs/designs/arch_20260901_current_log_driven_summary.md §6);目标硬件门禁、混沌门禁 与 orphan 审计(acceptance.sh 门禁 9,按设计失败闭合)均未完成,不得宣称可发布。
  • 提交事实的持久化界限(ADR-0018,2026-09-26):writer 连接的 Redis(默认 core 与全部 MessageStore 分片)必须 appendfsync always 且 no-appendfsync-on-rewrite no,启动探测不满足即拒绝启动;否则宿主 崩溃丢约 1 秒会让 conversation_seq/HLC 回退、复用已归档序号并静默覆盖归档。邮箱实例保持 everysec (由 dispatch 日志重放兜底)。生产要求带掉电保护的服务器 SSD;本机消费级盘上 always 使 ACK 超 SLO。 仍待办:升级前已在 Redis、不在 Outbox 保留期内的历史需一次性回填 ScyllaDB 后才能裁剪。
  • 历史保留期(ADR-0019):default 保留 30 天(default_history_retention_days,可配),到期时刻 = created_at + 保留期,Redis 热层到期 GC 与 ScyllaDB 逐行 TTL 同一策略;compliance_hold 永不自动到期, tenant_custom 在租户策略来源实现前失败闭合为不到期。一期不做对象存储冷归档与归档回读。
  • orphan 独立审计已实现:qimctl audit dispatch 先等提交恢复索引排空、fanout 追平 Outbox 高水位, 再以 Outbox(提交事实)推导应有的 (message_id, target_shard),与 dispatch 日志(展开回执)逐条比对; 有提交无 dispatch、有 dispatch 无提交、坐标/发送者不一致、分区错位均须为 0(§19.1 边界 2)。 acceptance.sh 门禁 9 由无条件失败改为执行该审计。当前为门禁时的全窗口审计;生产常驻、带持久 覆盖游标的增量审计尚未实现。

1. 文档目的

本文是 Q-IM 的总体架构基线,用于统一后续协议、服务、存储、部署和测试设计。 任何专题文档不得重新定义本文中的序列、游标、投递语义和服务边界;核心方案发生变化时, 应先修改本文或增加 ADR,再修改实现。

本版(v2)是 docs/REVIEW-001-plan-baseline.md 的闭环产物。评审报告列出 2 个阻断级缺陷、 61 个重大问题、19 个次要问题和 2 个开放决策,本文逐条给出方案,不保留"待评估"状态:

  • 阻断级 B-1(邮箱裁剪与"空洞可跳过"冲突导致静默丢消息)由 mailbox_trim_watermark 与 CURSOR_EXPIRED / REBUILD 闭环,见 §6.5.2、§9.3、§9.6。
  • 阻断级 B-2(在线目录缺失导致 PushBatch 字段推导不出来)由 §5.4 的 PresenceDirectory 闭环。
  • 命名与自洽性问题(F 组)由 §5.1 的实体与命名表闭环。

三处契约核心不得被任何专题文档重新定义:

契约核心 内容 允许专题文档做的事
§5.1 实体与命名表 系统中存在哪些实体、每个实体的唯一写法与禁止别名 补充实体内部模块划分
§6 标识、序列与游标 + §7 核心数据模型 五种序列的位级格式、所有持久结构的字段、分区键与聚簇键 补充编码细节、索引实现、表属性
附录 A 协议帧与错误码总表 + 附录 B 默认参数表 客户端可见的全部帧、错误码与全部默认数值 定义 opcode 字节值、帧布局、版本协商

改变上述三处必须先修改本文或新增 ADR,再修改实现。当前代码已进入实现阶段;目标契约与实现状态 必须分别陈述,禁止把“已设计”写成“已实现”。

2. 目标与成功标准

2.1 产品能力

  • 支持单聊、普通群、大群、聊天室。
  • 支持文本、表情、文件、图片、音视频元数据等持久消息。
  • 支持撤回、编辑、已读、输入状态、在线状态等控制消息。
  • 支持业务自定义消息,并明确其持久化、未读和排序语义(§13.3)。
  • 支持多设备登录、独立离线游标和用户级共享已读状态。
  • 支持 RTC 信令转发;媒体流本身不经过 IM 消息系统。信令帧为 RTC_SIGNAL(附录 A), RTC 的能力边界、与消息系统的耦合点见 §22。
  • 支持 SaaS 和私有化部署。端到端加密(E2EE)的适用范围与功能降级矩阵见 §22, 本文其余章节一律按"服务端可见类型级元数据、可能不可见正文"的前提设计。

2.2 容量基线

维度 目标 约束来源
平台同时在线连接数 千万级 容量一律以连接数计,不以人数计
规划人均在线设备数 d_online = 1.4(附录 B.7,全文统一取值) 千万连接 ≈ 714 万同时在线用户(10,000,000 / 1.4)
每用户设备数上限 max_devices_per_user = 8(附录 B) 超限踢最旧设备,见 §5.4
每用户会话数上限 max_conversations_per_user = 5000(附录 B) 见下方说明
普通群成员上限 10 万,保证所有成员获得持久邮箱引用 见下方说明
聊天室同时在线 100 万,实时广播 + 短期回放 room_msg_rate、room_outbound_frame_rate(附录 B)
单条自定义消息载荷 max_custom_payload_bytes = 32 KiB 附录 B
缩略图 media_thumbnail_max_bytes = 32 KiB 原文件只进对象存储

每用户会话数上限 5000:UserConversationState 与 UserSessionProjection 都以 (tenant_id, user_id) 为分区键(§7.0),会话列表排序发生在内存快照层并按需一次性载入全量分区 (§7.6)。没有这个上限,客服号、机器人号、通知号会把单分区撑到十万行以上, 导致快照载入 OOM 与分区级读放大。达到上限后禁止加入新会话,返回 ERROR{code=PERMISSION_DENIED, detail=conversation_quota_exhausted}; 需要更高会话数的业务形态必须走服务号/机器人开放平台路径,不使用普通用户形态。

普通群 10 万成员上限的前提:该群受 per_conversation_msg_rate 分档限制 (N > 10000 时为 2 msg/s,附录 B),且平台同时活跃的大群数受 tenant_fanout_quota 约束。 脱离这两条约束谈"10 万人群"没有意义——邮箱引用写入是 O(N)(§10.2), 它的峰值由"消息速率 × 成员数"决定,而不是由成员数单独决定。

一期口径(ADR-0007):本节全部容量基线描述的是目标档能力。 一期按简化形态交付,产品群规模上限为 1000 人(R_avg 约 22), MailboxStore 用 Redis 实现、MailboxNode 无主备、聊天室回放为进程内环形缓冲。 但全部不可变项(virtual_bucket_count = 65536、lane_count = 64、 message_seq_bucket_width = 4096、哈希族、分区键、契约核心)一律按目标档定死, 升级到 10 万人群档时只需按 §18.1.2 的灰度流程更换 MailboxStore 实现, 不改协议、不改客户端、不改游标。详见 ADR-0007。

  • 消息以文本和小型结构化数据为主;图片、视频只在消息中携带缩略图和对象引用。
  • 固定用户节点造成的一定负载不均衡可以接受,优先换取路由确定性和本地缓存命中率。

2.3 核心成功标准

六条标准全部改写为可验收表述。判定方法在 §26 展开为具体用例。

# 成功标准 可验收判据
1 离线同步只读自己的队列 分片内存在 500 个事件、目标用户只有 3 条引用时,一次 PULL_MAILBOX 在存储层的读取键数 ≤ 3 + 1 次前缀定位;不得出现对其他 user_id 前缀的任何访问(按存储层键访问计数断言)
2 群消息正文只存一份 10 万人群的一条消息:MessageRecord 行数 = 1,ConversationHead 更新次数 = 1,UserMailboxEntry 条数 = 该分片本地成员数之和 = 10 万,且每条条目大小 ≤ entry_ondisk_bytes 实测值
3 中心分发复杂度只与目标分片数相关 目标 MailboxShard 数 S = 100 时,FanoutCoordinator 产出的 GroupDispatch 记录数 = 100(误差 0),跨服务调用次数 = O(S);任何 O(N) 的中心侧调用即判定失败
4 在线成员仍然实时收到 在线连接数 O 时,最终 Socket 写入次数 = O;PushBatch 条数 = 命中的 ConnectionShard 数(默认拓扑下每个 MailboxShard ≤ 4,见 §5.2);端到端时延满足 §2.4
5 会话列表不同步写全体成员 同一群连续 1000 条消息,单个成员的持久投影写入次数 ≤ ceil(消息时间跨度 / projection_compaction_window) + 1(+1 为消息流结束后收尾 flush),且与群成员数无关
6 故障、重复投递、断连不丢消息 注入分片崩溃、重复消费、连接抖动后,邮箱条目集合按 (user_id, mailbox_seq, event_ordinal, event_id) 幂等收敛;mailbox_seq_regression_count = 0;长期离线设备登录不得出现 has_more=false 的静默成功(B-1 回归项)

2.4 SLO 与体验指标

v1 全文没有任何 SLO,导致 §26 的验收项大多不可判定。本节给出目标值。

立场声明:SLO 是产品目标,与 §25「容量常数必须实测」不冲突。 容量测试的目的正是求解"在满足本表 SLO 的前提下,单节点/单分片能承载多少负载", 因此 SLO 必须先定,容量常数后测。本表数值为 v2 新增默认值(附录 B 只收工程参数,不收 SLO), 租户合同可上调,不得下调到低于 P1 档。

延迟测量口径统一使用 §11.2 的六层到达层级命名 (COMMITTED / MAILBOXED / PUSHED / APPLIED / READ / NOTIFIED), 禁止用"已送达""已收到"等模糊表述。

分级 指标 目标值 测量点
P0 SEND_ACK 往返时延(同区) P99 ≤ 150 ms 客户端发出 SEND_MESSAGE → 收到 SEND_ACK
P0 在线端到端投递时延(同区) P99 ≤ 300 ms 发送者 SEND_MESSAGE 到达 ConnectionNode → 接收者连接 PUSHED
P0 在线端到端投递时延(跨区) P99 ≤ 800 ms 同上,发送者与接收者 home region 不同
P0 登录到 ONLINE_READY(无积压) P99 ≤ 2 s 客户端发出 AUTH → 收到 ONLINE_READY
P0 登录到 ONLINE_READY(1 万条积压) P99 ≤ 10 s 同上,pending_entry_count_hint ≈ 10000
P0 会话列表首屏 P99 ≤ 500 ms 客户端发出 PULL_SESSION_LIST → 第一页 SESSION_LIST_BATCH 可渲染
P0 消息最终到达率 ≥ 99.99% 24 小时窗口内 MAILBOXED 条目最终达到 APPLIED 的比例(对账任务)
P0 月度可用性 99.95% 可用 = AUTH 成功率 ≥ 99.9% 且 SEND_ACK P99 达标,按分钟判定
P0 mailbox_seq 回退 恒为 0(硬指标,非统计量) §24 的 mailbox_seq_regression_count
P1 离线推送触达延迟 P95 ≤ 5 s MAILBOXED → NOTIFIED(APNs/FCM 受理成功)
P1 心跳误断连率 < 0.1% (非客户端主动、非网络变更导致的断连数)/ 总连接数,按天
P1 会话投影滞后 P99 ≤ projection_lag_target_p99(5 min,附录 B) materialized_watermark[lane] - min(projection_mailbox_seq),按 {shard,lane} 分组取最差值(§24.1.9)
P1 历史分页 P99 ≤ 400 ms(新增默认值) PULL_HISTORY → HISTORY_BATCH,limit = 50

本表是延迟与可用性目标的唯一规范:§24 的告警目标值不得宽于本表;两者冲突时以本表为准, 且 §24 必须与本表使用同一测量点与同一分位数。§26 的验收用例同样以本表为准(§26.0)。

SLO 与降级的关系:当 §11.3 的慢连接降级、§8 的准入限流触发时,被降级请求 不计入可用性分母之外——即降级也是 SLO 违约,必须计入。否则限流会变成隐藏故障的手段。

3. 明确不采用的方案

# 禁令 原因 正确做法
1 不让用户扫描 MailboxShard 分发日志 的 10001...10500 区间来筛选自己的三条消息 分发日志是 MailboxNode 的可重放输入,不是客户端可见结构 客户端只读按 (tenant_id, user_id) 前缀组织的个人索引(§6.5、§9.1)
2 不为每个会话维护一套设备离线拉取游标 游标数正比于会话数,登录成本 O(会话数) 每设备一条 MailboxCursor(§6.8)
3 不在登录时逐个会话拉取离线消息或重新扫描全部历史 同上;且历史与离线是两种数据 AUTH_OK + PULL_MAILBOX 批量拉个人邮箱(§9.3)。见下方澄清
4 不为超出 inline_body_budget_bytes 的会话复制完整消息正文 N 份正文、N 次编码 正文单存 + 邮箱轻量引用(§10)。见下方澄清
5 不在发送链路中逐条同步更新所有成员的持久会话记录 一条群消息写 10 万行会话记录 ConversationHead + 异步可重建投影(§12)
6 不使用 HTTP 作为客户端实时通信和离线同步主链路 轮询延迟与连接开销不可接受 TCP/TLS 自定义协议,浏览器用 WSS 承载同一协议(§5.3)
7 不把图片、视频等原始大文件写入消息正文存储 撑爆消息分区、破坏范围查询 对象存储 + media_metadata 引用(§7.1)
8 不承诺端到端 exactly-once 分布式系统中不可实现 至少一次投递 + message_id / event_id 幂等去重
9 不让 Redis 成为每次路由、推送和心跳的必经路径 单点化、放大故障域 稳定虚拟桶算路由 + PresenceDirectory 订阅式本地缓存(§5.4)
10 不使用物理节点数量直接取模分配用户 扩缩容触发大规模迁移 稳定虚拟桶 + 版本化映射表(§5.2)
11 不使用客户端时间戳作为增量同步下界(新增) 晚到消息会被永久跳过且不可检测 见 §6.10.2;时间戳只用于展示与"跳转到某天"
12 不使用分片级水位与个人游标直接比较(新增) 个人队列稀疏,比较必然恒真,造成全网每心跳一次空拉 见 §6.10.1;判据是 last_pushed_user_seq 与 mailbox_dirty

对第 3 条的澄清(v1 措辞误杀了中间档):

禁止的是:登录时按会话列表逐个会话发起离线拉取(成本 O(用户会话数),且随会话数线性增长)。

不禁止:大群的读扩散降级档(§10.2 的 mailbox_write_policy = mention_only)在登录时
        一次性返回 N 个大群的会话头。该会话头由已有的 SESSION_LIST_BATCH.sessions[] 承载
        (PULL_SESSION_LIST 是 mention_only 生效时新增的强制登录步骤,
        多一个批量 RTT,计入 §2.4 首屏 SLO,见 §9.3.1;不扩展 AUTH_OK)。
        成本是 O(用户所属大群数),通常 < 20,与会话总数无关,
        且是一次批量响应而不是 N 轮请求-响应。

判定标准(写进实现评审):
  成本随"会话数"增长        → 禁止
  成本随"大群数"增长且单批返回 → 允许

v1 把这两种情况合并禁止,等于在"10 万人群全员写扩散"与"100 万聊天室无持久语义"之间 删掉了唯一的中间档。本版恢复该档位,默认不启用,启用条件与最小改动集见 §10.2。

对第 4 条的澄清(同一类过宽措辞的第二次出现):

禁止的是:为大会话复制正文(10 万人群 = 10 万份正文,这是 §10.2 成本模型的红线)。

不禁止:小会话的正文内联(ADR-0005)。判据是**乘积字节预算**而非成员人数:
        inline_body = (N × body_size <= inline_body_budget_bytes)   默认 8 KiB
                      AND (body_size <= inline_body_max_bytes)       默认 2 KiB
                      AND (retention_class == default)

理由:read-time join 的成本是 O(批次内不同 conversation 数),不是 O(条目数)。
      1 个大群的 500 条积压 = 1 次范围读;20 个单聊各 5 条 = 20 次范围读。
      大群承受不起内联,小会话承受不起 join —— 两边被各自的成本逼向相反选择。

判定标准(写进实现评审):
  N × body_size 超预算  → 禁止内联,走读时 join(§18.3.1)
  N × body_size 在预算内 → 允许内联

判定由 ConversationWriter 在提交时完成一次并写入 GroupDispatch.inline_body(§7.8), 禁止各 MailboxNode 独立判定——否则同一条消息在不同分片的内联结果不一致, 破坏 §6.7 的确定性物化与 §10.4 的重放同值覆盖。完整取舍与复评条件见 docs/adr/0005-small-conversation-body-inline.md。

4. 总体架构

                        客户端(iOS / Android / 桌面 / 浏览器)
                                        │
              TLS 1.3 + ALPN qim/1;浏览器用 WSS/443 承载完全相同的应用协议
                                        │
                                        ▼
      ┌──────────────────────────────────────────────────────────────┐
      │ ConnectionNode                持有 ConnectionShard 租约        │
      │  帧编解码 · 认证 · 心跳 · 流控 · Socket 写入                    │
      │  PresenceEntry 的唯一写入方                                    │
      └──────────────────────────────────────────────────────────────┘
            │ SEND_MESSAGE                          ▲ PushBatch(内部帧)
            ▼                                       │
      ┌────────────────────────┐   ①写入   ┌────────────────────────┐
      │ ConversationWriter     │─────────▶ │ MessageStore           │
      │ 分配 message_id /      │           │ MessageRecord 单份正文  │
      │ conversation_seq /     │           │ 与历史分页              │
      │ last_activity_id       │           └────────────────────────┘
      └────────────────────────┘                        ▲
            │ 可靠 Outbox                                │ ②读正文
            ▼                                            │
      ╔════════════════════════╗                         │
      ║ CommitLog(Outbox)     ║                         │
      ╚════════════════════════╝                         │
            │                                            │
            ▼                                            │
      ┌────────────────────────┐                         │
      │ FanoutCoordinator      │                         │
      │ 按 MailboxShard 合并   │                         │
      └────────────────────────┘                         │
            │ 每个目标分片恰好一条 GroupDispatch          │
            ▼                                            │
      ╔════════════════════════╗                         │
      ║ CommitLog(分发日志)   ║ mailbox_seq =           │
      ║ 与逻辑分片 1:1 固定映射 ║ (shard_epoch, offset)   │
      ╚════════════════════════╝                         │
            │                                            │
            ▼                                            │
      ┌─────────────────────────────────────────────────┴──────────┐
      │ MailboxNode              持有 MailboxShard 租约(单归属)   │
      │  UserMailboxEntry 个人队列(MailboxStore)                   │
      │  materialized_watermark[lane_count] · mailbox_trim_watermark │
      │  成员 Bitmap · 在线用户 Bitmap(过滤器)· 正文 LRU            │
      │  [目标态阶段二] UserSessionProjection · UserBadgeState        │
      └──────────────────────────────────────────────────────────────┘
            │ 按 ConnectionShard 合并 PushBatch
            └──────────────────▶ ConnectionNode ──▶ 在线设备 Socket

图注:

  1. ① 是单向写:ConversationWriter 只写 MessageStore,不从 MessageStore 读取以完成提交路径。
  2. ② 是读正文,起点是 MailboxNode,不是 FanoutCoordinator。 MailboxNode 每个 GroupDispatch 只读一次正文,且仅在本地 LRU 未命中时读取 (§10.3)。v1 把这条读箭头画在 FanoutCoordinator 上,会误导实现者在中心层引入 与成员数无关但完全不必要的正文读取,并让中心层持有正文缓存。
  3. CommitLog 是基础设施(Redpanda/Kafka),不是独立服务,见 §18.1。 图中出现两次:一次承载 Outbox,一次承载 MailboxShard 分发日志;两者是同一套基础设施的不同 topic。
  4. PushBatch 与 PRESENCE_STALE 是内部帧,不对客户端暴露(附录 A.6)。

旁路与支撑通道:

GroupMembership ──membership_version + 分片成员 Bitmap──▶ FanoutCoordinator / MailboxNode
ShardRegistry   ──桶映射 / shard_epoch / 租约 / 边界表──▶ 所有有状态节点
AuthService     ──access_token / route_token / session_epoch──▶ ConnectionNode

ConnectionNode ──PresenceEntry──▶ ╔═══════════════════════════════════════════╗
                                  ║ PresenceDirectory                         ║
                                  ║ CommitLog compacted topic                 ║
                                  ║ key = (tenant_id, user_id, device_id)     ║
                                  ║ 分区键 = user_bucket(与 MailboxShard 同源)║
                                  ╚═══════════════════════════════════════════╝
                                       │ MailboxNode 只订阅覆盖本地分片的分区
                                       ▼
                                  MailboxNode 本地 presence 缓存(推送路径 0 次远程调用)
                                       ▲
                                       └── PRESENCE_STALE(ConnectionNode 反馈,失效缓存)

SessionProjection ──快照 / 分页 / 后台压缩──▶ ConnectionNode
    当前 qsession 独立消费 dispatch、独立持久 checkpoint(§17.2);仅阶段二 mailbox-tail
    目标形态才与 MailboxNode 同实例,并让投影与邮箱共用 MailboxStore/检查点(§12.3、§19.3)

RoomWriter ── room_seq / 短期 Room Log ──▶ ConnectionNode(只发给有该房间在线连接的 ConnectionShard)

MediaService / NotificationService / ModerationService 见 §17

4.1 关键原则

  1. 消息提交、收件人索引和 Socket 投递是三个独立阶段,各自有独立的失败域与重试语义。
  2. 消息正文只存一次,用户邮箱中插入指向正文的轻量引用。
  3. 用户固定到逻辑 MailboxShard 和 ConnectionShard,经稳定虚拟桶推导,不经远程查询(§5.2)。
  4. 所有可能重试的步骤都有稳定幂等键:client_message_id、message_id、dispatch_id、event_id。
  5. 在线推送失败不回滚邮箱;客户端通过邮箱游标恢复。
  6. 设备游标只能由 MAILBOX_BATCH 连续推进;实时推送(PUSH_EVENTS)不得越位推进游标。 否则实时推送越过尚未拉取的区间,会造成静默丢消息(§6.8)。
  7. 所有参与物化的输入必须确定性:event_id、event_ordinal、created_at、dispatch_id 一律由确定性哈希或 dispatch 记录内的字段导出。禁止本地墙钟、随机 UUID、 随机迭代顺序参与物化,否则主备 MailboxNode 消费同一日志会物化出不同索引(§6.7)。

5. 实体命名、节点固定与路由

5.1 实体与命名表

v1 用四种写法指同一实体(MessageNode / MailboxNode / Mailbox Node / Message/Mailbox 节点), 并与真实独立服务 Message Store 撞车,共 18 处。本表是全文一致性的基础,属契约核心。

唯一写法 中文 职责(一句话) 禁止别名
ConnectionNode 接入节点 持有 ConnectionShard 租约,负责帧编解码、认证、心跳、流控、Socket 写入,并且是 PresenceEntry 的唯一写入方 Connection Gateway、Gateway、接入网关(作实体名时)
MailboxNode 邮箱节点 持有 MailboxShard 租约,物化个人邮箱、推进 lane 水位、匹配在线成员并合并 PushBatch MessageNode、Mailbox Node、消息节点、Message/Mailbox 节点
ConversationWriter 会话写入者 单会话单写,分配 message_id / conversation_seq / last_activity_id,提交正文与 Outbox Conversation Writer、会话写服务
FanoutCoordinator 扇出协调器 把提交事件按目标 MailboxShard 合并为 GroupDispatch Fanout Coordinator、扩散服务
HistoryArchiver 历史归档器 独立消费 Outbox,把 canonical MessageRecord 批量归档到 ScyllaDB 并发布归档水位(ADR-0018) History Archiver、归档服务、Archiver Service
MessageStore 消息存储 MessageRecord 单份正文、历史分页与 MessageIndex Message Store、MessageNode、消息存储节点
GroupMembership 群成员服务 成员关系、membership_version、分片成员 Bitmap 与 MemberSlotMap Group Membership、成员服务
ShardRegistry 分片注册中心 虚拟桶映射、逻辑分片、租约、shard_epoch 与边界表 Shard Registry、路由中心
PresenceDirectory 在线目录 PresenceEntry 的发布与订阅通道(compacted topic),见 §5.4 Presence Service、在线位置表、Redis 在线表
SessionProjection 会话投影服务 会话列表内存快照、分页与后台投影压缩 Session Projection、会话服务
RoomWriter 聊天室写入者 分配 room_seq、写短期 Room Log、按 ConnectionShard 合并广播 Room Router、Chatroom Service、Room Writer
MediaService 媒体服务 上传下载授权、缩略图、对象生命周期 Media Service
NotificationService 离线推送服务 APNs / FCM 等系统通知,读 UserBadgeState 填角标,不自行聚合未读 Notification Service、推送服务(作实体名时)
ModerationService 治理与管理服务 审计、封禁、内容治理、租户策略 Moderation/Admin、Admin Service
AuthService 认证与会话服务 签发 access_token、路由令牌、游标签名密钥,分配 session_epoch(§5.4) Auth/Session、Auth Service
MailboxShard 逻辑邮箱分片 用户邮箱的固定归属单位,与分发日志分区 1:1 固定映射 Mailbox Shard、邮箱分区
ConnectionShard 逻辑连接分片 用户全部设备连接的固定归属单位 Connection Shard、连接分区
MailboxStore 邮箱存储抽象 AppendBatch / RangeScan / TruncateBefore / Watermark 四原语的实现载体(§18.1) Mailbox Store、Pebble 层、RocksDB 层
CommitLog 提交日志 基础设施(Redpanda/Kafka),承载 Outbox、分发日志与 PresenceDirectory 的 compacted topic Commit Log、Kafka 层(作实体名时)

后续所有文档只能使用本表写法。 提到 v1 的历史写法时一律用中文描述,不得在正文里复写英文别名。

CI 断言按三条规则执行,命中阻断集即失败(可直接放进流水线):

#!/usr/bin/env bash
# §5.1 禁止别名断言
# 规则 1:整词匹配(\b),避免 MessageNodePool、Message Storey 之类的部分匹配误报
# 规则 2:跳过 §5.1 两个 alias-table 标记所包围的区间(只有该区间需要逐字列出禁止别名,
#         其余任何位置命中即为回归)。标记按「整行精确相等」识别,
#         因此本脚本自身引用标记文本时不会误触发状态机
# 规则 3:白名单 Notification Service Extension(Apple 官方 API 名,不是别名违规)
set -euo pipefail

BLOCKING='\bMessageNode\b|\bMailbox Node\b|\bMessage Store\b|\bconnection_epoch\b|\bModeration/Admin|\bNotification Service\b'
WARNING='\bConnection Gateway\b|\bConversation Writer\b|\bFanout Coordinator\b|\bGroup Membership\b|\bShard Registry\b|\bSession Projection\b|\bRoom Writer\b|\bMedia Service\b|\bCommit Log\b|\bMailbox Store\b'

BEGIN_MARK='<!-- alias-table:begin -->'
END_MARK='<!-- alias-table:end -->'

scan() {                       # 输出「文件:行号:已做白名单脱敏的行」
  find docs -name '*.md' -print0 |
  xargs -0 awk -v b="$BEGIN_MARK" -v e="$END_MARK" '
      FNR == 1  { skip = 0 }
      $0 == b   { skip = 1; next }
      $0 == e   { skip = 0; next }
      skip      { next }
      {
        line = $0
        gsub(/Notification Service Extension/, "<apple-api>", line)
        printf "%s:%d:%s\n", FILENAME, FNR, line
      }
  '
}

hits=$(scan | grep -E "$BLOCKING" || true)
warn=$(scan | grep -E "$WARNING" || true)

if [ -n "$warn" ]; then
  echo "别名警告(英文技术文献常见词,人工确认是否作实体名使用):"; echo "$warn"
fi
if [ -n "$hits" ]; then
  echo "禁止别名回归(§5.1):"; echo "$hits"; exit 1
fi

WARNING 组只告警不阻断:这些写法在引用英文技术文献时会自然出现,硬断言会产生大量误报; 它们作实体名使用时仍然是禁止别名,由文档评审把关。

connection_epoch(v1 §5.3 出现 1 次)与 session_epoch(7 次)是同一事物,统一为 session_epoch, 其权威定义见 §5.4。

5.2 稳定虚拟桶

避免直接使用 hash(user_id) % physical_node_count,防止扩缩容时大规模迁移。推导链:

user_bucket = be_u64(blake3(tenant_id || 0x00 || user_id)[0:8]) & (virtual_bucket_count - 1)

user_bucket ─▶ mailbox_shard_id     ─▶ primary/standby MailboxNode
            ─▶ connection_shard_id  ─▶ active ConnectionNode
            ─▶ PresenceDirectory 分区(同源,见 §5.4)

初始映射(算术掩码,无需查表):
    mailbox_shard_id    = user_bucket & (mailbox_shard_count - 1)
    connection_shard_id = user_bucket & (connection_shard_count - 1)
分裂之后(§5.5):由 ShardRegistry 的版本化映射表覆盖算术结果,映射表随 shard_epoch 版本化。

lane_id 不在本链条内:它由 §6.5.1 的独立哈希导出,与 user_bucket 及任何分片数完全解耦。

哈希函数固定为 blake3 取前 8 字节大端(算法条目见附录 B.1)。 与 §6.7 的 event_id、§6.5.1 的 lane_id 使用同一哈希族,减少客户端与服务端的算法依赖。

lane_id 与桶映射解耦:lane_id = blake3(tenant_id, user_id)[0] & (lane_count - 1)(§6.5.1), 既不由 user_bucket 推导,也不含任何分片数作为移位量或模数,因此在虚拟桶映射不变、 分片分裂、跨集群灾备下永久稳定。本节的桶映射只决定分片归属,不决定 lane 归属。

默认值与约束关系(全部取自附录 B):

参数 默认值 约束
virtual_bucket_count 65536 2 的幂;建集群后不可变
mailbox_shard_count 256(起步档 64) 2 的幂
connection_shard_count 1024 2 的幂
lane_count 64 2 的幂
硬约束 1:mailbox_shard_count <= virtual_bucket_count(掩码映射的自然上界)。
          lane_count 不参与该上界——lane_id 由独立哈希导出(§6.5.1),
          与 mailbox_shard_count 无乘积关系。
推论 1:分裂可达上界 = min( virtual_bucket_count, connection_shard_count )(后者来自硬约束 2)。
        默认参数下 = min(65536, 1024) = 1024,即分裂最多把 mailbox_shard_count 从 256 提升到 1024。
        要继续提升必须先提升 connection_shard_count;virtual_bucket_count 永不改变。

硬约束 2(本版新增):connection_shard_count 必须是 mailbox_shard_count 的整数倍。
推论 2:同一 MailboxShard 的用户恒定落在
        connection_shard_count / mailbox_shard_count = 4 个 ConnectionShard 上。
        因此一次 GroupDispatch 在本分片展开后,PushBatch 的目标 ConnectionShard 数 <= 4,
        与在线人数无关。这是 §2.3 第 4 条与 §10.3 批量合并的容量前提。

ConnectionShard 按用户(而非按设备)派生。v1 写的是 tenant_id + user_id + device_id -> connection_bucket,v2 改为直接复用 user_bucket,理由:

理由 说明
设备数上限可本地执行 max_devices_per_user = 8 与"同 device_id 才替换"需要单一权威点;按设备分片时同一用户的设备分散在多个节点,互踢需要分布式协调
单聊推送 RPC 数从 O(设备数) 降到 1 一个接收用户的全部设备在同一 ConnectionShard,一条 PushBatch 覆盖全部设备
大群 PushBatch 目标数恒定 见推论 2
region 亲和以用户为单位 §5.3 要求 home region 决定 ConnectionShard 归属,设备维度无法表达

代价:单用户的全部设备与单个 ConnectionNode 共命运。该风险由 §5.3 的 N+2 接管候选与 takeover_admit_rate 分批放行控制,不由分片粒度控制。

为什么桶数建集群后不可变:

user_bucket 被三处结构固化:
  1. MailboxCursor 签名令牌中的 mailbox_shard_id 由桶推导(§6.8;同令牌中的 lane_id
     取自独立哈希,不受桶数影响)
  2. PresenceDirectory 的分区键(§5.4)
  3. GroupMembershipVersion 的分片成员 Bitmap 按 mailbox_shard 预分片(§7.9)

改变 virtual_bucket_count 会同时改变以上三者的归属,等于一次性作废全部游标令牌、
全部 presence 分区订阅关系和全部成员 Bitmap 分片。这不是"重分片",是"重建集群"。
因此:逻辑分片数可以通过分裂增加(§5.5),virtual_bucket_count 永不改变。

物理节点故障只变更分片租约和 shard_epoch,不改变用户逻辑归属。 真正的重分片必须经过 §5.5 的双写、游标换发与切流流程,不能静默修改映射表。

5.3 接入、重定向与就近接入

5.3.1 四层与七层的分工(修正 v1 的自相矛盾)

v1 同时声称"用户固定到 ConnectionShard"和"L4 可用源地址做一致性分发"。 这两条不可能同时成立:源地址哈希与 user_bucket 无任何关系,几乎必然落错分片。v2 写死:

L4 负载均衡:只做四层直通,PROXY protocol v2 透传真实源地址与端口。
             分发键 = 最小连接数(least-conn),不做任何一致性哈希。
             禁止把物理 IP 固化到客户端配置。

ConnectionShard 亲和:完全由应用层达成,两个手段:
             1. 签名路由令牌 route_token(AuthService 签发)
             2. AUTH_OK.preferred_endpoint(下次连接的首选入口)

5.3.2 重定向与握手前协商

单次连接最多重定向 1 次        redirect_max_per_connection = 1(附录 B)
路由令牌 TTL <= 60 s 且单次使用  route_token_ttl = 60 s(附录 B)
超过一次仍未落到正确分片        直接 ERROR{code=SHARD_MOVED, retry_after_ms},由客户端退避重连

重定向信息应尽量在 AUTH 之前完成协商,避免"握手完再断"浪费一次 TLS 握手:

优先:SNI 承载桶提示     b<hex4>.im.<domain>      hex4 = user_bucket 的十六进制
      L7 前置在 TLS 握手阶段即可选中正确 region 与 ConnectionShard 的后端
      ALPN 只承载协议标识 qim/1 / http/1.1,不承载可变数据

回退:不支持 SNI 改写或首次登录无桶提示时,连接到任意入口后由服务端返回 REDIRECT
      客户端必须把 AUTH_OK.preferred_endpoint 与桶提示持久化,使稳态下重定向率趋近 0

user_bucket 是 (tenant_id, user_id) 的哈希,放进 SNI 不泄露用户身份,但仍属可观测元数据; 私有化部署可关闭该优化,退回"连上再重定向"。

5.3.3 分片到节点的映射必须按 region 分组

ConnectionShard 物理归属 = ( home_region(user), connection_shard_id )
MailboxShard   物理归属 = ( home_region(user), mailbox_shard_id )

每个 region 各自运行一套完整的 shard -> node 映射表;用户的 home region 决定用哪一套。

否则固定分片会直接破坏就近接入:一个中国用户的 user_bucket 可能落到欧洲的分片实例上, 每条消息多一次跨洋往返。

两个 region 概念必须区分,禁止混用:

概念 决定什么 定义位置
用户 home_region 该用户的 MailboxShard 与 ConnectionShard 在哪个 region 本节
会话 Home Region 该会话的 conversation_seq 由哪个 region 的 ConversationWriter 分配 §19.1

两者可以不同:跨国群的会话 Home Region 只有一个,但成员邮箱分布在各自 home region。 变更用户 home_region 等价于一次跨 region 重分片,走 §5.5 流程。

5.3.4 接管治理与重连风暴抑制

每个 ConnectionShard 配置 N+2 个接管候选(N 为当前活跃副本数,默认 N=1,即 1 主 + 2 候选)

接管流程:
  1. ShardRegistry 检测租约超时(shard_lease_ttl = 15 s)
  2. 按候选优先级授予新租约,递增分片租约代号
  3. 新节点按 takeover_admit_rate = 5 %/s 分批放行客户端认证
  4. 超额连接返回 ERROR{code=RATE_LIMITED, retry_after_ms}
  5. 放行的连接在 AUTH_OK 中携带 sync_delay_hint_ms(0 ~ 30000 随机),
     让重连用户错峰发起 PULL_MAILBOX,避免接管瞬间的拉取尖峰
  6. AUTH 之前的连接受 unauth_connection_timeout = 10 s 限制(slowloris 防护)

takeover_admit_rate 与 sync_delay_hint_ms 是一对:前者限制认证速率,后者削平拉取尖峰。 只做前者会把尖峰推迟到同一时刻爆发。

5.3.5 接入协议决策矩阵

维度 TLS over TCP + ALPN WebSocket over TLS 443 QUIC / WebTransport gRPC 双向流 MQTT over TLS
中间设备穿透 好(443 + 标准 TLS 外观) 最好(与浏览器流量同形) 中(UDP/443 在部分企业网与运营商被限速或封禁) 中(HTTP/2 over TLS) 好(可跑 443,但特征易被识别)
企业代理 差(显式 HTTP 代理环境不可直连) 好(支持 CONNECT 与 HTTP Upgrade) 差(代理普遍不转发 UDP) 中 中
切网迁移 无(五元组变更即断,靠重连 + 游标) 无 好(Connection ID 迁移) 无 无
握手 RTT TLS 1.3 1-RTT TLS 1.3 1-RTT + HTTP Upgrade 1-RTT 1-RTT,复用可 0-RTT TLS + H2 设置帧,约 2-RTT TLS + CONNECT
帧头开销 20 B 自定义头(附录 A.1) WS 帧头 2~14 B + 20 B 自定义头 低(流层复用) H2 帧头 + gRPC 长度前缀 + HPACK 状态 最小 2 B
四端实现成本 低(iOS/Android/桌面均有成熟 TLS 栈) 低(浏览器唯一可行选项) 中高(库成熟度、移动端功耗、UDP 兜底路径) 高(浏览器无双向流,需 grpc-web 代理) 中(浏览器需 MQTT over WS)
LB 与可观测性 好(L4 直通 + PROXY v2,指标自建) 好(L7 可见 Host/Path,接入既有网关体系) 中(需 UDP LB 与 CID 感知) 中(需 L7 LB,流控由 H2 决定不可控) 差(Broker 语义绑定,主题模型无法表达邮箱游标)

结论(保持 CLAUDE.md 已锁定的决策):主链路 TLS over TCP + ALPN, 浏览器用 WebSocket over TLS 443 承载完全相同的应用协议。工程约束:

1. 端口固定 443;TLS 1.3 ALPN 协商 qim/1(自定义帧)与 http/1.1(WSS 升级)
2. 强制回落顺序,每级超时 `transport_fallback_step_timeout` = 5 s(附录 B.4):
       ALPN qim/1  →  WSS 443  →  经系统代理 CONNECT 的 WSS 443
3. 客户端按 (网络类型, 运营商 MCC/MNC 或 WiFi BSSID 哈希) 缓存上次成功方式,
   TTL `transport_choice_cache_ttl` = 7 天(附录 B.4)。
   命中缓存时跳过前面失败的档位,直接使用上次成功方式。
4. Transport{ open, sendFrame, onFrame, close } 抽象层,
   TCP 与 WebSocket 两条路径强制复用同一 codec 实现,禁止各自演化帧方言。
   CI 断言:codec 单元测试对两条路径跑同一组用例向量。

拒绝理由(写进文档,避免后续反复重开):

  • gRPC 双向流:浏览器需 grpc-web 代理且不支持真正的双向流;帧头与流控由 HTTP/2 决定, 无法按 §11.3 的软/硬水位与帧优先级自行调度;一个大 MAILBOX_BATCH 会被 H2 流控与 HPACK 状态机放大延迟。
  • MQTT:QoS 0/1/2 语义与本文「至少一次投递 + mailbox_seq 幂等」重复且冲突—— 两套重传与去重机制叠加会互相掩盖故障;主题模型无法表达"个人邮箱游标 + 连续物化水位", 订阅关系也无法承载 10 万成员的分片展开。
  • QUIC / WebTransport:切网迁移的收益真实,但与"固定 ConnectionShard + 应用层游标恢复" 的收益重叠(本设计断连后靠游标补齐,不依赖连接存活)。列为二期, 决策与迁移路径落 docs/adr/0002-client-transport.md。

5.3.6 TLS 终止、mTLS、证书 pinning 与 0-RTT

项 默认值 理由 状态
TLS 终止位置 终止在 ConnectionNode 保留 ALPN 快路径与真实源 IP;L4 走四层直通 + PROXY protocol v2 已定
mTLS(客户端证书) 默认不启用 移动端证书分发与轮换成本高于收益;身份由 access_token 承担 已定,私有化租户可开启
证书 pinning 默认启用双 pin,且可热更新 双 pin(当前 + 备用)避免续期导致全量客户端不可用;pin 集合随配置通道下发,不硬编码进包 已定
TLS 0-RTT 默认关闭 0-RTT 数据可被重放,与 SEND_MESSAGE 的幂等窗口叠加会放大重复;若开启,白名单仅限 PING 与纯读的恢复帧 已定

5.3.7 分片归属的唯一权威与消费指派

MailboxShard 的归属同时被两条路径依赖,二者必须来自同一个权威:

写路径:谁消费该分片的分发日志、物化邮箱条目、推进 W[lane]
读路径:客户端的 PULL_MAILBOX / AUTH 该发给哪个节点

唯一权威是 ShardRegistry:

ShardRegistry
    mailbox_shard_id -> { owner_node, shard_epoch, lease_expire_at }
         │
         ├──▶ ConnectionNode 订阅式本地缓存(读路径解析落点)
         │      不是每次点查;缓存失效走 SHARD_MOVED + REDIRECT 收敛
         │
         └──▶ MailboxNode 先取得分片租约,再消费对应日志分区

日志消费必须使用静态指派,禁止自动 rebalance:

必须:assign(partitions)        分区集合由 ShardRegistry 授予的租约推导
禁止:subscribe(topic)          消费者组自动 rebalance

理由是双权威会分裂归属,并直接导致丢消息:

消费者组认为 节点A 拥有分区 7(rebalance 结果)
ShardRegistry 认为 节点B 服务分片 7 的读(租约结果)
  → B 回答 PULL_MAILBOX 时用的是自己尚未追平的 W[lane]
  → §9.3 规则 3 把真实存在的条目判为"可安全跳过的空洞"
  → 静默丢消息

消费者组的自动 rebalance 无法被 ConnectionNode 观测,也无法与 shard_epoch 递增、 租约 fencing、§10.4.2 的水位追平判定对齐——它是一套平行的、不可见的成员机制。 用 assign() 把分区归属降级为租约的纯函数,系统里就只剩一个成员真相。

这条同样适用于 ConnectionShard(§5.3.4 的接管流程本就由 ShardRegistry 驱动), 以及 §5.5 分裂期间的双写窗口——分裂产生的新分区同样按新租约 assign(),不走 rebalance。

与 §3 禁令 9 的关系:读路径解析走的是 ConnectionNode 的订阅式本地缓存, 稳态零远程点查,与 §5.4 的 PresenceDirectory 同构,因此不构成"必经路径上的远程目录"。

5.4 在线目录 PresenceDirectory 与发布通道

5.4.1 问题陈述

v1 §5.3 声称"按固定映射算 ConnectionShard,不逐条查询远程目录",但 §11.1 的 PushBatch 需要 per-device 的 connection_id 与 session_epoch:

稳定哈希能算出的:  connection_shard_id
稳定哈希算不出的:  该用户此刻是否在线、有几个设备在线、
                    每个设备的 connection_id 与 session_epoch
"在线用户 Bitmap": 用户级位图,承载不了 per-device 明细

因此投递链路最后一跳在 v1 中无法实现。不能同时声称"PushBatch 里有 connection_id"和"不查目录"。 v2 选择保留 connection_id,并用订阅式本地缓存消除同步远程调用。

5.4.2 权威写入方与发布通道

PresenceEntry 结构见 §7.10。

唯一写入方 = 持有该 ConnectionShard 租约的 ConnectionNode
             在 AUTH_OK 之后写入,按 presence_lease_renew 续期,
             超过 presence_lease_ttl 未续期即失效(数值见附录 B.4)
             无其他组件可写。ConnectionNode 失去分片租约即停止续期,条目自然过期。

发布通道 = CommitLog compacted topic
             key       = (tenant_id, user_id, device_id)
             分区键     = user_bucket(与 MailboxShard 同源)
             墓碑       = 下线时写 null value

订阅方 = MailboxNode,只订阅覆盖本地 MailboxShard 的分区
         → 推送路径 0 次同步远程调用
         → 因此不违反 §3 第 9 条「Redis 不在必经路径」:本设计根本没有必经的远程点查

分区键必须与 MailboxShard 同源,这是整个方案成立的关键:只有这样, 一个 MailboxNode 才能只订阅它本地用户所在的分区,而不是全量 presence 流。

量级估算:

千万连接、平均在线时长 30 分钟
  上线事件 = 10^7 / 1800 ≈ 5556 /s
  下线事件 ≈ 5556 /s
  合计 ≈ 1.1 万 events/s
compacted topic 的稳态存量 = 在线连接数量级(千万条 key),单条 < 200 B
相对于消息主链路(百万级 entry/s)可忽略。

5.4.3 收敛机制(不能只定义缓存,必须定义纠错)

缓存必然滞后,因此必须定义"用错了怎么办":

1. 反向纠错(即时)
   ConnectionNode 收到 PushBatch 中某个接收者的
       session_epoch 不匹配,或 connection_id 在本地不存在
   → 丢弃该接收者(不影响同批次其他接收者)
   → 回内部帧 PRESENCE_STALE{tenant_id, user_id, device_id, observed_epoch}
   → MailboxNode 收到后失效本地缓存条目,并按需从 compacted topic 回源

2. 周期对账(兜底)
   每个 MailboxNode 每 60 秒对本分片做一次全量对账:
       比对本地 presence 缓存与 compacted topic 的最新快照
       差异计入指标 presence_reconcile_diff(§24),持续非零即告警

3. 过期驱逐
   lease_expire_at 到期的条目一律视为离线,不再进入 PushBatch,
   其消息只留在邮箱中等待客户端拉取(这正是 §4.1 第 5 条的兜底)

在线用户 Bitmap 降级为快速过滤器:

语义 = "该用户至少有一个设备在线",用于与成员 Bitmap 求交,快速裁掉全离线成员
槽位 = 必须与 §7.9 的 MemberSlotMap 使用同一套槽位映射,否则求交结果无意义
per-device 明细 = 一律取自 presence 缓存,不由 Bitmap 承载
误判处理 = 过滤器允许假阳性(用户刚下线),结果由收敛机制 1 纠正;
           不允许假阴性(漏推),因此过滤器只在收到下线事件后清位,不做超时推测清位

5.4.4 session_epoch 的权威定义

分配方   = AuthService,在认证成功时分配
维度     = (tenant_id, user_id, device_id),单调递增并持久化
校验方   = ConnectionNode 只校验、不生成
使用点   = PushBatch.recipients[].session_epoch、PresenceEntry.session_epoch

多设备与替换规则:

同一 device_id 再次登录  → 视为替换:旧连接必须收到 KICKED{reason=replaced},
                            随后关闭;新连接使用更大的 session_epoch
不同 device_id           → 并存,互不影响
设备数达到上限            → max_devices_per_user = 8(附录 B)
                            踢出最久未活跃的设备,KICKED{reason=replaced, replaced_by_device}
远程登出 / 令牌吊销       → KICKED{reason=token_revoked},客户端清本地数据

session_epoch 只增不减是硬约束:它是"旧连接的推送不得写入新连接"的唯一判据(§11.1)。 v1 中该字段既无分配方也无告知机制,被替换的旧连接无从得知自己已失效。

5.4.5 替代方案与不选的理由

替代方案:PushBatch 改为按 user_id 寻址,由 ConnectionNode 本地展开设备并自行做 epoch 过滤。
          可以完全去掉 PresenceDirectory。

不选的三个理由:
  1. MailboxNode 无法感知在线状态,会向 0 在线的 ConnectionShard 发送空批次;
     默认拓扑下每个 GroupDispatch 恒定发 4 个批次(§5.2 推论 2),
     其中大部分在低峰期是空的,浪费与在线率成反比。
  2. 离线推送触发判断失去依据:推送触发方 MailboxNode(§16.1,键含 device_id)需要
     "该设备当前无有效 PresenceEntry"这一 per-device 事实才能为该设备产生 PushTask,
     本地展开方案下这个判断只能再查一次目录,
     等于把目录查询从推送路径挪到推送决策路径,没有消除。
  3. §11.1 的 epoch 校验会形同虚设:批次里没有 epoch,校验只能用 ConnectionNode 本地值,
     无法发现"MailboxNode 的在线视图已过期"这一类错误。

结论:保留 PresenceDirectory。代价是一条 compacted topic 与 1.1 万 events/s,
      收益是投递最后一跳可实现、离线推送有判据、epoch 校验有意义。

5.5 重分片、分片分裂与游标迁移

v1 只有一句话"真正重分片必须经过双写、游标迁移和切流流程"。而 MailboxCursor 是单分片结构、 mailbox_seq 绑定单分片日志 offset,用户逻辑归属一旦变更,游标不可换算。本节给出完整流程。

5.5.1 触发判据与可达上界

触发(任一满足,进入分裂评审):
  单 MailboxShard 的 entry/s 持续超过 per_shard_entry_budget 的 70%(预算值见附录 B.7)
  单 MailboxShard 的邮箱驻留字节超过节点可用容量的 70%
  单分片 lane 水位 P99 滞后超过 SLO(§2.4)且 CPU/IO 已确认为瓶颈

上界(§5.2 推论 1):
  mailbox_shard_count 最多提升到 min(virtual_bucket_count, connection_shard_count) = 1024
  virtual_bucket_count 永不改变

5.5.2 分裂流程

步骤                          写入方        可回滚          失败处理
─────────────────────────────────────────────────────────────────────────────
1 ShardRegistry 发起          ShardRegistry  是             直接放弃,无副作用
  冻结映射表版本
2 递增 shard_epoch            ShardRegistry  否(单调)      epoch 只增不减,重试用新 epoch
  为新旧分片各分配新 epoch
3 写 ShardSplitBoundary       ShardRegistry  是(未切读前) 删除边界记录即回滚
  {old_shard, old_epoch,
   split_at_seq, new_shard,
   new_epoch, bucket_range}
4 双写窗口开启                FanoutCoordinator 是          关闭双写即回滚
  迁出桶的 dispatch 同时写
  旧分片与新分片
5 新分片追平                  新 MailboxNode  是            追平失败则延长窗口或回滚
  从检查点 + 日志重放,
  推进 materialized_watermark
6 切读                        ShardRegistry  否(原子点)   切读后只能前滚
  映射表版本 +1,读走新分片
7 旧分片停写                  FanoutCoordinator 否          迁出桶不再写旧分片
8 游标换发                    MailboxNode     —             见 5.5.3
  下发 CURSOR_REBASED
─────────────────────────────────────────────────────────────────────────────
客户端不需要重连:ConnectionShard 不变,整个过程对客户端表现为一次 CURSOR_REBASED。

§7.11 ShardSplitBoundary.bucket_range 的语义在本文中统一定义为桶掩码区间 { bucket : bucket & mask == value },即一对 (mask, value),而不是连续区间—— 因为 §5.2 的初始映射用的是低位掩码,一个分片持有的桶集合是等差的而非连续的。 分裂即在 mask 中新增一位。任何按「连续区间」实现 bucket_range 的做法都会切错桶集合。

5.5.3 四个必须回答的问题

(1) 迁移窗口内 mailbox_seq 的归属方

规则:任一 GroupDispatch 的 mailbox_seq 由「实际接收该 dispatch 的分片」分配。
      双写窗口内,同一 dispatch 会在旧分片得到一个 seq、在新分片得到另一个 seq。

为什么不冲突:mailbox_seq 高 16 位是 shard_epoch(§6.5),新分片 epoch 严格大于旧分片,
              因此对同一用户而言两个 seq 天然可比且新的更大,复合序号仍单调不回退。
去重靠什么:靠 (message_id, event_id) 幂等,不靠 seq。
            客户端在双写窗口内可能收到同一条消息两次,按 §6.9 的排序键原位合并,UI 无重复。

(2) 客户端游标如何过渡

换发方式 = 按边界表换发签名令牌,不是数值映射。
           mailbox_seq 是"分片 + epoch + offset"的复合体,
           新分片的 offset 与旧分片没有任何算术关系,任何"映射公式"都是错的。

  服务端在 AUTH 或 PULL_MAILBOX 时发现 cursor.mailbox_shard_id 已迁移:
    查 ShardSplitBoundary
      → 边界可解析:返回 CURSOR_REBASED{ new_cursor, replay_from_seq }
                     new_cursor 指向新分片与新 epoch(lane_id 保持不变)
                     replay_from_seq = 新分片上覆盖"旧游标之后全部事件"的最小起点
                     客户端从 replay_from_seq 重放,按 message_id/event_id 幂等去重
      → 边界不可解析(边界记录已裁剪,或旧分片日志已过保留期):
                     才降级为 CURSOR_EXPIRED{trim_watermark, rebuild_required=true}
                     走 §9.6 REBUILD

lane_id 跨分裂稳定:lane_id 由 §6.5.1 的独立哈希导出,不含 mailbox_shard_count,
                    分裂不改变任何用户的 lane_id,换发令牌时原样保留,无需任何补偿措施。
                    需要重放的原因只有一个:新分片的 log offset 与旧分片没有算术关系。

(3) 双写期间的顺序保证与去重

顺序:同一用户在旧分片的全部条目 seq < 新分片的全部条目 seq(epoch 单调),
      因此"先拉旧分片尾部、再拉新分片"就是正确的拉取顺序,
      客户端不需要理解分裂,只需要按 CURSOR_REBASED 给出的顺序执行。
去重:完全依赖 message_id / event_id 幂等(§4.1 第 4 条)。
UI 顺序:由 conversation_seq 决定(§6.9.1),与分片迁移完全无关,不受影响。
禁止:禁止在双写窗口内用 seq 连续性做任何判断。

(4) 缺口检测口径

切读前:对客户端暴露的可见上界 = min( 旧分片 W_old[lane], split_at_seq )
        新分片在追平前不对客户端暴露任何水位
切读后:可见上界 = 新分片 W_new[lane](lane 不随分裂改变)
判据仍然只有 §6.10.1 的三条,迁移期不新增判据。

trim_watermark 的继承(否则 B-1 会在迁移后复活):
    新分片的 mailbox_trim_watermark 必须继承旧分片的值作为下界,不得回退到 0。
    否则"旧分片已裁剪的区间"在新分片上会被误判为"本来就没有事件",
    长期离线设备将再次出现静默成功。

5.5.4 PresenceDirectory 与成员 Bitmap 的连带处理

PresenceDirectory:分区键是 user_bucket,桶不变 → topic 分区不变,
                   MailboxNode 只需按新的 bucket_range 调整订阅集合,无数据迁移。
GroupMembershipVersion:分片成员 Bitmap 按 mailbox_shard 预分片,
                        分裂后必须为新分片重建 Bitmap 并递增 membership_version;
                        旧版本保留到 max(dispatch_progress_retention, 日志保留期)(§7.9)。
MemberSlotMap:槽位在 (tenant_id, group_id) 内分配,与分片无关,不受分裂影响。
lane:lane_id 由独立哈希导出(§6.5.1),分裂前后不变,无订阅或映射需要调整。

6. 标识、序列与游标

系统使用五种序列和三类辅助标识,禁止混用。本章是契约核心。

6.1 总则

  • 所有序列均为服务端生成,客户端不得伪造、不得推算、不得用本地时间参与任何序列判定。
  • 所有 ID 采用大端字节序编码,保证"字节序比较 == 数值比较",使存储层可直接按字节排序。
  • 任何"是否丢消息""是否需要补拉"的判定只能使用本章 §6.10 定义的规则。
序列 类型 作用域 是否严格连续 允许参与 UI 排序
message_id u128 全局 否 仅作去重键与追踪键
conversation_seq u64 单会话 否(允许空洞) 是(会话内主排序键)
last_activity_id u128 单会话 否 是(会话列表与跨会话时间轴排序键)
mailbox_seq u64 单 MailboxShard 否(个人队列稀疏) 禁止
room_seq u64 单聊天室 是(同 epoch 内) 仅房间内

6.2 message_id

message_id : u128(大端 16 字节)
  bits 127..80  hlc_ms       48  混合逻辑时钟毫秒,纪元 2020-01-01T00:00:00Z
  bits  79..68  region_id    12  最多 4096 个 region
  bits  67..48  writer_id    20  最多 1048576 个 ConversationWriter 实例
  bits  47..24  seq_in_ms    24  单实例单毫秒最多 16777216 条
  bits  23..0   reserved     24  置 0,保留给未来分片位

混合逻辑时钟(HLC)算法,由每个 ConversationWriter 实例独立维护:

on assign():
    now = 本地墙钟毫秒
    if now < last_hlc_ms - clock_regression_reject_ms:      # 默认 5000
        拒绝写入,返回 ERROR{code=CLOCK_UNSAFE},并触发 P1 告警
    if now > last_hlc_ms:
        last_hlc_ms = now; seq_in_ms = 0
    else:
        seq_in_ms += 1
        if seq_in_ms 溢出:
            last_hlc_ms += 1; seq_in_ms = 0                 # 向前借时间,不回退
    return compose(last_hlc_ms, region_id, writer_id, seq_in_ms)
  • 小于 clock_regression_reject_ms 的回拨由 HLC 吸收(last_hlc_ms 只增不减);超过阈值必须 fail-fast,不得静默生成可能重复的 ID。
  • hlc_ms 单调不减是硬约束,实例重启后必须先从持久化的 last_hlc_ms 恢复再接受写入。
  • message_id 大致按创建时间有序。跨 region、跨 writer 没有全序保证,因此它不是会话内的权威顺序,也不能用于增量同步的下界。

6.3 conversation_seq

  • u64,由该会话 Home Region 的 ConversationWriter 分配,单会话内严格递增。
  • 是会话内历史消息的权威顺序,也是 UI 会话内排序的主键。
  • 分配器实现:ConversationWriter 持有该会话的租约,内存自增;每次向持久存储预留 conversation_seq_reserve_window(默认 4096)个序号,用完再预留。
崩溃后从"已预留上界 + 1"继续分配,不回退、不复用。
  • 因此 conversation_seq 允许空洞。空洞的三种合法成因:
  • 预留窗口未用完即崩溃;
  • 消息被治理删除或到达 retention_class 保留期;
  • 该消息对当前用户不可见(定向系统消息、joined_at 之前的历史)。
  • 禁止用 conversation_seq 的差值判定丢消息(见 §6.10)。
  • 吞吐:单会话单写者,实测目标 ≥ 5000 次分配/秒;超过 per_conversation_msg_rate(附录 B)的会话由 §8 的准入层拒绝,分配器不承担限流职责。

6.4 last_activity_id

会话列表与跨会话时间轴的排序主键。格式与 message_id 完全相同(u128,同一 ID 空间,可直接互相比较)。

  • 由 ConversationWriter 在分配 conversation_seq 的同一临界区内分配。
  • 硬约束:同一会话内 last_activity_id 与 conversation_seq 严格同序。
  • 没有 conversation_seq 的活动(建会话、加群、被拉入群、群属性变更)由 ConversationWriter 或 GroupMembership 单独分配 last_activity_id,不推进 conversation_seq。这是 last_message_id 与 last_activity_id 必须并存的唯一原因:前者指向一条真实消息,后者可以指向无消息的活动。
  • 禁止使用裸墙钟生成 last_activity_id:晚到消息会把旧会话错误顶到列表最前。

6.5 mailbox_seq

mailbox_seq : u64
  bit  63      恒为 0                (见下方硬约束)
  bits 62..48  shard_epoch  15       由 ShardRegistry 单调递增分配,取值 [0, 0x7FFF]
  bits 47..0   log_offset   48       MailboxShard 分发日志的分区 offset

比较规则:作为 u64 整体无符号比较。跨 epoch 天然单调递增,永不回退。
容量:48 位 offset 在单分片 100 万事件/秒下可用约 8.9 年。

硬约束:bit63 恒为 0,即 shard_epoch <= 0x7FFF。ShardRegistry 分配时必须校验该上界。

理由:阶段一的 MailboxStore 由 ScyllaDB 承载,CQL bigint 是有符号 64 位。若 shard_epoch >= 0x8000, mailbox_seq 会被编码为负数,破坏聚簇键的排序,导致范围查询静默漏数据。 15 位提供 32768 次 epoch 递增,按最坏估计(每分片每天一次接管/分裂/灾备切换)可用 89 年,余量充足; 逼近上限时必须走 §5.5 的分片分裂重建,而不是回绕。

shard_epoch 必须递增的四类事件,任一发生后 ShardRegistry 先递增 epoch 再允许写入:

  1. MailboxShard 主备接管;
  2. 逻辑分片分裂或合并;
  3. 分发日志 topic/分区重建或截断后重建;
  4. 跨集群灾备切换。

日志与逻辑分片的映射约束(避免 offset 语义漂移):

  • 逻辑 MailboxShard 与日志分区是 1:1 固定映射,由 ShardRegistry 版本化管理。
  • 生产者必须显式指定分区,禁止使用 key hash 分区器。
  • 扩容只能新增逻辑分片或做分片分裂(§5.5),不做分区再哈希。
  • 分发日志生产者(FanoutCoordinator)默认关闭幂等(enable.idempotence=false,acks=all 不变,ADR-0017):幂等把每条 broker 连接限制为 5 个在途请求,而一批 dispatch 按分区拆成约 64 个请求,串行轮次成为 fanout 吞吐上限。重试可能造成重复追加与同一分区内两条 dispatch 的相对顺序对调;前者与故障切换重放同属既有语义,后者只让 mailbox_seq 先后与 conversation_seq 先后不一致,二者都由下一条的去重与“以 offset 构造 mailbox_seq、以 conversation_seq 排序会话内消息”吸收。无论是否开启幂等,MailboxNode 都必须按 dispatch_id 在保留窗口内去重,因为同一 dispatch 若被重复追加会得到两个不同 offset。

同一群消息在同一 MailboxShard 只分配一个 mailbox_seq,该分片内属于这条消息的所有用户邮箱引用共享该序号。每个用户拥有独立键空间,因此个人队列稀疏,但查询只走用户前缀,不扫描其他用户。

MailboxShard 事件范围:10001...10500

用户 A 的独立队列键:
  (A, 10008) -> entry
  (A, 10217) -> entry
  (A, 10491) -> entry

PULL_MAILBOX(A, after_seq=10001) 只访问 A 的三条索引。

6.5.1 lane:可见性隔离通道

实现覆盖(2026-09-01):下述是目标语义。当前 Redis 路径虽持久化 64-lane packed 水位,但 finish_dispatch 在一条 record 的全部 chunk 完成后以同一 observed_log_offset 推进全部 lane;空 lane 也不提前越过。因此当前只实现了 lane 数据格式与用户映射,未实现跨 lane 独立可见性推进,相关发布门禁必须保持失败。

分片级单一水位会让一个大群 dispatch 卡住同分片全部用户的可见性(含纯单聊用户)。因此水位按 lane 分组:

lane_id = blake3(tenant_id, user_id)[0] & (lane_count - 1)

默认 lane_count = 64,上限 256(受首字节取值域限制)

MailboxNode 维护 materialized_watermark[lane_count]
对某用户暴露的可见水位恒为 W[lane_id(user)]
min(W[0..K-1]) 为分片级水位,仅用于检查点与备节点接管判定,不对客户端暴露

lane_id 使用独立哈希、不从 user_bucket 推导,这是刻意的:若用 user_bucket 移位取模, 分片分裂(256 → 512)会改变移位量,进而改变全部用户的 lane_id,使分裂期所有游标失效并触发全网重拉。 独立哈希使 lane_id 在虚拟桶映射、分片分裂、跨集群灾备下永久稳定,代价仅是多算一次哈希。

  • mailbox_seq 的定义不受 lane 影响,仍是分片全局的分发日志 offset。
  • 一个 GroupDispatch 在分片内按 lane 拆成至多 lane_count 个子任务,各自独立推进 W[j],跨 lane 互不等待。
  • lane_id 由服务端从 (tenant_id, user_id) 计算并签入游标令牌,客户端不得上行伪造。
  • lane_count 建集群后不可变:它出现在 & (lane_count-1) 中,改变它会重排全部用户的 lane 归属, 等价于一次全网水位重建。需要更多隔离通道时只能新建集群或走 §5.5 的分片分裂。

6.5.2 mailbox_trim_watermark

mailbox_trim_watermark : u64   该用户可安全依赖的裁剪下界(TTL lane 前沿与
                               本用户主动裁剪位置的较大者,即 effective_trim(u),
                               §18.3.3),单调递增
  • 只随 AUTH_OK 下发。连接期内的裁剪推进不主动通知,由 ERROR{code=CURSOR_EXPIRED} 在实际越界时暴露。 (PONG 保持精简,理由见 §6.10.1 与 §15.1:PONG 一旦携带水位类字段,就会诱导客户端做分片级比较。)
  • 是 §9.3 "空洞可安全跳过"规则的前置条件:只有 after_seq >= mailbox_trim_watermark 时空洞才可跳过;否则该区间的空洞可能是"已被裁剪"而非"本来就没有",服务端必须返回 CURSOR_EXPIRED。
  • 裁剪水位的计算见 §18.3;邮箱条目仅按时间窗口裁剪,不以任何单设备游标为条件。

6.6 room_seq

room_seq : u64
  bit  63      恒为 0
  bits 62..48  room_epoch  15   房间迁移/重建时递增,取值 [0, 0x7FFF]
  bits 47..0   counter     48   房间内单调递增

与 mailbox_seq 同理,bit63 恒为 0 以兼容有符号 64 位存储介质(RoomRecord 的聚簇键)。

  • 只用于在线广播、断线后的短窗口回放和丢包检测。
  • 不为聊天室每个在线成员创建持久邮箱引用。
  • 聊天室消息也分配 message_id(用于客户端去重与举报追踪),但不分配 conversation_seq。
  • 房间提升为持久群会话时(§14),从提升时刻起该会话开始分配 conversation_seq,提升之前的房间消息不进入持久历史,joined_at_conversation_seq 取提升时刻的值。

6.7 event_id 与 event_ordinal

一个 mailbox_seq 下可能存在同一用户的多条事件(消息 + @提醒 + 成员变更)。它们构成一个事件组。

event_id : u64 = blake3(tenant_id, dispatch_id, user_id, event_type)[0:8]
  • 必须是确定性哈希,明令禁止随机 UUID 或时间戳。 主备 MailboxNode 消费同一日志必须物化出逐字节相同的索引,任何非确定性输入都会破坏这一点。同理,UserMailboxEntry.created_at 取自 GroupDispatch 记录内携带的提交时间,不取本地墙钟。
  • 作用域是 (tenant_id, user_id, mailbox_seq),组内至多 8 条,8 字节足以避免碰撞。
  • 硬约束:事件组内每种 event_type 至多 1 条;上限 8 为未来新增 event_type 预留(§27.1.2 未知 event_type 容忍规则的对偶),当前 4 种类型下组内至多 4 条。
event_ordinal : u8    组内确定性次序
  = event_type 的固定优先级值,1:1 映射:MESSAGE=0, MENTION=1, MEMBERSHIP=2, CONTROL=3

事件组硬上限:单个 (user_id, mailbox_seq) 的事件组 ≤ 8 条且 ≤ 8 KiB。FanoutCoordinator 超出时必须拆成多条 dispatch(即多个 mailbox_seq),禁止超发。这条上限是 §9.3 "批次切分只能在 mailbox_seq 边界"规则可行的前提。

6.8 设备游标 MailboxCursor

MailboxCursor {
    tenant_id
    user_id
    device_id
    mailbox_shard_id
    lane_id
    shard_epoch
    last_applied_mailbox_seq
    issued_at
    signature                     # 服务端 HMAC
}
  • 每设备一个游标,一个用户登录只拉一条个人邮箱队列。
  • 游标表示客户端已完成解析并持久化的最大安全位置,不是"网络已收到"的位置。
  • 不变量(必须写进实现):设备游标只能由 MAILBOX_BATCH 连续推进;实时 PUSH_EVENTS 送达的事件不得越位推进游标。否则实时推送越过未拉取区间会造成静默丢消息。
  • 签名与明文的关系(v1 此处自相矛盾,本版明确):
  • 令牌承载不可伪造部分:mailbox_shard_id、lane_id、shard_epoch、user_id、device_id、签发时间。
  • last_applied_mailbox_seq 在帧中以明文传输,服务端校验其 <= 该用户 lane 当前 materialized_watermark W[lane_id],越界即返回 ERROR{code=CURSOR_INVALID}。合法发放的 covered_through_seq 恒 <= W[lane](§9.3 批次不得跨越未物化区间),该判据跨重启/主备接管天然可用(W[lane] 在 §19.3.1 检查点内),且抬高 seq 至多跳过本设备自身未读,不能越权读取。
  • 这样客户端可以在本地推进游标而不需每次换发令牌,服务端也无需持久化任何每设备发放记录,同时无法越权读取其他分片。
  • 客户端与服务端不匹配时的三条"重来"路径,语义严格区分:
情况 服务端响应 客户端动作 数据代价
shard_epoch 落后但边界可解析 CURSOR_REBASED{new_cursor, replay_from_seq} 从 replay_from_seq 重放,按 message_id 幂等去重 少量重复,无丢失
last_applied_mailbox_seq < mailbox_trim_watermark CURSOR_EXPIRED{trim_watermark, rebuild_required=true} 走 §9.6 REBUILD 窗口外未读与提及计数不精确
服务端已丢弃在途正文/队列溢出 PONG.mailbox_dirty=true 或 ERROR{code=MAILBOX_DIRTY} 重新 PULL_MAILBOX 无

6.9 客户端排序契约

这是"单个用户视角的消息如何排序"的权威定义,三端必须一致实现。

6.9.1 会话内时间轴

已确认区排序键 = (timeline_anchor_seq ASC, event_ordinal ASC, event_id ASC)

timeline_anchor_seq:
    普通消息        = 自身 conversation_seq
    撤回 / 编辑     = target_conversation_seq   (原地更新已有行,不新增时间轴行)
    加群 / 系统提示  = 自身 conversation_seq(若有);否则按 last_activity_id 插入分隔位
本地未确认(pending)区恒排在已确认区之后
    内部排序 = (client_send_ts ASC, client_message_id ASC)
    收到 SEND_ACK 后按 client_message_id 原位升级为已确认三元组,触发一次局部重排
  • SEND_ACK 必须回带 client_message_id,UserMailboxEntry 也必须携带 sender_id 与(仅发送者自己的条目)client_message_id,否则多设备与断线重连时自发消息无法去重回显,UI 会出现重复气泡。该字段同时是登录对账的锚点(ADR-0008)。

6.9.2 跨会话时间轴(@我列表、通知中心、全局搜索结果)

排序键 = (last_activity_id DESC, conversation_id ASC, event_id ASC)

6.9.3 会话列表

置顶区:pin_rank ASC, last_activity_id DESC, conversation_id ASC
普通区:last_activity_id DESC, conversation_id ASC

6.9.4 禁令

mailbox_seq 仅用于拉取分页、游标推进与缺口检测;
room_seq 仅用于房间回放定位;
二者禁止参与任何 UI 排序。

message_id 仅用于去重与追踪,禁止作为会话内排序键
(它跨 region/writer 无全序,会造成跨地域场景下的顺序抖动)。

6.9.5 中间态与倒挂

mailbox_seq 顺序与 conversation_seq 顺序可能倒挂(同一会话的两条消息因分发重试而乱序到达)。约束:

  • 客户端一律按 §6.9.1 排序键插入而非追加,允许出现"新消息插到已有消息之前"。
  • 倒挂窗口上界 = fanout_retry_max_window(附录 B.2,默认 60 秒,正常运行期观测上界)。fanout 重试永不丢弃已 COMMITTED 的消息:Outbox 与分发日志持续重试直至物化成功,背压由 §8.2 三层准入与 RATE_LIMITED 承担;晚于窗口物化的条目照常写入邮箱并投递,由 §12.4.2 排序门控保证不顶排序、不覆盖预览,客户端按 §6.9.1 排序键原位插入。
  • 客户端在会话可见区间内检测到 conversation_seq 空洞时,不得据此判丢(见 §6.10),仅可选择延迟渲染 ≤ 500ms 等待补齐。

6.10 完整性判定规则

6.10.1 丢消息检测的唯一锚点是邮箱层

合法判据:
  last_applied_mailbox_seq < last_pushed_user_seq      → 有未应用的推送,需补拉
  PONG.mailbox_dirty == true                            → 服务端已丢弃在途数据,需补拉
  MAILBOX_BATCH.covered_through_seq < 请求的 up_to_seq 且 has_more → 继续拉

非法判据(禁止实现):
  conversation_seq 差值不为 1                            → 用户视角天然稀疏
  mailbox_seq 差值不为 1                                 → 个人队列天然稀疏
  last_applied_mailbox_seq < materialized_watermark      → 分片水位与个人队列无关,
                                                            会导致全网每心跳一次空拉
  本地最新消息时间戳 < 服务端时间                          → 见 6.10.2

唯一例外:读扩散档的会话历史。write_policy = mention_only 的会话(§10.2.3), 其 silent 期间的消息本就不产生邮箱条目,因此"邮箱层锚点"对该会话的历史完整性不适用。 该档的历史缺口由服务端显式告知,不由客户端推断:

SESSION_LIST_BATCH.sessions[].write_policy = mention_only
  → 客户端进入该会话时校验 本地最大连续 conversation_seq 与 latest_conversation_seq
  → 缺口走 PULL_HISTORY 补齐

注意这仍然不是"用 seq 差值判丢":判据是与服务端下发的 latest_conversation_seq 比对,
不是检查本地序列是否连续。会话内部的空洞(定向消息、治理删除、joined_at 之前)
仍然合法且不可据以判丢。

邮箱层锚点对该会话的实时投递依然有效(active 集仍逐条物化),两者不冲突。

6.10.2 禁止按时间戳补拉

禁止将 created_at 或客户端本地时间作为任何增量同步的下界。 时间戳只用于 UI 展示与"跳转到某天"入口;服务端必须先把日期映射为 conversation_seq,再按 seq 取数。

理由:§6.3 允许消息因分发重试而晚到,§6.2 的 message_id 只是大致时间有序且跨 region 无全序。按时间戳补拉会使"created_at 早于本地已记录时间戳但实际晚到"的消息永久丢失,且该丢失不可检测。

正确的两条兜底通道:

全局兜底(下拉刷新 / 客户端定期自检 / 网络恢复):
    PULL_MAILBOX(after_seq = cursor.last_applied_mailbox_seq, up_to_seq = 0)
    up_to_seq = 0 表示"拉到当前水位",由服务端以该用户 lane 的最新 W[lane] 填充,
    并在 MAILBOX_BATCH.lane_watermark 中回带最新上界。
    客户端因此**不需要**在连接期内持续获知水位,PONG 也不必携带它。
    纯读幂等;客户端限流 >= client_resync_min_interval(默认 5s)

会话级兜底(进入会话 / 向上翻页 / 发现渲染空洞):
    PULL_HISTORY{conversation_id, direction, anchor_conversation_seq, limit}
 -> HISTORY_BATCH{messages[], latest_conversation_seq,
                  earliest_available_conversation_seq, has_more}
    两个 *_conversation_seq 字段让客户端 O(1) 自检本地边界与保留边界

若产品需要"按时间找回消息",规定其为只读归档查询接口 search-by-time,走 HTTP 管理面(不占用实时协议 opcode, 与"HTTP 只用于非实时场景"一致);服务端先把日期映射为 conversation_seq 再取数, 结果不参与游标推进、不影响未读。


7. 核心数据模型

7.0 存储归属与主键总览

结构 存储 分区键 聚簇键 单分区上界
MessageRecord ScyllaDB (tenant_id, conversation_id, seq_bucket) conversation_seq DESC 4096 行
MessageIndex ScyllaDB (tenant_id, message_id) — 1 行
ClientDedup ScyllaDB (tenant_id, sender_id, client_message_id) — 1 行,TTL 7200(ADR-0008)
ConversationHead ScyllaDB (tenant_id, conversation_id) — 1 行
UserConversationState ScyllaDB (tenant_id, user_id) conversation_id 5000 行
UserMailboxEntry MailboxStore (tenant_id, user_id) mailbox_seq, event_ordinal, event_id 见 §7.3
UserSessionProjection MailboxStore (tenant_id, user_id) conversation_id 5000 行
UserBadgeState MailboxStore (tenant_id, user_id) — 1 行
DispatchProgress MailboxStore (tenant_id, mailbox_shard, dispatch_id) lane_id 64 行
GroupMembershipVersion ScyllaDB + S3 (tenant_id, group_id, membership_version) mailbox_shard 分片数行
MemberSlotMap ScyllaDB (tenant_id, group_id) user_id 10 万行
PresenceEntry compacted topic + 节点内存 (tenant_id, user_id, device_id) — 1 条
EpochBoundary / ShardSplitBoundary ShardRegistry (mailbox_shard_id) shard_epoch 少量
RoomRecord 短期日志 + LRU (tenant_id, room_id, room_epoch) room_seq 回放窗口

MailboxStore 是抽象接口,三级演进:一期 Redis(ADR-0007,§18.1.2b)→ 阶段一 ScyllaDB(§18.1.3)→ 阶段二 自研 LSM(Rust 用 rust-rocksdb),见 §18.1 与 docs/adr/0001-mailbox-store-selection.md。

7.1 MessageRecord

MessageRecord {
    tenant_id
    conversation_id
    seq_bucket                    # = conversation_seq / message_seq_bucket_width(默认 4096)
    conversation_seq
    message_id
    last_activity_id
    sender_id
    sender_device_id
    client_message_id
    message_type
    custom_type                   # 自定义消息时有效
    schema_version
    payload_or_ciphertext
    media_metadata                # 见下
    reply_to_conversation_seq
    mention_targets               # 用户列表或 ALL
    state                         # NORMAL | RECALLED | EDITED | DELETED
    recall_event_conversation_seq # 撤回/删除 CONTROL 事件的坐标,非空即事件已入提交日志(§13.4.1)
    edited_at_activity_id
    created_at
    retention_class
    dek_id                        # (key_scope, key_id, key_version),见 §21.3
}

PRIMARY KEY ((tenant_id, conversation_id, seq_bucket), conversation_seq)
WITH CLUSTERING ORDER BY (conversation_seq DESC)
  • seq_bucket 是确定性的、无空洞的分桶:conversation_seq / 4096。读最新一页时先读 ConversationHead.latest_conversation_seq 定位当前 bucket,向上翻页 bucket--。桶宽建表后不可变,可按 retention_class 在建表期配置。
  • 这解决了 v1 "大群会话按 conversation_id 单分区必然超限"的问题。
  • (tenant_id, sender_id, client_message_id) 是客户端重试幂等键,落在独立的 ClientDedup 表。
media_metadata {
    object_id                     # 对象存储键
    mime
    bytes
    width / height / duration_ms
    thumbnail                     # <= media_thumbnail_max_bytes(默认 32 KiB)
    blurhash
    checksum
}

原始大文件只进对象存储,消息正文只保存上述引用与缩略图。

retention_class : default | ephemeral_24h | compliance_hold | tenant_custom
  • default:按租户保留策略分层存储。
  • ephemeral_24h:24 小时后物理删除,对应的邮箱引用同步失效。
  • compliance_hold:不参与任何自动删除,仅可由 §21 的合规流程解除。
  • retention_class 同时决定 §18.3 的邮箱裁剪窗口与 §21 的删除路径。

7.2 MessageIndex 与 ClientDedup

MessageIndex {
    tenant_id, message_id -> conversation_id, conversation_seq, seq_bucket
}
PRIMARY KEY ((tenant_id, message_id))
  • 协议层强制:撤回、编辑、回复、举报等操作必须携带 (conversation_id, conversation_seq),正常路径不查 MessageIndex。
  • MessageIndex 只服务于 ModerationService 与故障排查,避免成为热路径。
ClientDedup {
    tenant_id, sender_id, client_message_id -> message_id, conversation_seq, created_at
}
PRIMARY KEY ((tenant_id, sender_id, client_message_id))
DEFAULT TTL = client_dedup_ttl_seconds(默认 7200,ADR-0008)

跨 ConversationWriter 实例的并发重试用 IF NOT EXISTS 收敛。TTL 到期后同一 client_message_id 的重试将产生新消息——这是有意的取舍(ADR-0008):幂等窗口 2 小时只兜底"已提交但客户端尚不可见"的暴露期;窗口外的重发必须以登录对账未命中为前提(§27.3.2),对账未命中意味着消息未提交,重发不会产生重复。

7.3 UserMailboxEntry

个人邮箱只存引用与投递属性,不复制正文。

UserMailboxEntry {
    tenant_id                            # 分区键
    user_id                              # 分区键
    mailbox_seq                          # 聚簇键 1
    event_ordinal                        # 聚簇键 2
    event_id                             # 聚簇键 3
    event_type                           # MESSAGE | MENTION | MEMBERSHIP | CONTROL
    message_id
    conversation_id
    conversation_seq
    last_activity_id
    sender_id
    visibility_floor_conversation_seq
    flags                                # counts_unread | affects_session_order | timeline_visible
    mention_type                         # NONE | AT_ME | AT_ALL | REPLY_ME
    created_at                           # 取自 dispatch 记录,非本地墙钟
    -- 以下为可选字段,缺省不编码 --
    client_message_id                    # 仅发送者自己的条目
    origin_device_id                     # 仅控制事件(已读同步等)
    target_message_id                    # 仅撤回 / 编辑
    target_conversation_seq              # 仅撤回 / 编辑
    target_sender_id                     # 仅撤回 / 编辑 / 治理删除,目标消息的发送者
    target_flags                         # 仅撤回 / 编辑 / 治理删除,目标消息的 flags
    target_mention_type                  # 仅撤回 / 编辑 / 治理删除,目标消息对本收件人的 mention_type
    -- 内联正文(ADR-0005),仅当 GroupDispatch.inline_body = true 时存在 --
    inline_payload_or_ciphertext         # 与 MessageRecord.payload_or_ciphertext 逐字节一致
    inline_message_type
    inline_custom_type / inline_schema_version
    inline_media_metadata                # 仅缩略图与对象引用,原文件仍在对象存储
    dek_id                               # 内联时必填,进入 §21 加密擦除链
}

PRIMARY KEY ((tenant_id, user_id), mailbox_seq, event_ordinal, event_id)
WITH CLUSTERING ORDER BY (mailbox_seq ASC, event_ordinal ASC, event_id ASC)

内联例外(ADR-0005):当 N × body_size <= inline_body_budget_bytes 且 body_size <= inline_body_max_bytes 且 retention_class == default 时, 正文随条目一并物化,读路径不再 join。判定由 ConversationWriter 一次性完成并写入 GroupDispatch.inline_body,MailboxNode 只执行不判定。内联条目必须携带 dek_id 并进入 §21.4 的加密擦除链;ephemeral_24h 与 compliance_hold 一律不内联。

存储态与线上态的区别(这是 §7.3 最容易被误读的地方):

存储态(本表定义)        = 引用 + 投递属性 + (内联时)正文
线上态(MAILBOX_BATCH /
        PUSH_EVENTS 的条目)= 存储态 + 读时 join 的正文(payload / media_metadata)

正文由 MailboxNode 在读路径上从本地 LRU 或 MessageStore 一次性 join,
同一 message_id 在一个批次内只 join 一次、只编码一次(§10.3)。
join 失败(正文已被治理删除、已过 retention_class 保留期、或超过帧上限)时,
条目携带 body_included=false,客户端据此走 PULL_HISTORY 补取或渲染占位。

因此「邮箱只存引用」与「同步批次直接带正文」两条同时成立, 不需要客户端为每条离线消息各发一次历史查询。

字节预算:必填字段逻辑大小约 110 字节;可选字段在绝大多数条目上不出现。entry_ondisk_bytes(含键前缀压缩、索引、WAL、压缩后真实结果)与 lsm_write_amp 属待实测参数,见附录 B 与 §25。

为什么每条引用要带这么多字段:

字段 缺失后果
last_activity_id §12.7 "快照 + 邮箱增量 = 会话列表"在排序维度不可实现
sender_id / client_message_id 多设备与重连时自发消息无法去重回显
visibility_floor_conversation_seq 投影层必须持有最新 UserConversationState 才能判可见性,重放结果不可重现
target_*(定位坐标) §12.4.2 与 §12.9 要求的"预览为该消息时修复预览"无法判定
target_sender_id / target_flags / target_mention_type §12.5.5 的未读/提及扣减无法判定目标消息是否曾计入本收件人未读——撤回自己发的消息或 counts_unread=false 的自定义消息会被错误扣减
origin_device_id 已读同步事件产生自回声,发起设备会收到自己刚发出的已读

事件组约束:单个 (user_id, mailbox_seq) 的条目 ≤ 8 条且 ≤ 8 KiB(§6.7)。

控制事件的写入量:已读同步等控制事件也进入个人邮箱,必须计入 §25.1 的容量公式。重度用户每天可产生数百条已读事件,因此:

  • 已读事件按会话做窗口合并(read_sync_merge_window,默认 3 秒),窗口内同一会话只写最后一条;
  • 发起设备通过 origin_device_id 在服务端侧过滤,不向自己回推;
  • 已读事件 counts_unread=false、affects_session_order=false、timeline_visible=false。

7.4 ConversationHead

ConversationHead {
    tenant_id
    conversation_id
    latest_conversation_seq
    earliest_available_conversation_seq   # 仍可读取的最小 conversation_seq,
                                  # 由保留策略与治理删除推进,单调不减。
                                  # HISTORY_BATCH 的同名字段取自本列;
                                  # 它同时是 seq_bucket 向上翻页的**终止条件**(docs/02)
    last_message_id
    last_activity_id
    last_sender_id                # 会话列表副标题"某某:内容"的发送者来源,见 §12.2
    preview_or_placeholder
    member_count                  # 展示用近似值,可滞后;fanout 成本计算一律使用
                                  # GroupMembershipVersion.member_count(§7.9),二者不得混用
    updated_at                    # 运维排查与陈旧检测用;不参与任何业务判定
    fencing_epoch
    head_version
}

PRIMARY KEY ((tenant_id, conversation_id))
  • 每条消息只更新一次,与群成员数量无关。
  • earliest_available_conversation_seq 不随每条消息更新,只在保留期裁剪或治理删除推进边界时更新(低频)。缺少它则 conversation_seq 的合法空洞(§6.3:预留窗口崩溃、治理删除、可见性裁剪)会让历史翻页无法判定何时终止,退化为连续扫描空 seq_bucket。
  • 并发语义:正常路径由 §19.1 的 Home Region 单写保证串行,使用 blind write,不使用 LWT(避免把 Paxos 延迟加进每条消息路径)。仅在故障切换窗口内使用条件更新 IF (fencing_epoch, head_version) < (新值),条件来源是 §19.2 已要求的 fencing token。
  • latest_conversation_seq 只允许单调前进,任何会造成回退的写入必须被拒绝。
  • 该写入失败不阻断 fanout:ConversationHead 是会话列表的公共输入,不是消息提交的强一致前置条件;失败进入重试队列并告警。

7.5 UserConversationState

只因用户主动操作或成员关系变化而更新,不因普通消息到达而更新。 唯一例外:mailbox_write_policy != always 时 delivered_conversation_seq 随消息按分片批量推进(§10.2.3),不逐成员逐条写。

UserConversationState {
    tenant_id                            # 分区键
    user_id                              # 分区键
    conversation_id                      # 聚簇键
    membership_state                     # ACTIVE | LEFT | REMOVED | BANNED
    role                                 # OWNER | ADMIN | MEMBER | GUEST
    joined_at_conversation_seq
    left_at_conversation_seq             # 可见区间上界,退群时赋值,重新加群时置空
    hidden_before_conversation_seq       # 清空/隐藏聊天记录水位,不影响列表成员资格
    deleted_before_conversation_seq      # 删除会话水位,决定是否产出投影行(§12.2)
    read_conversation_seq
    manual_unread_conversation_seq       # 手动"标记未读",u64,写入值 = 标记时刻 head.latest_conversation_seq
    delivered_conversation_seq           # 仅 mailbox_write_policy != always 时使用(§10.2)
    pin_rank
    muted / archived
    notification_policy
    updated_activity_id
    state_version
}

PRIMARY KEY ((tenant_id, user_id), conversation_id)

可见区间定义(权威):

visible(u, c) = ( max(joined_at_conversation_seq,
                      hidden_before_conversation_seq,
                      deleted_before_conversation_seq),
                  left_at_conversation_seq ?? +∞ )

下界为开区间:conversation_seq 等于下界的消息不可见;与 §12.5.1 未读定义式的 base(u,c) < m.conversation_seq(严格大于才计数)同一口径。

joined_at_conversation_seq 的写入值语义(权威):加入生效时刻会话已分配的最大 conversation_seq,即加群 MEMBERSHIP 事件自身 conversation_seq 减 1。这样 MEMBERSHIP 事件本身满足 seq > joined_at 仍可见(与用例 26.3.5 的 timeline_visible=true 兼容),而加群前的全部消息严格不可见。

有了这个序号边界,退群/删除的判定不再依赖"当前状态点查",投影重放与主备切换的结果可重现——这解决了 v1 "退群后在途消息让会话复活、结果不确定"的问题。

一期实现范围(ADR-0021):本结构按字段分布在三个一期权威里,不单建一张宽表—— membership_state / joined_at_conversation_seq / left_at_conversation_seq 在 GroupMembership 成员区间 (Redis grpmember:{bNNN}:…,与会话提交事实同实例、同一原子区写入;单聊恒为全区间); read_conversation_seq 在 read:{user};会话集合本身在 ScyllaDB user_conversation_state (kind, first_seen_at,会话首次进入集合时登记)。hidden_before / deleted_before / manual_unread / delivered / pin_rank / muted / archived / notification_policy / role 一期未实现。

state_version 的并发语义(多设备并发写,必须定义):

  • 由写入方递增;冲突按 (state_version, updated_activity_id) 大者胜。
  • 逐字段合并而非整行覆盖:pin_rank / muted / archived / notification_policy 各自独立取胜者。
  • read_conversation_seq、hidden_before_*、deleted_before_* 一律取 max(只进不退)。
  • manual_unread_conversation_seq 按"置空优先、否则取 max"处理(任一侧已置空则合并结果为空)。
  • 该表写频率低,可直接使用 LWT。

manual_unread_conversation_seq 的语义(read_conversation_seq 只进不退,无法表达"手动置未读"):

  • 写入值 = 标记时刻该会话 head.latest_conversation_seq(服务端处理标记时本就要读 ConversationHead,一次点读同时取得坐标,零反查)。
  • 非空时,该会话在会话列表中恒显示为未读(至少 1 条),无论 unread_count 计算结果如何。
  • 当 read_conversation_seq >= manual_unread_conversation_seq 时自动失效(置空);判定全程只在 conversation_seq 空间比较,不需要任何 activity_id -> conversation_seq 反查。
  • 它是用户主动状态,因此跨设备同步,并在投影重建后仍然存在(这正是不能把它放进 UserSessionProjection 的原因)。

7.6 UserSessionProjection

由个人邮箱派生、可重建的物化视图,不是消息提交的强一致前置条件。

UserSessionProjection {
    tenant_id                            # 分区键
    user_id                              # 分区键
    conversation_id                      # 聚簇键
    latest_conversation_seq
    last_activity_id
    last_message_id
    # preview_or_placeholder 不持久化:SESSION_DELTA / SESSION_LIST_BATCH 帧中的
    #   preview 由 MailboxNode 下发前按 conversation_id 从 ConversationHead
    #   读时填充并解密(§21.3.4),投影行不存正文片段
    unread_count
    unread_base_seq                      # 本次计数所对应的 base,见 §12.4
    unread_exact                         # false 表示已按 unread_precise_limit 截断
    mention_count
    mention_first_conversation_seq       # 支撑"跳到第一条 @我"
    projection_mailbox_seq               # 单调版本源
    projection_version
}

PRIMARY KEY ((tenant_id, user_id), conversation_id)
  • 排序不发生在存储层:last_activity_id 只是值列。会话列表排序发生在 SessionProjection 服务的内存快照层,快照按 snapshot_revision 版本化;冷用户按需一次分区读全量载入(≤ 5000 行)。这避免了把 last_activity_id 作为聚簇键带来的墓碑风暴。
  • projection_mailbox_seq 是唯一版本源,不再引入第二套计数器:用户的所有投影输入(消息、已读同步、成员变更)都经过本人邮箱,因此该 seq 在用户维度天然单调。
  • projection_version 仅用于结构演进(字段增删时的兼容判定),不参与并发控制。

7.7 UserBadgeState

UserBadgeState {
    tenant_id
    user_id
    total_unread
    total_mention
    muted_unread
    badge_projection_mailbox_seq
}

PRIMARY KEY ((tenant_id, user_id))
  • 与 UserSessionProjection 同实例、同一 WriteBatch、同一检查点更新,保证角标与会话列表不会互相撕裂。
  • 聚合口径(写死):
total_unread  = Σ unread_count over {
                    membership_state = ACTIVE ∧ ¬archived
                    ∧ (¬muted ∨ 租户策略 include_muted_in_badge = true) }
total_mention = Σ mention_count over { membership_state = ACTIVE }
                (静音会话仍计入,与"静音只影响通知"一致)
muted_unread  = Σ unread_count over {
                    membership_state = ACTIVE ∧ ¬archived ∧ muted }

muted_unread 不参与系统角标,它的用途有二:一是客户端在"全部消息"入口显示带静音的总数, 二是租户把 include_muted_in_badge 从 false 切到 true 时,服务端可直接用 total_unread + muted_unread 得到新值而不必全量重算。三个字段随 BADGE_UPDATE 与 AUTH_OK 登录快照一并下发。

  • 多租户/多账号客户端:服务端只给 (tenant, account) 维度数字,客户端求和后写系统角标。

7.8 GroupDispatch 与 DispatchProgress

GroupDispatch {                          # 分发日志中的一条记录
    tenant_id
    dispatch_id                          # = blake3(domain || message_id_be16 || shard_be4)[0:16]
    message_id
    conversation_id
    conversation_seq
    last_activity_id
    sender_id
    fencing_epoch                        # ConversationWriter 从会话写入租约携带(§19.2.1)
    membership_version
    target_mailbox_shard
    event_template                       # counts_unread / affects_session_order / mention 规则
    committed_at                         # 用于 entry.created_at,保证确定性
    inline_body                          # bool,ADR-0005 的内联判定结果
    [inline_payload_or_ciphertext]       # inline_body=true 时随记录携带,供各分片直接物化
    [dek_id]                             # 同上
}
  • 同一目标 MailboxShard 只生成一个任务。
  • dispatch_id 是确定性哈希,保证生产者重试或日志重复追加时 MailboxNode 可去重。
  • 哈希编码冻结为 domain = "qim.group-dispatch.v1\0",随后拼接 16 字节 message_id 大端编码与 4 字节无符号 target_mailbox_shard 大端编码,取 BLAKE3 输出前 16 字节;禁止语言原生整数布局、文本拼接或随机盐。
  • inline_body 由 ConversationWriter 在提交时一次性判定(判据见 §3 对第 4 条的澄清 与 ADR-0005),随记录下发到全部目标分片。MailboxNode 只执行不判定—— 各分片独立判定会使同一消息的内联结果不一致,破坏 §6.7 的确定性物化。
  • fencing_epoch 是 §19.2.1 校验点 2 的数据面依据:MailboxNode 消费时按 (tenant_id, conversation_id) 维护已见最大值的单调过滤器,小于该值的记录整条丢弃并计入 stale_epoch_dispatch_dropped_total。

实现现状与发布阻断:上述 fencing_epoch 是目标 wire/日志契约,不是可删的说明字段。 当前 qim_common::commit::GroupDispatch 及其编解码尚未携带该字段,MailboxNode 也尚未据此执行 单调过滤;因此当前实现不满足 §19.2.1 校验点 2。必须完成“写入租约 epoch → GroupDispatch 编解码逐字节保留 → MailboxNode 持久/可恢复的最大 epoch 过滤 → 恒零/拒收指标”全链路,且通过 §26.6.1,才可发布;不得通过从本结构或 §19.2 删除该字段来回避此缺口。

DispatchProgress {
    tenant_id, mailbox_shard, dispatch_id, lane_id
      -> chunk_done_bitmap, entries_written, completed_at
}
PRIMARY KEY ((tenant_id, mailbox_shard, dispatch_id), lane_id)
  • 分块进度必须与邮箱条目在同一次原子提交内写入,或采用等价的"严格顺序 + 确定性幂等重放": 先写全部邮箱条目、成功后再写进度;崩溃后重放该块,因 event_id 是确定性哈希(§6.7),重复写入收敛到同一结果。 阶段一的 ScyllaDB 实现必须走后者——UserMailboxEntry 的分区键是 (tenant_id, user_id), DispatchProgress 的分区键是 (tenant_id, mailbox_shard, dispatch_id),二者不同分区,无法做同分区原子批量。 详见 §10.4.1。
  • 保留期 = dispatch_progress_retention(默认 7 天),必须 ≥ 日志保留期。它是 replay-safe GC 窗口:仅在分发日志保留期已覆盖、且水位/重放证明不再需要该进度后才可删除, 绝不能把“满 7 天”当成忽略未完成 dispatch 的理由。

7.9 群成员版本与成员槽位

GroupMembershipVersion {
    tenant_id, group_id, membership_version, mailbox_shard
      -> member_bitmap (RoaringBitmap, 不可变), member_count, created_at
}
  • membership_version 由 GroupMembership 在成员变更提交时递增,并按 mailbox_shard 预分片保存不可变 Bitmap。
  • 发送时刻由 MessageCommitter 在 Outbox 前固化当前版本,FanoutCoordinator 只按该精确版本读取;同一版本可被任意多条消息复用——因此版本数正比于成员变更次数,不正比于消息数。
  • 高频进退群的大群通过 membership_version_merge_window(默认 30 秒)合并变更,避免版本爆炸。
  • 保留期:旧版本必须覆盖"尚未完成的分发任务 + 重放窗口",取 max(dispatch_progress_retention, log_retention_days) 并加安全余量(数值见附录 B.3)。
MemberSlotMap {
    tenant_id, group_id, user_id -> slot_id, assigned_at, released_at
}
PRIMARY KEY ((tenant_id, group_id), user_id)

槽位分配规则(v1 完全缺失,slot 复用会导致跨用户错投):

  • slot_id 在 (tenant_id, group_id) 内单调递增分配,永不复用,退群只置 released_at 并从当前版本 Bitmap 清位。
  • 槽位空洞由 RoaringBitmap 自身的稀疏压缩吸收,不需要紧凑化。
  • 当 max(slot_id) > member_count × slot_compaction_ratio(默认 4)时,才允许在一次停写窗口内重编号,并强制递增 membership_version;重编号期间所有旧版本 Bitmap 一律作废。
  • 在线用户 Bitmap 与成员 Bitmap 必须使用同一套槽位映射,否则求交结果无意义。
  • 跨语言互操作(契约性约束):所有持久化或跨节点传输的 RoaringBitmap(分片成员 Bitmap、在线成员 Bitmap、dirty_users 检查点等)必须使用 RoaringFormatSpec 的 portable 序列化格式,保证 Go / Rust 实现互读、以及中途更换实现语言时数据可迁移(ADR-0006 依赖本条;Rust 侧为 roaring-rs,Go 侧为 roaring)。

7.10 PresenceEntry

PresenceEntry {
    tenant_id, user_id, device_id
      -> connection_id
         node_id
         connection_shard
         session_epoch
         capabilities                    # 协议版本、是否支持 E2EE、推送能力
         client_platform
         lease_expire_at
}
  • 唯一写入方:持有该 ConnectionShard 租约的 ConnectionNode,在 AUTH_OK 后写入。
  • 租约续期 presence_lease_renew,过期 presence_lease_ttl(数值见附录 B.4)。
  • 发布通道与收敛机制见 §5.4。
  • 同一 compacted topic 允许发布一类 PresenceRevoke 控制记录(key 为 (tenant_id, user_id[, device_id])), 用于远程登出、设备吊销与账号封禁:ConnectionNode 消费到该记录后立即对匹配连接下发 KICKED{reason=token_revoked|banned} 并关闭。复用同一通道使吊销与在线状态共享同一条收敛路径(§20.3)。

7.11 ShardRegistry 中的边界表

EpochBoundary {
    mailbox_shard_id, shard_epoch
      -> start_mailbox_seq, prev_epoch_end_mailbox_seq, reason, created_at
}

ShardSplitBoundary {
    old_shard, old_epoch, split_at_seq -> new_shard, new_epoch, bucket_range
}
  • 两张表都进入 §19.3 的检查点内容。
  • 游标迁移是按边界表换发签名令牌,不是数值映射(§5.5)。

7.12 聊天室结构

RoomRecord {
    tenant_id, room_id, room_epoch, room_seq
      -> message_id, sender_id, message_type, payload, created_at
}
  • 保留 room_log_retention_minutes(默认 30 分钟),显著短于普通会话。
  • 不产生 UserMailboxEntry,不进入会话列表投影。

承载形态:RoomWriter 进程内环形缓冲,不持久化(ADR-0007)。

房间已由 RoomWriter 单写(分配 room_seq),回放窗口天然可以进程内维护:
    每房间一个环形缓冲,按 room_log_retention_minutes 与条数双上限封顶
    不写盘、不跨节点复制、不进检查点

RoomWriter 重启 / 迁移:room_epoch 递增(§6.6)
    -> 客户端在 ROOM_BATCH 中看到 epoch 变化
    -> 按 §14 直接跳到当前水位(replay_truncated=true)
    -> 与"离线超出回放窗口"完全同一条路径,不需要任何新增规则

这是本设计唯一允许丢失的持久化数据,其正当性来自 §14 已声明的 「超出回放窗口是聊天室的正常稳态」——回放本就是尽力而为。 若产品要求回放跨 RoomWriter 重启存活,才需引入外部存储(Redis zset 是合适载体), 届时按 ADR-0007 复评。

7.13 版本字段语义汇总

v1 中 head_version / state_version / projection_version 只出现在结构体里、无任何语义定义,会被实现者各自解释。本版统一:

字段 递增方 冲突处理 是否参与并发控制
head_version ConversationWriter 正常路径单写串行;切换窗口内按 (fencing_epoch, head_version) 条件更新 是(仅故障切换窗口)
state_version UserConversationState 写入方 大者胜 + 逐字段合并;水位类字段取 max 是
projection_version 结构演进时人工递增 不冲突 否(仅兼容判定)
projection_mailbox_seq MailboxNode 单调,更大者覆盖 是(投影与 SESSION_DELTA 的唯一版本源)

7.14 DevicePreKeyBundle(E2EE 预共享密钥)

DevicePreKeyBundle {
    tenant_id, user_id, device_id
      -> identity_pub                    # 设备长期身份公钥(私钥永不离开设备,§22.2)
         signed_prekey                   # {key_id, pub, signature},按
                                         #   e2ee_signed_prekey_rotation_days 轮换
         one_time_prekeys[]              # {key_id, pub},每个只发放一次,发放即删除
}
PRIMARY KEY ((tenant_id, user_id), device_id)
  • 服务端只存公钥,本表不含任何私钥或会话状态(§22.2 的服务端职责边界)。
  • 写入方:设备本人经 PREKEY_PUBLISH(附录 A.3);读取方:发起 E2EE 会话的客户端经 PREKEY_FETCH。
  • one_time_prekeys[] 耗尽时 PREKEY_FETCH 返回 SignedPreKey 并置 one_time_exhausted=true(计数进入 §24 可观测),补充水位见 e2ee_prekey_low_watermark(附录 B.6.1)。
  • 设备被吊销(§15.3、§20.3)时同步删除其 bundle,防止向已吊销设备建立新会话。

7.15 表情回应 MessageReactionSummary 与 MessageReaction

表情回应是大群里频率最高、单条价值最低的事件类型:一条消息可累积数千个回应。 若按普通消息走 fanout,一条消息 5000 个回应 × 10 万成员 = 5 亿条邮箱条目, 直接击穿 §10.2 的成本模型。因此它采用聚合存储 + 按需拉明细, 与 §13.6 的投递规则配套。

MessageReactionSummary {                 # 聚合计数,与消息同分区,随消息一次范围读取回
    tenant_id                            # 分区键(与 MessageRecord 完全相同的分区键)
    conversation_id                      # 分区键
    seq_bucket                           # 分区键
    conversation_seq                     # 聚簇键
      -> counts                          # map<reaction_key, u32>,reaction_key 为短字符串
         self_reaction_keys              # 读时按请求者填充,不持久化
         summary_version                 # u64 单调递增,聚合推送的幂等版本源
         updated_at
}

PRIMARY KEY ((tenant_id, conversation_id, seq_bucket), conversation_seq)
MessageReaction {                        # 明细,仅按需分页拉取
    tenant_id, conversation_id, conversation_seq   # 分区键
    user_id                                        # 聚簇键
      -> reaction_key, created_at
}

PRIMARY KEY ((tenant_id, conversation_id, conversation_seq), user_id)
单分区上界 = 该消息的回应人数;超过 reaction_detail_max_per_message(附录 B.6.1)
后停止记录明细,只累加聚合计数(大群下明细本就无展示价值)
  • MessageReactionSummary 与 MessageRecord 共用分区键,因此 §18.3.1 的分组批量读 可以在同一次范围读中把消息与其回应聚合一并取回,不增加往返。
  • summary_version 是 REACTION_UPDATE(附录 A.4)的唯一版本源,语义与 projection_mailbox_seq 对 SESSION_DELTA 的作用完全一致:绝对值覆盖、旧版本丢弃。
  • 回应不产生 UserMailboxEntry,因此不占邮箱容量、不参与 §25.1 的容量公式。

8. 消息提交与单聊发送

8.1 提交链路

客户端
  -> SEND_MESSAGE{request_id, client_message_id, conversation_id, message_type,
                  payload, mention_targets, reply_to_conversation_seq}
ConnectionNode
  -> L1 发送者维度准入(连接本地令牌桶,见 §8.2)
  -> 按 conversation_id 路由到该会话 Home Region 的 ConversationWriter
ConversationWriter
  -> 身份与成员关系校验(membership_state 必须为 ACTIVE,否则 PERMISSION_DENIED)
  -> 内容尺寸校验(max_custom_payload_bytes / max_frame_bytes,超限 PAYLOAD_TOO_LARGE)
  -> L1 复核 + L2 会话维度准入 + L3 租户 fanout 配额(见 §8.2)
  -> ClientDedup 幂等占位(IF NOT EXISTS,见 §8.3)
  -> 同一临界区内分配 message_id / conversation_seq / last_activity_id(§6.2 / §6.3 / §6.4)
  -> 写 MessageRecord
  -> 追加提交日志(可靠 Outbox)
  -> SEND_ACK{request_id, client_message_id, message_id, conversation_seq, last_activity_id}
  -> 异步更新 ConversationHead(失败重试并告警,不阻断 fanout,见 §7.4)
FanoutCoordinator
  -> 消费提交日志
  -> 固化 membership_version
  -> 按目标 MailboxShard 合并为 S 条 GroupDispatch
     单聊 S <= 2(发送者与接收者各一片,同片时合并为 1)
MailboxNode
  -> 按 dispatch_id 去重 -> 成员边界过滤 -> 按 lane 拆子任务
  -> 插入 UserMailboxEntry,达到持久性契约后推进 W[lane](§9.2)
  -> 与在线集合求交,按 ConnectionShard 合并 PushBatch
ConnectionNode
  -> PUSH_EVENTS 写入在线 Socket

单聊不走特例短路,与群消息复用同一条 fanout 路径。原因是单聊与群聊若各有一套提交与物化语义, 邮箱水位、幂等键和恢复流程就要维护两份,故障时无法互相验证。

8.2 三层准入

v1 在提交链路里只写了"限流"两个字,等于没有闸门:大群成本项是 O(N) 邮箱写入(§10.2), 没有准入层时一个脚本化客户端即可让平台 fanout 预算瞬间见底。本版把准入拆成固定顺序的三层。

判定顺序(先廉价后昂贵,任一层拒绝即终止,后续层不消耗令牌):

  L1 发送者维度   per_sender_in_conversation_rate  ->  per_user_msg_rate
  L2 会话维度     per_conversation_msg_rate(按 member_count 分档)
  L3 租户维度     tenant_fanout_quota(entry/s 令牌桶)
层 参数(附录 B) 默认值 计数点 超限响应
L1a per_sender_in_conversation_rate 1 msg / 3 s ConnectionNode 本地 + ConversationWriter 复核 ERROR{code=RATE_LIMITED, retry_after_ms}
L1b per_user_msg_rate 20 msg/s 同上 ERROR{code=RATE_LIMITED, retry_after_ms}
L2 per_conversation_msg_rate N≤1000 → 20 msg/s;100010000 → 2 msg/s ConversationWriter(会话单写,天然是唯一计数点) ERROR{code=RATE_LIMITED, retry_after_ms}
L3 tenant_fanout_quota 按合同配置 FanoutCoordinator 持权威桶,ConversationWriter 租借 ERROR{code=FANOUT_QUOTA_EXCEEDED, retry_after_ms}
  • L1 在 ConnectionNode 本地先判一次,把明显超频的帧挡在跨节点 RPC 之前;ConversationWriter 复核是权威判定, 因为同一用户的多设备会落在不同 ConnectionNode 上。
  • L2 的档位随 ConversationHead.member_count 变化,档位切换在成员数跨越阈值的下一条消息生效。 §2.2 的"10 万成员上限"以该分档限制为前提。
  • L3 的消耗量按预估 entry 数扣减,而不是按消息条数:单聊扣 2,群消息扣该 membership_version 的 GroupMembershipVersion.member_count(§7.9)。这是唯一能让配额与真实成本对齐的口径; ConversationHead.member_count 是展示用近似值,不得用于任何 fanout 成本计算(§7.4)。 为避免每条消息一次跨服务 RPC,FanoutCoordinator 按 tenant_quota_lease_interval (默认 1 s,附录 B.5)把下一窗口的令牌批量租借给 ConversationWriter,由后者本地扣减; 租约到期未续则按保守值(上一窗口实际用量的 50%)执行。

准入位置的硬约束:

准入判定必须发生在 conversation_seq 分配之前。
被拒绝的消息:不分配 message_id / conversation_seq / last_activity_id,
              不写 MessageRecord,不追加提交日志,不产生任何 UserMailboxEntry。

禁止的实现(会造成静默丢消息):
  正文已提交 -> 后置的配额检查失败 -> 丢弃部分或全部邮箱引用
  这会让一部分成员看得到消息、另一部分永远看不到,且没有任何错误码暴露。

超限的行为只有两种:发送侧拒绝(返回错误码,客户端 pending 气泡转为可重试的失败态)或 发送侧排队(客户端按 retry_after_ms 退避后重发同一 client_message_id,由 §8.3 的幂等收敛)。 两种都在发送侧闭环,接收侧的邮箱引用要么全写要么不写。

8.3 幂等窗口与登录对账

幂等键与存储见 §7.2:ClientDedup,主键 (tenant_id, sender_id, client_message_id), DEFAULT TTL = client_dedup_ttl_seconds(默认 7200,ADR-0008)。

热路径先查 ConversationWriter 的内存 dedup 缓存(会话单写,正常重试都落在同一实例,命中即直接回放 SEND_ACK);ClientDedup 表用 IF NOT EXISTS 收敛跨实例场景:客户端重连换了 ConnectionNode、 Home Region 故障切换、客户端在 SEND_ACK 丢失后重发。

提交步骤与坐标固化顺序(顺序不可调换):

1. ClientDedup LWT 占位:IF NOT EXISTS {state=INFLIGHT, writer_lease_id, created_at}
2. 同一临界区分配 message_id / conversation_seq / last_activity_id
3. 回填 ClientDedup:{message_id, conversation_seq, last_activity_id, state=INFLIGHT}
4. 写 MessageRecord(主键由第 2 步坐标决定,重复执行为同值覆盖)
5. 追加提交日志(幂等键 = message_id)
6. ClientDedup.state := COMMITTED
7. 返回 SEND_ACK

第 3 步必须早于第 4 步:坐标一旦固化,之后的所有步骤都可以由任意接管者按同一坐标幂等重做, 不会产生"两条 conversation_seq 对应同一 client_message_id"的孤儿正文。

崩溃点 ClientDedup 行状态 恢复动作 是否产生孤儿正文
1 后 2 前 INFLIGHT,无坐标 超过 dedup_inflight_timeout(附录 B.5.1)后由 LWT 条件抢占,从第 2 步重做 否,MessageRecord 尚未写
3 后 5 前 INFLIGHT,有坐标 持会话租约的 ConversationWriter 从第 4 步按同坐标重做 否,同主键覆盖
5 后 6 前 INFLIGHT,有坐标 从第 5 步重做,提交日志按 message_id 去重 否
6 后 7 前 COMMITTED 客户端重试直接命中并回放 SEND_ACK 否

2 小时窗口与登录对账(ADR-0008,取代最初的"24 小时 + 恒等对齐"方案):

  • 代价方向一:ClientDedup 是每条消息一行的额外写入,TTL 越长,行数与存储线性增长; 一期 Redis 形态下它还是键数量的主导项(每消息一键),24 h 窗口在目标速率下 是十亿级键(实测其键增速本身即引发 dict 翻倍抖动,见 ADR-0008 背景)。
  • 代价方向二:TTL 到期后,同一 client_message_id 的重试会被当作新消息,产生重复气泡。
  • 结论:窗口收窄到 2 小时,重复气泡的防线从"窗口覆盖全部重试期"改为登录对账: 仅发送者自己的 UserMailboxEntry 回带 client_message_id(§7.3), 客户端重连后必须先同步邮箱并对账 local_pending(§27.3.2)—— 命中者原位升级为已确认,未命中者才重发;未命中即未提交,重发不产生重复。
  • 窗口只需覆盖"已提交但对账时尚不可见"的暴露期:在线退避重试(秒级)、 物化在途(毫秒级)、物化水位停滞(运营修复时限 1 h,窗口取 2 倍余量)。 水位停滞超 2 h 且期间客户端完成对账并重发,会产生重复气泡(不丢消息)—— 已接受的残余风险。
  • client_pending_max_age(附录 B.5.1)与窗口解除恒等,保持 24 h: 断网一天内重连的 pending 仍自动补发(须先对账);超过该值转"发送失败", 用户手动重发时生成新的 client_message_id。

8.4 SEND_ACK 的字段与语义

字段见附录 A:{request_id, client_message_id, message_id, conversation_seq, last_activity_id}。

为什么必须回带 client_message_id:

客户端本地 pending 区按 (client_send_ts, client_message_id) 排序,恒排在已确认区之后(§6.9.1)。
收到 SEND_ACK 后,按 client_message_id 定位那一条 pending,
原位升级为已确认三元组 (conversation_seq, event_ordinal, event_id),触发一次局部重排。

只回 request_id 不够:request_id 是连接内标识,重连即失效,
                      重连后到达的 ACK 无法定位任何 pending。
只回 message_id 不够:客户端不知道这个服务端 ID 对应本地哪条 pending,
                      结果是气泡重复(一条 pending 永远转圈 + 一条新消息插入)。

发送者自己的邮箱条目同样携带 client_message_id(§7.3,仅发送者自己的条目)。 因此无论消息是通过 SEND_ACK、PUSH_EVENTS 还是 MAILBOX_BATCH 回到本设备, 客户端都能用同一个键完成去重回显,不会出现"自己发的消息在重连后变成两条"。

语义(写死,产品与监控必须使用同一口径):

SEND_ACK == 正文(MessageRecord)与可靠分发事件(提交日志)已提交
         == §11.2 的 COMMITTED 层级

SEND_ACK 不表示:
    任何接收者的邮箱引用已物化(那是 MAILBOXED)
    任何接收者的设备已收到(那是 PUSHED / APPLIED)
    任何人已读(那是 READ)

UI 禁令:SEND_ACK 只能把 pending 气泡升级为"已发送",
        不得展示为"已送达"或"已读"。

9. 个人邮箱与离线同步

9.1 为什么不是公共日志扫描

三者分工固定,客户端永远不直接读取分发日志:

提交日志 / 分片分发日志    恢复与复制用途,只被 FanoutCoordinator 与 MailboxNode 消费
UserMailboxEntry(个人邮箱) 客户端精准同步用途
MessageStore(MessageRecord) 正文与历史用途

MailboxNode 把日志事件物化成按用户前缀组织的独立索引,客户端查询的是个人索引:

MailboxShard 的事件范围:10001...10500

用户 A 的独立队列键:
  (A, 10008) -> entry
  (A, 10217) -> entry
  (A, 10491) -> entry

PULL_MAILBOX(A, after_seq=10001)

底层按 (tenant_id, user_id) 分区、mailbox_seq 聚簇做范围查询(§7.3),只访问 A 的三条索引。 不存在"扫描 500 条公共事件再过滤出自己的三条"的过程——那正是 §3 明令禁止的方案。

这种设计比"每用户维护一个投递计数器"更适合大群:既保留个人精准队列, 又避免为 10 万成员分别竞争并持久化 10 万个序号计数器。代价是个人队列稀疏, 因此 mailbox_seq 的差值与条数无关(§6.10.1 据此禁止用差值判丢)。

9.2 lane 物化水位与持久性契约

9.2.1 分片级单一水位为何造成队头阻塞

v1 要求 materialized_watermark 按 mailbox_seq 全局顺序推进。具体后果:

MailboxShard 7 上有 20 万用户,其中 3000 人属于某 10 万人大群。
该群一条消息在本分片得到 mailbox_seq = E,展开 3000 条引用耗时 t。

在 t 期间:分片水位停在 E-1。
        同分片上一个从不加群、只发单聊的用户 U,其新消息 mailbox_seq = E+5,
        虽然早已完成物化,但因为"不得越过连续可见水位"而不可见。
        U 的对话方看到"已发送",U 自己什么都收不到。

§10.3 的"配额和公平调度"解决不了这个问题:配额分配的是 CPU 与 IO 份额, 它能保证大群任务不吃满节点资源,但不能让 E+5 在 E 之前变得可见—— 可见性规则本身要求连续。这是语义阻塞,不是资源阻塞,必须用语义手段解决。

9.2.2 lane 向量水位

lane 的定义见 §6.5.1(lane_id = blake3(tenant_id, user_id)[0] & (lane_count - 1), 默认 lane_count = 64)。该公式与 user_bucket、分片数完全解耦,分片分裂不改变任何用户的 lane_id。 水位由标量改为向量:

MailboxNode 维护 materialized_watermark[lane_count]
对某用户暴露的可见水位恒为 W[lane_id(user)]

W[j] 的推进规则(每 lane 独立的连续水位,不是全局连续水位):
  W[j] 可推进到 S,当且仅当对所有 mailbox_seq ∈ (W_old[j], S],
  由该 seq 在 lane j 上派生的子任务集合为空,或全部已达到 §9.2.3 的持久性契约。

min(W[0..lane_count-1]) 为分片级水位,只用于检查点与备节点接管判定,不对客户端暴露。
  • dispatch 在分片内按 lane 拆子任务:MailboxNode 取 GroupMembershipVersion 的本分片成员 Bitmap,按 lane_id 分组,只为非空 lane 生成子任务(至多 lane_count 个)。 上例中 3000 人分散在 64 个 lane 上,用户 U 所在 lane 若不含该群成员,该 lane 无子任务, W[lane(U)] 直接越过 E 推进到 E+5——"无子任务"与"子任务已完成"在 lane 内等价。
  • 各自独立推进:子任务之间不互相等待,跨 lane 无顺序约束。
  • mailbox_seq 的定义不受 lane 影响,仍是分片全局的分发日志 offset; lane_id 由服务端按 §6.5.1 的独立哈希计算并签入游标令牌,客户端不得上行伪造。
  • 一个用户终生只属于一个 lane(blake3(tenant_id, user_id) 稳定),因此不存在跨 lane 的顺序拼接问题; 跨分片分裂同样稳定,分裂期不会触发全网重拉。

9.2.3 持久性契约

v1 只写了"达到复制要求",没有定义什么叫达到——副本数、是否 fsync、谁来 ack 全部缺失, 导致 §9.2 不可验收。本版按存储实现分别写死:

阶段一(MailboxStore = ScyllaDB 实现):
    该 lane 子任务的全部 UserMailboxEntry 写入返回 LOCAL_QUORUM 成功(RF=3,W=2),
    且同批次的 DispatchProgress{lane_id, chunk_done_bitmap} 已写入成功。

阶段二(MailboxStore = 自研 LSM 实现,Go 用 Pebble / Rust 用 RocksDB):
    该 lane 子任务的 WriteBatch 已 fsync 到本节点 WAL,
    且已复制到 >= 1 个备 MailboxNode 并收到其 fsync 确认。

两种实现的共同不变量:

  • 条目与进度必须成对生效(顺序约束见 §10.4),任一未满足则 W[j] 不推进。
  • 水位不依赖内存态。节点重启后必须从持久化存储重算 W[j], 禁止从内存快照恢复尚未落盘的水位,否则重启会凭空推进可见性。
  • W[j] 单调不减。任何会造成回退的计算结果必须被拒绝并告警。

9.2.4 硬约束

单 lane 单 WriteBatch 条数上界            mailbox_writebatch_max_entries
单 WriteBatch 提交 P99 上界               mailbox_writebatch_commit_p99
子任务超时                                mailbox_subtask_timeout
lane 停滞上界                             lane_stall_failover

数值见附录 B.5.1。

  • 超过 mailbox_writebatch_max_entries 的 lane 子任务按块拆分,块号记入 DispatchProgress.chunk_done_bitmap; 只有全部块完成才算子任务完成。
  • 子任务超过 mailbox_subtask_timeout 未完成:该 lane 进入 lane_degraded 状态, 触发 P1 告警,向该 lane 的在线连接下发 PONG.mailbox_dirty=true, 并把子任务转交独立的慢通道执行器(不再占用主流水线)。 降级不等于跳过:W[j] 仍然不推进,跳过未完成子任务推进水位就是静默丢消息。
  • 停滞超过 lane_stall_failover 未恢复:触发该分片的租约漂移接管与日志重放(§10.4.2 形态 B;若部署了热备则为形态 A,判定规则相同)。 这就是"不得无限期拖住 W[j]"的落地方式——拖住有上界,上界到了换节点。

9.3 登录同步协议

9.3.1 时序

AUTH_OK 已合并 v1 的 SYNC_REQUIRED / SYNC_EMPTY(附录 A),登录风暴时每连接省一个 RTT。

客户端 -> AUTH{access_token, device_id, client_version, capabilities, mailbox_cursor}

服务端 -> AUTH_OK{session_epoch, lane_id, lane_watermark, trim_watermark, sync_to_seq,
                  has_offline, pending_entry_count_hint, pending_bytes_hint,
                  total_unread, total_mention, muted_unread, badge_projection_mailbox_seq,
                  projection_complete, preferred_endpoint, sync_delay_hint_ms,
                  next_ping_interval_ms}

        sync_to_seq := AUTH 时刻的 lane_watermark 快照        ← 权威等式

  has_offline = false 时,客户端可直接发 SYNC_COMPLETE,不发任何 PULL_MAILBOX

客户端 -> PULL_MAILBOX{after_seq, up_to_seq=sync_to_seq, max_items, max_bytes, acked_seq}
服务端 -> MAILBOX_BATCH{entries[], covered_through_seq, lane_watermark, has_more}
        (可流水线,在途请求数 <= pull_mailbox_window = 4,见 9.3.2)
        entries[] 的结构见附录 A.4.1(与 PUSH_EVENTS.events[] 共用),
        lane_watermark 回带该 lane 的最新上界,使客户端在连接期内无需从 PONG 获知水位。

客户端 -> SYNC_COMPLETE{sync_to_seq}
服务端 -> ONLINE_READY                                        ← 解除登录屏障

条件步骤(强制):当 AUTH_OK.projection_complete=false,或该用户所属任一会话的 mailbox_write_policy=mention_only 时(服务端在 AUTH_OK 以既有字段组合或 capabilities 回显告知),客户端必须在 SYNC_COMPLETE 之后、首屏渲染会话列表之前 执行一次 PULL_SESSION_LIST。

sync_to_seq := AUTH 时刻的 lane_watermark 快照 是本协议的地基: 它把该设备的事件一刀切成"客户端拉"与"服务端推"两半(§9.4),两半都不需要再判断对方的进度。 v1 全文没有这个等式,导致 sync_to_seq 的来源可以被实现者随意解释。

AUTH 阶段的游标判定(在下发 AUTH_OK 之前完成,顺序固定):

if 游标签名无效 or last_applied_mailbox_seq 越界:
    -> ERROR{code=CURSOR_INVALID}                    客户端重新认证
if cursor.last_applied_mailbox_seq < effective_trim(user):    # 用户级有效裁剪线,§18.3.3
    -> ERROR{code=CURSOR_EXPIRED, trim_watermark, rebuild_required=true}
    -> 禁止退化为 AUTH_OK{has_offline=true}          走 §9.6 REBUILD
if cursor.shard_epoch 落后但 EpochBoundary 可解析:
    -> ERROR{code=CURSOR_REBASED, new_cursor, replay_from_seq}
else:
    -> AUTH_OK

9.3.2 流水线拉取与 acked_seq

串行 request-response 下 1 万条积压需要 20 次以上往返(pull_mailbox_max_items = 500), 登录延迟被 RTT 主导。流水线规则:

客户端把 (cursor.last_applied_mailbox_seq, sync_to_seq] 按 seq 值均分为
pull_mailbox_window = 4 个互不相交的子区间,并发发出 4 个 PULL_MAILBOX,
各自携带独立 request_id 与各自的 (after_seq, up_to_seq]。

子区间内部仍然串行:收到 MAILBOX_BATCH 且 has_more=true 时,
以本批的 covered_through_seq 作为下一次 after_seq 继续拉该子区间。

由于 mailbox_seq 稀疏,子区间条数天然不均衡;空子区间一次往返即结束,代价可忽略。

游标推进仍然要求连续:只有子区间 1..k 全部拉完并应用后,游标才能推进到子区间 k 的上界。 不得因为子区间 4 先返回就把游标推到最高处——那会跳过 1..3 尚未应用的区间。

acked_seq 搭在 PULL_MAILBOX 上(附录 A,合并了 v1 的 MAILBOX_ACK),省掉每批一个 RTT。语义写死:

  • acked_seq 是纯服务端遥测与裁剪辅助,不是游标权威。游标权威在客户端本地(§6.8)。
  • 服务端用途:记录该设备最近应用位置,供 §18.3 的裁剪安全余量统计与 device_inactive_gc_days 判活使用;同时校验 PULL_MAILBOX.acked_seq <= 该用户 lane 当前 materialized_watermark W[lane_id] (与 §6.8 对明文 last_applied_mailbox_seq 的校验同源同语义),越界返回 CURSOR_INVALID。
  • after_seq 的合法域为 [cursor.last_applied_mailbox_seq, up_to_seq) 且 (after_seq, up_to_seq] ⊆ (cursor.last_applied_mailbox_seq, sync_to_seq](登录同步阶段); ONLINE_READY 后 up_to_seq 不得超过该 lane 当前 W[lane]。 子区间内部切分点因此天然合法,无需曾被发放。
  • 同步结束后的最后一次 ack 搭在 SYNC_COMPLETE 或后续 PING.last_applied_mailbox_seq 上, 不单独发帧。

9.3.3 covered_through_seq 三条规则

v1 的规则会永久跳过事件,本版整体重写。

规则 1(区间语义)
  after_seq 是开区间下界,up_to_seq 是闭区间上界。
  返回集合 ⊆ (after_seq, up_to_seq]。

规则 2(切分边界)
  批次切分只能发生在 mailbox_seq 边界:
  同一 mailbox_seq 的全部条目(事件组,<= 8 条且 <= 8 KiB,§6.7)
  必须在同一个 MAILBOX_BATCH 内完整返回。
  max_items / max_bytes 降为软上限,唯一硬约束是 max_frame_bytes。
  只要本批扫描已到达 up_to_seq(无论本批是否为空、是否含条目),
  covered_through_seq 一律取 up_to_seq;
  未扫到 up_to_seq(软上限或 max_frame_bytes 截断)时,
  取本批已完整返回的最大 mailbox_seq
  (若末尾事件组因不可切分而整组未返回,取该事件组前一个 seq)。
  本批为空且未扫到 up_to_seq 的应答是**非法的**:
  只要 (after_seq, up_to_seq] 内存在事件组,服务端必须至少完整返回一个事件组
  (受 max_frame_bytes 约束;单组 <= 8 KiB ≪ max_frame_bytes,恒可满足)。

规则 3(空洞跳过的前置条件)
  仅当 after_seq >= effective_trim(user)(§18.3.3)时,
  (after_seq, covered_through_seq] 区间内的序列空洞才可安全跳过。
  否则服务端必须返回 ERROR{code=CURSOR_EXPIRED, trim_watermark, rebuild_required=true},
  禁止退化为"返回空批次 + has_more=false"。

规则 2 修正了什么:§7.3 的主键含 event_ordinal 与 event_id,一个 mailbox_seq 下可有多条条目。 v1 的"批次达到数量或字节上限就返回最后一条实际记录的序号"会在事件组中间截断: 返回了 seq=10217 的第 2 条,covered_through_seq 置为 10217, 客户端下次以 after_seq=10217 开区间续拉,第 3、4 条被永久跳过。 §6.7 的事件组硬上限(≤ 8 条、≤ 8 KiB)就是为了让"不切分事件组"这条规则始终可行。

规则 3 修正了什么:v1 的论证是"所有不大于水位的事件都已完成物化,所以空洞只可能是 该用户本来就没有事件"。

这条论证漏掉了第二种成因:
    事件曾经存在,但已被 §18.3 的保留期裁剪物理删除。

两种成因在范围扫描结果上**完全不可区分**——都是"扫不到行"。

于是长期离线设备登录后:
    服务端一路把 covered_through_seq 推到 sync_to_seq,返回 has_more=false,
    客户端把游标推到最新,SYNC_COMPLETE 成功,ONLINE_READY 下发,
    系统全链路自认为同步成功,而用户整段离线消息静默消失,无任何错误码。

mailbox_trim_watermark 是把这两种成因区分开的**唯一**信息,
因此它是规则 3 的前置条件,而不是可选优化。

条目内容:邮箱引用 + 读时 join 的正文

MAILBOX_BATCH.entries[] 与 PUSH_EVENTS.events[] 共用附录 A.4.1 的同一结构:

  邮箱引用部分    逐字段对应 §7.3 的 UserMailboxEntry —— 邮箱只存引用,不复制正文
  读时 join 部分  body_included 及其后的正文字段,由 MailboxNode 在**读路径**
                 从 LRU 或 MessageStore join 出来;同一 message_id 每批只 join、只编码一次(§10.3)

body_included = false 的三种成因(正文已被治理删除、已过 retention_class 保留期、
单批正文总量超过 max_frame_bytes)由客户端走 PULL_HISTORY 补取或渲染占位,
**不得视为丢消息**(§6.10.1)。

covered_through_seq 与游标推进只由邮箱引用部分决定,与 body_included 取值无关 ——
正文缺失不是同步缺口,邮箱引用缺失才是。

9.3.4 SYNC_COMPLETE 的服务端校验

登录屏障是服务端状态,不是客户端自觉。服务端收到 SYNC_COMPLETE 后必须校验:

1. SYNC_COMPLETE.sync_to_seq == 本次 AUTH_OK 分配的 sync_to_seq
2. 本次会话已发出的 MAILBOX_BATCH 中,各子区间的 covered_through_seq 已连续覆盖到 sync_to_seq

任一不满足 -> ERROR{code=SYNC_INCOMPLETE}
             不下发 ONLINE_READY,屏障保持,客户端继续拉取

这堵住"客户端提前解除屏障":否则实时队列会在客户端尚未拉完时开闸, mailbox_seq 更大的实时事件先于未拉取区间到达,客户端要么乱序渲染,要么误推游标。

9.3.5 AUTH_OK 为什么必须带两个 hint

mailbox_seq 是分片级序号,个人队列稀疏(§6.5),
因此 sync_to_seq - cursor.last_applied_mailbox_seq 的**差值与待拉条数无关**。

真实场景:分片水位一天推进 1 亿,某用户在同一区间只有 3 条事件。

没有 pending_entry_count_hint / pending_bytes_hint,客户端无法:

  • 决定"一次全量拉完再进 UI"还是"分批拉并显示进度";
  • 显示确定的同步进度条(只能显示不确定态转圈,长同步时用户以为卡死);
  • 决定流水线子区间数量与 max_items 取值。

两个 hint 允许是估算值(来自 MailboxStore 的分区统计),误差不影响正确性—— 它们只驱动客户端策略,不参与任何完整性判定。

9.4 登录期间的新消息与实时队列

9.4.1 屏障语义

AUTH 时刻确定 sync_to_seq 后,该设备的事件按 mailbox_seq 一分为二:

  mailbox_seq <= sync_to_seq   由客户端通过 PULL_MAILBOX 拉取
  mailbox_seq >  sync_to_seq   进入该连接的实时待发送队列

ONLINE_READY 之后,服务端按 mailbox_seq 升序把队列内容以 PUSH_EVENTS 发出。

PUSH_EVENTS.events[] 与 MAILBOX_BATCH.entries[] 共用附录 A.4.1 的同一结构 (邮箱引用 + 读时 join 的正文,含 body_included),客户端复用同一套解析、 排序与去重路径,不需要为"推来的"和"拉来的"维护两套逻辑。

9.4.2 实时队列里存什么

队列条目 = UserMailboxEntry 引用 + 正文指针(指向 §10.3 的公共正文缓存对象,引用计数)

禁止在队列中复制正文副本。
一条 10 万人群消息若为每个在线连接各存一份正文,节点内存开销与在线人数成正比,
这正是本设计在 §10.2 要消除的成本项,不能在推送侧重新引入。

"正文指针"与附录 A.4.1 的读时 join 是同一件事的两端:

正文既不在邮箱(§7.3 只存引用),也不在实时队列(队列只存指针)。
唯一的正文实体是 MailboxNode 的 LRU / MessageStore 中的那一份缓存对象。

出队编码 PUSH_EVENTS 时,沿指针取到该对象填入 A.4.1 的读时 join 部分,
置 body_included = true;指针已失效(对象被淘汰且回源失败)或本帧超过 max_frame_bytes 时,
置 body_included = false 照常下发引用部分,由客户端走 PULL_HISTORY 补取。

因此"正文取不到"退化为一次客户端补取,而不是丢事件 —— 引用部分恒完整下发。

与 §11.3 的连接发送缓冲是同一套水位账本:

实时待发送队列与连接发送缓冲共用同一组计数器:
    conn_send_soft_watermark / conn_send_hard_watermark / conn_send_low_watermark(附录 B)

字节计量口径:同一正文对象在同一连接的队列中只计一次字节,条目本身按固定开销计。

两处各算各的会产生不可观测状态:
    入队侧认为未超水位继续入队,发送侧已触发降级丢弃
    -> "入队成功但静默不发",既不 PUSH 也不 MAILBOX_DIRTY,客户端永远等不到。

队列溢出或连接持续无消费时:丢弃内存中的正文与队列,下发 MAILBOX_DIRTY(PONG.mailbox_dirty=true 或 ERROR{code=MAILBOX_DIRTY}),由客户端重新拉取个人邮箱。 持久数据不受影响,因为在线推送前邮箱引用已可靠物化。

9.4.3 不变量:实时推送不得越位推进游标

设备游标只能由 MAILBOX_BATCH 连续推进(§6.8)。
PUSH_EVENTS 送达的事件不得推进 cursor.last_applied_mailbox_seq。

与 §9.3 规则 3 叠加时的丢消息路径(这两条是同一个问题的两半,必须同时实现):

1. 设备在 mailbox_seq=100 处断开,游标 = 100
2. 重连,sync_to_seq = 1000,区间 (100, 1000] 有 30 条待拉
3. 屏障期一条新事件 mailbox_seq=1500 到达并被推送
4. 若允许 PUSH_EVENTS 推进游标 -> 游标被推到 1500
5. 叠加规则 3:此时 after_seq=1500 >= trim_watermark,空洞可跳过
   -> 服务端认为 (1500, …] 无事件即同步完成
6. 区间 (100, 1000] 的 30 条**永久丢失,且不可检测**

客户端实现要求:在 ONLINE_READY 之前收到的 PUSH_EVENTS(服务端不应发,但协议上要防御) 一律进本地暂存区,不写入持久层、不推进游标。

9.4.4 MAILBOX_DIRTY 后的客户端状态机

收到 ERROR{code=MAILBOX_DIRTY} 或 PONG.mailbox_dirty=true

1. 暂停应用推送
     后续 PUSH_EVENTS 进入本地暂存区,不写入本地库、不更新任何 UI 顺序、不推进游标
2. 重新拉取(走 §6.10.2 的全局兜底通道)
     PULL_MAILBOX(after_seq = cursor.last_applied_mailbox_seq, up_to_seq = 0)
     up_to_seq = 0 表示"拉到当前水位":上界由服务端以该用户 lane 的最新 W[lane] 填充,
     并在 MAILBOX_BATCH.lane_watermark 中回带。客户端不需要、也无处从 PONG 获知水位。
     受 client_resync_min_interval(附录 B.3)限流
3. 双键去重(任一命中即丢弃)
     主键 (mailbox_seq, event_ordinal, event_id)   邮箱层幂等
     副键 message_id                                跨路径幂等:
         覆盖 PULL_HISTORY / REBUILD / 自发消息回显 / 推送重复 四条来源
4. 恢复
     拉取到 covered_through_seq >= 暂存区最小 mailbox_seq - 1 后,
     按 §6.9.1 排序键把暂存区合并进时间轴,恢复正常应用推送

客户端去重表容量与淘汰窗口(写死,否则弱端设备会在大批量重放时误判重复):

容量 >= max(pull_mailbox_max_items × 4, 最近 10 分钟内出现过的全部键)
     = max(2000 个键, 10 分钟窗口)
淘汰按 mailbox_seq 升序,先淘汰最旧。
message_id 副键表与主键表同窗口维护。

9.5 离线消息与历史消息

这个划分是 v1 的亮点,定义沿用,本版补齐两者的转换关系。

维度 离线消息 历史消息
载体 UserMailboxEntry MessageRecord
索引 (tenant_id, user_id) 分区 + mailbox_seq (tenant_id, conversation_id, seq_bucket) 分区 + conversation_seq
定义 设备游标之后、lane_watermark 之前的个人邮箱事件 指定会话按 conversation_seq 查询的消息正文
拉取帧 PULL_MAILBOX / MAILBOX_BATCH PULL_HISTORY / HISTORY_BATCH
触发时机 登录、重连、MAILBOX_DIRTY、全局兜底自检 进入会话、向上翻页、REBUILD 补齐、渲染空洞兜底
完整性语义 有:连续游标 + mailbox_trim_watermark 无:只提供边界字段,不承诺无遗漏
保留期 mailbox_retention_days(默认 7,ADR-0023) retention_class(§7.1;default 30 天,ADR-0019)

登录同步只拉个人邮箱,不逐会话查询历史(§3 禁令)。

9.5.1 两者的转换关系

邮箱裁剪推进 mailbox_trim_watermark 后:
    原本属于"离线消息"的事件降级为"历史消息"。
    正文仍在 MessageStore(受 retention_class 保护,通常远长于 30 天),
    但个人邮箱中的引用已不存在。

这个转换必须由 CURSOR_EXPIRED **显式暴露**给客户端,不得静默发生(§9.3 规则 3)。
客户端收到 CURSOR_EXPIRED 后走 §9.6 REBUILD,
把同步模式从"按 mailbox_seq 精确同步"切换为"按 conversation_seq 逐会话补齐"。

代价是明确的、可写进产品文案的:窗口外的未读数与提及计数不再精确, 消息本身不丢(正文仍在),顺序不乱(conversation_seq 权威)。

9.5.2 时间戳禁令

created_at 与客户端本地时间禁止作为任何增量同步的下界,理由与两条正确兜底通道 (全局兜底 PULL_MAILBOX、会话级兜底 PULL_HISTORY)见 §6.10.2。此处不重复。

补一条只属于本节的推论:离线消息与历史消息的边界判定也不得使用时间戳。 "本地最新消息时间是昨天,所以拉昨天以后的"是错误实现—— 分发重试导致的晚到消息(上界 fanout_retry_max_window,附录 B.2)会被永久跳过。 边界判定只能用 mailbox_seq(离线侧)与 conversation_seq(历史侧)。

上表"全局兜底自检"一行的具体形态是 PULL_MAILBOX(after_seq = cursor.last_applied_mailbox_seq, up_to_seq = 0): up_to_seq = 0 表示"拉到当前水位",由服务端填充该用户 lane 的最新水位, 并在 MAILBOX_BATCH.lane_watermark 中回带最新上界(§6.10.2、附录 A.3)。 客户端要显式指定上界时,只能取自 AUTH_OK 或上一次 MAILBOX_BATCH.lane_watermark, 不存在 PONG 携带水位这条来源。

9.5.3 PULL_HISTORY / HISTORY_BATCH 的用途

字段见附录 A。四类用途:

用途 direction anchor_conversation_seq
进入会话加载最近一页 older latest_conversation_seq(取自 SESSION_LIST_BATCH 或 ConversationHead)
向上翻页 older 本地该会话最早已有 conversation_seq
REBUILD 逐会话补齐 newer 本地该会话最大连续 conversation_seq(§9.6)
渲染空洞兜底 newer 空洞下界
跳转到某天 服务端先把日期映射为 conversation_seq,再按 seq 取数(§6.10.2)

HISTORY_BATCH 的两个边界字段让客户端 O(1) 自检,不需要试探性重试:

  • latest_conversation_seq:该会话服务端最新位置,用于判断"本地是否落后"。
  • earliest_available_conversation_seq:MessageStore 仍可提供的最早位置(受 retention_class 约束), 用于把"还能继续往上翻"与"已经到保留边界"区分开,避免客户端在到头后无限重试。

注意:会话内 conversation_seq 对用户视角天然稀疏(§6.3), 因此渲染空洞不等于丢消息,客户端只可用它触发补齐,不得据此判丢(§6.10.1)。

9.6 新设备与 REBUILD 流程

9.6.1 新设备冷启动

1. AUTH(mailbox_cursor 为空,即 last_applied_mailbox_seq = 0)
   -> AUTH_OK{has_offline = true, ...}
2. PULL_MAILBOX{after_seq = 0}:零游标表示“从未应用过任何条目”,服务端**不判过期**,
   返回保留窗口内尚存的全部邮箱条目(显式 trim 之后,至少 mailbox_retention_days = 7 天),
   按 MAILBOX_BATCH 分页推进游标(ADR-0023)
3. PULL_SESSION_LIST -> SESSION_LIST_BATCH:取得会话集合、排序与未读
4. 窗口之前的消息按会话 PULL_HISTORY{direction=older} 按需分页(用户上滑时继续)

零游标没有本地状态,保留窗口之前的缺口不属于它,无需 CURSOR_EXPIRED 暴露;非零游标越过 过期边界才返回 CURSOR_EXPIRED 并走 §9.6.3 REBUILD。不要求新设备下载账号创建以来的全部 邮箱事件——窗口外的一律是历史消息。

9.6.2 会话列表快照的权威来源分层

v1 §12.6 把重建写成无条件等式,与邮箱裁剪矛盾,本节澄清三层职责:

层 结构 角色 可否缺失
权威 UserConversationState 会话集合的持久权威:成员关系、可见区间、置顶静音、read_conversation_seq 否
权威 ConversationHead 每个会话的公共最新状态:latest_conversation_seq / last_activity_id / 预览 否
缓存 UserSessionProjection 排序与未读的物化视图 是,可重建、可滞后、可缺失
新设备即使 UserSessionProjection 完全缺失,
也能通过一次 (tenant_id, user_id) 分区扫描(<= max_conversations_per_user = 5000 行)
拿到全量会话集合,再与 ConversationHead 合成会话列表。投影只是加速。

v1 §12.6 的等式:
    最近 UserSessionProjection 快照 + projection_mailbox_seq 之后的邮箱增量 = 当前会话列表

它是**有条件**成立的,条件是:
    projection_mailbox_seq >= mailbox_trim_watermark

一旦投影滞后超过保留窗口,增量已被裁剪,等式右边不再可算。
因此该等式只是**快路径**,权威来源恒为 UserConversationState + ConversationHead。
阶段二 mailbox-tail 形态的硬性约束
(mailbox_retention_days >= 投影压缩器最坏滞后 P99.9 × 3)
保证快路径在正常运行时成立;约束被违反时系统必须能退回权威路径,而不是产生错误的会话列表。

9.6.3 REBUILD 流程(CURSOR_EXPIRED 之后)

0. 重新握手:收到 ERROR{CURSOR_EXPIRED} 后,客户端保留旧令牌身份字段、
   以 mailbox_cursor=空 重新 AUTH;服务端按 §9.6.1 冷启动路径下发
   AUTH_OK{sync_to_seq = AUTH 时刻的 lane_watermark 快照, has_offline=false},
   客户端直接 SYNC_COMPLETE 取得 ONLINE_READY 后再执行步骤 1~5
   (§9.3.4 校验 2 对空拉取区间天然满足)
1. 保留本地已有消息,**不清空本地库**
   (清空会造成用户可见的"消息全没了",而本地数据本身是正确的)
2. PULL_SESSION_LIST 取得权威会话集合(来源见 9.6.2)
3. 逐会话补齐:
     anchor = 本地该会话最大**连续** conversation_seq
     PULL_HISTORY{conversation_id, direction=newer, anchor_conversation_seq=anchor, limit}
     直到 has_more=false 或达到 latest_conversation_seq
     按 message_id 去重;已存在的行按 MessageRecord.state 覆盖为当前状态
     补齐顺序按 last_activity_id DESC,并发 rebuild_concurrency(附录 B.5.1)个会话,
     其余会话惰性补齐(用户进入时再拉),避免 5000 个会话一次性拉历史
4. 未读重算(口径见 §12.5):
     由 latest_conversation_seq 与 read_conversation_seq 计算,
     base = max(read_conversation_seq, hidden_before_conversation_seq,
                deleted_before_conversation_seq, joined_at_conversation_seq)
     latest - base <= unread_precise_limit(附录 B.6)时向 MessageStore 单分区范围读精确重算
     超过时返回 unread_count = unread_precise_limit, unread_exact = false
5. 窗口外提及数不保证精确:
     mention_count 与 unread 同样置 unread_exact = false,
     由 UI 展示为不精确态("99+"),不得展示为精确数字
6. 完成后由服务端换发签名游标令牌:
     cursor.last_applied_mailbox_seq := 本次 REBUILD 连接 AUTH_OK 的 sync_to_seq
     (lane_id / shard_epoch 由服务端签入,§6.8)
     依据:撤回/编辑经 PULL_HISTORY 的 MessageRecord.state 收敛(见下),
     §9.6.2 权威路径已覆盖 (trim, sync_to_seq] 的全部状态,无需邮箱级回放
7. 补齐期间新到事件正常走实时队列,不受 REBUILD 影响;
   REBUILD 与实时推送的去重仍按 §9.4.4 的双键规则

撤回与编辑不需要邮箱事件流即可收敛:

撤回与编辑在 §7.1 中原地更新 MessageRecord.state(RECALLED / EDITED / DELETED)
与 edited_at_activity_id,不新增时间轴行(§6.9.1 的 timeline_anchor_seq 规则)。

因此每会话 PULL_HISTORY 返回的就是**当前状态**:
    REBUILD 天然拿到撤回后的结果,不会复活已撤回消息,
    也不需要把撤回事件重放一遍。

REBUILD 后不可精确重建的只有:窗口外的未读数与提及计数。
这是 CURSOR_EXPIRED 的**全部**代价,产品文案应按此描述。

9.7 多设备与已读同步

9.7.1 自回声问题

邮箱键空间是**用户级**:(tenant_id, user_id)
设备游标是**设备级**:每个 device_id 一个 MailboxCursor

因此设备 D1 发出的 MARK_READ 所产生的 CONTROL 事件,写入的是同一个用户邮箱,
D1 自己也会在下一次推送或拉取中读到它 —— 这就是自回声。

后果:D1 每次已读都会收到自己刚发出的已读回声,触发无意义的会话列表重算与流量; 重度用户每天数百条已读事件 × 最多 max_devices_per_user(8)个设备,是可观测的浪费。

9.7.2 三层解决

1. 条目携带 origin_device_id(§7.3,仅控制事件)

2. 服务端不向发起设备回推
     MailboxNode 生成 PushBatch 时跳过 origin_device_id == 目标 device_id 的条目。
     **只跳过推送,条目仍然写入邮箱**:
     D1 换机、重装或走 REBUILD 时仍能从邮箱恢复已读位置。

3. read_sync_merge_window(默认 3 s)窗口合并
     同一 (user_id, conversation_id) 在窗口内只写最后一条已读事件。
     已读事件恒为 counts_unread=false、affects_session_order=false、timeline_visible=false(§7.3)。

客户端补充规则:拉取时若读到 origin_device_id == 本机 的控制事件, 只用于校准本地 read_conversation_seq,不触发通知、不触发 UI 抖动。

9.7.3 已读只进不退,未读下降靠重算

read_conversation_seq 取 max(§7.5 的水位类字段规则),只进不退。

产品侧的"标记为未读"不得回退该水位,它由 §7.5 的独立字段
manual_unread_conversation_seq(u64,写入值 = 标记时刻 head.latest_conversation_seq)表达:
    非空 -> 会话列表恒显示为未读,与 unread_count 的计算结果无关
    read_conversation_seq >= manual_unread_conversation_seq -> 自动失效(置空)
    它是用户主动状态,跨设备同步,投影重建后仍然存在

回退水位会让所有设备重新收到已读区间的未读增量,且不可收敛,因此绝对禁止。

跨设备未读下降通过重算实现,而不是负增量:

D1 已读
  -> read_conversation_seq 前进(UserConversationState,只进不退)
  -> CONTROL 事件写入用户邮箱
  -> D2 收到该事件
  -> D2 侧按 §12.5 的权威定义式**重算** unread,得到更小的值
  -> SESSION_DELTA 以**幂等绝对值帧**下发(附录 A),不使用 unread_delta

v1 的 unread_delta 在"至少一次投递 + 推送可丢弃"下必然双减或少减,
且没有版本号可供收敛,误差**永久漂移**。绝对值帧按 projection_mailbox_seq 取大者覆盖,
重放与丢帧两条正常路径都自动收敛。

9.7.4 设备之间互不吞消息

一个用户最多 max_devices_per_user = 8 个设备,8 个彼此独立的游标。

邮箱条目的裁剪只按时间窗口(§18.3),**不以任何单设备游标为条件**。
因此 D1 拉走事件不会让 D2 少收 ——
"一个设备吞掉其他设备的离线消息"在本模型中结构上不可能发生。

反向约束:长期不上线的设备只会触及 CURSOR_EXPIRED 与 device_inactive_gc_days(默认 60 天),
不会因为要等它而阻止裁剪,也不影响其他设备的可见水位。

10. 大群消息分发

10.1 分发流程

 1. 准入
      §8 的三层准入通过。大群走 per_conversation_msg_rate 的 N>10000 档(附录 B.5),
      并按 GroupMembershipVersion.member_count 从 tenant_fanout_quota 扣减令牌
      (fanout 成本一律以该值为准,ConversationHead.member_count 只是展示用近似值,§7.4)。

 2. 提交正文
      ConversationWriter 写一份 MessageRecord,
      同一临界区内分配 message_id / conversation_seq / last_activity_id,
      追加提交日志后返回 SEND_ACK。

 3. 固化 membership_version
      MessageCommitter 在 conversation_seq 分配之后、追加可靠 Outbox 之前,
      读取 GroupMembership 当前已提交版本并写入 CommitIntent/Outbox。
      该版本是不可变快照引用;恢复与 FanoutCoordinator 只能按这个**精确版本**读取,
      禁止改用消费时的当前版本。该点之后的进退群变更属于下一条消息,不影响本条。
      版本数正比于成员变更次数,不正比于消息数(§7.9)。

 4. 取目标分片列表
      按 Outbox 已固化的 membership_version 读 GroupMembershipVersion,
      得到覆盖的 MailboxShard 列表(S 个)。快照保留期必须覆盖日志恢复窗口。

 5. 写分片分发任务
      每个目标 MailboxShard 追加一条 GroupDispatch,
      dispatch_id = blake3("qim.group-dispatch.v1\0" || message_id_be16 ||
                    target_mailbox_shard_be4)[0:16](§7.8)。
      同一目标分片只生成一个任务,无论该分片上有多少成员。

 6. 分配 mailbox_seq
      分发日志分区 offset 与 shard_epoch 组合成该分片的 mailbox_seq(§6.5)。
      同一群消息在同一分片只分配一次。

 7. 去重
      MailboxNode 消费 dispatch,先按 dispatch_id 在 dispatch_progress_retention(7 天)
      窗口内去重。去重发生在展开之前。

 8. 加载成员 Bitmap
      加载该 membership_version 的本分片不可变 RoaringBitmap(LRU 命中则不回源),
      槽位映射见 §7.9。

 9. 成员边界过滤
      [一期实现,ADR-0021] 成员变更与序号分配同一原子区:版本 V 的快照恰是
      分配本消息序号时的成员,已离开者不在 V 内、入群者只出现在其入群事件之后的
      版本里,因此按精确 membership_version 展开即等价于下列过滤;历史与会话列表
      另按成员区间裁剪。visibility_floor_conversation_seq 字段尚未写入邮箱条目。
      跳过 left_at_conversation_seq <= 本消息 conversation_seq 的成员
      跳过 joined_at_conversation_seq >= 本消息 conversation_seq 的成员
      每条写入的条目携带
        visibility_floor_conversation_seq
          = max(joined_at_conversation_seq, hidden_before_conversation_seq,
                deleted_before_conversation_seq)

10. 按 lane 拆子任务并写入
      命中成员按 lane_id 分组,至多 lane_count(64)个子任务,只为非空 lane 生成子任务。
      每子任务按 mailbox_writebatch_max_entries(2000)分块,
      条目与 DispatchProgress{lane_id, chunk_done_bitmap} 按 §10.4 的顺序约束提交。

11. 推进水位
      子任务全部块达到 §9.2.3 的持久性契约后,独立推进 W[lane],跨 lane 互不等待。

12. 在线推送
      本分片成员 Bitmap 与在线成员 Bitmap 求交(必须同一套槽位映射,§7.9)
        -> 经 PresenceDirectory 展开 per-device 明细(§5.4)
        -> 按 ConnectionShard 合并成 PushBatch(§11.1)
        -> ConnectionNode 逐 Socket 写入 PUSH_EVENTS(§10.3)

10.1.1 成员边界过滤为什么必须存在

v1 第 8 步"为每个本地成员批量插入引用"与 §7.5 的 joined_at / left_at / membership_state
自相矛盾:
    退群成员仍在被固化的旧版本 Bitmap 中 -> 会收到退群之后的在途消息
    新入群成员若 Bitmap 已更新而消息更早 -> 会收到 joined_at 之前的消息

边界过滤把"是否可见"从**当前状态点查**改为**序号比较**。
序号比较的结果只依赖被固化的 membership_version 与本条消息的 conversation_seq,
因此主备物化结果一致、崩溃重放结果一致 —— 这正是 §7.5 引入 left_at_conversation_seq 的目的。

visibility_floor_conversation_seq 写进每条条目,使投影层与客户端不依赖最新 UserConversationState 即可判可见性。否则每次投影重放都要点查当前状态, 状态在重放期间变化就会得到不可重现的结果。

10.1.2 dispatch_id 去重为什么必须存在

无论生产者是否开启幂等(§6.5:分发日志生产者默认关闭,ADR-0017),同一 dispatch 都可能被追加两次:
    FanoutCoordinator 跨实例故障切换后重投
    分发日志 topic 重建后重放
两次追加得到**两个不同的 offset**,也就是两个不同的 mailbox_seq。
日志层的幂等保证不了这一点,必须由 MailboxNode 按 dispatch_id 去重。

去重必须发生在展开之前:否则一条 10 万人群消息会产生 10 万条重复条目,
虽然主键相同不会真正翻倍存储,但写入量、水位推进与推送量全部翻倍。

10.1.3 加群不回填历史邮箱引用

这是写死的规则,不是优化选项。

**成员变更事件的投递范围(写死,v2 补)**:

```text
默认只写给当事人一条 event_type=MEMBERSHIP 的 UserMailboxEntry。
是否额外向**其他成员**广播"X 加入了群聊"这类系统消息,由会话规模决定:

  member_count <= membership_event_broadcast_max_members(附录 B.5.1,默认 500)
      -> 允许广播:作为一条 conversation_seq 消息走正常 fanout,其他成员正常收到
  member_count >  该阈值
      -> **禁止广播**:只写当事人条目,其他成员不产生任何邮箱条目
         成员变化只在成员列表(PULL_MEMBERS,附录 A.3)中体现

理由:大群的成员变更频率最高,而 10 万人群每次进退群广播即 10 万条 fanout。
      该阈值不设,实现者按产品需求"显示 X 加入了群聊"就会直接击穿成本模型。

当事人自己的那条条目按下述规则写入:

加群只写一条 event_type=MEMBERSHIP 的 UserMailboxEntry,携带: joined_at_conversation_seq affects_session_order = true 会话应立即出现在列表顶部 counts_unread = false 加群本身不产生未读 timeline_visible = true 会话内显示"你加入了群聊"

加群之前的历史一律走 PULL_HISTORY。 可见下界 = joined_at_conversation_seq; 若群策略允许新成员查看全部历史,下界取 0,但仍然走历史通道,不走邮箱通道。

理由:

```text
回填等于为一个新成员写入该群保留期内的全部历史引用。
代入:10 万人群、日均 2000 条消息、mailbox_retention_days = 7(ADR-0023)
      -> 单次加群 1.4 万条邮箱写入(原 30 天时 6 万)
而加群是高频操作(拉人、扫码、群分享)。

回填在成本上不成立;在语义上也不必要 ——
历史消息本来就有独立的、按 conversation_seq 索引的拉取通道(§9.5),
邮箱通道的职责是"离线期间发生了什么",不是"入群之前发生了什么"。

10.2 成本边界与档位

10.2.1 先澄清一个认知

**本设计没有消除写扩散,也没有消除推送扩散。**

它消除的是:N 份正文、N 次跨服务 RPC、N 次正文编码。
它的代价仍然是:N 条邮箱引用写入 + O 次 Socket 写入。

若产品侧理解为"10 万人群一条消息只写 1 次",那是误解,
会直接导致容量规划低估两个数量级。

10.2.2 成本表

对成员数 N、目标 MailboxShard 数 S、在线成员数 U、平均在线设备数 D 的一条群消息:

MessageRecord 正文存储        O(1)
ConversationHead 更新         O(1)
分发日志记录                  O(S)
邮箱轻量引用写入              O(N)         ← 全系统最大成本项
正文编码次数                  O(涉及节点数)  见 §10.3
Socket 写入次数               O(U × D)     ← v1 用"在线人数"低估 D 倍
Socket 写入次数 = 在线成员数 × 平均在线设备数

v1 §21.2 写的是"群消息在线成员总数",按人均 2 设备计算**低估 2 倍**。
D 必须取自 PresenceDirectory 的实测值,容量评审中不得取 1。

无法同时满足"所有离线成员精确收到每一条群消息"和"完全不产生成员维度索引"。 系统消除的是 N 份正文、N 次跨服务调用和 N 次正文编码,不是假装消除最终收件人工作量。

10.2.3 档位与降级预留

mailbox_write_policy : always | mention_only

默认 always。默认档不改变 §2.2 的承诺:10 万成员全部获得持久邮箱引用。

mention_only 的完整语义(预留档位,默认不启用):

适用范围(准入判据,不是单一人数阈值)
    同时满足两条才进入 mention_only:
      (1) N × (1 - α) > read_diffusion_min_saving      单条消息能省下的条目数足够大
      (2) α < read_diffusion_max_active_ratio          活跃比例足够低才划算
    其中 N = GroupMembershipVersion.member_count
        α = 该会话的活跃成员比例 = |active| / N(观测指标见 §24.1.3)

    big_group_lazy_threshold(附录 B.5.1)是由上述判据**反推**出的等效人数阈值,
    **属待实测参数**:α 随 N 增大而下降,但下降曲线必须由压测/线上数据回填。
    回填前该阈值不得用于容量结论,也不得据此切换任何生产会话。

    为什么不能只用人数:α 才是决定性变量。200 人的工作群 α 可能达 0.7,
    此时读扩散只省 30% 写入,却让全群未读变估算、历史需联网拉——不划算;
    5000 人兴趣群 α 约 0.15,省 85%,才划算。

成员集合拆两个 RoaringBitmap(共用 §7.9 的同一套槽位映射)
    active = 近 active_window_days(附录 B.5.1)内有读 / 发 / 在线记录的成员
    silent = 成员集合 - active

写入规则
    active 集:逐条写 UserMailboxEntry,与 always 档完全一致
    silent 集:不写邮箱条目,只更新 UserConversationState.delivered_conversation_seq
              按分片批量写入,成本为 O(silent 覆盖的分片数),不是 O(silent 成员数)
    @提及:**无条件逐条物化**,无论目标在 active 还是 silent 集
          -> mention_count 与 mention_first_conversation_seq 保持精确

用户回归
    大群会话头由**已有的** SESSION_LIST_BATCH.sessions[] 承载
    (mention_only 生效时 PULL_SESSION_LIST 为新增的强制登录步骤,多一个批量 RTT,
    计入 §2.4 首屏 SLO,见 §9.3.1;不扩展 AUTH_OK、不新增帧),字段沿用 §7.6 的
    conversation_id / latest_conversation_seq / last_activity_id / preview_or_placeholder /
    unread_count / unread_exact,客户端按 PULL_HISTORY 补齐正文
    数据来源(写死):SessionProjection 服务 PULL_SESSION_LIST 时,
        对 membership_state=ACTIVE 且本用户处于 silent 集的大群,
        必须在读路径 join ConversationHead 的
        latest_conversation_seq / last_activity_id / preview_or_placeholder
        (§9.6.2 权威分层的既有能力);unread_count 按下方未读口径公式
        以 delivered_conversation_seq 参与估算并置 unread_exact=false
    成员任一读 / 发 / 上线动作立即置入 active 集,下一条消息起恢复逐条物化

历史缺口的定位与补齐(**默认开启该档的前置条件**)
    问题:silent 期间的消息没有邮箱条目,而 §6.10.1 禁止用 conversation_seq
          差值判缺口(seq 对用户视角天然稀疏),因此客户端**无法自行发现**
          本地历史缺了哪一段。silent -> active 往返时缺口还会交替出现。

    规则(写死):
      1. SESSION_LIST_BATCH.sessions[] 必须下发 write_policy;
         write_policy = mention_only 时必须同时下发 delivered_conversation_seq。
      2. 客户端对 write_policy = mention_only 的会话,进入会话时必须校验
         本地最大连续 conversation_seq 与 latest_conversation_seq,
         缺口一律走 PULL_HISTORY 补齐,**不得依赖邮箱条目的连续性**。
      3. write_policy = always 的会话保持原行为:邮箱条目即完整,无需该校验。

    缺此三条,读扩散档在用户侧表现为"某段聊天记录缺失且永不补齐",
    且因为禁止 seq 差值判缺口而不可检测。

档位切换的滞回(防抖动)
    群人数在阈值附近波动会来回切换写入策略,在邮箱中造成
    "一段有条目、一段没有"的交替,正好放大上述缺口问题。因此:
      进入 mention_only:满足准入判据
      退出回 always:   N 降至等效阈值 × policy_switch_exit_ratio(附录 B.5.1)以下
      任一次切换后至少保持 policy_switch_min_interval(附录 B.5.1)不得再切
    切换时刻记入 UserConversationState 所属会话的元数据,供客户端按第 2 条校验。

未读口径(估算,复用 UserSessionProjection.unread_count 并必须置 unread_exact=false,
不新增字段)
    unread_count ≈ latest_conversation_seq
                   - max(read_conversation_seq, joined_at_conversation_seq,
                         hidden_before_conversation_seq)
    该值把对本用户不可见的消息也计入,只能作为估算展示(UI 按 §12.5 的不精确态渲染)

启用条件是触发条件,不是固定阈值:

fanout_entries_per_sec 持续超过平台 fanout 预算的 70%
    (持续 = 连续 quota_sustained_window,附录 B.5.1)
或
大群邮箱写占 MailboxNode 总预算 > 50%

-> 启动 ADR 评审:docs/adr/0003-large-group-fanout-policy.md

禁止由运维直接改配置切换:该开关改变 §2.2 的产品承诺,必须走 ADR。

10.2.4 对标说明

系统 大群策略 离线可达性 成本形态
微信群 成员上限 500 全员写扩散在小 N 下成本可控 O(N),但 N ≤ 500
Telegram 超级群(上限 20 万) per-channel 游标:服务端只推进 channel 自己的序号,不为成员维护更新流;客户端为每个 channel 存一个游标,上线后按会话补齐 无逐成员离线队列;未读为估算;历史必须联网拉 O(1) 写 + 推送 + 会话级补齐
Slack channel 每会话游标 + 服务端未读水位 无逐成员离线队列 O(1) 写 + 会话级状态
Q-IM(本设计) 10 万成员全员写轻量邮箱引用 离线精确可达 O(N) 轻量写

Telegram 的机制与本文 mention_only 档的逐项对应(协议层公开 API 口径):

Telegram 本文 mention_only(§10.2.3)
超级群/频道不写 per-user 更新流 silent 集不写 UserMailboxEntry
per-channel pts(会话级游标) UserConversationState.delivered_conversation_seq
updates.getChannelDifference(channel, pts) PULL_HISTORY(conversation_id, after_conversation_seq)
会话列表带 top_message + unread_count PULL_SESSION_LIST 读路径 join ConversationHead
未读 = top_id − read_inbox_max_id(估算) latest_conversation_seq − max(read, joined_at, hidden_before),unread_exact=false
differenceTooLong → 全量重同步 CURSOR_EXPIRED → §9.6 REBUILD
私聊 / 小群仍走用户级 pts active 集与小群仍走 always 档逐条写邮箱
未读提及单独维护(getUnreadMentions) @提及无条件逐条物化,mention_count 保持精确

结论:mention_only 不是本文发明的降级路径,它就是 Telegram 在 20 万成员规模上验证过的默认架构。 两者的差别只在默认值与承诺:Telegram 把它当地基并因此不承诺离线精确可达; 本文把它当逃生舱,默认关闭,启用需 ADR,因为启用即改变 §2.2 的产品承诺。

一个被有意放弃的手段:Telegram 的 pts 是 per-(user, channel) 严格连续的, 客户端靠 local_pts + pts_count == new_pts 即可判缺口——这是最简单的完整性检测。 本文明令禁止用序号差值判丢(§6.10.1),因为 conversation_seq 与 mailbox_seq 对用户视角天然稀疏(定向消息、治理删除、joined_at 之前的历史都会造成空洞)。 代价是完整性判定必须锚在邮箱层(last_pushed_user_seq + W[lane] + covered_through_seq), 换来的是不必为每个 (用户, 会话) 维护一个连续计数器。 这是一处有意的反向选择,记录于此以免后续文档反复重开。

本设计选择了更贵的路径,换取的是:
    任意成员离线任意时长(<= mailbox_retention_days)后登录,
    只拉一条个人队列即可精确、无遗漏、无逐会话查询地恢复全部离线事件。

这个取舍是**有意的**,写进本文以避免后续文档反复重开该议题。
要改变它必须走 ADR,而不是在专题文档里悄悄换成拉模式。

10.3 节点内优化

- GroupMembership 按 MailboxShard 预分片,以不可变版本保存 RoaringBitmap(§7.9)
- 一次 GroupDispatch 在节点内按 lane 拆子任务、按 mailbox_writebatch_max_entries 分块写入
- 同一 dispatch 的正文只从 LRU 或 MessageStore 获取一次
- PushBatch 携带一份公共正文和多个轻量个性化接收者头(§11.1)
- ConnectionNode 对公共正文只编码一次,只为每个 Socket 生成个性化帧头
- 大群任务使用配额与公平调度(CPU / IO 份额),避免饿死单聊与小群

关于公平调度的边界:它解决的是资源竞争,不解决可见性阻塞。 后者由 §9.2 的 lane 向量水位解决。两者是正交手段,不可互相替代。

10.3.1 推送扩散的三层成本

v1 只统计 Socket 写入次数,漏掉了下面三层,按此规划 100 万在线聊天室会低估一个数量级。

第 1 层  syscall 次数
    朴素实现:每连接每消息一次 write()
             -> 100 万在线 × 1 msg/s = 100 万 syscall/s
    优化:同一 event loop tick 内累积多帧后合并为一次 writev()
         合并窗口 push_coalesce_window(附录 B.5.1)
    效果:一个连接在窗口内收到 k 帧时,syscall 从 k 次降为 1 次

第 2 层  TLS 加密 CPU
    **公共正文一次编码消除的是序列化成本,不消除加密成本。**
    TLS 会话密钥是 per-connection 的,密文无法跨连接复用,
    因此 O(U × D) 次加密是硬成本,必须计入 §25 的容量模型。
    实现要求:优先走 AES-NI / ChaCha20 硬件加速路径;
             加密 CPU 与消息字节数成正比,进一步印证"正文不进邮箱、缩略图有上限"的必要性。

第 3 层  网卡 pps 与线速
    合并之后仍受 pps 上界约束。启用 TSO / GSO 与 GRO,使内核与网卡按大段处理,
    降低每包固定开销。
    容量规划必须同时校验 bps 与 pps 两条线 —— IM 的小包特征使 pps 常常先于 bps 触顶。

10.3.2 与附录 A.1 的呼应

附录 A.1 规定帧完整性校验(header_crc)**只覆盖帧头**。

这与"公共正文只编码一次"是同一个优化的两半:
    若校验覆盖整帧,一条 10 万人群消息将退化为 10 万次全帧扫描,
    一次编码省下的 CPU 会被逐连接的校验重新吃掉。

body 完整性由 TLS 记录层保证,不需要应用层再算一遍。

10.4 故障恢复

10.4.1 幂等键与提交顺序

dispatch_id + target_mailbox_shard 是任务幂等键(§7.8)。
同一用户条目的幂等键是确定性主键 (tenant_id, user_id, mailbox_seq, event_ordinal, event_id),
event_id 为确定性哈希(§6.7),因此重复执行是同值覆盖。

DispatchProgress 与邮箱条目的提交要求:§7.8 规定二者必须在同一次原子提交内写入, 或采用等价的"严格顺序(先条目后进度)+ 确定性幂等重放"。两种存储实现各取其一:

阶段二(自研 LSM 实现,Go 用 Pebble / Rust 用 RocksDB)
    条目与 DispatchProgress 的 chunk 位在**同一个 WriteBatch** 内 fsync,天然原子。

阶段一(ScyllaDB)——§7.8 规定必须走等价方案
    UserMailboxEntry 的分区键是 (tenant_id, user_id),
    DispatchProgress 的分区键是 (tenant_id, mailbox_shard, dispatch_id),
    二者不同分区,无法做同分区原子批量。
    以"严格顺序 + 确定性幂等重放"达成等价效果,顺序**不可颠倒**:

      1. 先写该块的全部 UserMailboxEntry,全部返回 LOCAL_QUORUM 成功
      2. 再置 DispatchProgress.chunk_done_bitmap 的对应位

    崩溃在两步之间 -> 恢复时该块判为未完成 -> 重放该块
                   -> 条目按确定性主键同值覆盖(event_id 为确定性哈希,§6.7),
                      结果逐字节相同,不重复不遗漏。

    反向顺序(先记进度后写条目)会造成崩溃后**跳过该块**,即静默丢消息,绝对禁止。

10.4.2 接管:起始位点与水位追平

本节对两种接管形态同时适用,不假设存在热备:

形态 A  热备接管:备 MailboxNode 一直在消费同一分发日志并物化相同索引
形态 B  漂移接管:分片被授予一个此前不持有该分片任何状态的节点

两者的差别只有"重放窗口有多长",判定规则完全相同。

物化输入必须全部确定性(event_id 确定性哈希、created_at 取自 dispatch 记录,§6.7)。 这条约束在两种形态下都承重:形态 A 保证主备索引一致,形态 B 保证重放产生同值覆盖。

(1) 接管起始位点由水位推导,不取消费者组的已提交位点

起始位点 = min_j( W[j] ) 对应的 log_offset + 1

换算是免费的:mailbox_seq 的低 48 位就是 log_offset(§6.5 复合序号,ADR-0004)。
W[j] 由持久化的 DispatchProgress(§7.8)重算得出,是自洽的真相来源。
重放时按 dispatch_id 去重、按 DispatchProgress 跳过已完成的块(§10.4.1)。

为什么不能直接用已提交位点:若实现为「先提交位点、后物化条目」,崩溃会使某个 dispatch 既不在进度里、又已被位点跳过,该 lane 的 W[j] 将永久停滞(由 lane_watermark_stall_ms 告警暴露,§24),只能靠人工回退位点修复。 按水位推导起始位点后,位点提交时机降级为纯性能优化,正确性自愈。

(2) W[j] 必须持久化一条下界,接管时校验

每次推进 W[j] 时同批写入 (mailbox_shard, lane) -> W_floor[j]

接管校验:重算 W[j] >= W_floor[j]
    不满足 -> 拒绝对该 lane 提供服务 + P1 告警(重算结果低于曾经发布过的水位,
             意味着 DispatchProgress 缺块或存储层数据丢失,属不可自愈故障)

没有这条下界,新节点无从知道前任曾对外发布过多高的水位——§9.2.3 的「W[j] 单调不减」 在单节点生命周期内成立,跨接管则失去比较基准。

(3) 逐 lane 判定服务可用性(不是整片判定)

对每个 lane j:重算并追平 W[j] 后,才允许对该 lane 的用户提供服务。

未追平的 lane 必须拒绝 AUTH / PULL_MAILBOX,返回 ERROR{code=SHARD_MOVED, retry_after_ms}。
**禁止用未追平的水位回答 PULL_MAILBOX** —— 那会让 §9.3 规则 3 看到"假空洞",
在 after_seq >= effective_trim(u) 的条件下被判为可跳过,直接丢消息。

接管必然递增 shard_epoch(§6.5),因此旧游标按 EpochBoundary 换发(CURSOR_REBASED)。 形态 B 的接管频率高于形态 A,mailbox_cursor_rebase_count(§24)的告警阈值应按形态取值, 不得把正常漂移计为异常。

RTO 目标:

mailbox_takeover_rto_target(附录 B.5.1)= lane 级恢复可用时间上限
  = shard_lease_ttl(附录 B.1)
  + 检查点加载时间
  + 日志重放追平时间

附录 B.7 的 checkpoint_bytes / checkpoint_interval / replay_rate 回填后必须满足:
    checkpoint_interval × 分片事件速率 / replay_rate + 检查点加载时间 <= 15 s
不满足时的处置顺序:缩短 checkpoint_interval -> 提高 replay_rate -> 拆分逻辑分片。

10.4.3 保留期的数值关系

v1 三处保留期互相引用,形成循环定义,没有一个可代入的数:

v1 §10.4  旧成员版本至少保留到所有相关分发任务完成和安全窗口结束
v1 §17.3  群成员旧版本必须覆盖尚未完成的分发和重放窗口
v1 §18.3  日志保留期必须大于最坏检查点恢复时间和安全余量

本版写死单向依赖链,方向为「日志 → 分发进度 → 成员版本」,不再互相引用:

log_retention_days           >= §19.3.3 的良定义不等式给出的下界   (附录 B.3)
dispatch_progress_retention  >= log_retention_days                (附录 B.3)
membership_version_retention >= max(dispatch_progress_retention, log_retention_days) + 安全余量
                                                                  (附录 B.3)

本章不重复推导 log_retention_days 的下界:唯一规范是 §19.3.3 的不等式 log_retention_days >= (checkpoint_interval + T_recover) × 2(T_recover 定义见 §19.3.3), 数值见附录 B.3。任何其他安全系数的写法(例如"最坏检查点恢复时间 × 3")一律作废。

  • 成员版本保留期必须同时覆盖"尚未完成的分发任务"(由 dispatch_progress_retention 界定) 与"可能被重放的日志区间"(由 log_retention_days 界定),取两者最大值再加安全余量。
  • 任一保留期被下调时,必须沿依赖链向后检查,不得单点调整。
  • 违反该链的直接后果:日志重放时读不到对应的 GroupMembershipVersion, dispatch 无法展开,该分片进入不可恢复状态。

10.5 大群成本控制与配额

10.5.1 平台 fanout 预算

本节只定义平台 fanout 成本量本身,它是全系统最大的单项成本,也是分片数下界的主输入:

platform_fanout_entries_per_sec = Σ_g (msg_rate_g × N_g) + 单聊事件/s

  N_g 的取值(一律取 GroupMembershipVersion.member_count 口径,§7.9):
    mailbox_write_policy = always       -> 群全员数
    mailbox_write_policy = mention_only -> |active_g| + |mention_targets_g|

fanout 成本 → 分片数下界的定性关系:platform_fanout_entries_per_sec 越大, 单 MailboxShard 需承担的 entry/s 越高;当它逼近 per_shard_entry_budget 的目标利用率上限时, 必须提高 mailbox_shard_count。分发日志侧的 partition_dispatch_budget 是第二条独立约束, 两条取大者。

本章不重复推导分片数:唯一规范公式在 §25.6(附录 B.7 已复述),三档代入数值见 §25.0 的三档容量表。 注意两个量纲不可混用——分片数下界的分母是分片预算 per_shard_entry_budget, per_node_entry_budget 是单节点预算,只用于节点台数估算,不得代入分片数公式。 两者均属附录 B.7 待实测参数,回填前不得据此做采购决策。

10.5.2 租户级配额与分级降级

tenant_fanout_quota(附录 B)是每租户的 entry/s 令牌桶,权威桶由 FanoutCoordinator 维护, 按 tenant_quota_lease_interval(§8.2)租借给 ConversationWriter。

桶水位 动作 客户端可见
< 70 % 正常 无
70 % ~ 90 % 告警;该租户的大群 fanout 降为低优先级队列,单聊与小群不受影响 无
90 % ~ 100 % 该租户大群发送侧排队,SEND_ACK 延迟上升 发送变慢
> 100 % 拒绝该租户的大群发送:ERROR{code=FANOUT_QUOTA_EXCEEDED, retry_after_ms} 明确的发送失败
**按租户降级,禁止全局降级。**
一个租户的大群风暴不得让另一个租户的单聊排队。

配额与 lane 是两条独立的隔离手段,缺一不可:
    lane(§6.5.1 / §9.2)解决**可见性隔离**  —— 顺序语义层
    配额(本节)        解决**资源隔离**     —— 吞吐预算层

只有 lane 没有配额:可见性不阻塞,但节点写入能力被大群吃满,所有 lane 一起变慢。
只有配额没有 lane:资源被公平分配,但单聊用户仍被大群 dispatch 的连续水位卡住。

分级动作全部作用于发送侧:排队或拒绝,绝不在物化侧丢弃邮箱引用(§8.2 的硬约束)。 对应指标、阈值与告警见 §24。

10.5.3 本章参数的取值来源

§8~§10 引用的运行参数数值一律见附录 B.3 / B.5 / B.5.1 / B.6,本章不再自建汇总表, 避免形成第二套参数源(tenant_quota_lease_interval 已并入附录 B.5)。

11. 在线推送与到达语义

11.1 PushBatch(内部帧)

PushBatch 是 MailboxNode 发往 ConnectionNode 的内部帧,不对客户端暴露(附录 A.6)。 v1 的 recipients[] 只有 {connection_id, session_epoch, mailbox_seq},缺少全部 per-recipient 差异字段: counts_unread、mention_type、visibility_floor_conversation_seq 都是逐收件人不同的, 没有它们,§10.3 承诺的"公共正文编码一次、每 Socket 只生成轻量个性化帧头"无法实现, 客户端也无法执行 §6.9 的排序契约与 §12.5 的未读规则。本版补齐:

PushBatch {                                  # MailboxNode -> ConnectionNode
    tenant_id
    message_id
    conversation_id
    conversation_seq
    last_activity_id
    sender_id
    body_included                            # 正文是否随本批下发(附录 A.4.1)
    common_encoded_body                      # 已按线上格式编码一次的公共正文;
                                             # 正文**不存储在邮箱**,由 MailboxNode 在读路径上
                                             # 从本地 LRU 或 MessageStore join(§7.3)
    recipients[] {
        connection_id
        session_epoch
        user_id
        device_id
        mailbox_seq
        event_ordinal
        event_id
        flags                                # counts_unread / affects_session_order / timeline_visible
        mention_type                         # NONE | AT_ME | AT_ALL | REPLY_ME
        visibility_floor_conversation_seq
        [target_sender_id]                   # 仅撤回 / 编辑 / 治理删除的 CONTROL 事件(§7.3)
        [target_flags]                       # 同上
        [target_mention_type]                # 同上,per-recipient 计算
    }
}
  • 一个 ConnectionShard 只收到一个合并批次,无论该分片上有多少在线接收者。
  • recipients[] 的 per-device 明细(connection_id、session_epoch)来自 §5.4 的 PresenceDirectory 本地缓存, 推送路径上零次同步远程调用。
  • common_encoded_body 在 MailboxNode 侧对同一 message_id 只 join 一次、只编码一次(附录 A.4.1、§10.3), PushBatch 内所有收件人共享同一份字节。
  • 帧内不携带优先级字段:MailboxNode 与 ConnectionNode 之间按 §11.3 的五个优先级分设五条独立通道, PushBatch 走哪条通道即为其优先级。

11.1.1 ConnectionNode 的展开规则

for r in batch.recipients:
    conn = local_connections[r.connection_id]
    if conn == nil:
        丢弃该收件人;回 PRESENCE_STALE{user_id, device_id, connection_id}
        continue
    if conn.session_epoch != r.session_epoch:
        丢弃该收件人;回 PRESENCE_STALE{...}          # 旧连接已被替换,禁止写入
        continue
    frame = PUSH_EVENTS{
        events: [ { -- 邮箱引用部分(逐字段对应 §7.3 的 UserMailboxEntry)--
                    mailbox_seq: r.mailbox_seq,
                    event_ordinal: r.event_ordinal,
                    event_id: r.event_id,
                    event_type: 由 event_ordinal 反查(§6.7 是 1:1 固定映射),
                    message_id, conversation_id, conversation_seq, last_activity_id, sender_id,
                    visibility_floor_conversation_seq: r.visibility_floor_conversation_seq,
                    flags: r.flags,
                    mention_type: r.mention_type,
                    created_at,
                    -- 读时 join 的正文部分(附录 A.4.1)--
                    body_included: batch.body_included,
                    message_type / payload_or_ciphertext / media_metadata:
                        引用 batch.common_encoded_body(零拷贝,不重新序列化) } ],
        last_pushed_user_seq: max(conn.last_pushed_user_seq, r.mailbox_seq)
    }
    写入 stream 1(实时流)
  • PUSH_EVENTS.events[] 与 MAILBOX_BATCH.entries[] 共用附录 A.4.1 的同一条目结构: 邮箱引用字段(逐字段对应 §7.3)+ 读时 join 的正文字段(body_included 与 payload_or_ciphertext 等)。客户端因此复用同一套解析、去重、排序与投影路径, 离线批量与在线推送的唯一区别是触发时机,不是数据形状。
  • 正文不存储在邮箱:正文读取只发生在 MailboxNode 的读路径(§7.3「存储态与线上态的区别」)。 ConnectionNode 不做任何正文读取,只把已 join 并编码好的 common_encoded_body 原样引用进每个收件人的帧。
  • body_included=false(正文已被治理删除、已过 retention_class 保留期、 或单批正文总量超过 max_frame_bytes)时,客户端走 PULL_HISTORY 补取或渲染占位, 不得视为丢消息(附录 A.4.1)——条目本身已经到达,完整性判定只看邮箱层(§6.10.1)。
  • event_type 不需要在 PushBatch 中重复携带:§6.7 的优先级表 MESSAGE=0 / MENTION=1 / MEMBERSHIP=2 / CONTROL=3 是 1:1 映射,ConnectionNode 由 event_ordinal 确定性反查。
  • session_epoch 校验是强制的,不匹配一律丢弃并回 PRESENCE_STALE; MailboxNode 收到后失效本地 presence 缓存并按需回源(§5.4)。
  • ConnectionNode 按连接维护 last_pushed_user_seq = max(已写入该 Socket 的 mailbox_seq), 它是 PONG 的字段来源(附录 A.4),也是 §6.10.1 唯一合法的"是否需要补拉"判据。
  • 实时推送不推进设备游标(§6.8 不变量):客户端收到 PUSH_EVENTS 后可以立即渲染, 但 last_applied_mailbox_seq 只能由 MAILBOX_BATCH 连续推进。
  • 客户端按 (message_id, event_id) 幂等去重,按 §6.9.1 排序键插入时间轴。

11.1.2 与邮箱物化的顺序

物化 UserMailboxEntry 并推进 W[lane]   →   求交在线 Bitmap   →   发送 PushBatch

顺序不可颠倒。推送失败、连接断开、PushBatch 被丢弃都不回滚邮箱, 客户端一律通过 PULL_MAILBOX 恢复。这是"在线推送尽力而为、持久语义由邮箱兜底"的实现基础。

11.2 到达层级

COMMITTED      正文与 Outbox 已可靠提交(SEND_ACK 的语义边界)
MAILBOXED      接收者的 UserMailboxEntry 已可靠物化并被 W[lane] 覆盖
PUSHED         服务端已把 PUSH_EVENTS 写入目标连接的发送缓冲
               (不构成设备到达证据,不得单独作为取消离线推送的依据,§16.1.2)
APPLIED        客户端已解析、持久化并通过 PULL_MAILBOX.acked_seq 推进游标
READ           用户已读,属于会话级用户状态(read_conversation_seq)
NOTIFIED       离线推送已提交给 APNs/FCM(见 §16),仅表示已交给外部通道
  • 产品与监控必须使用这些名称,不得把 SEND_ACK 描述为"对方已收到"或"已读"。 SEND_ACK 只到 COMMITTED;UI 上的"已送达"最早只能对应 MAILBOXED,"已读"必须对应 READ。
  • NOTIFIED 是外部通道的投递受理,不是设备到达。APNs/FCM 均不保证送达, 因此 NOTIFIED 不得用于任何完整性判定,也不得推进任何游标。
  • 六个层级中只有 COMMITTED、MAILBOXED、READ 是持久事实; PUSHED 与 NOTIFIED 是尽力而为,APPLIED 是客户端断言。

11.3 慢连接与流控

v1 只有"软/硬水位"四个字,没有数值、没有优先级、没有节点级预算、没有退出降级态的条件。本版写死。

11.3.1 连接级双水位与滞回

conn_send_soft_watermark   1 MiB   或 2000 条        (附录 B.5)
conn_send_hard_watermark   4 MiB   或 8000 条
conn_send_low_watermark    256 KiB                   (滞回下界)

状态机:
  NORMAL   --缓冲 >= soft-->   DEGRADED
  DEGRADED --缓冲 <= low -->   NORMAL          # 必须降到 low 才恢复,不是降到 soft
  DEGRADED --缓冲 >= hard-->   CLOSING
  任意状态 --连续 idle_timeout 无消费--> CLOSING
  • DEGRADED 态行为:停止推送积压的 PUSH_EVENTS 正文,丢弃该连接待发的实时队列, 只保留一个标志位;随下一个 PONG 下发 mailbox_dirty=true,或立即发一帧 ERROR{code=MAILBOX_DIRTY}。控制流(stream 0)不受降级影响。
  • CLOSING 态行为:关闭连接。消息早已进入用户邮箱(§11.1.2),断开不造成持久消息丢失。
  • 滞回是必需的:只用单一软水位会在水位线附近反复进出降级态,产生 dirty 抖动。

11.3.2 帧优先级

控制流  >  单聊  >  小群  >  大群  >  聊天室          (附录 B.5)
  • 控制流 = stream 0 的 PONG/ERROR/KICKED/REDIRECT,永不因业务积压被丢弃或延迟, 这也是附录 A.5 要求多路复用的原因。
  • 小群与大群的分界为 large_group_member_threshold(默认 1000,与 per_conversation_msg_rate 第一档边界对齐;附录 B.6.1)。
  • 优先级只作用于两处:MailboxNode → ConnectionNode 的五条分发通道选择, 以及节点预算超限时的丢弃顺序(从低到高丢)。
  • 丢弃只发生在推送通道,不发生在邮箱层:被丢弃的大群消息仍在收件人邮箱里, 客户端按 mailbox_dirty 补拉即可。聊天室消息被丢弃则真的丢失(§14 有意如此)。

11.3.3 节点级发送缓冲预算

node_send_buffer_budget(= min(节点可用内存 × 20%, 8 GiB),附录 B.5)

超预算时的动作顺序(严格按序执行,每步后重新评估):
  1. 丢弃聊天室待发帧(ROOM_BATCH)
  2. 丢弃大群 PUSH_EVENTS,对应连接置 DEGRADED 并标 dirty
  3. 丢弃小群 PUSH_EVENTS,同上
  4. 对本节点全部连接下发拉长后的 next_ping_interval_ms,降低心跳与拉取压力
  5. 仍超预算则按缓冲占用降序关闭连接,并按 takeover_admit_rate 限速允许重连

单连接水位只能防单个慢客户端;没有节点级预算时,10 万个各自"刚好没超软水位"的连接可以合计打爆节点内存。 两级必须同时存在。

11.3.4 为什么 dirty ↔ pull 不会活锁

一个自然的担心是:服务端因为忙而发 mailbox_dirty,客户端因此发起 PULL_MAILBOX, 拉取又让服务端更忙,于是永远收敛不了。本设计不会活锁,原因有三条,缺一不可:

1. PULL_MAILBOX 是客户端限速的有界请求
   带 max_items(500)/ max_bytes(512 KiB)软上限、并发在途 pull_mailbox_window(4)、
   全局兜底最小间隔 client_resync_min_interval(5 s)。
   拉取产生的出向流量有确定上界,不随积压量放大。

2. 拉取走 stream 2(批量流),推送走 stream 1(实时流)
   降级态只停 stream 1。拉取不会再次触发降级判定所依据的实时积压。

3. 低水位滞回保证单向收敛
   进入 DEGRADED 后必须降到 conn_send_low_watermark(256 KiB)才回 NORMAL,
   即缓冲至少被排空到软水位的 1/4。每轮 dirty 之后连接的净积压严格下降。

极端情况下(客户端持续不消费)连接会命中硬水位被关闭,这是有界的失败而不是活锁。 验收判据见 §26:连接被置 dirty 后,客户端按协议行为在 3 个拉取轮次内必须回到 NORMAL 态。

11.4 @全体成员与推送风暴抑制

AT_ALL 是唯一一种"一条消息把整群的未读与通知同时点亮"的能力, 在 10 万人群里它同时放大邮箱写、推送写和离线推送三条链路,必须有独立闸门。

11.4.1 展开规则:不逐条物化 MENTION 事件

默认(mailbox_write_policy = always):
    AT_ALL 不产生额外的 MENTION 事件;
    FanoutCoordinator 在 GroupDispatch.event_template 中置 mention_type = AT_ALL,
    MailboxNode 展开时直接写在每个成员那条 MESSAGE 条目的 mention_type 字段上(§7.3)。
    → 事件组仍为 1 条,邮箱写入量与普通群消息完全相同(O(N),不是 2N)。

定向 @(AT_ME / REPLY_ME)同样只置 MESSAGE 条目的 mention_type,不额外建条目。

唯一需要独立 MENTION 条目的情况:
    mailbox_write_policy = mention_only(§10.2 的降级档)。
    此时沉默成员不写 MESSAGE 条目,被 @ 的成员必须收到一条 event_type=MENTION 的条目,
    以保证 mention_count 与"跳到第一条 @我"在降级档下仍然精确。

理由:mention_type 已经是 UserMailboxEntry 的 per-recipient 字段,再建一条 MENTION 条目 只是把同一信息写两遍,却让 §6.7 的事件组从 1 条涨到 2 条、邮箱字节翻倍。

11.4.2 频率限制

at_all_rate_per_conversation      1 次 / 10 min / 会话     (附录 B.5)
at_all_daily_quota_per_sender     10 次 / 天 / 用户        (附录 B.5)
at_all_min_role                   成员数 > large_group_member_threshold 时要求 ADMIN 及以上
                                                          (附录 B.5)
  • 与 per_conversation_msg_rate(附录 B.5)的关系是串联而非替代: 一条 AT_ALL 消息必须同时通过会话消息速率、发送者速率与上述 AT_ALL 专用配额, 任一不过即在 ConversationWriter 侧拒绝,返回 ERROR{code=RATE_LIMITED, retry_after_ms}。
  • AT_ALL 配额独立计量,不消耗 per_conversation_msg_rate 的令牌, 否则一次 @全体会挤掉同一秒内其他成员的正常发言。
  • 超限一律是发送侧拒绝,绝不允许静默降级为普通消息——否则发送者以为全员被提醒,实际没有。

11.4.3 推送侧抑制

在线侧:AT_ALL 与普通群消息共用同一条 PUSH_EVENTS,不额外产生帧,不额外占用连接水位。
        优先级按群规模走 §11.3.2,AT_ALL 不提升优先级。

离线侧(§16):AT_ALL 触发的系统通知按 UserConversationState.notification_policy 过滤;
        muted = true 的会话默认**不因 AT_ALL 发系统通知**(租户可配置为发送);
        AT_ME / REPLY_ME 在 muted 会话下默认仍发送。
        这是"静音只影响通知、不影响未读"的具体落点:
        被抑制的只是通知,mention_count 与 unread_count 照常增长。

AT_ALL 的角标口径见 §7.7:total_mention 对静音会话仍然求和。


12. 会话列表

12.1 设计目标

会话列表要同时满足三个互相冲突的约束:

  1. 新消息到达后,在线用户的会话列表应在百毫秒级更新。
  2. 10 万人大群不能每条消息同步写 10 万条持久会话记录。
  3. 在邮箱被裁剪之后,会话列表仍然必须正确——即会话列表不能是"邮箱增量的纯累加结果", 它必须存在一个不依赖历史邮箱的权威事实源,否则 mailbox_retention_days 一到期, 排序、未读、成员关系就会集体失真。第三条是 v1 缺失的约束,也是本章大量规则的来源。

方案是"公共会话头 + 用户主动状态 + 可重建异步投影"。

12.2 三层模型与权威分层

层 结构 权威性 更新触发 丢失后果
会话公共最新状态 ConversationHead(§7.4) 会话维度权威 每条消息一次,与成员数无关 预览与最新位置需从 MessageStore 重读
用户会话集合与主动状态 UserConversationState(§7.5) 持久权威 只因用户操作或成员关系变化 不可重建,必须持久且多副本
用户会话列表视图 UserSessionProjection(§7.6) 可重建的物化视图 由个人邮箱增量异步合并 可从 UserConversationState + 邮箱/历史重建

权威分层必须写死,否则实现者会把三者当成同一份数据的三个副本:

"我有哪些会话、我在其中的状态如何"     -> UserConversationState 是唯一权威
"这个会话最新一条是什么"               -> ConversationHead 是唯一权威
"我的列表怎么排、未读多少、预览什么"    -> UserSessionProjection,可丢、可重建、可滞后

ConversationHead(§7.4)三个字段在本层的确定用途,避免被当成孤儿字段各自解释:

last_sender_id   会话列表副标题"某某:内容"的**唯一发送者来源**,与 preview_or_placeholder 同批更新;
                 投影层不另存一份,渲染时按 conversation_id 读会话头即可
member_count     **展示用近似值**,允许滞后;
                 fanout 成本计算一律使用 GroupMembershipVersion.member_count(§7.9),二者不得混用
updated_at       仅供运维排查与陈旧检测,**不参与任何业务判定**(排序看 last_activity_id,
                 内容看 latest_conversation_seq)

推论(直接决定实现):

  • 投影可以整表删除并重建,用户不会丢会话;UserConversationState 不可以。
  • 投影里出现而 UserConversationState 中 membership_state != ACTIVE 的会话, 一律以 UserConversationState 为准(不展示或展示为只读)。
  • 投影中的 unread_count 与 UserConversationState.read_conversation_seq 冲突时, 以 §12.5 的定义式重算,永远不以投影为准。
  • 重建时的会话成员资格规则(权威):deleted_before_conversation_seq >= head.latest_conversation_seq 的会话不产出投影行(列表不展示,新消息到达后按 §12.10 复活); hidden_before_conversation_seq 只影响可见内容与未读 base,不影响列表成员资格—— 清空过聊天记录的会话在重建后仍以空占位行保留在列表中。

12.3 投影压缩器(后续目标形态)

当前一期由 qsession 独立消费 durable dispatch,语义与实现状态见 §17.2;本节的 MailboxNode mailbox-tail 压缩器、per-user projection_mailbox_seq 和同批 UserBadgeState 不是当前实现。若与 §17.2 冲突,以 §17.2 为当前实现裁决。

v1 只说"冷用户由后台投影压缩器批量推进",没有定义驱动方式。 如果压缩器按用户主键全表扫描,就等于把"每次登录再计算"换成了"常驻全量扫描",成本更差。本版写死驱动方式。

12.3.1 驱动方式:邮箱写入流的日志尾

压缩器 = MailboxNode 内的常驻协程,每 lane 一个

输入:本节点本地邮箱写入流,与物化 UserMailboxEntry 是同一次 WriteBatch 产生的事件序列
      **消费上界 = 该 lane 的 materialized_watermark[lane](W[lane],§9.2.2)**:
      只按 mailbox_seq 顺序消费 <= W[lane] 的连续前缀。
      WriteBatch 完成事件在子任务重试路径下(§9.2.3)完成顺序会乱序,
      但 W[lane] 仅在 (W_old, S] 内全部子任务达到持久性契约后才推进,
      以其为上界的连续前缀消费同时保证输入完备性与重放确定性
聚合:内存中按 (user_id, conversation_id) 折叠
      同一用户同一会话在一个窗口内无论多少条消息,只保留一份聚合结果
flush:每 projection_compaction_window(5 s)或 512 条事件,先到者触发(附录 B.6)
       按 user_id 分组,每用户一个 WriteBatch,
       同批写入 UserSessionProjection + UserBadgeState(§7.7 要求同批)
       并把 projection_mailbox_seq 推进到 min(本批覆盖的最大 mailbox_seq, W[lane]),
       **禁止越过 W[lane]**(否则晚完成的低 seq 条目会被 §12.5.5 的单调消费规则永久跳过)

**明令禁止按用户主键全表扫描。** 压缩器的成本正比于"本窗口内有事件的用户数",
与总用户数、总会话数、冷用户数量全部无关。
  • 维护 per-node dirty_users RoaringBitmap(与 §7.9 同一套槽位映射域,但作用域是节点内用户), 聚合时置位,flush 成功后清位。它只是"待 flush 用户集合"的紧凑表示,不是持久状态, 节点重启后由检查点 + 日志重放自然重建。
  • 据此删去 v1 的"冷用户"分类:日志尾驱动下冷热同路径,冷用户只是恰好没有事件, 不需要任何单独的扫描器、单独的调度或单独的降级逻辑。少一条代码路径就少一类线上事故。
  • 在线用户的实时 SESSION_DELTA(§12.6)由同一份内存聚合结果产生, 不是第二条计算路径——避免"在线算一套、压缩算另一套"导致的口径分裂。

12.3.2 SLO 与背压

projection_lag_seq{shard,lane} = materialized_watermark[lane] - min(projection_mailbox_seq)

  materialized_watermark 是 [lane_count] 向量(§6.5.1),**禁止当标量使用**;
  min(projection_mailbox_seq) 取该 lane 内全部用户投影位置的最小值;
  指标按 {shard, lane} 两个标签分别统计,口径与 §24.1.9 完全一致。

目标:p99 < projection_lag_target_p99(5 min,附录 B.6)

超阈值时的降级顺序:
  1. 优先推进压缩:把 CPU 配额从 SESSION_DELTA 实时合并转给批量 flush
  2. 暂停 SESSION_DELTA 实时合并(客户端退化为靠 PULL_SESSION_LIST 与 PULL_MAILBOX 自算)
  3. 扩容压缩并发度(按 lane 拆分更多协程)
  4. 仍不收敛则对该分片停止接收新的大群 dispatch,向 FanoutCoordinator 反压

第 2 步安全,因为 §12.6 已声明 SESSION_DELTA 只是低延迟缓存,权威输入是邮箱与 read_conversation_seq。

12.3.3 与邮箱裁剪的闭环

裁剪由两条规则同时约束,两条都不能省:

必删线(唯一强制依据,时间窗口):
    超过 mailbox_retention_days(7 天,ADR-0023)的条目必须删除,
    不因任何设备游标或投影滞后而推迟。
    这就是 §6.5.2 "邮箱条目仅按时间窗口裁剪,不以任何单设备游标为条件"的含义:
    否则一个永不上线的设备可以让邮箱无限增长。

提前回收线(可选优化的上界):
    early_trim_watermark = min( 所有有效设备游标, 投影检查点 seq ) - 安全余量
        (用户级量,即 §18.3.2 的 user_trim_seq(u))
    有效设备 = device_inactive_gc_days(60 天)内活跃过的设备
    在 TTL 未到期时,只允许回收到这条线以下,禁止越过。

两条线不会互相打架,因为附录 B.3 已有硬性约束:
    mailbox_retention_days >= 投影压缩器最坏滞后 P99.9 × 3

告警与自愈:

projection_lag_vs_retention > 1/3   -> 告警(附录 B.3 强制要求,指标定义见 §24.1.9)
projection_lag_vs_retention > 1/2   -> 自动扩容压缩并发度并执行 §12.3.2 的降级
                                       (自愈触发阈值 projection_lag_selfheal_threshold,附录 B.6)

12.4 会话排序

排序键使用 §6.9.3 的定义,不在本节重复。

12.4.1 为什么 last_activity_id 必须与 message_id 同源、与 conversation_seq 同序

同源(同为 u128、同一 ID 空间、同一 HLC 时钟):
    会话列表要和"@我列表 / 通知中心"(§6.9.2)用同一把尺子排序。
    若两者不同源,一条消息在会话列表里排第 3、在通知中心里排第 7,用户直接看到不一致。

与 conversation_seq 同序(§6.4 硬约束,由 ConversationWriter 在同一临界区分配):
    否则会出现"会话列表显示的最新消息不是会话内最后一条"。
    v1 用"服务端生成的时间有序活动 ID"描述,但没有约束它与 conversation_seq 的关系,
    在重试晚到的场景下二者可以逆序。

禁止用裸墙钟:晚到消息会把旧会话错误顶到列表最前(§6.4)。
禁止用 mailbox_seq:它是分片维度的到达顺序,跨会话不可比,且 §6.9.4 已禁止参与 UI 排序。

12.4.2 三条独立门控(v1 把它们错误合并成一个 AND)

v1 §12.3 要求"同时检查 conversation_seq > 且 affects_session_order=true 且可见"。 这是实质性错误:三者管的是三件不同的事,合并成一个 AND 会让撤回与编辑既不更新排序(正确) 也不更新预览(错误)。本版拆开:

排序键更新条件: event.last_activity_id > projection.last_activity_id
                 且 event.flags.affects_session_order == true
内容更新条件:   event.conversation_seq  > projection.latest_conversation_seq
                 (仅适用于 anchor_mode = own_seq 的事件,§6.9.1;
                  anchor_target 事件的内容更新走下方的定位判断)
可见性条件:     event.conversation_seq 落在 §7.5 的 visible(u, c) 区间内
                 (三个条件的公共前置:不可见事件一律整条忽略)

anchor_mode = anchor_target 的事件(撤回、编辑、治理删除)不推进 projection.latest_conversation_seq、last_message_id 与 last_activity_id: 其内容更新仅限 target_conversation_seq == projection.latest_conversation_seq 时 触发 SESSION_DELTA 重下发——投影不持久存预览(§21.3.4),帧中的 preview_or_placeholder 由 MailboxNode 下发前从 ConversationHead 读时填充, 撤回/编辑对预览的修复由 ConversationWriter 对 ConversationHead.preview_or_placeholder (与 last_sender_id)的条件改写一次完成,对全部收件人生效。 若允许推进,撤回 CONTROL 事件自带的新 conversation_seq 会把 latest 顶到控制事件自身位置, 随后撤回"当前预览那条消息"时定位判断 target == latest 永不成立,预览修复失效。

三条条件的组合语义:

事件 排序键 预览 最新位置 结果
普通新消息 更新 更新 更新 会话上浮,预览换新
晚到的旧消息 不更新(last_activity_id 更小) 不更新(conversation_seq 更小) 不更新 会话不动,预览不倒退
撤回 / 编辑(affects_session_order=false) 不更新 条件更新(target_conversation_seq == 当前预览位置时修复预览) 不更新 会话不上浮,预览改为"消息已撤回"/新内容
已读同步 不更新 不更新 不更新 只改未读

合并成一个 AND 的具体后果:撤回一条正是当前预览的消息时, affects_session_order=false 会让整条事件被跳过,会话列表继续显示已被撤回的原文, 直到用户点进会话才发现不一致——这正是 §12.9 要求的"修复预览"无法实现的原因。

撤回/编辑的内容更新还需要一次定位判断:仅当 event.target_conversation_seq == projection.latest_conversation_seq 时才需要改预览(否则被撤回的不是当前预览那条,列表无需变化)。 这依赖 UserMailboxEntry.target_conversation_seq(§7.3),没有该字段就无法判定。

12.5 未读数

未读是全系统最容易出错的地方:它有两个事实源(累加值与已读水位),且两者都会被并发修改。

12.5.1 权威定义式

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,
                  deleted_before_conversation_seq )

若 membership_state != ACTIVE,上界取 min(head.latest_conversation_seq, left_at_conversation_seq)
UserSessionProjection.unread_count 是该式的缓存。
UserConversationState.read_conversation_seq 是权威输入。
两者冲突时,一律以定义式为准,重算并覆盖缓存。

权威输入必须能下行到客户端:read_conversation_seq 是 user 级、跨设备共享的 主动状态(§7.5),只存在于服务端;而未读在客户端本地按同一定义式求值(§12.6、 docs/11 §6)。若协议不下发它,客户端换设备、重装或清缓存后只能从 0 起算 base, 把同步回来的历史(包括自己发过的消息)全部计成未读——这不是显示偏差,是 定义式缺了一个输入。因此 SESSION_LIST_BATCH.sessions[] 必须回带 read_conversation_seq(附录 A.4)。客户端按只进不退合入本地水位; 本地反而更高时(离线期间标的已读尚未上行)回补一次 MARK_READ,两侧收敛。

m.sender_id ≠ u 不是可选项:缺了它,自己发的消息在自己这侧算成自己的未读。 "发送即已读"(客户端收到 SEND_ACK 后把水位推到自己那条)只是让该项在水位在手 时不显形,一旦本地水位丢失就立刻暴露。两条防线都要有:水位下行保证 base 正确, sender 项保证即使 base 为 0 也不会自己给自己制造未读。

mention_count 与 mention_first_conversation_seq 遵循同一套定义, 只是把 counts_unread 换成 mention_type != NONE。

UserConversationState.manual_unread_conversation_seq(§7.5)非空时,该会话恒显示为未读: 定义式算出 0 也按至少 1 条未读展示(unread_exact=false),它是叠加在定义式之上的显示态覆盖, 不改写 unread_count 的计算过程。失效条件见 §12.9。

12.5.2 有界重算

全量重算在大群里不可接受(一个 10 万条历史的群要扫 10 万行),因此重算是有界的:

if head.latest_conversation_seq - base <= unread_precise_limit(200,附录 B.6):
    向 MessageStore 做一次单分区范围读(§7.1 的 seq_bucket 保证不跨分区或至多跨 1 个)
    精确计数 -> unread_count = 精确值, unread_exact = true
else:
    unread_count = 200, unread_exact = false
  • unread_exact=false 与 UI 的"99+"截断闭环:客户端展示 99+,不展示 200。
  • 截断态在用户把会话读到 latest - read <= 200 之后会自动恢复为精确态,不需要人工干预。
  • unread_precise_limit 同时是重算成本的硬上界:任何一次 reconcile 最多读 200 行。

12.5.3 reconcile 触发点

1. 新设备登录 / 任意设备登录时 AUTH_OK 给出 projection_complete=false
2. 检查点回滚重放后(投影可能落后于 UserConversationState)
3. 收到或下发 MAILBOX_DIRTY 之后
4. read_conversation_seq 被跨设备前推到"非最新位置"
   (即 new_base < head.latest_conversation_seq)
   -> 此时必须**重算**,不得清零。
      v1 §12.8 的"已读同步 -> 清零至指定位置"没有定义"指定位置不是最新"时怎么办,
      直接清零会把 base 之后仍未读的消息一并抹掉。
5. 后台对账:对"有未读且 24 h 无变更"的会话按 unread_audit_sample_rate
   (默认 1%/天,附录 B.6)抽样重算

后台对账产出指标 unread_drift_ratio(抽样中重算结果与缓存不一致的比例),入 §24 指标集。 该指标是本设计中未读正确性的唯一可观测证据,长期应趋近 0。

12.5.4 unread_base_seq 的用途:三态判定

UserSessionProjection.unread_base_seq(§7.6)记录"当前 unread_count 是相对哪个 base 算出来的"。 没有它,read_conversation_seq 变化时无法区分三种情况:

new_base == unread_base_seq                    -> 无变化,什么都不做
new_base >= head.latest_conversation_seq       -> 清零:unread_count=0, mention_count=0,
                                                  unread_exact=true, unread_base_seq=new_base
unread_base_seq < new_base < latest            -> 三选一:
    (a) 上次 flush 时该会话的 projection.latest_conversation_seq <= unread_base_seq
        (可判定条件;结合 §12.3.1 按 W[lane] 连续前缀消费的规则,
         (unread_base_seq, new_base] 区间内条目必然已全部落入本窗口)
        -> 增量扣减,同批执行:
           unread_count  -= 该区间内 counts_unread 的条数,clamp 到 >= 0
           mention_count -= 该区间内 mention_type != NONE 的条数,clamp 到 >= 0
           mention_first_conversation_seq 前移为窗口内 > new_base 的下一条 mention 的 seq;
           窗口内无法确定时,仅对 mention 两字段降级走 (b) 的有界重算
           (unread_count 仍可走增量扣减)
    (b) 否则 latest - new_base <= 200           -> 有界重算(§12.5.2)
    (c) 否则                                    -> 截断:unread_count=200, unread_exact=false
无论走哪条,最后都必须把 unread_base_seq 更新为 new_base。

12.5.5 撤回、删除与幂等

扣减条件按 CONTROL 条目携带的目标消息属性(target_* 字段,§7.3)判定,
条目自身的 flags / sender_id 是控制事件的属性,不得用于判定:

target_conversation_seq ∈ (unread_base_seq, latest]
    ∧ target_flags.counts_unread ∧ target_sender_id ≠ user_id:
    unread_count   = max(0, unread_count - 1)
target_mention_type != NONE 时另有:
    mention_count  = max(0, mention_count - 1)
  • clamp 是必需的:截断态(unread_exact=false)下 unread_count 本来就不精确, 不 clamp 会出现负数。
  • 幂等由 event_id + projection_mailbox_seq 水位保证,不需要额外的环形去重集: 撤回事件在每个收件人邮箱里只有一条,event_id 是确定性哈希(§6.7), 压缩器只处理 mailbox_seq > projection_mailbox_seq 的条目 (以 W[lane] 为上界的连续前缀消费,§12.3.1), 重放同一区间会得到同一结果(幂等重放,不是"跳过重复")。 v1 §12.4 要求"以事件幂等键避免重复扣减",但没说这个键存在于何处;本版明确它就是邮箱主键本身。
  • 撤回被撤回消息之后又发生 reconcile 时,定义式的 ¬recalled 条件让结果收敛到同一个值。

12.5.6 其他固定规则

自己发的消息      更新 last_activity_id 与预览,不增加未读(定义式的 sender_id ≠ u)
静音              只影响 §16 的通知与 §7.7 的 total_unread 聚合口径,不清未读、不停未读增长
归档              不改未读,只改 §7.7 的聚合口径与列表分区
mention_first_conversation_seq   记录 (base, latest] 内第一条 mention 的位置,
                                 支撑"跳到第一条 @我";base 前推时随重算一并更新
                                 (增量路径 (a) 的 mention 处理见 §12.5.4)

12.6 在线更新 SESSION_DELTA

12.6.1 v1 的致命问题

v1: SESSION_DELTA { ..., unread_delta, mention_delta }

unread_delta 是增量语义,而该帧走的是"至少一次投递 + 可丢弃推送"的通道(§11.3 明确允许丢弃), 并且帧内没有任何幂等键或版本号。两条正常路径(不是异常路径)必然破坏它:

路径 A:MAILBOX_DIRTY 后客户端重新 PULL_MAILBOX
        同一批事件被重放,客户端已应用过的 delta 又被加了一次   -> 未读双加
路径 B:连接被替换或缓冲降级,若干 SESSION_DELTA 被丢弃
        对应的 delta 永久消失                                  -> 未读少算

两种偏差都永久漂移:没有任何机制能把它拉回来,因为客户端不知道自己错了多少。 用户看到"3 条未读点进去一条没有"或"红点消不掉",且重启无效。

12.6.2 改为幂等绝对值帧

字段见附录 A.4,本节只定义语义:

版本源 = projection_mailbox_seq(§7.6),不引入第二套计数器。
         用户的所有投影输入都经过本人邮箱,因此该 seq 在用户维度天然单调。

客户端规则(写死):
    if delta.projection_mailbox_seq > local[conversation_id].projection_mailbox_seq:
        用绝对值直接覆盖 unread_count / mention_count / latest_conversation_seq /
        last_activity_id / last_message_id / preview_or_placeholder /
        unread_exact / mention_first_conversation_seq
    else:
        整帧丢弃                      # 迟到帧或重复帧,不做任何补偿

绝对值 + 单调版本 = 重复投递无副作用、丢帧只造成延迟不造成偏差。这两点是增量语义拿不到的。

driving_event_id = 触发本次投影更新的 UserMailboxEntry.event_id(§6.7),与 driving_mailbox_seq 联合唯一定位驱动条目,仅用于缺口定位与 §24 链路追踪(trace 关联键),不参与 §12.6.2 的覆盖判定与 §12.6.3 的合并规则。

12.6.3 合并规则

合并窗口 session_delta_merge_window(100 ~ 200 ms,附录 B.6)
允许的合并:同一 conversation_id 的后帧**整体覆盖**前帧(丢弃式合并)
禁止的合并:任何累加型语义合并(unread 相加、mention 相加、预览拼接)

丢弃式合并之所以安全,正是因为帧携带绝对值:丢掉中间态不影响最终态。 一旦允许累加,§12.6.1 的两条路径立刻复活。

12.6.4 缺口自愈

客户端发现 delta.driving_mailbox_seq 与本地已应用的邮箱位置不连续时:
    **不做任何补偿计算**,不推断中间发生了什么,
    直接发起 PULL_MAILBOX(受 client_resync_min_interval 限流)重算该会话。

"不做补偿计算"是一条硬性禁令:客户端一旦开始猜测缺口内容,就重新引入了不可收敛的偏差。

12.6.5 权威声明

客户端未读的权威输入是 UserMailboxEntry 与 read_conversation_seq。 SESSION_DELTA 只是低延迟缓存,任何冲突以邮箱重算为准。

这条声明允许服务端在任何时刻停发 SESSION_DELTA(§12.3.2 的降级第 2 步)而不损失正确性, 也允许客户端在弱网下直接忽略该帧。

12.7 离线恢复

最近 UserSessionProjection 快照
  + projection_mailbox_seq 之后的个人邮箱增量
  = 当前会话列表

这个等式有前提,v1 没写,因此在裁剪场景下是错的:

成立前提: projection_mailbox_seq >= mailbox_trim_watermark(即 effective_trim(u),§18.3.3)

不满足时:快照与当前之间的邮箱增量已被物理删除,等式右边缺一段,
          必须退化为 §9.6 的 REBUILD 流程(会话列表按 UserConversationState 重建,
          每会话最近一页走 PULL_HISTORY,窗口外的未读与提及标为不精确)。
  • 满足前提时,恢复不是"重新计算所有会话",也不是"读取全部历史": 邮箱增量本来就是离线同步要读的数据,会话投影在同一次顺序处理中顺带合并,边际成本接近零。
  • 积压很大时,AUTH_OK 先给 projection_complete=false(附录 A.4), 客户端先展示快照,随后的 MAILBOX_BATCH 与 SESSION_DELTA 逐步补齐。
  • §12.3 的压缩 SLO 保证快照滞后受控,避免每次登录都处理长期积压。

12.8 分页

12.8.1 keyset 分页 + 服务端快照隔离

排序键(§6.9.3):(pin_rank ASC, last_activity_id DESC, conversation_id ASC)
page_cursor 编码上述三元组(服务端签名,客户端不可构造)

PULL_SESSION_LIST { snapshot_revision, page_cursor, limit }
SESSION_LIST_BATCH { sessions[], snapshot_revision, next_page_cursor, has_more, projection_complete }
  • 禁止 offset 分页:会话顺序在分页期间会变,offset 必然重复或漏项。
  • limit 默认 session_list_page_limit(50),服务端上限 200(附录 B.6),超出按上限截断。
  • 排序不发生在存储层(§7.6):SessionProjection 服务把该用户的会话一次性载入内存快照 (max_conversations_per_user = 5000 行封顶),在内存快照上做 keyset 分页, 单页成本 O(limit) 而不是 O(会话数)。

12.8.2 snapshot_revision 的生成规则

由 SessionProjection 服务在**载入某用户的内存快照时**分配:
    首次取值 1,此后每次重新载入该用户快照 +1,单调递增,进程内分配
    作用域 = (tenant_id, user_id)
    TTL = snapshot_ttl(5 min,附录 B.6);TTL 内的增量在快照上原地合并,不改 revision
    快照被驱逐或 TTL 过期后,下次请求重新载入并分配新 revision

    例外(结构性变更强制失效):凡改变排序分区的 UserConversationState 变更
    (pin_rank、archived、membership_state)合并进某用户内存快照时,
    必须使 snapshot_revision += 1,客户端按下方"revision 不一致 -> 丢弃中间结果、
    从第一页重拉"规则收敛;仅改 unread / 预览 / last_activity_id 的增量
    维持原地合并不改 revision

客户端携带的 snapshot_revision 与服务端当前值不一致时:
    服务端返回当前 revision 的第一页,并置 has_more,
    客户端丢弃已翻页的中间结果、从第一页重新开始(不静默续翻)

12.8.3 分页期间会话前移的补回路径

由新活动导致的前移必然伴随一条 SESSION_DELTA(任何新活动都会经过本人邮箱并产生投影更新),
经 SESSION_DELTA 补回;置顶/归档/退群等**结构性变更**不产生新活动,
走 §12.8.2 的 revision 失效重拉路径(SESSION_DELTA 无 pin_rank/archived 字段,无法表达此类变更)。

因此补回路径是:
    客户端按 conversation_id 对 SESSION_DELTA 做覆盖合并(§12.6.3),
    把该会话插入到本地已渲染列表的正确位置,
    并在后续翻页结果中按 conversation_id 去重(同一会话可能既在 delta 里又在下一页里)。

这条路径只在已 ONLINE_READY(实时通道可用)时成立。

纯拉取阶段(尚未 ONLINE_READY,SESSION_DELTA 不会到达):
    不做任何增量补偿,直接重新拉取第一页。
    该阶段通常只有数秒,重拉一页的成本远低于维护一套仅在此阶段生效的补偿逻辑。

12.9 控制事件对会话列表的影响

"是否入邮箱"这一列是 v1 缺失的:它决定该事件能否跨设备同步、能否在重放中重现。

事件 改变排序 改变未读 更新预览 是否入邮箱
普通消息(他人) 是 是(+1) 是 是(MESSAGE)
自己发送消息 是 否 是 是(MESSAGE,带 client_message_id)
消息编辑 否 否 仅当预览指向该消息 是(CONTROL,带 target_*)
消息撤回 否 可能 −1(clamp 到 ≥0) 仅当预览指向该消息,改为"消息已撤回" 是(CONTROL,带 target_*)
消息删除(治理/管理员) 否 可能 −1 仅当预览指向该消息,改为占位 是(CONTROL,带 target_*)
消息 TTL 过期(retention_class=ephemeral_24h) 否 是(下次 reconcile 收敛) 是,必须改占位 否(见下方说明)
已读同步(跨设备 MARK_READ) 否 清零或重算(§12.5.3 第 4 条) 否 是(CONTROL,origin_device_id 过滤自回声)
标记未读 否 置为 ≥1 且 unread_exact=false(写 manual_unread_conversation_seq) 否 是(CONTROL)
清空聊天记录 否(会话仍在列表) 清零 改为空占位 是(CONTROL,写 hidden_before_conversation_seq = head.latest_conversation_seq)
输入状态 否 否 否 否(瞬时)
在线状态 否 否 否 否(瞬时)
置顶 / 取消置顶 是(改 pin_rank 与分区,不改 last_activity_id) 否 否 是(CONTROL)
静音 / 取消静音 否 否(只改 §16 通知与 §7.7 聚合口径) 否 是(CONTROL)
归档 / 取消归档 是(移出/移回主列表分区) 否(改 §7.7 聚合口径) 否 是(CONTROL)
建会话 / 加群 是(用新分配的 last_activity_id) 否 使用 ConversationHead 是(MEMBERSHIP)
退群(主动 LEFT) 是(移出或转为只读) 清零 冻结在 left_at 位置 是(MEMBERSHIP)
被踢出群(REMOVED / BANNED) 是(移出或转为只读) 清零 冻结在 left_at 位置 是(MEMBERSHIP)
删除会话(本地删除) 移出列表 清零 移除 是(CONTROL,写 deleted_before_conversation_seq = head.latest_conversation_seq)
表情回应(§13.6) 否 否 否 否(ephemeral_aggregate,不产生 UserMailboxEntry;聚合值经 REACTION_UPDATE 只推在线成员)
他人加群/退群(成员数 > membership_event_broadcast_max_members) 否 否 否 否(大群不广播成员变更,只在 PULL_MEMBERS 体现,§10.1)

上表中"隐藏会话" = 清空聊天记录的别名,同落点(写 hidden_before_conversation_seq,会话保留在列表中,内容与未读按 base 清零,与用例 26.4.5 对齐)。 两个水位字段的分工:hidden_before_conversation_seq 只影响可见内容与未读 base,不影响列表成员资格;deleted_before_conversation_seq 决定会话是否产出投影行(重建规则见 §12.2)。

"标记未读"的落点:

持久字段 = UserConversationState.manual_unread_conversation_seq(u64,§7.5)
           写入值 = 标记时刻该会话的 head.latest_conversation_seq
           (服务端处理标记时本就要读 ConversationHead,一次点读同时取得坐标,零反查)

三条性质(全部来自它写在 UserConversationState 而不是投影里):
  跨设备同步    与 pin_rank / muted 同属用户主动状态,按 §7.5 的规则合并(置空优先、否则取 max)
  重建后仍存在  UserSessionProjection 可整表重建,UserConversationState 不可重建也不参与重建
  自动失效      read_conversation_seq >= manual_unread_conversation_seq 时置空
                (即用户重新读到该位置之后,"标记未读"自然消失,不需要第二条清除指令;
                 判定全程只在 conversation_seq 空间比较,不查 MessageIndex)

展示口径见 §12.5.1:字段非空时会话恒显示为未读,与 unread_count 的计算结果无关。
投影侧只需把该字段随会话一并读出用于渲染,**不得**把它写进 UserSessionProjection。

消息 TTL 过期为什么不入邮箱:

ephemeral_24h 的过期是**可预测的**(created_at + 24 h),三端可以各自确定性判定,
不需要事件通知。若为它写邮箱事件,10 万人群的每条阅后即焚消息会产生第二轮 O(N) 邮箱写入,
把 §10.2 的成本结构直接翻倍。

因此规则是:
  客户端与 SessionProjection 各自按 (retention_class, created_at) 本地判定到期,
  预览到期后改为占位("消息已过期"),
  未读按 §12.5.1 的定义式在下一次 reconcile 时收敛(¬deleted 条件自然生效)。

代价必须写明:过期后到下一次 reconcile 之前,未读数可能偏大。
这是一档**不精确但确定**的行为,不是 bug;产品文案与验收用例必须按此描述。

12.10 退群、删除与"会话复活"

v1 用一行"退群、删除会话 | 移除或隐藏 | 清理 | 移除"把两种语义完全不同的操作合并了, 三端无法实现一致行为。本版按 §7.5 的序号边界严格区分:

本地删除会话:
    写 deleted_before_conversation_seq = 当前 head.latest_conversation_seq
    membership_state 保持 ACTIVE
    -> 会话从列表移除、未读清零、历史在本地不可见
    -> **新消息会让会话复活**:新消息的 conversation_seq > deleted_before,
       落在 visible(u,c) 区间内,投影重新产生该会话
    -> 复活后只展示 deleted_before 之后的消息,之前的不回来

退群 / 被踢:
    写 left_at_conversation_seq = 退群时刻的 conversation_seq
    membership_state = LEFT | REMOVED | BANNED
    -> visible(u,c) 的**上界**被封死
    -> **不复活**:即使有在途消息晚到,其 conversation_seq >= left_at,
       落在可见区间之外,投影一律忽略
    -> §10.1 的成员展开同时会跳过 left_at <= 本消息 conversation_seq 的成员,
       两层防护(发送侧过滤 + 消费侧边界)都必须实现

对照表:

维度 本地删除 退群 / 被踢
写入字段 deleted_before_conversation_seq(下界) left_at_conversation_seq(上界)
membership_state 保持 ACTIVE LEFT / REMOVED / BANNED
新消息 会话复活 不复活
历史可见性 水位之后可见 水位之前只读可见(按产品策略可整体不可见)
重新加群 不适用 left_at 置空,写新的 joined_at_conversation_seq

为什么必须用序号边界而不是"当前状态点查":

点查版本(v1 的隐含实现):投影处理一条事件时查一次"该用户现在还在群里吗"
    -> 结果取决于**查询时刻**,同一条日志重放两次可能得到两种结果
    -> 主备 MailboxNode 物化出不同的投影,主备切换后会话列表突变

序号边界版本(本版):判定只依赖 (event.conversation_seq, visible(u,c))
    -> 纯函数,无外部时刻依赖
    -> 重放可重现、主备一致、检查点回滚后结果不变
    -> 这也是 §7.3 必须携带 visibility_floor_conversation_seq 的原因:
       投影层不需要持有最新的 UserConversationState 就能判可见性

13. 消息类型与控制消息

13.1 持久消息

  • 文本、富文本、表情和业务卡片。
  • 图片、视频、音频和文件的元数据及缩略图(原文件走 MediaService 与对象存储,见 §7.1 media_metadata)。
  • 可配置持久化的自定义业务消息(§13.3)。
  • 撤回、编辑、已读同步等需要多端恢复的控制事件(§13.4)。

持久消息一律进入 MessageStore 与收件人个人邮箱,参与 §6.9.1 的时间轴排序。

媒体消息的上传与下载全流程(v1 只说"原文件走对象存储",没有给出任何步骤):

1. 换票   客户端 -> MEDIA_TICKET{intent=upload, bytes}(附录 A.3)
          MediaService 校验 per_user_media_upload_quota(附录 B.5),
          分配**不可枚举的随机 object_id**(§20.2.3),
          签发 media_ticket_ttl(300 s,附录 B.5)内有效的直传凭证

2. 直传   客户端 --HTTP--> 对象存储(S3 / MinIO,§18.2)
          原文件**不经过** ConnectionNode、MailboxNode、MessageStore,
          也不进入任何 IM 帧(§3 禁令:大文件不得写入消息正文存储)

3. 发消息 客户端本地生成缩略图(<= media_thumbnail_max_bytes 32 KiB,附录 B.5)、
          blurhash 与 checksum,随 SEND_MESSAGE 提交,
          消息正文只写 media_metadata 引用(§7.1):
              object_id / mime / bytes / width / height / duration_ms /
              thumbnail / blurhash / checksum

4. 下行   MAILBOX_BATCH.entries[] 与 PUSH_EVENTS.events[] 在 body_included=true 时
          携带 media_metadata(附录 A.4.1),接收端先按缩略图渲染,原文件按需再取

5. 下载   MEDIA_TICKET{intent=download, object_id, bytes} 换取签名 URL;
          对象存储**不开放匿名读**,票据到期或对象被删除后即刻失效(§20.2.1、§20.2.3)
  • 缓存边界见 §18.3.1:MailboxNode 与 ConnectionNode 只缓存缩略图,不缓存原始大文件。
  • 删除与合规见 §21.4:原文件与缩略图同属加密擦除范围, 对象的可达性由 MediaService 的反向索引维护,避免用户删除后产生孤儿对象。
  • 媒体消息的未读、排序、预览与普通消息完全一致,preview_or_placeholder 使用类型占位 (如"[图片]"),E2EE 会话下不解析密文生成预览(§13.3、§22.3)。

13.2 瞬时消息

  • 输入状态(TYPING)。
  • 临时在线状态(PRESENCE_SUB 订阅的结果)。
  • 实时呼叫振铃与 RTC 协商信令(§13.5)。
  • 可丢弃的聊天室互动效果(§14)。
瞬时消息只向在线连接发送:
    不进入个人邮箱、不影响会话列表、不计未读、不参与任何游标推进、不触发离线推送
    在 §11.3 的降级态下**第一批被丢弃**

v1 只列举了这两类瞬时消息的上行帧,从未定义下行用什么帧,三端无法实现。 本版不发明新帧名:TYPING 与 PRESENCE_SUB 都复用同 opcode 的双向帧, 方向由帧头 request_id 区分(上行请求非 0,服务端主动帧为 0,见附录 A.1)。

输入状态 TYPING

上行  TYPING{conversation_id}                                (附录 A.3)
下行  TYPING{conversation_id, user_id, expires_in_ms}        (同 opcode 的服务端主动帧)

转发规则:
    只转发给该会话**其他**在线成员的连接,不回声给发送者自己的任何设备
    成员数 > large_group_member_threshold 的会话**一律不转发**(大群输入状态无产品价值,
        却是一条 O(N) 的瞬时广播路径)
    服务端按 typing_merge_window(默认 2 s,附录 B.6.1)对
        同一 (conversation_id, user_id) 合并,最多 1 帧 / 窗口
    expires_in_ms 默认 typing_ttl(默认 5 s,附录 B.6.1):
        客户端到期自动清除,**不需要 stop 帧**,因此丢帧不会留下卡死的"正在输入"
走 stream 1(实时流),优先级等同其所属会话类型(§11.3.2)

在线状态 PRESENCE_SUB

上行  PRESENCE_SUB{action(subscribe|unsubscribe|replace), conversation_id | user_ids[]}
下行  PRESENCE_SUB{entries[]{user_id, state, last_active_at}}   (同 opcode 的服务端帧)

订阅粒度:按 conversation_id(订阅该会话全部成员)或显式 user_ids[] 列表,
          同一帧**二选一**,不允许混用
订阅上限:presence_sub_max_targets(默认 200 个用户 / 连接,附录 B.6.1);
          超出返回 ERROR{code=RATE_LIMITED},不静默截断订阅集
退订方式:action=unsubscribe(按同样的粒度撤销)或 action=replace(整体替换订阅集);
          **连接关闭即自动清空**——订阅是连接级软状态,不持久、不跨连接保持,
          REDIRECT 或重连后必须重新订阅
下行时机:subscribe / replace 的应答先回一次**当前快照**(全部订阅目标),
          此后状态变化按**增量**推送(同一帧,只带发生变化的 entries[])
数据来源:§5.4 PresenceDirectory 的 compacted topic 本地缓存,推送路径零次远程调用
频率控制:每连接按 presence_sub_merge_window(默认 2 s,附录 B.6.1)合并;
          端到端目标 presence_propagation_target(P99 ≤ 1 s,附录 B.4)

在线状态与离线推送判定不共用一条路径:PRESENCE_SUB 是给 UI 看的近似状态, 可丢可延迟;§16 的推送判定使用 PresenceEntry 明细(§7.10、§16.1.2),二者不得互相替代。

13.3 自定义消息契约

每种自定义消息必须在租户配置中声明以下契约,服务端据此决定投递属性,不解析业务载荷:

custom_type
schema_version
delivery_class        : persistent | ephemeral
counts_unread         : bool          -> 写入 UserMailboxEntry.flags
affects_session_order : bool          -> 写入 UserMailboxEntry.flags
timeline_visible      : bool          -> 写入 UserMailboxEntry.flags(§6.9.1 依赖)
anchor_mode           : own_seq | anchor_target | none   (§6.9.1 依赖)
preview_template
max_payload_bytes     : <= max_custom_payload_bytes(32 KiB,附录 B.5)
minimum_client_version

新增三项的作用(v1 缺失,导致 §6.9.1 的排序契约无法执行):

timeline_visible = false
    条目仍进入邮箱、仍可计未读或影响排序,但**不在会话时间轴渲染**。
    典型用例:会话属性变更、业务状态同步、静默数据下发。
    没有这个字段,客户端只能靠 message_type 白名单猜测,三端必然不一致。

anchor_mode
    own_seq        时间轴锚点 = 自身 conversation_seq(普通消息)
    anchor_target  时间轴锚点 = target_conversation_seq(撤回、编辑、对某条消息的状态更新)
                   -> 原地更新已有行,不新增时间轴行
    none           不参与时间轴(必须与 timeline_visible=false 同时使用)

preview_template
    服务端生成 ConversationHead.preview_or_placeholder 时使用的模板。
    E2EE 会话下**一律使用占位**(如"[自定义消息]"),
    服务端不得尝试解析密文生成预览(§7.1)。真实预览由客户端本地渲染。

未知类型的处理是硬性要求:

客户端遇到未知 custom_type 或高于本端支持的 schema_version 时:
    必须按条目自带的 flags 与 anchor_mode 处理未读与排序(这些是类型级契约,不需要解析载荷),
    渲染为通用占位或整条跳过(timeline_visible=false 时),
    **不得导致整个同步批次失败**,不得中断游标推进。

服务端侧:低于 minimum_client_version 的客户端可以收到条目本身,
    但发送方在发送时若目标端不支持,由业务侧决定是否降级为文本,服务端不做静默丢弃。

一个批次因一条未知消息而整体失败,会让该设备永久卡在同一个 mailbox_seq 上—— 这是自定义消息体系最典型的线上事故,必须在协议层杜绝。

13.4 撤回与编辑

v1 只在 §12.8 的表格里提到撤回与编辑,没有定义它们的序列语义,导致排序、预览、未读三处都无法实现。

13.4.1 序列语义

上行:RECALL / EDIT { conversation_id, target_conversation_seq, [new_payload] }(附录 A.3)
      必带定位坐标,正常路径不查 MessageIndex(§7.2)

服务端处理(固化顺序与 §8.3 同构,顺序不可调换):
  1. 校验权限与窗口(§13.4.3)
  2. **同一临界区分配自己的 conversation_seq 与 last_activity_id,
     并将 RECALL/EDIT CONTROL 事件追加提交日志**
     (幂等键 = 确定性 event_id,§6.7;重复追加按 event_id 去重)
  3. 日志追加成功后,更新 MessageRecord.state = RECALLED | EDITED,
     并写 MessageRecord.recall_event_conversation_seq = 第 2 步分配的 conversation_seq;
     EDITED 同时更新 payload 与 edited_at_activity_id
     (该步可由日志消费侧幂等执行,重复执行为同值覆盖)
  4. 若被撤回/编辑的是当前预览消息,更新 ConversationHead
     (仅改写 preview_or_placeholder 与 last_sender_id,
      不改 latest_conversation_seq / last_activity_id / last_message_id)
  5. 作为 event_type=CONTROL 的条目进入所有可见成员的个人邮箱,
     携带 target_message_id、target_conversation_seq、target_sender_id、
     target_flags 与 target_mention_type(§7.3);
     后三者由 ConversationWriter 在处理 RECALL/EDIT 时从目标 MessageRecord 填充,
     target_mention_type 按收件人从目标消息的 mention_targets 逐收件人计算
     (与 §11.1 PushBatch 的 per-recipient 字段同路径)

崩溃点恢复表(对齐 §8.3 的坐标固化纪律:CONTROL 事件坐标先固化于提交日志,任意接管者按同一坐标幂等重做):

崩溃点 持久状态 恢复动作 撤回事件是否丢失
第 2 步日志未追加 recall_event_conversation_seq 为空 客户端超时重试,服务端从第 2 步重做(event_id 确定性去重) 否,重试重新产生事件
日志已追加、state 未更新 日志中已有 CONTROL 事件,recall_event_conversation_seq 为空 日志消费侧幂等补写第 3 步;客户端重试时按 event_id 去重,不产生第二条事件 否,事件已在日志中
第 3 步后 recall_event_conversation_seq 非空 客户端重试命中 §13.4.4 幂等短路,直接返回成功 否

为什么必须分配自己的 conversation_seq:

撤回/编辑必须可靠投递给**离线成员**。
离线投递的唯一通道是个人邮箱,而邮箱条目的可见性判定依赖 conversation_seq
落在 visible(u,c) 区间内(§7.5)。不分配 seq 的事件无法判可见性,
也无法在 REBUILD(§9.6,按 conversation_seq 拉历史)后被重新发现。

代价:conversation_seq 因此包含"非时间轴消息",这正是 §6.3 声明它允许空洞、
      §6.10.1 禁止用其差值判丢的原因之一。

13.4.2 时间轴锚点

timeline_anchor_seq = target_conversation_seq        (§6.9.1,anchor_mode = anchor_target)
  • 原地更新已有行,不新增时间轴行:撤回把原行渲染为"消息已撤回",编辑把原行内容替换并标注"已编辑"。
  • 因此撤回/编辑的 affects_session_order = false(不把会话顶到最前), 但走 §12.4.2 的定位判断(target_conversation_seq == projection.latest_conversation_seq 时改预览), 会修复会话列表预览;anchor_target 事件不推进 projection.latest_conversation_seq、 last_message_id 与 last_activity_id(§12.4.2)。
  • 如果客户端本地没有 target_conversation_seq 对应的行(该消息在保留窗口外或从未拉取), 则整条忽略,不生成孤儿占位行;后续 PULL_HISTORY 拿到的 MessageRecord.state 已是撤回后的终态,自然收敛。

13.4.3 窗口与权限

recall_self_window            2 min      (附录 B.6.1,租户可配置)
edit_self_window              15 min     (附录 B.6.1,租户可配置)

发送者本人:窗口内可撤回/编辑自己的消息
群主 / 管理员(role = OWNER | ADMIN):**不受窗口限制**,可撤回任意成员消息
ModerationService:不受窗口限制,走消息删除路径(state = DELETED),
                   与用户撤回区分渲染("该消息违反社区规定")
超出窗口的用户请求:返回 ERROR{code=PERMISSION_DENIED},不重试

13.4.4 未读扣减的幂等

每个收件人对同一次撤回只有一条邮箱条目(event_id 确定性哈希,§6.7),
压缩器按 mailbox_seq > projection_mailbox_seq 单调消费(以 W[lane] 为上界的连续前缀消费,§12.3.1),
因此 §12.5.5 的 unread_count = max(0, unread_count - 1) 天然幂等:
    重放同一区间 -> 从同一个 projection 检查点出发 -> 得到同一个结果
    不需要环形去重集,不需要额外的"已扣减"标记

重复撤回同一条消息(用户点两次、客户端重试)在 ConversationWriter 侧收敛: MessageRecord.recall_event_conversation_seq 非空(即 CONTROL 事件已入提交日志)时直接返回成功, 不再产生第二条 CONTROL 事件。禁止仅凭 state = RECALLED 判定撤回完成—— state 更新与日志追加跨系统无法原子提交,仅凭 state 短路会在崩溃窗口内永久丢失撤回事件(崩溃点见 §13.4.1)。

13.5 RTC 信令

v1 在 §2.1 承诺了 RTC 信令转发,但全文只有一句"媒体流本身不经过 IM 消息系统"。本节补齐。

13.5.1 转发语义

RTC_SIGNAL { conversation_id, target_user_id, signal_payload }   (附录 A.3)

硬性语义(四条,全部为"否"):
    不入个人邮箱
    不影响会话列表(不改排序、不改预览)
    不计未读、不计 mention
    不参与任何游标推进

转发路径:
    ConnectionNode -> 按 §5.4 PresenceDirectory 查目标用户的在线设备
                   -> 对目标用户的**所有在线设备**转发(多端响铃)
                   -> 某设备接听后,由主叫补发 signal_payload 内的 cancel 语义帧
                      使其他设备停止响铃
    信令帧走 stream 1(实时流),优先级等同单聊(§11.3.2)

RTC_SIGNAL 属于瞬时消息(§13.2),在连接降级态下会被丢弃——这是可接受的: 信令丢失的表现是"呼叫失败",由 §13.5.3 的超时兜底,不会造成持久数据不一致。

13.5.2 被叫离线

目标用户无任何在线设备(PresenceDirectory 查询为空):
    -> 走 §16 的高优先级离线推送通道
       iOS 使用 VoIP push(PushKit),可唤起 CallKit 全屏来电界面
       Android 使用 FCM high priority message
    -> 被叫设备被唤起后建立连接并完成 AUTH,主叫的后续信令按在线路径转发
    -> 该推送**不写 UserBadgeState**、不改角标(§7.7 的聚合口径不含 RTC)

VoIP push 有平台配额与滥用治理约束(iOS 要求收到后必须报告来电), 因此 RTC_SIGNAL 触发的离线推送必须受 per_user_msg_rate(20 msg/s,附录 B.5)与 rtc_voip_push_daily_quota(默认 200 次/设备/天,附录 B.6.1)双重限制。

13.5.3 呼叫生命周期信令

rtc_ring_timeout          60 s   (附录 B.6.1)
rtc_signal_max_bytes      8 KiB  (附录 B.5,远小于 max_custom_payload_bytes)

INVITE   主叫发起,携带媒体协商参数
RINGING  被叫任一设备确认已响铃
ACCEPT   被叫某设备接听 -> 其余设备收到 CANCEL{reason=answered_elsewhere}
REJECT   被叫拒绝
BUSY     被叫已在通话中,由被叫端或服务端根据当前通话状态返回
CANCEL   主叫取消,或 rtc_ring_timeout 到期由服务端下发 CANCEL{reason=timeout}
BYE      任一方挂断

所有状态迁移由端侧驱动,服务端只做转发与超时兜底,不维护通话状态机的权威副本。

通话记录是普通持久消息,不是信令:通话结束后由发起方(或服务端代发) 写一条 custom_type = call_record 的持久消息,走 §13.3 的自定义消息契约, counts_unread 按产品配置(未接来电通常为 true)。 这样"通话记录出现在聊天记录里、可离线同步、可跨设备一致"是自然结果, 而不需要给 RTC 信令加任何持久语义。

13.5.4 媒体流

媒体流(音频、视频、屏幕共享)**不经过** IM 系统的任何组件:
    不经过 ConnectionNode、不经过 MailboxNode、不写 MessageStore
    由独立的 SFU / TURN 设施承载,IM 只在 signal_payload 中透传其地址与凭证

13.6 表情回应

表情回应必须单独定义,不能按 §13.3 的自定义消息处理。理由是量级: 一条大群消息可累积数千个回应,而回应的单条价值极低。按自定义持久消息处理会产生 回应数 × 成员数 的邮箱写入——10 万人群一条消息的 5000 个回应即 5 亿条条目。

13.6.1 投递类别:聚合瞬时

delivery_class = ephemeral_aggregate     (§13.3 三类之外的第三类)

不产生 UserMailboxEntry                  → 不占邮箱容量,不参与 §25.1 容量公式
counts_unread          = false           → 永不计入未读或角标
affects_session_order  = false           → 永不改变会话列表排序
timeline_visible       = false           → 不在时间轴上新增行,只改已有消息的挂载状态
不参与 §6.9 的任何排序键

持久的是聚合结果,不是事件流:MessageReactionSummary(§7.15)随消息一同持久化, 因此回应在离线、换设备、REBUILD 后都能正确恢复——恢复路径是"读消息时同分区带回聚合", 而不是"重放回应事件"。这是它与 §13.2 纯瞬时消息(输入状态、在线状态)的关键区别。

13.6.2 投递规则

上行  REACT{conversation_id, conversation_seq, reaction_key, action(add|remove)}
      幂等键 = (tenant_id, conversation_id, conversation_seq, user_id, reaction_key)
      重复 add 或对不存在的 remove 一律返回成功,不改变聚合值

下行  REACTION_UPDATE{conversation_id, conversation_seq, counts, summary_version,
                      [self_reaction_keys]}
      **幂等绝对值帧**:counts 是全量映射,不是增量
      客户端规则:summary_version 更大才应用,绝对值直接覆盖,否则丢弃
      与 SESSION_DELTA 采用完全相同的语义(§12.6),理由也相同——
      推送通道是至少一次且可丢弃的,增量语义必然永久漂移

明细  PULL_REACTIONS{conversation_id, conversation_seq, reaction_key, offset, limit}
   -> REACTION_LIST{users[], total, has_more}
      仅在用户主动查看"谁点了"时发起,limit <= reaction_detail_page_limit(附录 B.6.1)

13.6.3 大群下的量级控制(必须实现,不是优化)

1. 推送范围:只推给**该会话当前在线**的成员,离线成员在下次读消息时随聚合取回
             回应永不触发离线推送(§16),永不写 UserBadgeState

2. 合并窗口:同一 (conversation_id, conversation_seq) 的聚合推送按
             reaction_push_merge_window(附录 B.6.1,默认 2 s)合并,
             窗口内只发最后一帧的绝对值 —— 因为是绝对值,合并即丢弃前帧,无累加风险

3. 明细降级:会话成员数 > reaction_detail_max_members(附录 B.6.1)时,
             停止写 MessageReaction 明细,只累加聚合计数,
             PULL_REACTIONS 返回 total 与空 users[](大群下"谁点了"本无展示价值)

4. 发送限流:per_user_reaction_rate(附录 B.6.1),与 per_user_msg_rate 独立计量

13.6.4 与其他机制的关系

机制 回应的行为
撤回 / 删除消息 目标消息 state=RECALLED/DELETED 时,聚合与明细一并物理删除
加密擦除(§21) 聚合 counts 不含正文,无 dek_id,物理删除即可;明细同理
E2EE(§22) reaction_key 是短枚举字符串,不加密——它不构成消息内容,且服务端需聚合
读时 join(§18.3.1) 与 MessageRecord 共用分区键,同一次范围读带回,不增加往返
mention_only 档(§10.2.3) 不受影响:回应本就不写邮箱,两档行为一致

14. 聊天室

聊天室与普通群不能共用"全员个人邮箱"的持久语义:100 万在线成员 × 每条消息一条邮箱引用, 在 room_msg_rate 20 msg/s 下是 2000 万 entry/s,比全平台其他所有写入之和还高一个数量级。

14.1 分发流程

RoomWriter
  -> 校验发送权限与 room_msg_rate 准入
  -> 分配 message_id(用于客户端去重与举报追踪)与 room_seq
  -> 写入短期 RoomRecord(§7.12,保留 room_log_retention_minutes,附录 B.3)
  -> 按"存在该房间连接"的 ConnectionShard 合并广播,每个分片只发一个批次
ConnectionNode
  -> 维护本节点 room_id -> connections 集合(由 ROOM_JOIN / ROOM_LEAVE 维护,见 §14.5)
  -> 按 room_outbound_frame_rate 合并后写入本地房间 Socket(ROOM_BATCH,stream 3)
  • 房间消息有 message_id,无 conversation_seq(§6.6)。 因此它不进入 MessageStore 的会话历史,不参与 §6.9.1 的会话内排序, 房间内排序只用 room_seq(§6.9.4 允许 room_seq 在房间内排序,禁止用于其他任何排序)。
  • 房间不产生 UserMailboxEntry,不进入会话列表投影, 除非产品显式把某个房间提升为持久群会话(§14.4)。
  • ROOM_BATCH 在 §11.3.2 的优先级中最低,节点预算超限时第一个被丢弃。

14.2 硬性速率约束

v1 完全没有房间侧的速率上限,这是 100 万在线目标下最危险的缺口。

room_msg_rate               20 msg/s / 房间       (附录 B.5)
room_outbound_frame_rate    10 frame/s / 连接(合并后)   (附录 B.5)
  • room_msg_rate 在 RoomWriter 侧事前拒绝,超限返回 ERROR{code=RATE_LIMITED, retry_after_ms}。
  • room_outbound_frame_rate 是出向合并的硬上限:ConnectionNode 在 100 ms 窗口内 把同一房间的多条消息合并进一个 ROOM_BATCH,因此 20 msg/s 的房间在每个连接上 最多产生 10 frame/s,而不是 20 frame/s。

为什么 §11.3 的水位不够:

§11.3 的软/硬水位是**事后被动降级**:它要等缓冲已经涨到 1 MiB 才动作。

100 万在线房间的一条消息 = 100 万次 Socket 写入。
若无事前限速,一个房间可以在 1 秒内把 20 × 100 万 = 2000 万帧压进共享 ConnectionNode 的发送路径,
在水位生效之前就已经:
    1. 打满节点发送缓冲,触发 node_send_buffer_budget 的全局丢弃
    2. 挤占**同一批 ConnectionNode 上的单聊与群聊连接**(聊天室与 IM 共享网关)
    3. 大量连接命中硬水位被关闭 -> 集体重连 -> 触发全局重连风暴 -> 放大故障

事前限速(room_msg_rate + room_outbound_frame_rate)把峰值削在**产生侧**,
水位则作为兜底处理"限速之内仍然消费不动的慢客户端"。两者是不同层的机制,不可互相替代。

14.3 缺口与回放

客户端按 ROOM_BATCH.latest_room_seq 与本地已收 room_seq 比对发现缺口
  -> ROOM_REPLAY { room_id, after_room_seq }
  -> ROOM_BATCH { room_id, room_epoch, events[], latest_room_seq, replay_truncated }

规则:
  after_room_seq 落在 room_log_retention_minutes(附录 B.3)窗口内 -> 回放该区间,
      replay_truncated = false
  落在窗口外(即 after_room_seq < earliest_replay_room_seq)      -> **不返回错误码**,
      返回空批次 + 当前水位:ROOM_BATCH{events=[], latest_room_seq, replay_truncated=true},
      客户端直接**跳到当前 latest_room_seq**,本地标注"部分消息未显示"
  room_epoch 不匹配(房间迁移或重建,§6.6)                        -> 同上,一律跳到当前水位

为什么超窗口不用 ERROR 而用空批次 + 跳转水位:

超出回放窗口是聊天室的**正常稳态**(房间只保留 room_log_retention_minutes),
不是异常。用 ERROR 表达会有三个后果:
  1. 客户端必须为一个正常事件写错误分支,且 ERROR 走 stream 0,与房间流 stream 3 分离,
     顺序无法与后续 ROOM_BATCH 对齐;
  2. 错误码天然诱导重试,而这里重试永远不会成功;
  3. 客户端拿不到"跳到哪里"——它仍需再发一次请求才能得到 latest_room_seq。
空批次 + replay_truncated=true + latest_room_seq 一帧给全,客户端一步收敛。
`replay_truncated` 与 `earliest_replay_room_seq`(§14.5)字段定义见附录 A.4。

聊天室允许丢消息,这是与普通群的根本区别,必须在产品文案与验收用例中显式承认: 房间消息没有持久投递保证,room_seq 只用于缺口检测与短窗口回放, 不得作为任何完整性判定的输入(§6.10.1)。

14.4 房间提升为持久群会话的序列衔接

v1 只有"除非产品显式把某个房间提升为持久群会话"一句,没有定义序列如何衔接。本版写死:

提升时刻 T:
  1. GroupMembership 以房间当前在线成员为初始成员集,创建 conversation_id 与成员版本
  2. ConversationWriter 为该会话初始化 conversation_seq,从 1 开始分配
     (提升时刻起才开始分配,此前的房间消息从未有过 conversation_seq)
  3. 提升前的房间消息**不进入持久历史**:
     不回填 MessageRecord、不回填 UserMailboxEntry,
     RoomRecord 仍按 room_log_retention_minutes 到期删除
  4. 每个成员的 joined_at_conversation_seq = 0
     (提升时刻尚未分配任何 seq,全部初始成员从 seq=1 起可见且计未读)
     -> visible(u,c) 的下界即为提升时刻,之前的内容天然不可见,
        不需要任何额外的过滤规则
  5. 提升后房间的 room_seq 停止分配;若产品要求房间与群会话并存,
     二者视为**两个独立实体**,不共享序列、不互相回填

不回填的理由:回填意味着为房间历史补发 O(在线成员数 × 历史条数) 条邮箱引用, 正是 §14 开头拒绝的那笔成本;且房间消息没有 conversation_seq, 回填时必须重新分配,会与"message_id 已发给客户端"的既有事实产生两套坐标。 统一规则"提升前的内容留在房间侧、按房间保留期消失"是唯一可实现且可验收的选择。

14.5 房间加入与退出

ROOM_JOIN / ROOM_LEAVE 在附录 A.3 已定义,但 v1 从未说明它们做什么。 二者是 §14.1 中 ConnectionNode room_id -> connections 集合的唯一维护入口: 没有这两条信令,广播路径就没有收件人集合,房间分发无从成立。

加入

上行   ROOM_JOIN{room_id}                                          (附录 A.3)
应答   ROOM_BATCH{room_id, room_epoch, events=[],
                  latest_room_seq, earliest_replay_room_seq}       (stream 3)

三个定位字段让客户端一次性确定回放起点,不需要额外 RTT:
    room_epoch                房间当前世代(§6.6)。与本地记录不一致 ->
                              丢弃本地全部 room_seq 状态,直接从 latest_room_seq 开始
    latest_room_seq           当前水位。实时 ROOM_BATCH 从此位置之后开始到达
    earliest_replay_room_seq  服务端仍可回放的最早位置
                              (即 room_log_retention_minutes 窗口的下界,附录 B.3)。
                              只有 after_room_seq >= 它时才值得发 ROOM_REPLAY(§14.3)

服务端动作:
  1. 校验房间存在与进入权限,失败返回 ERROR{code=PERMISSION_DENIED}(不区分"无权限"与"不存在")
  2. 把本连接加入 ConnectionNode 的 room_id -> connections 集合
  3. 若本 ConnectionShard 此前没有该房间的任何连接,向 RoomWriter 注册本分片为广播目标
     ——§14.1 所说"存在该房间连接的 ConnectionShard"就是这个集合
  4. 回应答帧

幂等:同一 (connection, room_id) 重复 ROOM_JOIN 不产生第二份订阅,
      只刷新应答中的三个定位字段。
上限:max_rooms_per_connection(默认 20,附录 B.6.1),超出返回 ERROR{code=RATE_LIMITED}。

退出

上行   ROOM_LEAVE{room_id}
应答   ROOM_BATCH{room_id, events=[], latest_room_seq}     # 仅作确认,不携带事件

服务端动作:从本节点 room_id -> connections 集合移除本连接;
            该分片最后一个连接离开时,向 RoomWriter 注销广播目标。
房间不存在或本连接未加入时返回 ERROR{code=PERMISSION_DENIED},不重试。

生命周期(硬性规则)

房间订阅是**连接级软状态**:不持久、不写 UserConversationState、不入邮箱、不跨连接保持。

连接关闭(正常断开 / KICKED{replaced} / 命中 conn_send_hard_watermark 被关 / 节点故障)
    -> 该连接从所有 room_id -> connections 集合中移除,等价于一次隐式 ROOM_LEAVE
REDIRECT 或重连之后
    -> 客户端必须**重新 ROOM_JOIN**,服务端不做任何跨连接恢复;
       重新加入后按 §14.3 判断是回放还是跳到当前水位
  • 不做持久订阅的理由:房间集合是 O(在线连接数) 的纯内存结构,若做成持久订阅, 100 万在线房间在每次节点接管时会产生一次百万级订阅恢复风暴; 而聊天室本身已声明允许丢消息(§14.3),持久订阅换不来任何正确性收益。
  • 按连接生效,不按用户生效:同一用户的两个设备各自 ROOM_JOIN、各自收 ROOM_BATCH、 各自维护 room_seq 与回放位置。房间侧不存在任何用户级持久状态, 这也是房间既不写 UserMailboxEntry(§14.1)也不写 UserConversationState 的直接原因; 只有 §14.4 的"提升为持久群会话"才会开始产生这两类记录。

裁决(已并入附录 A):TYPING 与 PRESENCE_SUB 采用双向帧写法——上下行共用同一 opcode, 方向由 request_id 区分(上行非零、服务端主动下行为 0)。理由:这两个帧是纯瞬时状态, 上下行载荷高度同构,另分配下行 opcode 只会让客户端多写一套分发逻辑。 双向标注、TYPING 下行的 user_id/expires_in_ms、PRESENCE_SUB 的 action/targets[]/entries[], 以及 ROOM_BATCH.replay_truncated 与 earliest_replay_room_seq,均已列入附录 A.3/A.4。

15. 连接、心跳与流控

连接层的目标只有三个:用最低的开销确认连接活着、在连接死掉时尽快发现、在系统过载时有序拒绝而不是雪崩。 v1 的 §15 只有 11 行,三个目标都没有闭环。本章重写。

本章使用的帧全部来自附录 A,不新增帧;数值一律以附录 B 为准,正文只引用不重复定义。

15.1 心跳协议

上行  PING { ping_id, client_time, last_applied_mailbox_seq, network_type }
下行  PONG { ping_id, server_time, echo_client_time,
              last_pushed_user_seq, mailbox_dirty,
              next_ping_interval_ms, idle_timeout_ms }

PING 不携带 session_epoch:epoch 在一条连接的生命周期内不变,由 ConnectionNode 侧持有,客户端上报没有信息量。

15.1.1 PONG 必须回 last_pushed_user_seq,不能回分片水位

v1 的 PONG.materialized_watermark 是全文最贵的一处错误。

错误链条:
  §6.5  个人邮箱队列天然稀疏(一个分片的 10001..10500 中用户 A 只有 3 条)
  → 用户的 last_applied_mailbox_seq 与分片级 materialized_watermark 天然不相等
  → 客户端若按 last_applied_mailbox_seq < materialized_watermark 判缺口,判定恒为真
  → 每个在线用户每个心跳周期都发起一次必然为空的 PULL_MAILBOX

代价(按 v1 的 30 秒固定心跳):
  千万在线连接 / 30 s = 333,333 次/秒空拉
  每次空拉是一次跨节点范围查询 + 一次帧编解码 + 一次 RTT,全部产出为零

改为回 last_pushed_user_seq:

last_pushed_user_seq = ConnectionNode 在该连接上"已写入 Socket 的最大 mailbox_seq"

数据来源不需要任何新增字段:
  §11.1 PushBatch.recipients[].mailbox_seq 本就是该收件人自己的序号,
  ConnectionNode 展开 PushBatch 逐 Socket 写入时取 max 即可,O(1) 内存、无额外存储。

连接建立时初始化为 AUTH_OK.sync_to_seq;连接替换后新连接从新的 sync_to_seq 重新起算。

客户端规则(写死,三端必须一致):

发起 PULL_MAILBOX 的充分必要条件:
    PONG.mailbox_dirty == true
 或 cursor.last_applied_mailbox_seq < PONG.last_pushed_user_seq
 或 用户主动下拉刷新(受 client_resync_min_interval 限流,默认 5 s)

严禁:把 last_applied_mailbox_seq 与任何分片级水位(materialized_watermark、
      lane_watermark)比较来判定缺口。见 §6.10.1 的非法判据表。

lane_watermark 只出现在 AUTH_OK 与 MAILBOX_BATCH;PONG 不携带任何水位字段。它在 AUTH_OK 中作为本次同步的上界 sync_to_seq,在 MAILBOX_BATCH 中回带最新上界(附录 A.4)。全局兜底拉取用 PULL_MAILBOX(after_seq = cursor.last_applied_mailbox_seq, up_to_seq = 0),up_to_seq=0 表示"拉到当前水位",由服务端填充该用户 lane 的最新水位(§6.10.2),客户端无需、也不得从 PONG 获知水位。lane_watermark 不是缺口判据。

验收(§26):某用户 24 小时无新消息、其所在分片水位推进 100 万,该用户任一在线设备不得发起一次 PULL_MAILBOX。

15.1.2 自适应心跳

固定 30 秒在移动网络上同时踩两个坑:运营商 NAT 的 UDP/TCP 映射超时差异极大(部分省份移动网络约 60 秒回收,部分固网与 WiFi 超过 5 分钟),30 秒对宽松网络是纯粹的电量与信令浪费,对严苛网络又可能因为设备休眠错过一次心跳而被静默断链——客户端认为连接还在,服务端和 NAT 都已经放弃,此时发消息会卡到 TCP 重传超时才失败。

探测状态键 = (network_type, 运营商 MCC/MNC 或 WiFi BSSID 的哈希)
    只存哈希,不存 BSSID 明文(§20 隐私要求)
    本地持久化,条目上限 32,LRU 淘汰,有效期 7 天

爬升:ping_interval_initial = 60 s 起
      连续 3 次 PONG 正常 → +ping_interval_step(30 s)
      前台上限 ping_interval_max_foreground = 120 s
      后台上限 ping_interval_max_background = 240 s

回退:任意一次 PONG 缺失(超过 next_ping_interval 未收到)
      → 立即回退到"上次成功值 × ping_backoff_factor(0.8)"并锁定 30 分钟不再爬升
      → 同时对该探测状态键写入回退结果,下次同网络直接从该值起步

判死:PONG 缺失后除回退间隔外,立即在 stream 0 补发一次 PING{probe=true}
      (复用 §15.1.4 与附录 A.3 的探测帧);
      ping_probe_timeout(3 s,附录 B.4)内未收到任何下行帧
      → 判定链路失效,关闭连接并进入 §15.2 重连。
      探测期间间隔回退学习逻辑不变;
      一次探测周期内只允许一次 probe,避免与周期心跳叠加。

前后台切换、网络类型变化、蜂窝小区切换 → 立即重新评估一次,不等待当前周期结束。

心跳间隔是每连接状态,不是全局常量;ConnectionNode 必须按连接保存当前值,超时判定使用该连接的值。

15.1.3 服务端下发能力

PONG.next_ping_interval_ms 与 PONG.idle_timeout_ms 是服务端指令,客户端必须遵从:客户端的自适应结果只能在服务端给出的上界内爬升,不得超过 next_ping_interval_ms。

超时判定:idle_timeout = next_ping_interval × 2 + 10 s
          替代 v1 的"连续两个硬编码周期"

  next_ping_interval = 60 s  → idle_timeout = 130 s
  next_ping_interval = 240 s → idle_timeout = 490 s

+10 s 是为了吸收调度抖动与移动网络的单次长 RTT,避免把"这一次心跳晚了 3 秒"判成断链。

过载降载:节点 CPU 或事件循环延迟超过阈值时,ConnectionNode 可以在 PONG 中全局拉长 next_ping_interval_ms(不超过 ping_interval_max_background),一次调整即可把心跳 pps 线性下降。这是连接层唯一不需要断开任何连接的降载手段,优先于慢连接淘汰(§11.3)。

注意 idle_timeout 只是"完全没有下行数据时"的发现上界。只要服务端有数据要发,内核的 tcp_user_timeout(15 s)会先触发,死连接在 15 秒内暴露。后台 490 秒的窗口不会延迟消息投递的失败发现。

15.1.4 发送侧独立超时

v1 只有连接级超时,60 秒才发现死连接。对"用户点了发送,消息发不出去"这个最高频的可感知故障,60 秒完全不可接受。因此发送路径必须有独立于心跳的超时:

1. SEND_MESSAGE 携带 request_id(附录 A.1 帧头字段),SEND_ACK 按同一 request_id 配对返回。
2. send_ack_timeout(3 s)内未收到 SEND_ACK
     → 客户端立即补发一次 PING{probe=true}(探测心跳,probe 字段见附录 A.3,
        服务端处理逻辑与周期 PING 完全相同并立刻回 PONG,
        probe 仅用于把探测心跳与周期心跳在指标上区分开)
3. 再 3 s 内未收到 PONG
     → 判定链路失效,关闭连接并进入 §15.2 的重连流程
     → UI 把该消息置为"发送中(网络异常)",本地 pending 区保留(§6.9.1)

端到端不可用检测上界 = 3 s + 3 s + 关闭耗时 ≈ 7 s,与心跳间隔无关。

补发的探测心跳走 stream 0(控制流),不会被正在传输的大批量帧阻塞(§15.4)。

超时不等于失败:SEND_MESSAGE 的幂等键是 client_message_id(§7.2 ClientDedup,TTL 24 小时),重连后客户端用同一个 client_message_id 重发,服务端返回同一个 message_id 与 conversation_seq。禁止客户端在超时后换新的 client_message_id,否则会产生重复消息。

内核兜底参数(附录 B.4):

tcp_user_timeout = 15 s      有未确认数据时,15 s 内必然报错,替代不可控的默认重传上限
tcp_keepalive    = idle 60 / intvl 10 / cnt 3

TCP Keepalive 只作系统级兜底:它探测不到"进程活着但应用层已经死锁"的连接,也无法携带 mailbox_dirty 与水位,不能替代应用层心跳。

15.1.5 开销量化

v1 只说"用时间轮",没有给出任何量级,无法判断实现是否达标。写死如下:

单节点 20 万连接、tick = 1 s、槽位 = 512(heartbeat_wheel_tick / heartbeat_wheel_slots,附录 B.4)
  每 tick 需要扫描的连接数 ≈ 200000 / 512 ≈ 390
  即:单线程每秒处理约 390 次惰性校验,开销可以忽略

超时判定用惰性校验,不用精确定时器:
  收到任意合法帧 → 仅更新 conn.last_active_at(一次原子写,O(1),不重排定时器)
  槽位到期时读取 last_active_at:
      now - last_active_at >= idle_timeout  → 关闭连接
      否则                                   → 按剩余时间重新挂回未来槽位
  禁止"每次收帧就取消并重建定时器",20 万连接下会产生每秒数万次堆调整。

PONG 批量写出(pong_batch_window = 10 ms,附录 B.4):
  同一事件循环批次内,该连接的 PONG 与其他下行帧合并为一次 writev,
  减少"每个 PONG 一次唤醒 + 一次 write"的开销。
  跨连接的 syscall 合并需要 io_uring 批量提交,列为实现可选项,不作为基线要求。

心跳容量公式(进入 §25):

心跳 pps = 在线连接数 / 平均心跳间隔 × 2        (× 2 = PING + PONG)

代入:1000 万连接、平均心跳间隔 90 s
    = 10,000,000 / 90 × 2 ≈ 222,000 pps

稳态可折减:任何经过鉴权与帧头校验的业务帧都刷新 last_active_at,
活跃会话用户在心跳周期内通常已有业务帧,实际 PING 量低于上式;
但**容量规划必须按上式取值**,因为夜间与弱交互时段没有业务帧可折减。

对比 v1 的 30 秒固定心跳:10,000,000 / 30 × 2 ≈ 667,000 pps,自适应心跳直接把连接层的基础信令量降到三分之一。

15.2 重连与准入控制

15.2.1 客户端重连

退避:reconnect_backoff = 1 s 起,× 1.8,上限 120 s,叠加 ±30% 抖动
      抖动是强制项:无抖动的指数退避只是把风暴推迟,不会削峰

快速重连:网络类型变化、从后台回到前台、系统网络可达性回调触发时,
          允许**立即**尝试一次,不等退避
          约束 1:一次网络事件只允许一次快速重连,失败后回到标准退避序列
          约束 2:收到 ERROR{code=RATE_LIMITED} 后禁用快速重连通道,直到下一次成功认证
          约束 3:若持有未过期的 route_token(TTL 60 s),快速重连必须携带以跳过重定向

15.2.2 连接替换与 session_epoch

session_epoch 由 AuthService 在认证通过时分配:
  作用域 (tenant_id, user_id, device_id),单调递增,重启与故障切换后不得回退
  ConnectionNode 只搬运不生成,认证成功后写入 PresenceEntry(§7.10 唯一写入方约定)

同一 device_id 的新连接认证成功后:
  1. PresenceDirectory 发布新的 PresenceEntry(epoch 更大者胜)
  2. 旧连接所在 ConnectionNode 必须向旧连接下发 KICKED{reason=replaced, replaced_by_device}
     然后关闭;客户端收到 KICKED 后不得重连该连接
  3. 携带旧 session_epoch 的 PushBatch 一律丢弃并回 PRESENCE_STALE(§5.4 收敛)

设备数上限 max_devices_per_user = 8:
  超限时踢出"最久未活跃"的设备,同样使用 KICKED{reason=replaced}
  被踢设备的邮箱游标保留,重新登录按 §9 正常同步;超过 device_inactive_gc_days 才回收

新旧连接并存窗口(切网快速重连的正常现象):
  以 session_epoch 大者为唯一权威,不做"谁先到谁赢"
  旧连接在被 KICKED 前可能仍收到少量推送,客户端按 message_id/event_id 幂等去重(§6.9)

15.2.3 服务端准入控制

v1 完全没有准入控制。"固定 ConnectionShard + 网络切换立即快速重连"这两条已锁定的决策叠加,会构成一个自我放大的正反馈环:

1. 固定分片意味着某个 ConnectionNode 故障后,它承载的 20 万连接**全部**指向同一个接管节点,
   不像随机负载均衡那样被摊薄到集群
2. 客户端"网络切换立即快速重连"取消了退避的第一层保护,20 万次重连在 1~2 秒内到达
3. 每次重连的成本远高于稳态:TLS 完整握手(非会话复用)+ 令牌验签 + 游标验签
   + lane 水位查询 + 首次 PULL_MAILBOX,是纯 CPU 与跨节点读
4. 接管节点握手队列打满 → 客户端超时 → 立即再次快速重连 → 回到第 2 步

没有准入控制时,这个环不会自己收敛,只会把故障从一个节点扩散到整个分片组。

因此接管与常态都必须有闸门:

连接建立速率限制(`per_ip_connect_rate` 数值见附录 B.5):
  per_ip_connect_rate                     按 IP 的建连速率上限
  per_ip_max_connections   = 200          (企业 NAT 出口按租户白名单放大,附录 B.5)
  监听侧开启 SYN cookie;backlog 只用于吸收瞬时抖动,禁止把 backlog 当限速手段

未认证连接超时:
  unauth_connection_timeout = 10 s
  TCP/TLS 建立后 10 秒内未收到合法 AUTH 帧 → 直接关闭
  这是 slowloris 类攻击的唯一防线:未认证连接不消耗邮箱与目录资源,但占满 fd 与内存

接管分批放行:
  takeover_admit_rate = 5 %/s(按该 ConnectionShard 的目标连接数计算令牌桶)
  20 万连接的分片 → 每秒放行 1 万 → 20 秒完成接管,握手 CPU 保持在可控区间
  超出预算的连接:完成 TLS 后立刻回
      ERROR{code=RATE_LIMITED, retry_after_ms}
  retry_after_ms = 基础退避 × (1 + 当前排队比例),并叠加 ±30% 抖动
  **禁止所有被拒连接收到同一个 retry_after_ms**,否则只是把风暴整体平移

认证后错峰:
  AUTH_OK.sync_delay_hint_ms(0 ~ 30000 随机)
  客户端必须在该延迟后才发起首个 PULL_MAILBOX / PULL_SESSION_LIST
  作用:把 2 万个并发同步请求摊到 30 秒,保护 MailboxNode 与 SessionProjection
  例外:sync_delay_hint_ms 不延迟 SEND_MESSAGE 与 PING,用户主动发消息不受影响

优先级:接管期若必须取舍,先放行已有离线积压的设备(has_offline=true 需要在认证阶段判定,因此该判定必须先于放行决策),再放行纯在线保活设备。

15.3 令牌生命周期

一条长连接的寿命可以是 7 天甚至更长,而 access_token 的有效期(access_token_ttl)远短于此。v1 没有定义这个矛盾如何解决,实现者只有两个坏选择:把 token 有效期拉到与连接同长(吊销失效),或者每个 TTL 周期断一次连接(重连风暴)。本节定义第三条路。

本节涉及的 access_token_ttl、refresh_token_ttl(滑动续期,每次刷新重置)、token_refresh_lead(过期前多久开始刷新)、token_expiry_grace(过期后的只读宽限期)、revocation_propagation_target(吊销到断连的端到端时延目标)数值见附录 B.4。令牌参数以本节为唯一规范,§20.3 只引用不重复定义。

15.3.1 连接内静默续期

客户端在 access_token 剩余寿命 < token_refresh_lead 时:
  1. 通过 HTTP 认证接口用 refresh_token 换取新的 access_token(不占用实时链路)
  2. 在**同一条已建立的连接**上重发 AUTH 帧完成重认证:
       AUTH{ access_token=新令牌, device_id=本连接绑定值,
             client_version, capabilities, mailbox_cursor=当前游标 }

服务端重认证规则(与首次认证严格区分):
  - 校验 device_id 与本连接绑定值一致,不一致直接 ERROR{PERMISSION_DENIED} 并关闭
  - session_epoch **保持不变**,不重新分配,不发布新的 PresenceEntry
  - 不重置 last_pushed_user_seq,不重新进入登录同步屏障,不重发 ONLINE_READY
  - 回 AUTH_OK 仅表示续期成功;客户端**不得**据此清空本地状态或重建会话列表
  - 若 AUTH_OK 中 has_offline=true(重认证期间确有积压),客户端按 §9 正常拉取即可

刷新失败(refresh_token 也过期或被吊销):
  → 服务端在 access_token 过期后进入 token_expiry_grace 只读宽限(时长见附录 B.4):
       允许 PING / PULL_MAILBOX / PULL_HISTORY
       拒绝 SEND_MESSAGE / MARK_READ / RECALL / EDIT,返回 ERROR{code=TOKEN_EXPIRED}
  → 宽限期结束仍未续期 → ERROR{code=TOKEN_EXPIRED} 后关闭连接
  → 客户端回到登录页;本地已持久化的消息与游标保留,避免用户数据丢失

只读宽限期的意义:令牌服务短暂不可用时,用户仍然能收消息,只是不能发。这比直接踢掉所有连接的体验和负载都好得多。

15.3.2 吊销与远程登出

吊销必须立刻断开活跃连接,不能等 token 自然过期——否则"远程登出"和"设备丢失"这两个安全功能形同虚设。

吊销事件的传播路径(复用 §5.4 已有通道,不新增基础设施):

  AuthService 写入吊销记录((tenant, user, device) 或 (tenant, user) 全量)
      │
      ▼
  PresenceDirectory 的 compacted topic
      key   = (tenant_id, user_id, device_id)
      分区键 = 与 MailboxShard 同源的 user_bucket
      │  (全量登出 = 对该用户全部 device_id 各写一条)
      ▼
  ConnectionNode 消费到覆盖本地 ConnectionShard 的分区
      │
      ├─ 匹配到本地活跃连接 → 下发 KICKED{reason=token_revoked} 并关闭
      ├─ 删除对应 PresenceEntry(写墓碑),使后续 PushBatch 不再指向该连接
      └─ 通知 NotificationService 回收该 device 的推送 token(§16.6)

客户端收到 KICKED{token_revoked} → 清除本地消息、游标与缓存,回到登录页。
客户端在重连时若使用已吊销令牌 → ERROR{code=TOKEN_REVOKED},动作相同。

吊销记录保留期 revocation_record_retention = access_token_ttl + 安全余量(默认 2 h,附录 B.4)。
超过该时间的令牌本就无法通过校验,无需继续保留吊销记录,因此吊销表是**有界**的。

为什么走 compacted topic 而不是同步 RPC:吊销是低频事件(量级远低于登录),但要求覆盖所有 ConnectionNode 且不丢。compacted topic 天然满足"最终必达 + 新节点启动可回放全量当前状态",同步 RPC 会在节点重启窗口内静默漏掉。

15.4 帧、多路复用与出向调度

帧头见附录 A.1,opcode 分段见附录 A.2,流划分见附录 A.5。本节只解释设计理由与调度规则。

15.4.1 为什么完整性校验只覆盖帧头

若 header_crc 覆盖整帧 body:
  一条 10 万人群消息的公共正文在每个 Socket 上都要重新扫描一次做 CRC
  → 10 万次全帧扫描,正文越大越贵
  → 与 §10.3 "公共正文只编码一次、每个 Socket 只生成轻量个性化帧头"的优化**完全抵消**
  → 大群分发的 CPU 收益归零,正文缓存也失去意义

因此 header_crc 只覆盖帧头(固定 20 字节):
  - 它的职责是**防止 body_len 被破坏导致的流失步(framing desync)**,
    即让"读错长度 → 后续所有帧全部错位"这种不可恢复故障可以被立即检测并断连
  - 它**不是防篡改机制**。防篡改由 TLS 1.3 记录层的 AEAD 保证,覆盖包括 body 的全部字节
  - 公共正文只需在编码时算一次帧头 CRC 之外的零次校验,per-Socket 成本为 O(帧头)

推论(必须写进实现):任何不经 TLS 的链路(内部明文调试通道、私有化环境的裸 TCP)
一律禁止承载生产流量。body 完整性的唯一来源是传输层加密,去掉它就没有替代品。

15.4.2 四条 stream 的划分理由

stream 0  控制流   AUTH / PING / PONG / ERROR / KICKED
stream 1  实时流   PUSH_EVENTS / SEND_ACK / SESSION_DELTA / BADGE_UPDATE
stream 2  批量流   MAILBOX_BATCH / HISTORY_BATCH / SESSION_LIST_BATCH
stream 3  房间流   ROOM_BATCH
流 划分理由 不划分的后果
0 控制流 心跳与错误必须永不排队。一个 4 MiB 的 MAILBOX_BATCH 在 1 Mbps 弱网上要传 32 秒 PONG 被压在批量帧后面 → 客户端 PONG 缺失探测失败(§15.1.2)→ 断开重连 → 重新拉同一批数据,形成"越拉越断"的死循环
1 实时流 新消息与 SEND_ACK 的时延直接决定产品体感,必须优先于历史回填 用户在翻历史时收不到新消息,或者自己发的消息迟迟不上屏
2 批量流 批量帧大、可中断、可重来(纯读幂等),是唯一可以被降级丢弃的持久数据通道 与实时流混流会让积压同步把实时推送整体拖慢
3 房间流 聊天室是可丢弃语义(§14),且帧率最高(room_outbound_frame_rate 10 frame/s/连接) 聊天室广播挤占共享出向缓冲,把单聊和大群一起拖进慢连接降级

15.4.3 出向调度与流控

调度:按流做加权轮转,权重与附录 B.5 的帧优先级一致
      控制流 > 单聊 > 小群 > 大群 > 聊天室
      单次调度片 stream_scheduling_quantum = 64 KiB(附录 B.4)
      超过调度片的 body 使用帧头 flags bit1 分片传输;
      同一 stream 内的分片必须连续,禁止交错,接收端无需重组缓冲区索引

水位与降级:沿用 §11.3 与附录 B.5
      conn_send_soft_watermark = 1 MiB / 2000 条 → 停止推送积压,改发 MAILBOX_DIRTY
      conn_send_hard_watermark = 4 MiB / 8000 条 → 关闭连接
      conn_send_low_watermark  = 256 KiB          → 滞回阈值,退出降级态
      滞回是强制项:没有低水位会在软水位附近反复进出降级态,产生抖动与重复 MAILBOX_DIRTY

节点级预算:node_send_buffer_budget 是硬上限。
      超出后按帧优先级从低到高丢弃(先丢聊天室,最后丢控制流),
      被丢弃的持久事件不影响可靠性——它们已经在个人邮箱里,客户端按游标恢复。

16. 离线推送与角标

v1 的 §16 服务表只有一行"离线推送:APNs/FCM 等系统通知"(v1 在这里用的是一个带空格的英文服务名,本版一律使用 §5.1 的唯一写法 NotificationService)。这意味着移动端不可用:iOS 后台没有长连接,没有离线推送就没有消息;aps.badge 需要一个绝对数字,v1 的数据模型里没有任何地方能给出它。本章补齐全链路。

本章的第一原则:离线推送不是可靠消息通道。消息可靠性由个人邮箱(§9)保证,推送只是"叫醒用户"的旁路。推送丢了,用户下次打开应用一条不少;推送重了,客户端按 event_id 去重。任何把推送当成消息传输通道的设计都会在这里出错。

16.1 触发判定

16.1.1 触发点

推送判定的粒度是设备,不是用户。 用户级的在线 Bitmap 只是粗过滤器,判定必须下沉到 device_id。

§10.1 中"本地成员 Bitmap ∩ 在线成员 Bitmap"这一步(第 10 步)之后,分两步判定:

第一步(粗过滤,用户级,O(1) 位运算):
  U_on  = 本地成员 ∩ 在线成员 Bitmap    # Bitmap 只回答"该用户至少一个设备在线"
  U_off = 本地成员 \ 在线成员 Bitmap    # 该用户一台设备都没在线

第二步(判定,设备级,明细取自 PresenceEntry §7.10):
  对 U_on 中的每个用户展开其设备全集 D(user):
      在线集合 I = { (user, device) | 存在未过期的 PresenceEntry(user, device) }
                   → 走 §11 在线推送,并按 16.1.2 做二次确认
      离线集合 F = { (user, device) | 无有效 PresenceEntry }
                   → 由 MailboxNode 产生 PushTask 交 NotificationService
  U_off 中每个用户的全部设备直接进入 F。

  D(user) 取自本地 DeviceToken 摘要缓存(device_token_digest topic,§16.6)∪ PresenceEntry:
  没有注册推送 token 的设备无法被唤醒,不产生 PushTask(但仍会在下次连接时按 §9 同步)。
  摘要缓存滞后仅造成多推/少唤醒一次,由 §16.2 撤销与幂等兜底(滞后指标见 §24.1.6)。

PushTask {
    tenant_id, user_id, device_id     # 主键三元组:产生、去重、合并、频控一律按设备
    conversation_id, message_id, event_id
    mailbox_seq                       # 用于 APPLIED 证据比对与幂等
    conversation_type                 # SINGLE | GROUP(ROOM 不产生 PushTask)
    counts_unread / mention_type      # 决定是否可被静音抑制
    preview_or_placeholder            # E2EE 下为占位,见 16.7
}

不变量 PD-1(per-device 推送不变量,写进实现与验收):

对任意用户 u 的任意一台可唤醒设备 d:
  若 u 的邮箱条目 e 已可靠物化,且 d 在 push_grace_window 内既无 PUSHED 也无 APPLIED 证据,
  则必须立即为 (u, d) 产生一条 PushTask,**与 u 的其他设备是否在线无关**;
  若有 PUSHED 但无 APPLIED 证据,则进入延长确认(push_confirm_extended_window,§16.1.2),
  连接关闭即补推。

推论:一个用户手机离线、桌面在线时,手机必须收到推送,桌面不得收到(§16.2 第 2 条)。
禁止:以"该用户至少一个设备在线/已 PUSHED"为由取消整个用户的推送——
      这正是 v1 用户级判定的缺陷,会让多设备用户的离线设备永久静默。

前置约束:PushTask 只能在该用户的邮箱条目已可靠物化之后产生。顺序颠倒会出现"用户收到推送、点开却没有消息"的典型缺陷。这与 §9.2 的"先物化再推送"是同一条规则。

聊天室(§14)不产生任何 PushTask:它没有持久邮箱语义,唤醒一个不在房间里的用户没有可投递的内容。

16.1.2 在线 Bitmap 是过滤器,不是权威

在线成员 Bitmap 由 PresenceDirectory 的 compacted topic 驱动(§5.4),存在传播时延(目标 presence_propagation_target,数值见附录 B.4)。两类误判的后果严重不对称:

误判 成因 后果 是否可接受
实际在线,判为离线 Bitmap 置位延迟 多推一条通知;用户前台时看到重复提醒 可接受,由 16.2 去重与客户端撤销兜底
实际离线,判为在线 Bitmap 清位延迟、连接是僵尸态 漏推,用户在应用被杀期间完全收不到消息 不可接受

因此设备级在线集合 I 中的每一台设备都必须做二次确认:

push_grace_window / push_confirm_extended_window(数值见附录 B.5.2)

MailboxNode 对 I 中的每个 (用户, 设备) 各挂一个延迟确认任务(复用 §15.1.5 的时间轮):
    key   = (tenant_id, user_id, device_id)
    value = 待确认的最大 mailbox_seq

取消条件(满足即删除**该设备**的任务;其他设备的任务不受影响):
    b. 收到 APPLIED 证据:**该 device_id** 上行的
       PING.last_applied_mailbox_seq 或 PULL_MAILBOX.acked_seq >= 待确认 mailbox_seq
       (游标本就是 per-device 的,见 §6.8,该证据天然按设备归属,不得跨设备套用)

暂缓条件(不删除任务,只转入延长确认窗口):
    a. 收到 ConnectionNode 的 PUSHED 确认:**该 device_id 绑定的连接**已成功写入 Socket
       且未进入慢连接降级(§11.3)。
       PUSHED 只是写入发送缓冲,不构成设备到达证据(§11.2);任务不删除,改挂长窗口
       push_confirm_extended_window = tcp_user_timeout(15 s) + push_grace_window(3 s) = 18 s:
         - 延长期内收到条件 b 的 APPLIED 证据 → 删除任务
         - 连接以任何原因关闭且该设备 APPLIED 证据 < 待确认 mailbox_seq → 立即产生 PushTask
         - 18 s 到期且连接仍存活(tcp_user_timeout 未报错即数据已被对端内核确认)→ 删除任务

立即补推条件(不等窗口结束,同样只作用于该设备):
    c. ConnectionNode 回 PRESENCE_STALE(该设备的连接不存在或 session_epoch 失配)
    d. ConnectionNode 回执"该连接已关闭 / 已进入慢连接降级丢弃"
    e. 该设备在窗口内断开

窗口到期仍无 a/b → 判定**该设备**实际离线,为该设备产生 PushTask(不变量 PD-1)。

兜底通道(覆盖僵尸连接):ConnectionNode 在任何连接关闭路径
(含 §15.1.1 idle_timeout、tcp_user_timeout 报错、§11.3 CLOSING)必须向该用户所属
MailboxShard 回执 CONN_CLOSED{device_id, session_epoch, last_pushed_user_seq},
MailboxNode 对 (该设备最新 APPLIED 证据, last_pushed_user_seq] 区间补产生 PushTask;
多推由 §16.2 第 1 条撤销 + push_dedup_ttl 兜底("多推可接受",见本节误判表)。

3 秒是"推送延迟"与"漏推风险"的平衡点:小于 1 秒会因为正常网络抖动大量误补推,大于 5 秒用户会明显感到通知迟到。

16.2 去重与抑制

1. 已 APPLIED 的不推(证据按 device_id 归属,§6.8)
   任务发出前:**该设备的** APPLIED 证据 >= 该 entry 的 mailbox_seq → 丢弃该设备的 PushTask
   任务发出后:收到该设备的 APPLIED 证据 → 发一条静默推送更新角标并撤销该通知(见 16.3)

2. 多设备按设备粒度判定,不按用户粒度
   PresenceEntry 是 per-device 的(§7.10),因此:
     在线设备 → 走 Socket,不推
     离线设备 → 各自产生一条 device 级推送
   一个用户手机离线、桌面在线时,手机必须收到推送,桌面不能收到。
   v1 的"用户级在线 Bitmap"承载不了这个判定,必须取 PresenceDirectory 的 per-device 明细。
   §16.1.1 的两步判定与不变量 PD-1 就是这条规则在触发侧的实现。

3. 同会话合并窗口 push_merge_window(数值见附录 B.5.2)
   合并键 = (tenant_id, user_id, device_id, conversation_id)
   窗口内第一条**立即发出**(保证首条通知的低延迟)
   窗口内后续消息不逐条发送,在窗口末尾合并为一条:
       "<会话名>: N 条新消息"
   覆盖投递而非追加投递:
       APNs  apns-collapse-id = hash(conversation_id)
       FCM   collapse_key     = hash(conversation_id)
       厂商通道使用各自的同类字段
   效果:一个活跃群聊 1 分钟 60 条消息 → 最多 12 条通知,且通知栏只占 1 条。

4. 幂等
   push_dedup_ttl(数值见附录 B.5.2)
   幂等键 = (device_token, event_id),与 PushTask 的 (tenant_id, user_id, device_id) 同粒度
   NotificationService 重试、MailboxNode 主备切换后重放,都不会产生第二条通知。

16.3 角标

实现覆盖(2026-09-01):本节定义目标契约。当前 qsession 尚未持久化 UserBadgeState,MailboxNode 也未运行下述 mailbox-tail 投影压缩器;角标聚合、 BADGE_UPDATE 与离线推送的同版本收敛链路仍待实现,不能作为现行能力对外承诺。

目标态数值来源唯一:UserBadgeState(§7.7)。阶段二 mailbox-tail 形态由 MailboxNode
                    投影压缩器维护,并与 UserSessionProjection 同实例、
                    同一 WriteBatch、同一检查点;采用其他实现形态也必须提供等价原子性。
NotificationService **只读不算**(见 16.9)。

两条下发通道、同一个数据源(禁止各算各的):
    在线(长连接) BADGE_UPDATE{ total_unread, total_mention, muted_unread,
                                badge_projection_mailbox_seq }(附录 A.4,走 stream 1)
    离线(推送)   APNs aps.badge / FCM notification_count

    两条通道的取值同源于 UserBadgeState 的同一次聚合结果,携带同一个
    badge_projection_mailbox_seq,客户端对两条通道使用**同一套**乱序保护规则。
    路由按设备:设备在线时只发 BADGE_UPDATE,设备离线时只发推送角标,
    因此同一设备不会就同一版本收到两份角标(与 §16.1.1 的 per-device 判定一致)。

推送 payload 携带**绝对值**,不携带增量:
    APNs   aps.badge            = total_unread(按租户 include_muted_in_badge 策略取值,§7.7)
    FCM    notification_count   = total_unread
    自定义 total_mention、muted_unread、badge_projection_mailbox_seq 一并下发
    muted_unread **不参与系统角标**,仅供客户端在"全部消息"入口展示,
    口径见 §7.7(与 BADGE_UPDATE 下发的字段完全一致)

乱序保护(推送通道**不保证顺序**,这是必须处理的正常情况):
    payload 必带 badge_projection_mailbox_seq(§7.7 已有字段)
    客户端规则:仅当收到的 badge_projection_mailbox_seq 大于本地已应用值时才写角标,
                否则整条角标更新丢弃。绝对值 + 单调版本号 = 幂等且抗乱序。
    该规则对 BADGE_UPDATE 与推送 payload 同样适用,两条通道共用同一个本地已应用值。

清零必须显式推送:
    total_unread 变为 0 时同样要发一条(静默)推送,否则系统角标会永久残留一个数字。

跨设备已读的角标收敛:

用户在桌面端读完某会话 → MARK_READ → 该用户邮箱写入控制事件(§7.3)
  → 目标态角标投影器重算 UserBadgeState
  → 对该用户**所有当前离线的设备**(按 §16.1.1 的 per-device 判定)发一条静默推送:
       APNs   aps.content-available = 1,只带 aps.badge 与 badge_projection_mailbox_seq
       FCM    data-only 消息
     该用户其余在线设备走 BADGE_UPDATE 收到同一版本的角标
  → 客户端(iOS 走通知扩展 / Android 走后台服务):
       更新系统角标
       调用系统接口撤销该会话已投递但已读的通知(removeDeliveredNotifications)

这条链路解决的是"手机锁屏上还挂着 3 条已经在电脑上读完的通知"这个高频体验问题。
静默推送受系统配额限制,因此它是**尽力而为**:失败不影响正确性,用户下次打开应用即收敛。

16.4 静音生效层与免打扰

静音在推送层生效,不在未读层生效。 §12.5 已规定静音不清未读,本节是它在推送侧的对应规则:

UserConversationState.notification_policy 的推送层解释(权威映射):
    ALL           → 所有 counts_unread=true 的事件都产生通知
    MENTION_ONLY  → 仅 mention_type ∈ {AT_ME, AT_ALL, REPLY_ME} 产生通知
    NONE          → 不产生任何通知

muted = true 等价于把该会话的推送策略下压为 MENTION_ONLY;
是否连 @我 也抑制,由 notification_policy = NONE 显式表达,不由 muted 隐含。

静音**不影响**:
    未读计数(§12.5.1 权威定义式)
    UserBadgeState.total_mention(静音会话的 @我 仍计入,§7.7 已写死)
    会话列表排序与预览
静音**影响**:
    是否发出带提示音/横幅的通知
    total_unread 是否计入该会话(由租户策略 include_muted_in_badge 决定,§7.7)
    静音会话的未读始终计入 muted_unread(§7.7),随 BADGE_UPDATE 与推送 payload 下发

免打扰(push_quiet_hours,数值见附录 B.5.2):

按用户本地时区判定(时区随 device token 一并注册,见 16.6)
生效期内:
    普通消息 → 只更新角标,不发提示(APNs interruption-level = passive)
    @我     → 由用户配置是否突破,默认突破
    退出免打扰时段**不补发**积压通知,只发一条合并的角标更新

16.5 频控

推送通道对上游的容忍度远低于自有链路:APNs/FCM 会对异常高频的应用降权,厂商通道有硬性日配额。频控是保护上游,也是保护用户。

push_rate_per_user_per_min / push_rate_per_conversation_per_min(数值见附录 B.5.2)
    两个限额都按 (tenant_id, user_id, device_id) 维度计量,与 PushTask 主键同粒度

超限行为(关键):转为**静默角标更新**,不再发通知。
    绝不丢弃消息本身——消息在邮箱里,用户打开应用一条不少。
    绝不静默丢弃 PushTask 而不更新角标——那会让角标与实际未读长期不一致。

大群的额外约束:
    N > 10000 的群默认只推 mention_type != NONE 的事件;
    普通消息只更新角标。这与 per_conversation_msg_rate 分档(附录 B.5)一致:
    一个万人群按 2 msg/s 满速发送,若逐条推送,单用户每分钟就是 120 条通知,
    任何频控参数都无意义,必须在策略层就关掉。
聊天室:不产生推送(16.1.1)。

本章参数(push_grace_window、push_merge_window、push_dedup_ttl、push_rate_per_user_per_min、push_rate_per_conversation_per_min、push_quiet_hours、push_token_inactive_days、push_retry_max_attempts、nse_pull_max_items)数值见附录 B.5.2,presence_propagation_target 见附录 B.4;均可按租户配置。

push_retry_max_attempts 的退避序列为 1 s / 4 s / 16 s,且只对 5xx 与超时重试(附录 B.5.2)。

16.6 device token 生命周期

存储:NotificationService 内部表(**不属于 §7 契约核心**,仍由本服务独立拥有与演进);
      device_token_digest 摘要 topic 是对外契约,字段变更须走 §7 契约核心流程。

DeviceToken {
    tenant_id, user_id, device_id          # 主键
    provider                               # APNS | FCM | HUAWEI | XIAOMI | OPPO | VIVO | WEBPUSH
    token                                  # 加密存储
    bundle_env                             # production | sandbox(APNs 必须区分,否则全量投递失败)
    client_platform, app_version, locale, timezone
    capabilities                           # 是否支持静默推送、是否有通知扩展
    registered_at, last_success_at, last_failure_code
}
注册:客户端在 AUTH 成功后,通过 HTTP 管理接口注册(§4 允许 HTTP 承载非实时场景)。
      **不占用实时链路**,也不需要新增协议帧。
      注册请求必须携带与当前连接相同的 device_id,服务端按 (tenant, user, device) 覆盖写。

刷新:系统回调 token 变化、应用启动、应用升级、用户切换账号时重新注册。
      同一 device_id 的旧 token 在覆盖写的同一事务内失效,避免"一条消息两台设备响"。

失效回收:
    APNs 返回 410 Unregistered / BadDeviceToken → **立即删除**该 token
    FCM  返回 UNREGISTERED / INVALID_ARGUMENT   → **立即删除**该 token
    厂商通道的同类错误码按各自映射表处理
    5xx 与超时 → 按 push_retry_max_attempts 退避重试,**不得删除**
                (上游抖动导致的批量误删会造成大面积静默失联)

主动删除:
    KICKED{reason=token_revoked}(§15.3.2)→ 同步删除该 device 的 token
    用户注销、租户注销 → 走 §21 的删除编排一并清理
GC:
    last_success_at 早于 push_token_inactive_days(90 天)→ 删除

摘要发布(device_token_digest,compacted topic):

NotificationService 在 DeviceToken 注册 / 覆盖 / 删除
(含上方 410 / UNREGISTERED 立即删除路径)的同一事务内,
向 compacted topic device_token_digest 发布最小摘要:
    key   = (tenant_id, user_id, device_id)
    value = { provider, capabilities, bundle_env 有效性标志 }    # 不含 token 密文
删除以 null value 墓碑收敛(与 §5.4 PresenceDirectory 同模式,分区键同源 user_bucket)。
MailboxNode 订阅并维护本地摘要缓存,用于 §16.1.1 的 D(user) 展开;
禁止访问 DeviceToken 内部表(§17.3)。

16.7 E2EE 下的推送

E2EE 会话服务端只持有密文(§22),推送内容必须降级:

服务端可以放进 payload 的:
    会话名(若会话名本身未加密)
    发送者显示名(若未加密)
    通用占位文案,如"发来一条消息"
    aps.badge / notification_count(未读计数是**类型级契约**,counts_unread 不需要解析明文)
    mutable-content = 1 / data-only 标志

服务端**禁止**放进 payload 的:
    任何解密后的正文、缩略图、文件名
若租户策略要求会话名也保密 → 只推"您有一条新消息"。

真实内容由客户端在收到推送后本地生成:
    iOS   通知扩展(NSE)拉取 → 本地解密 → 修改通知内容后展示
    Android 后台服务同理
    拉取动作使用 PULL_MAILBOX,单次上限 nse_pull_max_items(50),见 16.8

E2EE 能力边界(大群是否支持、密钥体系、新设备历史)见 §22,本节不重复定义。

16.8 iOS 后台降级路径

v1 完全没有这条路径,而它决定了 iOS 端能不能用。

前台:TCP/TLS 长连接,一切走 §11 在线推送

进入后台:
    客户端**主动关闭连接**(iOS 会在数秒到数分钟内回收后台 Socket,
    与其等系统回收产生一个僵尸连接,不如显式关闭)
    → ConnectionNode 感知 close → 删除 PresenceEntry → 在线 Bitmap 清位
    → 之后该设备的消息全部走 APNs

这一条主动关闭规则同时消除了"presence 显示在线但实际收不到"的最大来源。
异常路径(进程被杀、网络突然消失)由 §15.1.4 的 tcp_user_timeout(15 s)暴露死连接
+ ConnectionNode 关闭回执(CONN_CLOSED,§16.1.2)触发补推兜底,最坏补推延迟 ≈ 18 s。

APNs alert 推送(有内容可展示):
    用户点击 → 应用回到前台 → 重连 → AUTH → PULL_MAILBOX → 正常同步

APNs 静默推送(content-available = 1,用于角标更新与 E2EE 内容补全):
    系统按配额唤醒 → 通知扩展或后台任务发起一次**短拉取**
    约束(必须写进实现):
      - 通知扩展有约 30 秒执行时限与严格内存上限
      - 只允许一次 PULL_MAILBOX,max_items <= nse_pull_max_items(50)
      - **禁止**在扩展内做完整登录同步、会话列表拉取或投影重建
      - 扩展修改的角标与游标必须写入 App Group 共享存储,
        否则主 App 启动后会用旧值覆盖,角标回退
      - 扩展中拉取到的条目**不得推进设备游标**(§6.8 的不变量:游标只由
        MAILBOX_BATCH 在主进程完成解析并持久化后推进);扩展只缓存,主进程再确认

VoIP push:**只用于 RTC 来电**,收到后必须立即上报 CallKit,否则应用会被系统封禁。
    严禁用 VoIP push 投递普通消息——这是 App 下架的直接原因。

Android:
    国内发行版并存 FCM 与厂商通道(华为/小米/OPPO/vivo),
    按 DeviceToken.provider 路由,上层仍是同一个 PushTask 抽象。
    Android 的后台长连接存活率显著高于 iOS,但**不得据此简化设计**:
    同一套 PushTask 与频控逻辑必须覆盖两端。

16.9 NotificationService 的职责边界

项 结论
未读聚合 不负责。只读 UserBadgeState(§7.7),禁止自行按会话累加
可靠消息源 不是。推送丢失不构成消息丢失,可靠性由个人邮箱(§9)保证
消息正文持久化 不负责。只保留生成 payload 所需的最小快照,TTL ≤ push_merge_window + 重试窗口
解密 不负责。E2EE 内容一律由客户端本地生成通知(16.7)
在线判定 不负责。在线/离线由 MailboxNode 按 §16.1 的 per-device 规则判定后下发 PushTask(键含 device_id)
负责的 通道对接与凭证管理、合并与频控、device token 生命周期、投递结果回执与指标

推论:NotificationService 是无状态服务(token 表外置),可以独立扩缩容、独立降级。整个推送链路完全不可用时,IM 的消息可靠性、未读、会话列表全部不受影响,只是移动端唤醒能力下降。


17. 微服务边界

服务名称一律使用 §5.1 实体与命名表的写法。v1 表格中带空格或另起别名的写法(网关、邮箱节点、正文存储、聊天室服务各有两到四种拼法)在本版全部作废,只保留 §5.1 的唯一写法。

17.1 服务总表

服务 主要职责 不负责 状态
ConnectionNode TCP/TLS 与 WebSocket 接入、帧编解码、心跳与准入控制(§15)、多路复用与出向流控、把 PushBatch 展开为逐 Socket 写入、写入 PresenceEntry、SESSION_DELTA/BADGE_UPDATE 的合并窗口下发 消息持久化、群成员展开、未读计算、序号分配 有状态:连接、session_epoch、last_pushed_user_seq、出向缓冲;全部可丢失重建,不进检查点
AuthService 认证、分配 session_epoch、签发 access/refresh token 与静默续期、吊销(§15.3)、设备会话管理、签发路由令牌与游标签名、设备数上限 消息顺序、投递、在线目录的权威副本 无状态:签名密钥与吊销表外置
ShardRegistry 虚拟桶→逻辑分片映射、分片租约与 shard_epoch、EpochBoundary/ShardSplitBoundary(§7.11)、分裂与切流编排 每条消息的路由查询(路由由稳定哈希本地计算) 有状态:强一致小集群,写频率极低
PresenceDirectory per-device 在线目录的发布通道(compacted topic)、租约过期回收、周期全量对账、承载吊销事件传播(§15.3.2) 消息投递、未读聚合、连接管理 有状态:compacted topic + 各节点内存缓存
ConversationWriter 权限与限流准入、client_message_id 幂等、分配 message_id/conversation_seq/last_activity_id、写 MessageRecord 与 Outbox、更新 ConversationHead 成员级在线推送、邮箱物化、未读计算 有状态:会话租约 + 序号预留窗口 + HLC
MessageStore 正文与历史分页、MessageIndex、ClientDedup、retention_class 生命周期 用户离线游标、未读、会话列表 有状态,分层(ADR-0018):提交状态、ClientDedup 与 history_hot_window 内的近期历史在 Redis(提交热路径只写 Redis);HistoryArchiver 消费 Outbox 异步批量归档到 ScyllaDB,超出热窗口且已归档的历史以 ScyllaDB 为准
GroupMembership 成员关系与角色、membership_version 不可变快照、按 MailboxShard 预分片的成员 Bitmap、MemberSlotMap 槽位分配 Socket 管理、消息顺序、邮箱写入 有状态
FanoutCoordinator 消费 Outbox、按已固化的精确 membership_version 读取快照、按目标 MailboxShard 生成 GroupDispatch、事件组上限拆分(§6.7)、租户 fanout 配额 改用当前成员版本、逐用户 RPC、逐 Socket 写入、正文读取 无状态:消费位点在日志中
MailboxNode 个人邮箱物化、lane 水位推进、DispatchProgress、成员 Bitmap ∩ 在线 Bitmap、按 ConnectionShard 合并 PushBatch、向 qsession 发送尽力低延迟 Projection 补充、产生 PushTask(§16.1)、检查点 原始媒体、正文权威存储、会话列表排序与分页、qsession durable checkpoint 随 MailboxStore 实现而变:一期(Redis)与阶段一(ScyllaDB)下权威数据全在节点外,MailboxNode 是分片化无状态计算节点,故障走租约漂移接管(§10.4.2 形态 B),不做主备;仅阶段二(自研 LSM)本地持有权威数据,此时热备是可选的 RTO 优化而非正确性要求
SessionProjection 独立消费 dispatch 日志、持久每 partition checkpoint、维护会话集合、排序与 keyset 分页、snapshot_revision 管理、PULL_SESSION_LIST 的服务落点 消息正文权威存储、把未读缓存当权威、允许 MailboxNode 尽力 Projection 推进 durable checkpoint 可重建持久投影:保留窗口内由 Redpanda dispatch + canonical MessageStore 重放;超过窗口或投影全失时必须由 UserConversationState + MessageStore 权威重建。当前 qsession 只完成前一路径,完整兜底是发布阻断
RoomWriter 分配 room_seq、写短期 RoomLog、按存在房间成员的 ConnectionShard 合并广播、回放窗口服务 全员持久邮箱、会话列表投影、离线推送 有状态:房间序号与短期日志
MediaService 上传下载授权、缩略图与转码、对象生命周期、加密擦除(§21) IM 长连接、消息顺序、正文存储 无状态:对象存储外置
NotificationService APNs/FCM/厂商通道投递、合并与频控、device token 生命周期、投递回执与指标(§16) 未读聚合、可靠消息源、解密、在线判定 无状态:token 表外置
ModerationService 审计、封禁、内容治理、租户策略、合规导出与删除编排(§21) 核心投递顺序、实时链路 无状态
SearchService(V2) 消息检索索引构建与查询 投递、顺序、未读 有状态,明确列为 V2 边界,一期不实现

"有状态/无状态"的判定口径:丢失全部本地数据后能否在不损失正确性的前提下自动重建。ConnectionNode 的连接可由重连恢复;SessionProjection 在 dispatch 保留窗口内可从绑定身份的日志与 canonical MessageStore 重放,超过窗口则必须回到 UserConversationState 权威集合。当前 qsession 尚未实现后一条,因此现状不能宣称“任意时刻删除投影都能完整重建”。MailboxNode 的权威状态位于外部 MailboxStore,进程本身不拥有本地权威副本。

17.2 会话投影归属裁决

v1 的同一份数据被三处同时声称拥有:服务表把它列在邮箱节点的职责里,同一张表又有一个独立的会话投影服务,存储章节又说它存在节点本地 Pebble。当前日志驱动实现裁决为:

增量重放输入:Redpanda dispatch 日志 + canonical MessageStore
    qsession 对每个静态 partition 按序消费
    对全部收件人幂等写 Redis 会话集合,全部成功后才 CAS 推进 next_offset
    checkpoint 绑定 cluster_id + topic_id + partition_count;身份缺失、同名重建或越界均 fail closed

全量权威输入:UserConversationState + canonical MessageStore / ConversationHead
    投影缺失或 checkpoint 已落到 dispatch retention 之外时,以一次用户分区读恢复完整会话集合
    再由 canonical MessageStore/ConversationHead 合成会话头与定义式未读

读侧服务:SessionProjection
    Redis 投影在保留窗口内可从增量输入重放;窗口外必须先完成全量权威重建
    启动追到初始高水位、或全量重建完成后,才开放监听
    快照按 snapshot_revision 版本化
    负责排序(§6.9.3)、keyset 分页、快照一致性视图

补充路径:MailboxNode 的 TCP Projection 只做幂等低延迟补充,不拥有 checkpoint,
          断开或 Redis 写失败不得造成永久缺项

实现状态(2026-09-01):当前 qsession 的 durable dispatch checkpoint 与保留窗口内重放已实现, 但 build_snapshot 仍从 Redis convs:{user} 取得会话集合,尚未读取 UserConversationState;投影全失或 checkpoint 早于日志 low watermark 时的完整权威兜底未闭环, 必须由 §26.4.3 失败并阻断发布。

为什么 checkpoint 必须属于 SessionProjection 自身:

旧的 MailboxNode → qsession 50 ms 尽力 RPC 会在断连或 Redis 整批失败时永久丢投影。
qsession 直接以 durable dispatch 为输入并在全部收件人写成功后推进自己的 checkpoint,
失败即可重放;canonical MessageStore 提供会话头与未读定义,投影不成为消息可达性的权威。

当前 broker 最低能力为 KIP-516 非零 topic UUID + cluster id;隔离基线使用 Redpanda v26.2.2,v24.3.6 缺该身份时必须拒绝就绪。

禁止事项(写进实现约束):

禁止 MailboxNode 的尽力 RPC 推进 qsession durable checkpoint
禁止任何服务用无 topic/cluster identity 的位点恢复会话投影
禁止把 UserSessionProjection 作为"另一个可查询数据库"对外暴露读接口,
     所有读必须经 SessionProjection,以保证快照版本语义统一

读写路径:

读:客户端 PULL_SESSION_LIST
      → ConnectionNode
      → SessionProjection(命中内存快照则直接分页返回)
      → 未命中:从 Redis 会话集合分页,并按 conversation_id 从 canonical MessageStore
        读取会话头/未读摘要;SessionProjection 不接触 DEK 明文(§21.3.4)
        建立快照并分配 snapshot_revision,之后按 keyset 分页
      → SESSION_LIST_BATCH{..., snapshot_revision, projection_complete}

写:SessionProjection 静态消费 durable dispatch
      → 对全部 recipients 幂等更新 UserSessionProjection;成功后推进 partition checkpoint
      → MailboxNode TCP Projection 仅作同值低延迟补充
      → 产生绝对值 SESSION_DELTA / BADGE_UPDATE(§12、§7.7;当前增量推送可继续演进)
      → ConnectionNode 在 session_delta_merge_window(100~200 ms)内按 conversation_id
        **覆盖式**合并后下发(禁止任何累加型合并)

17.3 内部通信与鉴权

传输:内部服务之间使用固定长连接或高效 RPC(gRPC 或自定义二进制),
      连接池按 (源服务, 目标分片) 复用,长连接常驻。
      客户端实时协议不依赖 HTTP 请求轮询;内部链路同样禁止轮询式拉取,
      推送型数据一律走日志订阅(Outbox、分发日志、PresenceDirectory 的 compacted topic)。

内部调用必须双向鉴权(v1 完全没有提及内部鉴权,等于默认"进了内网就是可信的"):

1. mTLS + 工作负载身份(SPIFFE 风格)
   调用方与被调方**互相**校验对方证书中的服务身份,不是只有客户端校验服务端。
   内部证书有效期 internal_cert_ttl = 24 h(附录 B.5.3),自动轮换,私钥不落盘明文。

2. 方法级白名单(最小权限)
   每个服务身份对应一张"允许调用的方法集",例如:
       NotificationService  → 只能读 UserBadgeState,不能写
       SessionProjection    → 可读 MessageStore 摘要,并读写自身投影与 durable checkpoint;
                              禁止写 MessageRecord / UserConversationState 等 canonical 权威
       FanoutCoordinator    → 只能写分发日志,不能直接写 UserMailboxEntry
       MailboxNode          → 只读 device_token_digest topic,禁止访问 DeviceToken 内部表
   拒绝时返回明确的权限错误并计入安全指标,禁止静默降级为成功。

3. 租户上下文必须显式传递并在被调方**重新校验**
   被调方禁止信任调用方传入的 (tenant_id, user_id) 直接放行;
   凡是可以由调用参数越权的接口(历史读取、会话列表、媒体票据)都必须在被调方
   重新执行租户与会话权限校验。跨租户越权的最常见成因就是"内部调用免检"。

4. 禁止把网络位置当作唯一信任依据
   VPC、安全组、内网网段都不构成身份。私有化交付环境同样适用本条。

5. 幂等与背压
   所有可重试的内部 RPC 必须携带幂等键(dispatch_id / event_id / message_id),
   超时重试不得产生第二份效果(§10.4)。
   所有内部 RPC 必须支持流控与显式拒绝(资源耗尽错误),禁止无界排队——
   无界队列会把一个慢依赖变成全链路雪崩。

6. 可观测性
   §24 的 trace_id、tenant_id、message_id/event_id、dispatch_id、mailbox_shard
   必须在内部调用间透传,否则大群分发链路无法排障。

18. 存储与缓存基线

18.1 MailboxStore 抽象与分阶段选型

18.1.1 必须先承认的风险

v1 直接把"Pebble/RocksDB 自研 MailboxNode"选为基线唯一方案,并在同一张表的"主要代价"里写着"需要自行完成复制、检查点和故障接管"。这句话的真实含义是:

自研 MailboxNode = 自研一套分布式有状态存储

必须自己实现且必须做对的部分:
    多副本复制与一致性协议
    主备切换、租约与 fencing(做错就是双写)
    连续物化水位与检查点的原子关系
    重分片(分裂/合并)时的数据搬迁与游标换发
    备份、恢复演练、跨地域复制
    修复(副本长期落后后的追平)
    磁盘故障、坏块、静默损坏的检测

这是一个独立的、以年为单位的存储团队工作量,
把它作为 IM 一期基线的**唯一**方案,会让整个项目的交付风险集中在最难的一块上。

因此本版把邮箱存储抽象为 MailboxStore 接口,阶段一用成熟分布式存储实现,达到判据后再切自研,切换过程按 MailboxShard 灰度。

18.1.2 抽象接口

MailboxStore 接口(两种实现必须语义完全一致)

AppendBatch(shard, lane, mailbox_seq, entries[]) -> Ack{durable}
    原子性要求:同一 (shard, lane, mailbox_seq) 的整个事件组要么全可见要么全不可见
    必须与 DispatchProgress(§7.8)的分块进度在同一次提交内写入
    幂等:按 (dispatch_id, lane, chunk_id) 重复调用不得产生重复条目

RangeScan(tenant, user, after_seq, up_to_seq, max_items, max_bytes)
    -> entries[], covered_through_seq
    after_seq 为开区间下界,up_to_seq 为闭区间上界
    切分只能发生在 mailbox_seq 边界(§9.3),max_items/max_bytes 是软上限

TruncateBefore(user, seq)
    删除该用户 seq 之前条目的可读性;物理回收按实现分派(两种实现语义仍一致:
    调用返回后,seq 之前的条目对读路径不可见):
    ScyllaDB 实现(阶段一):**纯逻辑操作**——仅推进 mailbox_trim_watermark,
        并据此对越界游标返回 CURSOR_EXPIRED;**禁止下发任何 CQL DELETE(含范围删除)**,
        物理回收完全由 default_time_to_live + TWCS 整文件过期承担(§18.1.3:
        TWCS 不做跨窗口 compaction,范围墓碑既不能提前释放空间,还会击穿
        §26.1 的 tombstones_scanned == 0 断言)
    自研 LSM 实现(阶段二):执行真实物理裁剪(DeleteRange + compaction filter)
        + 后台回收

Watermark(shard, lane)
    -> materialized_watermark[lane], ttl_trim_frontier[lane]
    trim 返回值为 lane 级 TTL 前沿;per-user 部分由 TruncateBefore 写入的
    用户级元行提供,二者合成 effective_trim(u) 见 §18.3.3

灰度与验证要求:

1. 切换粒度 = 单个 MailboxShard。一次只切一个分片,可随时回切。
2. 影子读比对:灰度期按 mailbox_store_shadow_read_ratio(1 %,附录 B.5.3)
   对同一 RangeScan 双读两套实现,比对返回的
   (mailbox_seq, event_ordinal, event_id) 序列与条目字节。
3. 差异计入指标 mailbox_store_shadow_mismatch,**该指标非零即阻断切换**,
   并触发 P1(差异意味着两套实现对空洞、边界或幂等的理解不一致)。
4. 双写期的水位以**较慢的一方**为准,避免客户端越过尚未完成的实现。

18.1.2b 一期实现(Redis)——ADR-0007 简化形态

当前实现的部署兼容性(发布约束):生产仅支持 Redis 7.0.0+ standalone,启动必须从 INFO server 证明唯一、严格三段式 redis_version >= 7.0.0,并启用 AOF 与 maxmemory-policy noeviction。开发放宽不绕过版本/standalone 校验。Redis Cluster 未实现且必须拒绝启动;任何连接到 Cluster 端点的 writer、mailbox 或 MessageStore 进程不得进入 ready。

这不是补一个局部 hash tag 就能解除的限制:GroupMembership 的成员版本/快照操作会跨槽, MessageStore 的全局 {commit} 键会把提交热路径固定到单个热槽,而现有 ConnectionManager 没有 Cluster 路由、MOVED/ASK 重试与拓扑刷新。按槽拆分 Lua 既不能 恢复跨槽原子性,也不能形成完整提交状态机。若未来考虑 Cluster,必须先独立完成状态机、数据迁移、 路由与端到端故障验证;它不是 standalone Redis 到 ScyllaDB 之间的默认阶段。

当前保留口径固定如下:邮箱按 Entry.created_at 分 30 天日桶; DispatchProgress 为 7 天 replay-safe GC;客户端提交/ClientDedup 窗口为 2 小时; 消息历史则只按 retention_class 生命周期处理,不能用邮箱 TTL 推断历史保留期。

三级演进路径(阶段一 / 阶段二 的既有编号不变,Redis 位于二者之前):

一期实现(Redis)  ->  阶段一(ScyllaDB)  ->  阶段二(自研 LSM)
  ADR-0007            §18.1.3               §18.1.4、ADR-0001
  R_avg <= 25         规模化                 四条切换判据之一成立

一期按 ADR-0007 建设,产品群规模上限 1000 人、R_avg 约 22, MailboxStore 用 Redis 实现。四原语映射:

键设计:按天分桶,避免重度账号形成大 key(standalone Redis 的单 key 仍不可拆)
    mailbox:{tenant}:{user}:{day_bucket}      zset,score = mailbox_seq
    day_bucket 由 entry.created_at 推导;30 天窗口最多跨 32 个 key

AppendBatch     Lua 脚本单次原子执行:ZADD 多条 + HSET DispatchProgress 块位
                同一 (shard, lane, mailbox_seq) 的事件组必须在同一次调用内完成
RangeScan       ZRANGEBYSCORE 跨最多 8 个 day_bucket key 归并,
                按 §9.3.3 规则 2 在 mailbox_seq 边界切分(事件组不可切分)
TruncateBefore  **真删**:按天 key 直接 EXPIRE(天粒度)或
                ZREMRANGEBYSCORE(细粒度),随后推进 user_trim_seq(u)
                —— 比 ScyllaDB 实现更干净,后者受 TWCS 墓碑约束只能做逻辑裁剪(§18.1.2)
Watermark       HGET;W[lane] 与 W_floor[lane] 各一个小 key

按天分桶的额外收益:TTL 可以直接用 Redis 原生 EXPIRE 挂在天粒度 key 上, 不需要维护 (小时, max_mailbox_seq) 映射表来定裁剪边界—— mailbox_seq 不编码时间,若用单 key + ZREMRANGEBYSCORE 则必须另建该映射。

持久性缺口与其唯一兜底(必须一并实现,不可分割):

Redis AOF everysec 有 1 秒持久性缺口,单看不满足 §2.3 的"故障不造成不可恢复丢失"。

该缺口可接受的唯一理由是分发日志兜底:
    崩溃后从 min_j(W[j]) 对应 offset 重放(§10.4.2)
    → 确定性 event_id 保证同值覆盖(§6.7)
    → 缺口窗口内的条目被重新物化

因此 Redis 实现与分发日志是**捆绑关系**:不得以"已经有 Redis 持久化"为由
弱化分发日志的保留期或事务保证(ADR-0007 后果第 1 条)。

准入与退出判据:

适用:R_avg <= 25,且 30 天邮箱日桶驻留满足 standalone Redis 的实测资源边界。
退出:任一条不满足,或出现 key 倾斜且按天分桶无法缓解
      -> 优先按 §18.1.2 的灰度流程切换到 ScyllaDB 实现,客户端无感。
      Redis Cluster 不是退出后的默认落点;需独立立项后再评估。

18.1.3 阶段一实现(ScyllaDB)

CREATE TABLE user_mailbox_entry (
    tenant_id        uuid,
    user_id          uuid,
    mailbox_seq      bigint,
    event_ordinal    tinyint,
    event_id         bigint,
    event_type       tinyint,
    message_id       blob,      -- u128,大端 16 字节
    conversation_id  uuid,
    conversation_seq bigint,
    last_activity_id blob,      -- u128,大端 16 字节
    sender_id        uuid,
    visibility_floor_conversation_seq bigint,
    flags            int,
    mention_type     tinyint,
    created_at       bigint,
    -- 可选字段,缺省不写入,Scylla 不为 null 列分配存储
    client_message_id       text,
    origin_device_id        uuid,
    target_message_id       blob,
    target_conversation_seq bigint,
    PRIMARY KEY ((tenant_id, user_id), mailbox_seq, event_ordinal, event_id)
) WITH CLUSTERING ORDER BY (mailbox_seq ASC, event_ordinal ASC, event_id ASC)
  AND default_time_to_live = 691200           -- (mailbox_retention_days(7) + 1) × 86400;行级 USING TTL 为准
  AND compaction = {'class':'TimeWindowCompactionStrategy',
                    'compaction_window_unit':'DAYS',
                    'compaction_window_size':1}
  AND gc_grace_seconds = 86400;               -- 附录 B.5.1
一致性级别:读写均为 LOCAL_QUORUM,RF = 3(mailbox_replicas)
分区键 (tenant_id, user_id) 与 §7.3 完全一致,个人队列查询只走用户前缀
TWCS window = 1 天,与 default_time_to_live 配合:整个 SSTable 过期后**整文件丢弃**,
              不产生逐行墓碑,这是选 TWCS 而不是 STCS/LCS 的唯一理由

编码约束(必须写进实现):
    mailbox_seq 是 u64,而 CQL bigint 是有符号 64 位。
    shard_epoch 的 15 位与 bit63 恒零约束见 §6.5;
    本实现依赖该约束以保证 CQL bigint 的有符号排序与 u64 无符号排序一致。
    若该约束被破坏,大 epoch 会编码为负数,破坏聚簇键排序,直接造成范围查询漏数据。

删除约束(必须写进实现):
    user_mailbox_entry 表**禁止任何显式 DELETE 写入**(含范围删除):
    TWCS 不做跨窗口 compaction,范围墓碑落在当前窗口、被删数据在更早窗口,
    显式删除既不能提前释放空间,还把墓碑引入读路径。
    gc_grace_seconds = 86400 仅为防御性配置,不构成对修复周期的依赖;
    §26.1 的 tombstones_scanned == 0 发布断言以本条为前提。
    若阶段一被迫引入显式删除(如合规删除无法用加密擦除覆盖),
    必须先重开 ADR-0001(见其复评条件)。

18.1.4 这个实现的代价(不隐藏)

1. 大群展开退化为 N 条独立行写入
   本地 WriteBatch 实现中,一个 GroupDispatch 在分片内是"一次批量落盘";
   ScyllaDB 实现中,它是该分片本地成员数 N 条独立行写入,
   每条经协调者路由到 RF=3 个副本。
   没有跨分区批量的合并收益:unlogged batch 只能按 token 分组减少协调者跳数,
   **不提供跨分区原子性**,也不减少副本写次数。
   网络放大 ≈ N × RF。

2. 水位推进条件退化
   本地实现:WriteBatch 提交成功即可推进 W[lane]
   Scylla 实现:必须"该 lane 的**全部分块**都返回 LOCAL_QUORUM"才能推进 W[lane]
   → 因此必须有 DispatchProgress(§7.8)记录分块进度,
     且分块进度必须与条目在同一分区批量内提交。
   **这是相比本地 WriteBatch 新增的机制,不是免费的**:
   它增加了一次写入、一份存储,以及"进度记录与条目不一致"这一类新的故障模式。

3. 尾延迟耦合
   邮箱写与消息历史写共用同一个 ScyllaDB 集群时,大群 fanout 的写入尖峰会抬高
   历史读的 P99。基线要求二者**至少使用独立 keyspace + 独立资源组**,
   容量到达一定规模后必须物理分集群。

4. 换来的收益(这是选它的理由)
   复制、修复、备份、接管、扩容、坏盘处理全部由 ScyllaDB 承担。
   一期团队不需要自研分布式有状态存储,交付风险从"最难的一块"上移开。

18.1.5 切换到自研实现的判据

四选一,任一成立即启动 docs/adr/0001-mailbox-store-selection.md 的第二阶段评审:

1. 峰值邮箱写入 > 150 万 entry/s
2. LOCAL_QUORUM 写 P99 > 20 ms,或读 P99 > 30 ms,
   **且已排除数据模型与压缩策略问题**(分区大小、TWCS 窗口、tombstone、
   coordinator 热点、客户端 token-aware 路由必须先全部核查并留下结论)
3. 邮箱层成本 > 全系统基础设施成本的 25 %
4. 基准测试中,大群展开的协调者放大导致目标分片的扇出成本 > 本地展开的 3 倍

判据必须由基准测试数据触发,不接受"感觉会慢"。任何一条成立时,切换也只从压力最大的 MailboxShard 开始灰度,MailboxStore 抽象保证上层代码不改。

18.2 其他存储选型

数据 默认候选 选择原因 主要代价 何时重新评估
消息历史、ConversationHead、群与用户元数据 ScyllaDB(历史为永久权威;history_hot_window 内的近期历史与提交状态在 Redis 热层,由 HistoryArchiver 异步归档,ADR-0018) 高吞吐顺序写、按分区范围查询、水平扩展、无 JVM GC 抖动 数据模型必须围绕查询设计,跨分区事务弱,LWT 昂贵(实测每条消息约 6.5 次串行 LWT 即让 ACK 超 SLO,故提交热路径不写 ScyllaDB) 单会话历史读 P99 > 30 ms;或需要多维二级查询(此时应引入 SearchService 而非换存储)
提交日志、Outbox、分片分发日志 Redpanda / Kafka 分区有序、可重放、成熟消费模型、offset 可直接充当 log_offset(§6.5) 分区规划与积压治理复杂;分区数变更会破坏 offset 语义(§6.5 已禁止再哈希) 单分片分发事件 > 单分区可持续吞吐;或跨地域复制延迟不满足 §19.2 的 RPO
个人邮箱、UserSessionProjection、UserBadgeState、DispatchProgress MailboxStore 抽象(见 §18.1) 阶段一 ScyllaDB 换取交付确定性,阶段二自研换取成本与延迟 见 §18.1.4 见 §18.1.5 的四条判据
媒体原文件、检查点、冷归档 S3 / MinIO 低成本大对象、生命周期管理、跨地域复制成熟 不适合低延迟细粒度随机读;删除依赖生命周期规则,需配合 §21 的加密擦除 检查点恢复速率不满足 §19.3 的 RTO;或私有化环境无对象存储可用
群成员集合、在线成员集合 RoaringBitmap 稀疏与聚集整数集合的压缩与快速交并;一次求交即可得出在线收件人 需要稳定的成员槽位映射(MemberSlotMap,§7.9),槽位复用会导致跨用户错投 单群成员数上限提高一个数量级;或槽位空洞率长期超过 slot_compaction_ratio

这些是默认基线,正式实施前必须通过容量基准测试确认。基准未完成前,本表任何一行都不构成采购决策依据(附录 B.7)。

双生态客户端基线(说明性,不进契约核心;实施前按 CLAUDE.md 依赖规则复核,选型见 ADR-0006):

存储/组件 Rust 候选 Go 候选 差异点
ScyllaDB scylla-rust-driver(官方,原生 token-aware / shard-aware) gocql 的 scylladb fork(shard-aware) 两者均满足 §18.1.5 判据 2 要求核查的 token-aware 路由
Redpanda / Kafka rust-rdkafka(librdkafka 的 C 绑定;注意静态链接与交叉编译成本;备选纯 Rust 的 rskafka,功能子集) franz-go(纯 Go) 当前 fanout 只要求幂等生产、acks=all 与“全部 dispatch 已 durable 后才确认 source”;不以事务生产者为 fencing 前提(ADR-0012)
S3 / MinIO aws-sdk-rust / object_store aws-sdk-go-v2 / minio-go 无显著差异
RoaringBitmap roaring-rs roaring(Go) 序列化必须用 RoaringFormatSpec portable 格式互通(§7.9 契约性约束)

§18.1.5 判据 2 的 token-aware 核查在两生态均可满足,驱动可用性本身不构成语言选择的约束项。

已选型(ADR-0006,现行已接受决策):服务端主语言 Rust, 日志 Redpanda(不使用 Apache Kafka),一期 MailboxStore 用 Redis(ADR-0007)。 上表 Go 列保留,作为 ADR-0006 复评时的对照基线。

选定 Rust 后,若生产日志客户端继续采用 rust-rdkafka,其 C 绑定的交叉编译与静态链接成本仍须 计入交付计划;但它不再是 fencing 的正确性前提:

ADR-0012 已取消 FanoutCoordinator 的 Kafka 事务:正确性顺序为
  全部 GroupDispatch 获得 durable delivery(acks=all)
  -> 才持久化/确认对应 Outbox source 进度。
崩溃后的重复由确定性 dispatch_id 与 MailboxStore 去重收敛;旧 owner 的拒绝由
§19.2.1 的 writer self-fence、ConversationHead 条件更新与 GroupDispatch 数据面过滤承担,
而非 transactional_id / ProducerFenced。

纯 Rust 客户端不因“不支持事务生产者”被自动否决,但必须实测并满足上述 durable ACK、固定分区、
重放与故障恢复契约。当前 `RdkafkaCommitLog::publish_dispatches_and_ack_batch` 已实现上述
“全部 dispatch durable → Sync commit source offset”顺序;`TransactionalFanout` /
`FanoutBatchTransaction` / metrics 的历史名称,以及虽未使用却强制读取的
`QIM_FANOUT_TRANSACTIONAL_ID`,是待清理的命名/配置漂移,不表示仍在使用 Kafka transaction。
发布前必须移除无用 env 的强制读取,并保留同等故障注入证明。

一期新增依赖 standalone Redis(当前实现为 redis-rs,需 pipeline + Lua),见 §18.1.2b。Redis Cluster 未实现且必须在启动探测中拒绝,不能把客户端库候选能力 写成当前系统已经具备的部署能力。

18.3 本地缓存与保留裁剪

18.3.1 本地缓存

MailboxNode 与 ConnectionNode 优先缓存:

1. 已编码的文本消息与小型自定义消息正文(大群分发的核心复用对象,也是读路径 join 的命中来源)
2. 图片、视频缩略图(<= media_thumbnail_max_bytes,32 KiB),**不缓存原始大文件**
3. 群成员分片 Bitmap(不可变,按 membership_version 作为键的一部分)
4. 在线成员 Bitmap
5. 用户 → ConnectionShard 的映射(映射本身是纯函数计算,实际缓存的是
   shard → node 的租约结果)
准入与淘汰:加权 LRU 或 TinyLFU
    容量按**实际字节**计量,必须计入对象头、索引结构、编码缓冲与内存碎片
    **禁止写死条数**("1 GiB 缓存 N 条消息"这类假设在真实负载下永远不成立)
    选 TinyLFU 的具体理由:它具备扫描抗性。
    一次大群历史回填或一次批量同步会顺序touch 大量冷正文,
    纯 LRU 会被这一次扫描整体冲刷,把热点单聊正文全部挤出去。

缓存键必须包含 tenant_id(防跨租户命中,§20)
不可变对象(正文、成员 Bitmap)可以不设 TTL
可变对象(在线 Bitmap、租约映射)必须设 TTL,并以 epoch 校验,
    失效收敛走 §5.4 的 PRESENCE_STALE 路径

正文 LRU 是附录 A.4.1「读时 join」的命中来源:

正文不存储在个人邮箱中(§7.3)。MailboxNode 在**读路径**组装
MAILBOX_BATCH.entries[] 与 PUSH_EVENTS.events[](两者共用附录 A.4.1 的同一结构)时,
按以下**强制顺序**执行,禁止逐 message_id 点查 MessageStore:

    1. 内联条目(ADR-0005,条目自带正文)直接跳过,不进入 join
    2. 其余条目按 message_id 查本节点正文 LRU,命中的剔除
    3. 未命中的按 (conversation_id, seq_bucket) 分组(§7.1 的分区键前两段)
    4. 每组一次多行范围读(按 conversation_seq 的 IN 或 range),组间并发
    5. 回填 LRU;同一 message_id 在一个批次内只 join 一次、只编码一次(§10.3)
    join 结果写入条目的 body_included 与 payload_or_ciphertext 等读时字段

为什么必须分组:join 成本是 O(批次内不同 conversation 数),不是 O(条目数)。 同一会话的连续消息落在同一分区、同一 seq_bucket,一次范围读即可全取回:

1 个大群的 500 条积压   → 1 次范围读     单条 join 成本 0.002
20 个单聊各 5 条        → 20 次范围读    单条 join 成本 0.2

朴素实现(逐 message_id 点查)会把上面第一行也变成 500 次点查,在离线回填路径上 是十倍级差异。这也是 ADR-0005 只对小会话内联的依据——大群的 join 本就极便宜。

因此该缓存的命中率直接决定同步与推送路径对 MessageStore 的读放大:
    大群在线推送的命中率天然极高(同一条正文被同批次成千上万个收件人共用)
    离线批量回填的命中率低,是 MessageStore 读的主要来源,容量规划按此取值(§25)

未命中且正文已被治理删除、已过 retention_class 保留期,
或单批正文总量超过 max_frame_bytes 时,条目以 body_included=false 下发,
客户端走 PULL_HISTORY 补取或渲染占位,**不得视为丢消息**(附录 A.4.1)。

18.3.2 裁剪水位定义

v1 在这里有一个循环依赖:§12.2 说"邮箱清理前必须保证投影检查点覆盖待删除范围",§17.3 说"个人邮箱只保留设备恢复所需窗口",两句话互相引用,谁也没定义具体水位。本版闭环:

user_trim_seq(u) = min( min{ u 的有效设备的 last_applied_mailbox_seq },
                        u 的投影检查点的 projection_mailbox_seq )
                   - trim_safety_margin      (用户级量,与 §12.3.3 同一公式)

有效设备 = 最近 device_inactive_gc_days(默认 60 天)内活跃过的设备
trim_safety_margin = 24 h 对应的时间跨度(附录 B.3;按时间而非按 seq 数量取,
                     因为 mailbox_seq 的推进速率随负载变化)

阶段二 mailbox-tail 形态的硬约束(附录 B.3 已声明,此处给出用途):
    mailbox_retention_days >= 投影压缩器最坏滞后 P99.9 × 3
    否则 TTL 会删掉尚未合并进投影的增量,会话列表出现不可恢复的错误。
    §24 必须对"投影压缩滞后 > 保留窗口 1/3"告警。

裁剪执行后推进该用户的有效裁剪线 effective_trim(u)(定义见 §18.3.3,§6.5.2)。
mailbox_trim_watermark **只随 AUTH_OK 下发**(下发发起认证设备所属用户的
effective_trim(u));连接期内的裁剪推进不主动通知,
由 ERROR{code=CURSOR_EXPIRED} 在游标实际越界时暴露(PONG 不携带任何水位字段)。

安全论证:时间窗口的物理到期**可以**越过设备游标,因为 TTL 不等待任何设备;安全性来自
`expired_gap_boundary` / `effective_trim(u)` 的显式证明。游标落后时读路径必须返回
`CURSOR_EXPIRED` 并走 REBUILD,绝不能把已删除区间伪装成空洞成功。`user_trim_seq` 只提供
可选的提前逻辑裁剪/回收,`device_inactive_gc_days` 则是另一条设备失活判定。

18.3.3 两类裁剪:时间窗口与条目上限

本节只定义邮箱裁剪。MessageStore 的历史热窗口裁剪(Redis 热层只删「超过 history_hot_window ∧ 已被归档水位覆盖」的记录,ADR-0018)是另一类对象,不越过任何 设备游标、不产生 CURSOR_EXPIRED,也不属于下表的两类之一。

邮箱裁剪有且只有两个触发条件,性质完全不同,必须分开实现:

时间窗裁剪(常规) 条目上限裁剪(兜底)
触发条件 时间窗口(mailbox_retention_days) 条目数超过 max_mailbox_entries_per_user
是否可能越过设备游标 是,TTL 不等待设备 是
用户可感知 仅落后于到期边界的设备收到 CURSOR_EXPIRED → §9.6 REBUILD 落后设备收到 CURSOR_EXPIRED → §9.6 REBUILD
定位 正常保留策略,不因触发本身告警 异常容量兜底,触发即告警

强制裁剪的规则:

触发:某用户在 MailboxStore 中的条目数 > max_mailbox_entries_per_user(附录 B.5.1,默认 5 万)
动作:按 mailbox_seq 从旧到新裁剪至 max_mailbox_entries_per_user × 0.8(滞回,避免边界抖动)
      推进 user_trim_seq(u),进而抬高 effective_trim(u)
后果:游标落在新 effective_trim(u) 之下的设备,在下次 AUTH 收到
      ERROR{code=CURSOR_EXPIRED, rebuild_required=true}
指标:mailbox_entry_cap_evicted_total{tenant}   非零即 P2 告警

为什么需要这条上限:30 天窗口内一个重度账号可以堆到 30 万条以上 (客服号、机器人号——正是 §2.2 为 max_conversations_per_user 设限时点名的同一类账号)。 30 万条 × entry_ondisk_bytes ≈ 42 MB 单分区,在 ScyllaDB 上进入大分区警戒区; 若 MailboxStore 是 Redis 实现,则是一个 60 MB 级的单 key,集群无法拆分, 会造成内存倾斜、迁移阻塞与范围读延迟尖刺。

强制裁剪必须是兜底而非常规:正常情况下 §8.2 的 per_conversation_msg_rate 与 per_user_msg_rate 应当先把消息洪流拦在写入侧。触发条目上限说明限流未生效或阈值不当, 因此该指标非零即告警,而不是静默执行。

时间窗裁剪的规则(下述内容对条目上限裁剪不适用):

邮箱条目按时间窗口裁剪时,**不以任何单设备游标为条件**。

物理删除由 default_time_to_live(mailbox_retention_days,默认 30 天)单独决定,
    它不等待任何设备游标、不等待任何投影检查点。
user_trim_seq 的作用:阶段一(ScyllaDB)仅用于**提前收窄可读窗口**
    (TruncateBefore 的逻辑裁剪,不提前释放空间,§18.1.2);
    阶段二(自研 LSM)额外允许**提前物理回收**活跃用户的空间;
    在时间窗裁剪语境下两者都是优化,不是删除条件。
    (条目上限裁剪同样通过抬高 user_trim_seq(u) 生效,但它**是**删除条件,
     且允许越过设备游标——两条路径共用同一个水位字段,区别只在触发方。)

因此每用户的有效裁剪线为:
    effective_trim(u) = max( ttl_trim_frontier[lane], user_trim_seq(u) )
    其中 ttl_trim_frontier[lane] 是 TTL 已删除到的 lane 级前沿。
    AUTH 校验(§9.3.1)与规则 3(§9.3.3)的比较对象是发起请求用户/设备的
    effective_trim(u);AUTH_OK.trim_watermark 下发该值
    (AUTH_OK 本就按设备下发,无帧改动)。

为什么必须这样:
    如果删除条件包含"所有设备游标都已越过",那么**一个永不回归的设备会把该用户的
    邮箱永久钉死**——用户换了手机、卸载了应用、设备丢失,服务端就再也不能回收空间。
    这是一条会在上线半年后才爆发、且无法在线修复的容量缺陷。

代价与兜底:
    落后于 mailbox_trim_watermark 的设备游标一律返回 ERROR{code=CURSOR_EXPIRED},
    走 §9.6 的 REBUILD 流程(快照 + 每会话最近一页 + 按需历史),
    代价仅限于"窗口外的未读与提及计数不精确"。
    超过 device_inactive_gc_days(60 天)未活跃的设备游标直接过期回收,
    重新登录同样返回 CURSOR_EXPIRED。

保护动作:
    若 TTL 即将删除的条目尚未被投影压缩器消费(即 projection_mailbox_seq 落后于
    即将过期的区间),必须:
      1. 触发 §24 的 P1 告警
      2. 强制推进该用户投影,并把受影响会话标记 unread_exact=false
      3. 由 §12.5.2 的有界重算在用户下次进入会话时收敛
    不允许静默删除未被消费的条目。

18.3.4 各类数据的保留期

数据 保留期 依据
MessageRecord 按租户策略与 retention_class 分层;compliance_hold 不参与自动删除 §7.1
UserMailboxEntry mailbox_retention_days 附录 B.3,邮箱层容量的线性因子
设备游标 device_inactive_gc_days 附录 B.3;过期返回 CURSOR_EXPIRED
DispatchProgress dispatch_progress_retention,必须 ≥ log_retention_days §7.8、附录 B.3
分发日志 log_retention_days;下界见 §19.3.3 的不等式 附录 B.3、§19.3.3
GroupMembershipVersion max(dispatch_progress_retention, log_retention_days) + 安全余量 §7.9,旧版本必须覆盖未完成的分发与重放窗口
RoomRecord room_log_retention_minutes 附录 B.3;聊天室只做短期回放,保留期显著短于普通会话,这是"100 万在线不写 100 万条邮箱引用"能成立的直接原因
推送 token push_token_inactive_days 附录 B.5.2、§16.6
吊销记录 access_token_ttl + 2 h §15.3.2,天然有界
## 19. 一致性与容灾

19.1 一致性边界

系统不追求全局强一致。下表是权威边界表:每条边界写明保证目标、由什么机制保证、违反时由什么信号发现。 指标名进入 §24,验收项进入 §26。

# 边界 保证目标 保证机制 违反时如何被发现
1 单会话顺序 Home Region 单写,conversation_seq 严格递增、只进不退 会话租约 + fencing_epoch(§19.2);ConversationHead 在切换窗口内条件更新;分配器按预留窗口恢复 conversation_seq_regression_count 恒应为 0;同一 (conversation_id, conversation_seq) 出现两个不同 message_id 触发 P1;ConversationHead.latest_conversation_seq 的回退写入被拒绝并计数
2 消息提交 正文与分发日志等价可靠提交,客户端重试不产生重复消息 先写 MessageRecord、再经可靠 Outbox 产出 GroupDispatch(§8);ClientDedup 用 IF NOT EXISTS 收敛(§7.2) outbox_lag 超阈值告警;对账任务扫描"有 MessageRecord 无对应 dispatch"与"有 dispatch 无 MessageRecord",两类孤儿计数恒应为 0
3 用户邮箱 至少一次物化、幂等引用、按 lane 连续水位发布 dispatch_id / event_id 均为确定性哈希(§6.7、§7.8);DispatchProgress 与邮箱条目在同一原子提交,或等价的严格顺序(先条目后进度)+ 确定性幂等重放内写入(§7.8,阶段一 ScyllaDB 走后者);W[lane] 仅在该 lane 内所有 mailbox_seq <= s 的子任务全部达到复制要求后才推进到 s mailbox_seq_regression_count 恒应为 0;W[lane] 单调性断言;主备节点检查点内容哈希对账不一致即告警;客户端上报 covered_through_seq 跨越未物化区间的事件计数恒应为 0
4 在线推送 尽力实时,允许丢;丢失后必须能由个人邮箱完全恢复 先物化后推送(§11);session_epoch 校验丢弃陈旧连接;PONG.last_pushed_user_seq 提供缺口锚点(§6.10.1) push_drop_count、mailbox_dirty_rate;混沌测试中断连接后比对客户端最终消息集合与邮箱区间,差集恒应为空
5 会话列表 在线近实时;持久投影最终一致;投影丢失后仍能恢复完整会话集合 保留窗口内由 qsession 的 durable dispatch checkpoint 重放;窗口外由 UserConversationState + canonical MessageStore/ConversationHead 权威重建;未读按定义式计算(§9.6.2、§12、§17.2) checkpoint/日志身份或位点越界 fail closed;§26.4.3 比对权威会话集合与定义式未读,缺失/多余/差异均须为 0;当前权威兜底未实现,发布阻断

"达到复制要求"的持久性契约(v1 未定义):

MailboxStore = ScyllaDB 实现(阶段一):
    写入以 LOCAL_QUORUM 成功返回视为达到复制要求(mailbox_replicas 默认 3)

MailboxStore = 自研 LSM 实现(阶段二,Go 用 Pebble / Rust 用 RocksDB):
    主节点 WAL fsync 完成,且至少 1 个备节点确认已持久化同一 (mailbox_seq, event_id) 批次

在达到复制要求之前,W[lane] 不得前进;条目可以先写入内存与本地 WAL,但对客户端不可见。

19.2 Home Region 单写与 fencing

一期适用范围(ADR-0020):本节是会话写入租约模型的目标契约。一期 writer 不持有序号窗口、 conversation_seq 由 MessageStore 的 Redis Lua 原子分配,本节的 fencing_epoch 与两个校验点暂不实现; 引入本地序号预留窗口、ShardRegistry 会话租约或 writer 条件更新 ConversationHead 之前必须先实现。

v1 只有一句"故障切换必须使用 fencing token 防止双写",既没有说 token 是什么,也没有说在哪里校验。本节把它补成可实现、可验收的机制。

19.2.1 fencing token 的载体

fencing_epoch : u32   单调递增,由 ShardRegistry 的租约服务颁发
                      每一次会话写入租约的"授予"都递增一次(续期不递增)
                      按 (tenant_id, conversation_id) 维度持久化当前值
  • 颁发方唯一:ShardRegistry。ConversationWriter 自身不得生成或猜测 fencing_epoch。
  • ConversationWriter 取得租约时同时取得 (fencing_epoch, next_conversation_seq),两者一起构成写入资格。

两个强制校验点缺一不可。Writer self-fence 是租约失效前停止写入的前置约束, 不能替代任一校验点。Fanout 不使用 Kafka 事务,也不依赖 transactional_id / ProducerFenced(ADR-0012):

校验点 1:ConversationHead 的条件更新
    IF (fencing_epoch, head_version) < (新 fencing_epoch, 新 head_version)
    条件不成立 → 拒绝写入 → 该 Writer 立即放弃租约并自杀,不得重试

校验点 2:分发日志记录内 fencing_epoch 的消费侧过滤(数据面校验)
    ConversationWriter 把租约携带的 fencing_epoch 写进每条 GroupDispatch 记录(§7.8)
    MailboxNode 消费时按 (tenant_id, conversation_id) 维护"已见最大 fencing_epoch"的
        单调过滤器——分发日志同分区有序,新 Writer 的首条记录必然先于旧 Writer 的
        迟到追加被消费,因此过滤器不依赖消费路径同步查询 ShardRegistry
    fencing_epoch 小于该过滤器值、或小于 ShardRegistry 本地缓存当前值的记录
        → 整条丢弃、不物化任何邮箱条目,并计入指标 stale_epoch_dispatch_dropped_total 告警

Fanout 的日志职责边界(ADR-0012,明确写死,防止误实现):

FanoutCoordinator 不使用 Kafka 事务;应用级唯一性也不依赖 broker 事务。
执行顺序固定为:
  1. 产出本批全部 GroupDispatch,并等待每条 durable delivery(acks=all);
  2. 全部成功后才持久化/确认该批 Outbox source 进度。
若第 1 步成功而第 2 步失败,整批重放,重复 dispatch 由 dispatch_id + payload_digest 收敛;
禁止先确认 source 再等待 dispatch durable,否则会永久跳过消息。

`transactional_id`、InitProducerId 与 ProducerFenced 不承担任何 fencing 或提交正确性职责。
当前 `RdkafkaCommitLog::publish_dispatches_and_ack_batch` 已按本节顺序执行;
TransactionalFanout / FanoutBatchTransaction / metrics 的历史名称不改变这一事实。
但 QIM_FANOUT_TRANSACTIONAL_ID 虽未参与该语义仍被强制读取,属配置漂移:发布前必须移除
该无用 env 门禁,并以“dispatch durable 后 source checkpoint”的故障注入持续验证本节顺序。

被校验点 2 过滤的旧 epoch 记录仍占用分发日志 offset。这与
    mailbox_seq = (shard_epoch:16, log_offset:48) 的稀疏语义天然兼容
    (§6 已禁止用 seq 差值判定丢消息),不需要任何额外补偿。

Writer self-fence 缩小旧 owner 继续写入的窗口;校验点 1 保护会话公共头与顺序权威; 校验点 2 保护已进入分发管道的迟到旧记录。旧 Writer 即使因进程暂停(stop-the-world:GC 停顿、 调度停滞、VM 挂起等)在租约过期后"复活",其 ConversationHead 条件更新必须失败;若旧 epoch 记录已在其前进入日志,MailboxNode 仍须 100% 拒收,不产生任何邮箱条目。当前 GroupDispatch 未携带 fencing_epoch 的实现缺口使校验点 2 尚不能成立,见 §7.8 与 §26.6.1。

19.2.2 租约参数

参数 值 说明
租约时长 shard_lease_ttl 会话写入租约复用同一参数
续期间隔 writer_lease_renew_interval 必须 ≤ 租约时长 / 3;容忍连续续期失败至 writer_self_fence_deadline(12 s),期间快速重试(见下)
失效等待 writer_failover_wait 必须 > 租约时长
自杀阈值 writer_self_fence_deadline Writer 自续期失败超过该时长后必须主动停止写入,必须 < 租约时长

以上四项数值见附录 B.1 与附录 B.5.3,本节只定义约束关系。

续期失败后的重试节奏(写死,否则"容忍失败"与数值组合不自洽):续期失败后放弃固定间隔,立即转入每 1 s 快速重试,直至成功或到达 writer_self_fence_deadline。若按固定 5 s 间隔重试,两次连续失败发生在 t=10 s、第三次尝试(t=15 s)晚于自杀阈值 12 s,"容忍两次失败"将退化为"两次失败必然自杀"。

writer_self_fence_deadline < shard_lease_ttl < writer_failover_wait 是防脑裂的核心不等式:旧 Writer 在租约过期前就已自行停写,新 Writer 在租约过期后才开始写。

19.2.3 切换流程

1. 探测失联
   ShardRegistry 连续 writer_lease_renew_interval × 2 未收到续期,标记该会话租约 SUSPECT
   同时旧 Writer 侧:自续期失败超过 writer_self_fence_deadline → 主动停止写入并丢弃内存序号

2. 等待租约自然过期
   等待 writer_failover_wait,期间不授予新租约
   期间到达的发送请求一律返回 ERROR{code=REGION_FAILOVER, retry_after_ms}

3. 新 Writer 取得租约
   ShardRegistry 递增 fencing_epoch,授予新租约
   新 Writer 以新 epoch 恢复会话写入;
   此后旧 epoch 的 dispatch 记录被消费侧按记录内 fencing_epoch 拒收(校验点 2,§19.2.1)

4. 恢复 conversation_seq
   从持久化的"已预留上界"读取 reserved_upper_bound
   next_conversation_seq = reserved_upper_bound + 1
   不扫描历史消息、不复用旧窗口内未使用的序号
   → 由此产生的空洞是合法的(§6.3 第 1 类空洞)

5. 恢复 last_activity_id
   从 ConversationHead.last_activity_id 读取,新分配值必须严格大于它

6. 接受写入
   第一条写入必须以校验点 1 的条件更新成功为准;失败即回到步骤 2

验收(§26):注入旧 Writer 的 30 秒进程暂停(stop-the-world:GC 停顿、调度停滞、VM 挂起等),切换完成后旧 Writer 恢复并尝试写入:其 ConversationHead 条件更新必须 100% 被校验点 1 拒绝;其追加的旧 epoch dispatch 记录必须 100% 被校验点 2 在消费侧拒收(stale_epoch_dispatch_dropped_total 相应递增,物化条目数 == 0);且全程 conversation_seq_regression_count = 0。

19.2.4 切换期间的客户端表现

服务端:ERROR{code=REGION_FAILOVER, retry_after_ms}
        retry_after_ms 默认 2000(region_failover_retry_after_ms,附录 B.5.3),带 ±30% 抖动

客户端强制行为:
  1. 消息进入本地发送队列,气泡保持 pending 态(§6.9.1 的 pending 区)
  2. 按 retry_after_ms 退避后以**同一 client_message_id** 重试
  3. 在总重试预算 send_retry_budget = 60 s(附录 B.5.3)耗尽之前,
     禁止向用户提示"发送失败",只允许展示"发送中"
  4. 超过预算后标记为失败可重发,仍保留 client_message_id,
     用户手动重发时复用它以命中 ClientDedup(§7.2,TTL 7200 s,ADR-0008)

其他会话不受影响:切换只作用于该会话的 Home Region,用户在其他会话的收发与全部离线同步链路照常工作。

19.2.5 跨地域写延迟

会话 Home Region 是 conversation_seq 的唯一分配点,因此非本地成员的发送延迟必然包含一个跨区往返。

发送 P99 ≈ 客户端 → 就近 ConnectionNode(本地 RTT)
         + ConnectionNode → 会话 Home Region 的 ConversationWriter(跨区 RTT)
         + 提交 MessageRecord + 追加分发日志(本区内)
         + 返回 SEND_ACK(跨区 RTT)

跨区 RTT 的工程参考值(观测参考值,非配置项):欧洲 ↔ 美东约 90 ms,亚太 ↔ 美西约 120~180 ms。 跨区发送的延迟目标以 §2.4 的跨区 SLO 行为准,本节不重复定义数值。

四条缓解手段:

  1. Home Region 按群主要成员分布选择:建群时按创建者 region 落定;GroupMembership 每 24 h 统计成员 region 分布,主导 region 占比超过 home_region_migration_threshold(数值见附录 B.5.3)且与当前 Home Region 不同时,产生迁移建议。
  2. Home Region 可迁移:迁移复用 §19.2.3 的切换流程(停写窗口 ≤ writer_failover_wait),迁移后 region_id 变化只影响新分配的 message_id(§6.2 的 region_id 位段),不影响任何历史。
  3. 发送侧本地确认不提前:禁止在跨区提交完成前返回 SEND_ACK。伪造的低延迟会破坏 §11.2 的到达层级定义。客户端用 pending 气泡承载这段延迟。
  4. 只有分配点跨区,分发不跨区串行:GroupDispatch 产出后按目标 MailboxShard 所属 region 镜像到本地日志,各 region 的 MailboxNode 并行物化,不需要回到 Home Region。

19.2.6 "用户邮箱所在 region" 与 "会话 Home Region" 的关系

v1 完全没有定义这层关系,导致无法判断一次地域切换是否会波及 mailbox_seq 与设备游标。本版写死:

维度 用户邮箱所在 region 会话 Home Region
绑定对象 user_id(通过 user_bucket → MailboxShard) conversation_id
决定什么 mailbox_seq 的分配点、W[lane]、设备游标、UserSessionProjection、UserBadgeState conversation_seq、last_activity_id、ConversationHead
变更条件 用户数据驻留策略变更(罕见,走 §5.5 重分片) 群成员分布变化或 region 故障(相对频繁)
变更影响 换发游标令牌(CURSOR_REBASED) 不影响任何 mailbox_seq 与设备游标
硬约束(写进实现与验收):
  用户邮箱固定在用户自己的 home region;
  会话 Home Region 只决定 conversation_seq 的分配点;
  两者解耦。

推论 1:会话 Home Region 切换不改变任何 MailboxShard 的 shard_epoch 与 log_offset,
        因此不影响 mailbox_seq、不影响 W[lane]、不影响任何设备游标,
        客户端在会话 Region 切换期间不会收到 CURSOR_REBASED / CURSOR_EXPIRED。
推论 2:跨 region 分发通过分发日志镜像完成——
        FanoutCoordinator 在会话 Home Region 产出 GroupDispatch 后,
        按 target_mailbox_shard 所属 region 镜像到该 region 的分发日志;
        mailbox_seq 由**目的 region 的日志**分配,与源 region 无关。
推论 3:镜像链路是至少一次的,重复镜像由 dispatch_id 在目的 region 去重(§7.8)。
推论 4:用户 home region 整体不可用时才会波及邮箱与游标,走 §19.3 的区域级恢复。

19.3 检查点、恢复与备份

v1 的 §18.3 没有任何数值,且"日志保留期必须大于最坏检查点恢复时间"是循环论证(恢复时间本身依赖日志保留期决定的重放量)。本节全部改为良定义、可测量。

19.3.1 检查点内容

检查点按实现阶段拆开,禁止把阶段二本地 LSM 快照套到当前一期:

  • 当前一期:MailboxNode 的外部持久状态由 Redis/日志恢复,检查点只覆盖 W[lane]、W_floor[lane] 与 DispatchProgress;qsession 独立持有 durable per-partition dispatch checkpoint。两者是不同恢复域,不得合并推进(§17.2、 docs/07-reliability-security-operations.md §2.1)。
  • 阶段二自研 LSM 目标态:一个 MailboxShard 的本地检查点必须是下面的自洽快照, 缺任何一项都会导致恢复后状态撕裂。此形态下 MailboxNode 负责本地邮箱、投影与角标快照; 这不改变当前 qsession checkpoint 已实现且独立拥有的事实。
Phase2Checkpoint(mailbox_shard_id, shard_epoch, checkpoint_id) { # 阶段二由 MailboxNode 生成
    1. 邮箱索引            UserMailboxEntry 的全量或增量段
    2. 会话投影            UserSessionProjection            ← MailboxNode 权威副本
    3. 角标状态            UserBadgeState                  ← MailboxNode 权威副本,与 2 必须同一快照点,否则角标与列表撕裂
    4. 分发进度            DispatchProgress                ← 缺它则无法判断某 dispatch 的分块是否已完成
    5. 分片 epoch          shard_epoch
    6. 日志 offset         log_offset(重放起点,闭区间下界 = offset + 1)
    7. 各 lane 水位        materialized_watermark[lane_count]
    8. 各 lane 裁剪水位     mailbox_trim_watermark[lane_count]
    9. 边界表              EpochBoundary / ShardSplitBoundary(§7.11)
}
  • 检查点写入对象存储,键为 (mailbox_shard_id, shard_epoch, checkpoint_id),不可变。
  • 采用"全量基线 + 增量段":每 checkpoint_full_multiple 个增量检查点做一次全量基线(数值见附录 B.5.3)。
  • 检查点内容必须逐字节可对账:主备节点在同一 log_offset 生成的检查点内容哈希必须相同,这是 §6.7 要求全部物化输入确定性的直接验收手段。

19.3.2 五个可测量参数(v1 缺失,导致 §26 不可验收)

参数 定义 目标值 来源
检查点间隔 相邻两次检查点的 log_offset 时间距离 见附录 B.5.3 checkpoint_interval
检查点大小 单分片一次检查点的字节数(增量段 + 最近全量基线) 待实测 checkpoint_bytes(附录 B.7)
重放速率 单节点从分发日志重放并物化的条目/秒 待实测,下界必须 ≥ per_node_entry_budget × 3(附录 B.7),该下界须覆盖下方 RTO 推导式 replay_rate(附录 B.7)
追平判据 备节点可接管的量化条件 见下方 本节定义
分级 RTO 各级故障的恢复时间目标 见 §19.3.4 本节定义

replay_rate 下界与 shard_rto_target 的关系(推导,v1 缺失):

把 checkpoint_interval 内积压的日志压进 shard_rto_target,要求:
  replay_rate >= checkpoint_interval × per_shard_entry_budget
                 / (shard_rto_target - 检查点加载时间预算)

按 checkpoint_interval = 900 s、per_shard_entry_budget = 5 万 entry/s、
检查点加载时间预算 = 2 min(新参数 checkpoint_load_time_budget,附录 B.7 待实测)计:
  900 s × 5 万 / (600 s - 120 s) ≈ 9.4 万 entry/s
另加重放期间的并发追加速率(5 万 entry/s)→ 约 14.4 万 entry/s。

既有下界 per_node_entry_budget × 3 = 45 万 entry/s 覆盖该需求并保留约 3 倍余量。
本推导式与上表下界是同一规范的两种表达,§26 只引用、不另立系数。

追平判据(写死,v1 只有"追平连续物化水位"这句无法验收的话):

catchup_lag_entries(lane) = 日志末端 offset - W_replay[lane] 对应的 offset

接管条件(三条同时成立):
  1. ∀lane: catchup_lag_entries(lane) <= takeover_catchup_lag_entries(默认 1000,附录 B.5.3)
  2. 上述条件持续 takeover_catchup_stable_window(默认 5 s,附录 B.5.3)不再恶化
  3. 备节点已加载的检查点 shard_epoch 与 ShardRegistry 当前值一致

接管顺序:
  ShardRegistry 递增 shard_epoch(§6.5 的第 1 类事件)
  → 写入 EpochBoundary{start_mailbox_seq, prev_epoch_end_mailbox_seq}
  → 备节点转主,按 lane 逐个恢复水位发布
  → 游标落后的客户端收到 CURSOR_REBASED,从 replay_from_seq 重放并按 message_id 幂等去重

恢复流程:

1. 从对象存储加载最近的自洽检查点(全量基线 + 其后的增量段)
2. 从 checkpoint.log_offset + 1 重放分发日志
3. 重放期间对客户端暴露的水位**不得超过**检查点内记录的 W[lane]
   (重放中的中间状态不可见,避免暴露未完成物化的区间)
4. 满足追平判据后,按 lane 逐个把 W[lane] 推进到重放后的真实值
5. 恢复期间该分片的发送不受影响:写入侧只依赖分发日志,不依赖 MailboxNode

19.3.3 日志保留期的良定义不等式

v1 的 L818 是循环论证。本版改为可直接代入求解的不等式。 本不等式是分发日志保留期下界的唯一规范,其他章节只能引用,不得另立系数(历史文稿中的 ×3 版本已作废):

log_retention_days >= ( checkpoint_interval + T_recover ) × 2

其中 T_recover = 检查点加载时间
               + checkpoint_interval × per_shard_entry_budget / replay_rate
    (检查点是"加载"不是"重放",重放量是检查点间隔内积压的日志条目;
      两个加项的量纲均为时间,回填实测值后可直接求解)

×2 是安全余量,覆盖:重放期间日志仍在追加、恢复过程本身失败并需要重来一次。

代入(附录 B.5.3):T_recover 以 shard_rto_target = 10 min 为上界——该上界不是
  推导结果,而是由 §26 用例 26.6.3 的单分片 RTO 实测保证的目标值,故:
  log_retention_days >= (15 min + 10 min) × 2 = 50 min

默认值与本不等式的关系:附录 B.3 的 log_retention_days 默认取 1 d(24 h), 它不是由本不等式解出的值,而是在满足下界(50 min)的前提下,为覆盖"周末/夜间故障延迟响应" 留出的运维余量。因此二者的关系是「默认值 >> 下界」,而不是「默认值 = 下界」: checkpoint_bytes / replay_rate 回填后若解出的下界仍远小于 1 d,默认值不必调整; 只有当下界超过 1 d 时,才必须上调 log_retention_days。

三条保留期的偏序关系(必须同时成立,任一违反都会造成不可恢复的数据缺失;数值见附录 B.3、B.5.3):

dispatch_progress_retention
  >= log_retention_days
  >= ( checkpoint_interval + T_recover ) × 2      (T_recover 定义见本节上方)

且   阶段二 mailbox-tail 形态满足
     mailbox_retention_days >= 投影压缩器最坏滞后 P99.9 × 3   (附录 B.3)
且   GroupMembershipVersion 保留期 >= max(dispatch_progress_retention, log_retention_days) + 安全余量(§7.9)

检查点加载时间与 replay_rate 回填后必须重新求解本不等式;若求解结果超过 dispatch_progress_retention,必须先增大 dispatch_progress_retention,不得下调 log_retention_days。

19.3.4 分级 RTO / RPO

故障级别 影响范围 目标 RTO 目标 RPO 恢复手段
MailboxNode 主节点故障(备节点热追平) 1 个 MailboxShard ≤ mailbox_takeover_rto_target(附录 B.5.1) 0 备节点满足追平判据后接管
ConversationWriter 故障 1 组会话 ≤ writer_failover_wait + 5 s(附录 B.5.3) 0 §19.2.3 切换流程
单分片承载节点丢失且无可用接管候选 1 个 MailboxShard shard_rto_target 0 检查点 + 日志重放(本节流程)。一期(Redis)/ 阶段一(ScyllaDB)下权威数据在节点外,恢复只需重算 W[lane] 并重放;阶段二(自研 LSM)才需从对象存储检查点重建
单 region 整体不可用(标准租户) 该 region 全部分片 region_rto_target rpo_target 异地检查点 + 镜像日志,逐分片并行恢复
单 region 整体不可用(高级租户) 该 region 全部分片 ≤ shard_rto_target 0 同步复制热备,见 §19.4
检查点与日志同时损坏 1 个 MailboxShard ≤ 4 h(disaster_rto_target,附录 B.5.3) rpo_target §19.3.5 备份时间点恢复

以上均为目标值,数值见附录 B.5.3;回填 checkpoint_bytes / replay_rate 后必须由 §26 的演练实测校验。 全区域 region_rto_target 的可行性直接依赖"逐分片并行恢复":单分片按 shard_rto_target 计,mailbox_shard_count 个分片必须并行恢复,恢复并发度不足时该目标不成立。

19.3.5 备份与时间点恢复(v1 完全缺失)

检查点解决的是"节点故障恢复",不解决"逻辑错误":一次错误的批量删除、一个把水位写错的 bug、一次误操作的租户清理,都会被检查点如实记录下来。因此必须有独立备份。

备份目标                 内容
  全量快照              MessageStore 全量 + 各分片最近全量检查点基线
  增量                  分发日志段 + 检查点增量段 + ScyllaDB commitlog 归档

频率
  backup_full_interval          = 24 h    (附录 B.5.3)
  backup_incremental_interval   = 5 min   (附录 B.5.3)

目标
  RPO <= rpo_target            (附录 B.5.3;由 backup_incremental_interval + 上传与校验余量得出)
  时间点恢复粒度 <= backup_incremental_interval

存储
  独立对象存储 bucket,**独立账号 / 独立凭证**,与生产集群不共用删除权限
  启用对象锁(WORM),锁定期 backup_retention_days(附录 B.5.3)
  → 这同时是防勒索与防误删的边界;生产侧凭证泄露不能删除备份

演练
  每季度一次,随机抽取 1 个 MailboxShard + 1 个 ScyllaDB keyspace 做完整时间点恢复
  演练必须记录实测 RTO / RPO 并回填 §26 的验收矩阵;未演练的备份视为不存在

与加密擦除的关系(重要):备份是 WORM 的,无法逐行删除。因此 §21 的删除承诺只能由销毁 DEK 实现——备份中的密文在 DEK 销毁后即刻不可解。这是 §21.2 与 §21.3 存在的直接原因。

19.4 灾难恢复等级

按租户分两级,写进合同,不由实现临时决定。

维度 标准租户 高级租户
跨区域复制 异步复制 同步复制 / 双地域提交
目标 RPO rpo_target(附录 B.5.3) 0
目标 RTO(region 级) region_rto_target(附录 B.5.3) ≤ shard_rto_target
conversation_seq 分配 单 Home Region 单 Home Region + 热备 Writer 常驻,租约转移无需等待完整 writer_failover_wait
邮箱物化 目的 region 单写 双 region 双写,mailbox_seq 仍由主 region 分配,备 region 只做只读副本
发送 P99 影响 无额外开销 每条消息增加一个跨区 RTT(90~180 ms 量级)
存储与带宽成本 基线 约 2×(副本翻倍 + 跨区流量)
适用场景 SaaS 默认档 金融、政务、合同明确 RPO=0 的租户
硬约束:
  1. 同一会话在任何时刻只有一个 Home Region 分配 conversation_seq。
     高级租户的"双地域提交"指的是**提交的持久化跨区**,不是**分配点跨区**。
  2. 故障切换一律经 §19.2 的 fencing 流程,不因租户等级放宽。
  3. RPO=0 不等于 RTO=0:切换期间客户端仍会看到 REGION_FAILOVER(§19.2.4)。
  4. 标准租户的非零 RPO 必须在产品与合同中显式披露,
     不得用"消息不会丢"这类模糊表述覆盖它。

20. 安全与多租户

20.1 传输与协议安全

端口          443(唯一对外端口)
TLS           强制 TLS 1.3;私有化部署允许降到 1.2 但必须记录例外并单独审批
ALPN          qim/1(自定义帧)与 http/1.1(WSS 升级),协商与回落顺序见 §5.3
0-RTT         默认关闭。若开启,白名单仅限 PING,禁止承载任何写操作(重放风险)
mTLS          客户端侧可选(企业私有化);**内部服务之间强制 mTLS**,禁止明文内网信任
终止位置      终止在 ConnectionNode,见 §5.3;L4 只做四层直通 + PROXY protocol v2 透传源 IP
  • 帧头校验范围见附录 A.1:header_crc 只覆盖帧头,body 完整性由 TLS 记录层保证。这不是省事,是必须——覆盖整帧会让一条 10 万人群消息退化为 10 万次全帧扫描,与 §10.3 "公共正文只编码一次"直接抵消。
  • 尺寸与批量上限(全部取自附录 B,不在此重复定义):max_frame_bytes、max_custom_payload_bytes、media_thumbnail_max_bytes、pull_mailbox_max_items / pull_mailbox_max_bytes、mailbox_event_group_max_items / mailbox_event_group_max_bytes。任一超限返回 ERROR{code=PAYLOAD_TOO_LARGE},在解码前按 body_len 判定并直接断连,不得先分配缓冲区再检查。
  • 认证前连接生存期 unauth_connection_timeout = 10 s(附录 B.4),用于 slowloris 防护。
  • 未知 opcode、未知 version、magic 不匹配一律断连并计入 IP 维度风控。

20.2 认证与授权

20.2.1 四类凭证

凭证 签发方 TTL 绑定项 失效方式
access_token AuthService(§17) access_token_ttl(§15.3 与附录 B.4) (tenant_id, user_id, device_id) 到期 → TOKEN_EXPIRED;吊销 → TOKEN_REVOKED(§20.3)
route_token ConnectionNode route_token_ttl = 60 s(附录 B.1),单次使用 connection_shard + 客户端标识 使用后立即作废;每连接最多重定向 redirect_max_per_connection = 1 次
MailboxCursor.signature MailboxNode(HMAC) 无固定 TTL,随 shard_epoch 失效 (tenant_id, user_id, device_id, mailbox_shard_id, lane_id, shard_epoch) CURSOR_INVALID / CURSOR_REBASED
媒体下载凭证 MediaService media_ticket_ttl(附录 B.5) object_id + intent + user_id + 字节上限 到期失效;对象删除后即刻失效

游标的签名与明文关系见 §6.8。v1 在这里自相矛盾(声称游标是不透明签名 token,协议帧里传的却是明文 seq),本版已澄清:令牌只承载不可伪造部分,last_applied_mailbox_seq 明文传输并由服务端校验其 <= 该用户 lane 当前 materialized_watermark W[lane_id](§6.8)。安全边界由"不可越权读取其他分片/其他用户"保证,而不是由"seq 不可见"保证。

20.2.2 每次发送与历史读取的四重校验

1. 租户    帧内一切 ID 的 tenant_id 必须等于连接会话的 tenant_id
           所有存储结构的分区键首段都是 tenant_id(§7.0),跨租户读取在存储层即不可达
2. 用户    session_epoch 有效、设备未被吊销、access_token 未过期
3. 会话    UserConversationState.membership_state = ACTIVE
           且目标 conversation_seq 落在 visible(u, c) 区间内(§7.5)
4. 角色    UserConversationState.role 满足该操作的最低角色要求(下表)
操作 OWNER ADMIN MEMBER GUEST
发送消息 是 是 是 按群设置(默认否)
撤回自己的消息 是 是 是(限 recall_self_window,附录 B.6.1) 是(同窗口)
撤回他人消息 是 是 否 否
编辑自己的消息 是 是 是(同 recall_self_window) 否
读历史 是 是 是(限 visible(u, c)) 是(限 visible(u, c))
加人 / 踢人 是 是 按群设置 否
改群属性 / 公告 是 是 否 否
解散群 / 转让 是 否 否 否

鉴权失败一律返回 ERROR{code=PERMISSION_DENIED},不重试、不区分"无权限"与"不存在"(避免通过错误码枚举资源)。

20.2.3 重放与猜测防护

client_message_id 重放   由 ClientDedup 收敛(§7.2,TTL 7200 s);
                         超窗口的重复由客户端负责不再发起,服务端将其视为新消息
跨租户 ID 猜测           所有分区键首段为 tenant_id,猜中 ID 也读不到跨租户分区
旧 session_epoch 写入     ConnectionNode 侧丢弃并回 PRESENCE_STALE(附录 A.6 内部帧)
游标伪造                 lane_id / shard_epoch 由服务端签入令牌,客户端不得上行伪造(§6.5.1)
媒体对象猜测             object_id 使用不可枚举随机键;下载必须换取 MEDIA_TICKET,
                         对象存储不开放匿名读

20.3 会话生命周期与吊销(v1 缺失)

长连接可以存活数天,而 access_token 的 TTL 远短于此。v1 没有定义这段落差怎么处理,也没有任何主动断连的路径。

20.3.1 令牌续期

续期、宽限与吊销传播的参数与行为以 §15.3 与附录 B.4 为唯一规范,本节不重复定义任何数值与时长: access_token_ttl、refresh_token_ttl、token_refresh_lead、token_expiry_grace、 authz_recheck_interval、revocation_propagation_target 一律以该处为准。

本节只补充两条与吊销直接相关的约束:

  • 禁止用"长连接已建立"作为持续授权依据。连接内必须按 authz_recheck_interval 周期重新校验会话有效性(命中本地缓存即可,不必回源);这条轮询是 §20.3.2 广播通道故障时的唯一兜底。
  • 宽限期是只读宽限(§15.3):宽限期内允许 PING / PULL_MAILBOX / PULL_HISTORY,拒绝 SEND_MESSAGE / MARK_READ / RECALL / EDIT。因此"令牌已过期但连接尚未断开"不构成越权写入窗口。

20.3.2 吊销的四种触发与统一收敛路径

触发 作用范围 客户端可见结果
用户主动远程登出某设备 单设备 KICKED{reason=token_revoked}
用户改密 / 全端登出 该用户全部设备 KICKED{reason=token_revoked}
管理员吊销设备 单设备 KICKED{reason=admin}
用户被封禁(ModerationService) 该用户全部设备 KICKED{reason=banned}
设备数超过 max_devices_per_user(附录 B.5) 最旧设备 KICKED{reason=replaced, replaced_by_device}

统一收敛路径(复用 §5.4 的 PresenceDirectory 通道,不新增基础设施):

1. AuthService / ModerationService 产出一条 PresenceRevoke 控制记录(§7.10),
   key = (tenant_id, user_id[, device_id])
2. 通过 PresenceDirectory 的同一 compacted topic 发布(§7.10 已允许该 topic 承载此类控制记录)
3. 全部 ConnectionNode 订阅:消费到 PresenceRevoke 且命中本地连接
   → 立即发 KICKED{reason=token_revoked|banned|admin|replaced} → 关闭连接
   命中不到(用户不在本节点)→ 忽略
4. 未来的 AUTH 由 AuthService 在签发环节拒绝

时限目标:吊销提交 → 活跃连接断开 P99 <= revocation_propagation_target(附录 B.4)
兜底:即使发布通道故障,authz_recheck_interval(附录 B.4)也会在下一次复查时断开。
      两条路径必须同时实现——只有广播则通道故障时封禁失效,
      只有轮询则封禁最慢要等一个 authz_recheck_interval 才生效。

被封禁用户的处理边界:立即断开全部连接、拒绝新连接、拒绝发送;已进入个人邮箱的历史条目不删除(删除属于 §21 的独立流程,封禁不等于删除)。

20.4 限流与风控

v1 的 §8 只有"限流"两个字,大群成本因此没有任何闸门。本节给出完整维度表。

20.4.1 限流维度表

维度 参数 默认值 执行点 超限行为
每用户消息速率 per_user_msg_rate 20 msg/s(附录 B.5) ConnectionNode ERROR{code=RATE_LIMITED, retry_after_ms}
每发送者每会话速率 per_sender_in_conversation_rate 1 msg / 3 s(B.5) ConversationWriter RATE_LIMITED
每会话消息速率 per_conversation_msg_rate N≤1000 → 20 msg/s;100010000 → 2 msg/s(B.5) ConversationWriter RATE_LIMITED
每租户 fanout 配额 tenant_fanout_quota entry/s 令牌桶,按合同配置(B.5) FanoutCoordinator ERROR{code=FANOUT_QUOTA_EXCEEDED},发送侧拒绝或排队,绝不静默丢弃邮箱引用
每 IP 连接建立速率 per_ip_connect_rate 附录 B.5 L4 / ConnectionNode 直接拒绝握手,不返回应用层帧(避免放大)
每用户历史拉取速率 per_user_history_pull_rate 附录 B.5 MessageStore 前置 RATE_LIMITED
全局兜底拉取间隔 client_resync_min_interval 5 s(B.3) ConnectionNode 服务端忽略并计数,不断连
邮箱拉取在途窗口 pull_mailbox_window 4(B.3) ConnectionNode 超出的请求排队,不并发放大
媒体上传配额 per_user_media_upload_quota 附录 B.5 MediaService 拒发 MEDIA_TICKET,返回 RATE_LIMITED
房间消息速率 room_msg_rate 20 msg/s/房间(B.5) RoomWriter RATE_LIMITED
每连接房间出向帧率 room_outbound_frame_rate 10 frame/s(B.5) ConnectionNode 服务端侧合并,超出按 §11.3 降级
每用户设备数 max_devices_per_user 8(B.5) AuthService 挤掉最旧设备(KICKED{replaced})
每用户会话数 max_conversations_per_user 5000(B.5) ConversationWriter 拒绝新建会话
接管放行速率 takeover_admit_rate 5 %/s(B.4) ConnectionNode RATE_LIMITED + retry_after_ms

执行位置的确定性:用户与会话都已固定到分片(§5.2),因此每用户 / 每会话令牌桶可以在单节点内存中维护,不需要跨节点一致的分布式限流器,也不需要把 Redis 放进每条消息路径(§3 禁令)。每租户 fanout 配额是全局量,采用"中心配额分片下发 + 本地令牌桶":中心每 quota_refill_interval(附录 B.5)按节点在途负载重新分配份额。

统一行为约束:

1. 超限的语义是"拒绝或排队",永远不是"接受后静默丢弃"。
2. 所有限流错误必须带 retry_after_ms,客户端必须遵守(±30% 抖动)。
3. 限流计数进入 §24 指标,按 (tenant, 维度) 分组,用于识别噪声邻居(§20.5)。
4. 控制帧(PING/PONG/AUTH/ERROR)不受业务限流影响,走 stream 0(附录 A.5)。

20.4.2 风控挂载点

挂载点 1:发送前内容检查
  位置    ConversationWriter 分配 conversation_seq **之前**
  方式    同步旁路调用 ModerationService,超时 moderation_sync_timeout_ms = 300 ms(附录 B.5)
  超时策略 按租户配置 fail-open(默认)或 fail-closed;两种策略都必须记录审计
  命中动作 拒绝(PERMISSION_DENIED)/ 静默投递但标记 / 投递后异步撤回
  E2EE 会话 跳过本挂载点(服务端无明文),降级为挂载点 2、3 与客户端举报(§22.3)

挂载点 2:频率与行为异常
  信号    同一 payload 摘要短时间跨多会话广播、单位时间新建会话数、
          单位时间加群数、被举报率、被拉黑率、消息被撤回率
  动作    降档限流(把该用户的 per_user_msg_rate 降到 1/5)、
          要求二次验证、进入人工审核队列

挂载点 3:新账号限制
  new_account_probation_hours = 24 h(附录 B.5)
  期内:per_user_msg_rate 与 tenant_fanout_quota 占用份额减半、
        禁止创建 N > 1000 的群、禁止 @全体成员、媒体上传配额减半

风控本身不得成为发送链路的可用性单点:ModerationService 不可用时按租户策略降级,且降级状态必须触发 P2 告警。

20.5 多租户隔离

v1 只有 tenant_id 一个字段,没有隔离档位、没有配额对象、没有数据驻留、没有私有化差异。

20.5.1 三档隔离

档位 隔离方式 MailboxShard / ConnectionShard 存储 适用 代价
A 共享集群(逻辑隔离) 分区键首段 tenant_id + 配额 与其他租户共享,按 user_bucket 均匀打散 共享 keyspace,按 tenant_id 分区 SaaS 默认,中小租户 存在噪声邻居风险(见 20.5.2)
B 独立分片(物理隔离) 租户绑定到指定的 MailboxShard 集合与 ConnectionShard 集合 独占分片,桶映射在 ShardRegistry 中按租户固定 独立 keyspace,可独立扩缩容 超大租户、有性能 SLA 的租户 分片利用率下降;租户内部仍需自己承担突发
C 独立集群(私有化) 独立部署全套服务与存储 全部独占 全部独占,可独立密钥与备份 政企私有化、强合规 运维成本最高;能力集见 20.5.4

档位 B 的实现不引入新机制:user_bucket → MailboxShard 的映射本来就由 ShardRegistry 版本化管理(§5.2),只需为该租户的桶区间指定专属分片集合。档位切换走 §5.5 的重分片流程。

20.5.2 噪声邻居与一条真实的级联路径

必须诚实写出 v1 存在的级联故障路径:

§5.2 的均匀打散      → 租户 T 的用户被打散到全部 MailboxShard
v1 §9.2 的分片级单一连续物化水位
                    → 某分片上 T 的一个 10 万人大群 dispatch 展开缓慢
                    → 该分片的 materialized_watermark 停止推进
                    → 同分片上**其他租户**的纯单聊用户可见水位一并被卡住
                    → 这些用户收不到自己的单聊消息,且看不到任何错误
                    → 表现为"某个大租户发大群 = 全平台部分用户消息延迟"

这是 v1 中一条跨租户的、无告警的、由架构本身产生的级联路径,不是实现 bug。

§6.5.1 的 lane 水位如何缓解:

水位由标量改为 W[lane_count],lane_id = blake3(tenant_id, user_id)[0] & (lane_count-1)
(§6.5.1,独立哈希,不从 user_bucket 推导,与分片分裂解耦)。
一个 GroupDispatch 在分片内按 lane 拆成至多 lane_count 个子任务,各自独立推进 W[j]。

阻塞半径:整个分片  →  1/lane_count 个分片(默认 lane_count = 64,即 1/64)

诚实的边界:lane 只是把半径缩小 64 倍,**没有消除跨租户阻塞**。
落在同一 lane 的其他租户用户仍会被卡住。lane_id 的哈希输入虽含 tenant_id,
但 blake3 输出在租户间均匀混合,同一 lane 必然同时承载多租户用户,
因此不能靠 lane 做租户隔离。

因此写死三条策略:

  1. 超大租户(tenant_fanout_quota 占单分片 per_shard_entry_budget 的比例超过 tenant_shard_share_limit,数值见附录 B.5 与 B.7)必须启用档位 B 独立分片。这是硬性准入条件,不是建议。
  2. 档位 A 下,tenant_fanout_quota 必须小于等于 per_shard_entry_budget × tenant_shard_share_limit,由 FanoutCoordinator 在签约时校验,不允许超卖。
  3. §24 必须按 (tenant_id, lane_id) 维度暴露 lane_watermark_stall_ms;单 lane 停滞超过 lane_stall_alert(附录 B.5.1)告警,并在告警中标注贡献最大的 tenant_id,使噪声邻居可定位。

20.5.3 数据驻留与租户生命周期

数据驻留(region 绑定)
  租户签约时绑定 residency_region 集合。
  硬约束:user_bucket → MailboxShard 的映射不得跨出 residency_region;
          会话 Home Region 的选择与迁移(§19.2.5)不得跨出 residency_region;
          媒体对象与备份 bucket 同样受约束;
          跨境只允许传输路由元数据(tenant/shard/endpoint),不传输正文与邮箱条目。

租户生命周期
  创建   分配桶区间 → 写 ShardRegistry 映射 → 生成 TenantMasterKey(§21.3)
        → 建立配额对象(tenant_fanout_quota 等)
  停用   拒绝新连接与新发送,保留全部数据;已有连接收 KICKED{reason=admin}
        (停用是可逆的,不触发任何删除)
  退租   进入 tenant_deletion_grace = 30 d 的宽限期(附录 B.5.3),期内可恢复;
        宽限期结束后执行 §21.4 的级联删除清单,
        以销毁 TenantMasterKey 作为"逻辑不可读"的完成标志

20.5.4 私有化形态与 SaaS 的差异

能力 SaaS 私有化(档位 C)
跨租户共享分片 是 否
mailbox_retention_days 30 可下调至 7(附录 B.3)
多 region 容灾 默认提供 可关闭(单机房部署)
离线推送(APNs/FCM) 默认开启 可关闭(无外网出口时必须关闭,见 §16)
媒体对象存储 平台 S3 客户自有 MinIO / 对象存储
KMS 平台托管 可使用客户自有 KMS/HSM(此时平台无法解密任何数据)
内容审核 ModerationService 默认开启 可关闭或替换为客户自有服务
服务端全文检索(V2) 计划提供 取决于是否使用客户自有 KMS(§21.6)
遥测回传 开启 默认关闭

"可关闭的能力清单"必须作为部署形态的显式配置项落地,不允许以代码分支形式在两种形态间分叉。

20.6 日志与追踪的输出约束

禁止输出(任何级别、任何环境,包括调试构建):
  access_token / route_token / MailboxCursor.signature / 媒体签名 URL 的签名部分
  任何密钥材料:KMS 密钥、DEK 明文、E2EE 私钥与预共享密钥
  payload_or_ciphertext 的明文内容、preview_or_placeholder 的正文片段
  media_metadata.thumbnail 的二进制内容

允许输出(即 §24 的追踪字段白名单):
  trace_id / tenant_id / user_id / device_id / conversation_id
  message_id / event_id / dispatch_id
  mailbox_shard / lane_id / mailbox_seq / conversation_seq / connection_shard
  message_type / custom_type / 字节长度 / 错误码

约束的落地方式(否则只是口号):
  1. 上述敏感字段在类型层实现 Redact 包装,默认打印为 <redacted>,
     需要明文时必须显式调用 Reveal() 并触发审计;
  2. CI 增加 grep 断言,禁止对 payload / token / key 类型直接使用格式化打印;
  3. 日志采集侧再加一层正则脱敏作为兜底。
  三层同时存在,任何一层都不足以单独依赖。

注意:user_id 与 conversation_id 属于可关联到自然人的元数据,日志保留期受 §21.5 约束,不得无限期保存。


21. 数据保留、删除与合规

v1 完全没有本章。删除路径约束存储格式,不能后补——这是把它放进基线阶段的唯一理由。

21.1 保留分层

保留策略由 MessageRecord.retention_class(§7.1)与租户策略共同决定。

retention_class 热层(ScyllaDB) 冷归档(对象存储) 邮箱条目 删除方式
default hot_retention_days(附录 B.5.3) hot_retention_days 之后转 Parquet 归档,保留至租户策略上限 mailbox_retention_days(附录 B.3) 到期物理删除 + DEK 到期销毁
ephemeral_24h 24 h 不归档 24 h,与正文同步失效 到期物理删除 + 时间桶子密钥到期销毁(见下方"ephemeral 的加密擦除")
compliance_hold 不删除 归档到 WORM bucket 不删除 不参与任何自动删除,仅由 §21.5 的合规流程解除
tenant_custom 按租户配置 按租户配置 取 min(租户配置, mailbox_retention_days) 同 default

实现状态(2026-08-23):本表仍是发布目标。当前仅 ephemeral_24h 自动到期;default/tenant_custom 的策略映射、热层 TTL、Parquet 冷归档与 归档回读尚未落地,compliance_hold 保持无 TTL。未完成前不得对外承诺默认归档期限。

分层规则
  热层     ScyllaDB,按 (tenant_id, conversation_id, seq_bucket) 分区(§7.1),毫秒级点查与范围读
  冷归档   按 (tenant_id, conversation_id, seq_bucket) 打包为 Parquet 对象,
           seq_bucket 天然对齐分桶边界(宽度 message_seq_bucket_width,附录 B.2),
           归档单位与查询单位一致,不需要重新分块
  回读     PULL_HISTORY 命中归档区间时由 MessageStore 触发归档回读,
           延迟从毫秒级降到秒级;HISTORY_BATCH 的 earliest_available_conversation_seq
           必须如实反映"当前可回读的最早位置",不得把归档区间报成不存在

不变量:邮箱条目的保留窗口 <= 正文的保留窗口。
        邮箱条目指向已被删除的正文时,客户端展示"消息已过期"占位,不视为丢消息。

ephemeral 的加密擦除(时间桶子密钥):物理删除对 WORM 备份与 SSTable 历史版本不可达(§21.2),因此 ephemeral_24h 的失效承诺不能依赖物理删除,必须与 §21.2 的加密擦除基线一致:

EphemeralDEK = KDF(ConversationDEK, date_bucket)
    密文头 dek_id 置 key_scope = EPHEMERAL、key_version = date_bucket;
    密钥服务在 date_bucket 创建后 24 h 销毁该 EphemeralDEK 版本
    (销毁纳入 §21.3.3 的流程与 key_destroy_lag 计量),
    ConversationDEK 本身随会话保留,不受影响。

对外口径:ephemeral 的"不可读"承诺由子密钥销毁保证(含备份与归档中的密文);
    物理副本随备份保留期消失(与 §21.4 整体 SLA 同口径)。

21.2 删除的根本困难

必须诚实写出:在当前架构下,"把某条消息的每一份物理副本逐行删掉"是不可完成的。

一条消息正文在系统内的物理副本位置:

  1. MessageStore 主副本                          × mailbox_replicas(默认 3)
  2. MessageStore 的 SSTable 历史版本              compaction 完成前旧版本仍在盘上
  3. MailboxNode 检查点(对象存储,**不可变**)      × 保留期内的每一个检查点
  4. 备份全量快照 + 增量(对象存储,**WORM 锁定**)  锁定期 backup_retention_days(附录 B.5.3),
                                                  技术上无法删除,这是防勒索的设计目标
  5. 冷归档 Parquet                                不可变对象
  6. ConversationHead.preview_or_placeholder        正文片段,1 份
  7. UserSessionProjection                          不持久存正文片段(预览由 MailboxNode
                                                    读时从 ConversationHead 填充,§21.3.4),
                                                    无独立密文副本
  8. NotificationService 的离线推送 payload 缓存     已投递的推送无法召回
  9. 客户端本地库                                   服务端完全不可控

叠加"至少一次投递"(同一条目可能在多处存在重放痕迹)与"多副本 + 不可变对象存储检查点 + WORM 备份",逐行物理删除既不能保证完成,也无法证明已完成。

结论(基线级决策):
  删除承诺的语义是"删除后不可读",而不是"删除后每一份字节都已从介质上消失"。
  这一承诺**只能**由加密擦除(crypto-shredding)实现:销毁密钥,密文即刻不可解。

  加密擦除约束存储格式(哪些字段加密、密钥粒度、dek_id 放在哪里),
  一旦有存量数据以明文写入,就再也无法追溯性地擦除。
  因此它必须在基线阶段确定,不能等到 V2。

21.3 加密擦除方案

21.3.1 密钥层级

KMS 根密钥(region 级,HSM 保护,永不导出)
  └── TenantMasterKey (TMK)          每租户 1 个
        │  轮换 tenant_key_rotation_days = 365(附录 B.5.3)
        ├── ConversationDEK           每会话 1 个(下称"会话 DEK")
        │     保护:MessageRecord.payload_or_ciphertext、media_metadata.thumbnail、
        │           ConversationHead.preview_or_placeholder、
        │           归档 Parquet 中的正文列
        │     (UserSessionProjection 不持久存预览,无独立密文,§21.3.4)
        └── UserDEK                   每用户 1 个
              保护:离线推送 payload 缓存等用户级派生密文

正文片段的归属(与 §7.1 / §7.3 的一致性,必须写死):

1. MessageRecord.payload_or_ciphertext 是正文密文的唯一持久位置,
   其 dek_id 由 §7.1 的 MessageRecord.dek_id 字段承载。

2. UserMailboxEntry **不需要** dek_id:个人邮箱只存引用与投递属性、不含任何密文(§7.3)。
   MAILBOX_BATCH / PUSH_EVENTS 中的正文是 MailboxNode 在**读路径**从 LRU/MessageStore
   join 出来的(附录 A.4.1),join 出来的就是 MessageRecord 的密文与其 dek_id,
   邮箱层不复制、不重新加密、也不需要自己的密钥标识。
   **下发前的解密**:非 E2EE 会话由 MailboxNode 按 dek_id 解密后再编码下发;
   E2EE 会话原样透传密文(解密点与 DEK 缓存规则见 §21.3.4)。

3. ConversationHead.preview_or_placeholder **含正文片段**,必须用**会话 DEK** 加密
   (不是 UserDEK——同一会话的预览在所有收件人处必须随该会话的 DEK 一起失效),
   随密文存 dek_id,且**必须计入 §21.4 的级联删除清单**(第 8 行)。
   UserSessionProjection **不持久存预览**:SESSION_DELTA / SESSION_LIST_BATCH 中的
   preview 由 MailboxNode 下发前从 ConversationHead 读时填充并解密(§21.3.4),
   系统中不存在"每收件人一份"的预览密文。
密文格式
  算法   AES-256-GCM
  AAD    MessageRecord / ConversationHead 正文与预览:(tenant_id, conversation_id, conversation_seq)
         用户级派生密文(推送缓存):                (tenant_id, user_id, message_id)
         → 密文无法跨会话/跨用户搬运,即使攻击者拿到密文块也不能移植
  头部   dek_id = (key_scope, key_id, key_version)(§7.1),随密文同存

21.3.2 删除即销毁

删除对象 销毁的密钥 立即失效的数据 仍需物理删除的部分
单个用户 该用户 UserDEK 其推送缓存等用户级派生密文 UserMailboxEntry(无密文)、UserConversationState、UserSessionProjection(无密文,§21.3.4)、UserBadgeState、成员关系、MemberSlotMap 等元数据
单聊会话 该会话 ConversationDEK 全部正文、缩略图、归档,以及双方的会话预览(ConversationHead 与 UserSessionProjection) 会话元数据、双方邮箱条目
群会话(解散) 该群 ConversationDEK 全部正文与归档,以及全体成员的该会话预览 成员关系、各成员邮箱条目
整个租户 TenantMasterKey 该租户全部密文(含备份与归档) 元数据、桶映射、配额对象
关键取舍(必须写明,避免实现者误解):
  1. 群成员退租只销毁其 UserDEK,**不销毁群 ConversationDEK**——
     否则一个成员的删除请求会让整个群的历史对其他成员不可读。
     该成员的可见性由 left_at_conversation_seq、其邮箱分区的物理删除
     与其投影行的物理删除(§21.4 第 4、6 行)共同终结。
  2. 密钥轮换只影响新写入。旧 DEK 版本必须保留到其覆盖的数据全部过保留期,
     否则历史消息不可读。轮换 ≠ 删除。
  3. 加密擦除保护的是**静态数据不可解**。它不保护:
     已解密并落到客户端本地的内容、已经送达的离线推送、
     以及未加密的元数据(谁在什么时候与谁有过会话)。
     元数据必须由 §21.4 的清单尽力物理删除。

21.3.3 销毁流程与审计

1. 受理    删除请求进入删除工单(ticket_id),记录 subject_id、法律依据、发起人
2. 校验    检查是否存在 compliance_hold(§21.5);命中则拒绝并记录冲突
3. 冷却    tenant_deletion_grace(附录 B.5.3);用户级为 user_deletion_grace(附录 B.5.3)
           冷却期内数据逻辑不可见但可恢复
4. 复核    双人复核(four-eyes),TenantMasterKey 销毁必须由两名授权人各自确认
5. 销毁    KMS 硬销毁密钥版本,不可恢复
6. 审计    写 WORM 审计记录 {ticket_id, actor[], subject_id, dek_ids[], ts, 依据}
7. 清扫    触发 §21.4 的物理删除清单,尽力删除元数据与可删副本

时限目标
  key_destroy_lag(冷却期结束 → 密钥销毁完成) <= 24 h(附录 B.5.3)
  销毁审计记录必须可在 §21.5 的 DSAR 响应中直接引用

21.3.4 读路径解密与 DEK 缓存

§21.3 把正文与预览全部改为会话 DEK 静态加密后,非 E2EE 会话的客户端不持有任何 DEK,下发前必须由服务端解密。解密执行点与缓存规则写死如下,禁止各实现自行选点:

执行点(唯一,禁止旁路)
  写路径加密点   ConversationWriter:MessageRecord 正文/缩略图元数据密文、
                 ConversationHead.preview_or_placeholder 密文
  读路径解密点   MailboxNode:MAILBOX_BATCH / PUSH_EVENTS 的正文(读时 join 后解密)
                 MessageStore 读服务:PULL_HISTORY / HISTORY_BATCH 的正文,以及当前
                 qsession 读取会话摘要所需的服务端可见 preview
                 阶段二 mailbox-tail 目标形态:MailboxNode 在 SESSION_DELTA /
                 SESSION_LIST_BATCH 下发前读 ConversationHead 并填充 preview
  E2EE 会话      不适用:服务端不持密钥,密文原样透传(§22)
  SessionProjection / qsession **禁止持久化或缓存 DEK 明文**;当前实现只经
                 MessageStore 摘要接口读取允许返回的会话元数据/preview,阶段二才接收
                 MailboxNode 读时填充的投影帧。密钥不出 MailboxNode/MessageStore

DEK 缓存(解密点各自维护)
  形态       进程内 LRU,键 = (tenant_id, dek_id),值 = 解封后的 DEK 明文
  容量       按该节点活跃会话数上界配置,按实际字节计量
  TTL        dek_cache_ttl = 5 min(附录 B.5.3)
  主动失效   订阅密钥销毁事件(§21.3.3 步骤 5 产生),命中即刻剔除;
             "密钥销毁 → 全部节点缓存失效"的端到端时延是
             key_destroy_lag <= 24 h 的分解项,必须单独计量(§24)
  禁止       DEK 明文落盘、进检查点、进日志(§20)

投影预览的来源(写死,消除"压缩器无正文可加密"的矛盾):

UserSessionProjection **不持久存** preview_or_placeholder。
  投影压缩器只消费邮箱条目中的元数据、不读正文(§12.3.1、§22.3)——保持成立。
  阶段二 mailbox-tail 目标形态下,SESSION_DELTA / SESSION_LIST_BATCH 帧中的
  preview_or_placeholder 由 MailboxNode 在下发/返回前按 conversation_id 从
  ConversationHead 读时填充并解密;当前 qsession 则通过 canonical MessageStore
  摘要读接口取得等价字段,不接收 MailboxNode 持久投影行
  (ConversationHead 预览按 §12 的既有规则由 ConversationWriter 维护,
    撤回/编辑对预览的修复在 ConversationHead 侧一次完成,天然对全部收件人生效)。
  因此系统中"每收件人一份"的预览密文不存在,预览密文只有 ConversationHead 一份。

21.4 级联删除清单

删除一个用户或一个租户时必须按各行的「适用主体」覆盖以下全部位置。这份清单本身就是验收项(§26):漏掉任何一行都会造成残留。适用主体取值:用户删除 / 会话删除 / 租户删除。

# 位置 归属 适用主体 删除方式 目标时限
1 MessageRecord.payload_or_ciphertext / media_metadata MessageStore 会话删除、租户删除(租户删除随 TMK 销毁自动覆盖,无需逐会话销毁);用户删除见表下规则 加密擦除(按 MessageRecord.dek_id 定位,§7.1;销毁会话 DEK 仅限会话/租户删除)+ 到期物理删除 擦除 ≤ 7 d
2 MessageIndex MessageStore 会话删除、租户删除 物理删除 ≤ 7 d
3 ClientDedup MessageStore 全部主体 TTL 自然过期(7200 s,ADR-0008) ≤ 2 h
4 UserMailboxEntry MailboxStore(MailboxNode) 用户删除、租户删除 物理删除(分区级 range delete)(阶段一 ScyllaDB 上执行该删除前必须先重开 ADR-0001,见 §18.1.3 删除约束)。非内联条目不含密文(§7.3)、无 dek_id、不参与加密擦除;内联条目(ADR-0005)持有正文密文与 dek_id,必须与第 1 行同批加密擦除——会话/租户删除时销毁会话 DEK 即同时失效所有内联副本,无需逐条目定位 ≤ 24 h
5 UserConversationState ScyllaDB 用户删除、租户删除 物理删除(分区级) ≤ 7 d
6 UserSessionProjection MailboxStore(MailboxNode 权威副本) 用户删除、租户删除 物理删除(分区级) ≤ 7 d
7 UserBadgeState MailboxStore(MailboxNode 权威副本) 用户删除、租户删除 物理删除 ≤ 7 d
8 ConversationHead.preview_or_placeholder ScyllaDB 会话删除、租户删除(销毁会话 DEK);用户删除见表下规则 含正文片段,用会话 DEK 加密(§21.3.1):销毁会话 DEK + 改写为通用占位(不能只删消息不改头) ≤ 24 h
9 ~~UserSessionProjection.preview_or_placeholder~~ — —(对任何主体均无持久数据) 自 §21.3.4 起投影不持久存预览(SESSION_DELTA / SESSION_LIST_BATCH 的 preview 由 MailboxNode 读时从 ConversationHead 填充),本行无独立密文与删除动作;投影行整体随第 6 行物理删除 随第 6 行
10 对象存储媒体(原文件 + 缩略图) MediaService 全部主体(用户删除按 MediaOwnerIndex 枚举) 物理删除(依赖反向索引,见下) ≤ 7 d
11 MailboxNode 检查点(§19.3.1,责任方 MailboxNode) 对象存储 全部主体 不可逐行删除,靠加密擦除;旧检查点按保留期轮转消失 随保留期
12 冷归档 Parquet 对象存储 全部主体 同上 随保留期
13 备份(全量 + 增量) 独立 bucket,WORM 全部主体 不可删除,只能靠加密擦除 backup_retention_days(附录 B.5.3)后随锁定期到期消失
14 搜索索引(V2) 见 §21.6 全部主体 物理删除 + 索引词条随 DEK 失效 ≤ 7 d
15 日志与追踪 日志平台 全部主体 按 observability_log_retention_days = 30 d(附录 B.5.3;注意与附录 B.3 的分发日志保留期 log_retention_days 是两个不同参数,不得混用)到期删除;命中删除请求的 user_id 需主动清理 ≤ 30 d
16 离线推送 payload 缓存 NotificationService 用户删除、租户删除 立即物理删除 + device_token 解绑 ≤ 1 h
17 PresenceEntry PresenceDirectory 用户删除、租户删除 发布 tombstone,compacted topic 自然收敛 ≤ 1 min
18 MessageReaction(明细) MessageStore(§7.15) 全部主体 物理删除(用户删除按 user_id 聚簇键定位;会话/租户删除按分区级) ≤ 7 d
19 MessageReactionSummary(聚合) MessageStore(§7.15) 会话删除、租户删除 物理删除(分区级,与第 1 行同分区)。不含正文、无 dek_id,不参与加密擦除;用户删除时只需按第 18 行扣减对应计数,不删聚合行 ≤ 7 d
18 MemberSlotMap / 成员 Bitmap GroupMembership 用户删除、租户删除 置 released_at 并从当前版本 Bitmap 清位;slot_id 永不复用 ≤ 7 d

用户删除主体的明确规则(防止字面执行清单销毁他人可读的密钥):

用户删除不销毁任何 ConversationDEK(§21.3.2 取舍 1——否则一个成员的删除请求
    会让其所在全部会话的历史对其他成员不可读)。
其正文可见性由 UserDEK 销毁 + 第 4、6 行的分区物理删除终结。
第 8 行仅当 ConversationHead.last_sender_id = 被删用户且租户策略要求匿名化时
    执行"改写为通用占位",不销毁会话 DEK。
第 9 行对任何主体均无持久数据(§21.3.4),用户删除无需处理。

object -> owner 反向索引的必要性(v1 完全没有,会造成永久孤儿):

问题:media_metadata.object_id 是**单向引用**——从消息指向对象。
      给定一个 user_id,无法枚举"该用户上传过哪些对象";
      给定一个 object_id,无法判断"还有没有消息在引用它"(转发会共享同一对象)。
      结果:用户删除后,其上传的对象成为不可发现的孤儿,永久残留在对象存储中,
            既违反删除承诺,也持续产生成本。

因此 MediaService 必须维护两个反向索引(属于 MediaService 内部结构,不进入 §7):
  MediaOwnerIndex   (tenant_id, owner_user_id) -> object_id[]
  MediaRefIndex     object_id -> { tenant_id, ref_count, last_ref_at }

删除规则:
  删除用户 → 遍历 MediaOwnerIndex → 对每个 object_id 递减 ref_count
           → ref_count 归零且超过 media_orphan_grace = 24 h(附录 B.5)后物理删除
  转发消息 → 递增 ref_count,不复制对象
  对账     → 每日全量扫描对象存储与 MediaRefIndex,孤儿对象计数进入 §24

整体 SLA:

删除请求受理 → 用户侧逻辑不可见       <= 24 h
            → 加密擦除完成(不可解)  <= 7 d
            → 元数据物理清除完成      <= 7 d
            → 备份与归档中的密文消失   <= backup_retention_days(附录 B.5.3)+ 归档周期

对外承诺应使用"≤ 7 天内不可读、副本在 backup_retention_days 内随保留期消失",不要承诺"立即彻底删除"——那是做不到的。

21.5 合规能力

21.5.1 数据主体权利(DSAR)

权利 实现路径 SLA
访问 导出该用户的元数据与其可见会话的消息(受 visible(u, c) 约束) dsar_response_days(附录 B.5.3)
携带 / 导出 见 §21.6,V2 能力;基线阶段提供后台异步导出 同 dsar_response_days
更正 仅适用于账号资料,不适用于已发送消息(消息只能撤回/删除) 同 dsar_response_days
删除 §21.3 + §21.4,冷却期 user_deletion_grace(附录 B.5.3) 冷却期后 ≤ 7 d
限制处理 账号停用(保留数据,停止投递与风控画像) 立即

DSAR 的所有操作本身必须写入审计。

21.5.2 审计留存

审计对象   管理员操作、封禁与吊销、密钥销毁、删除工单、compliance_hold 变更、
          跨境访问、DSAR 处理、限流与配额的人工调整
存储      独立 WORM bucket,开启对象锁
保留      audit_retention_years = 5 年(附录 B.5.3),法务可按辖区延长
不变量    审计记录**不参与加密擦除**(销毁 TMK 不影响审计),
          但审计记录本身不得包含消息正文,只记录标识与操作

21.5.3 法务保留(compliance_hold)

优先级:compliance_hold > 任何自动删除与用户删除请求

生效范围可以是 (tenant) / (conversation) / (user) / (时间区间) 四种粒度。
命中 compliance_hold 的 MessageRecord.retention_class 被置为 compliance_hold(§7.1),
从此不参与 TTL、不参与归档过期、其 ConversationDEK 不得销毁。

冲突处理(必须显式,不能静默):
  用户提交删除请求 → 命中 compliance_hold →
    1. 拒绝执行擦除,返回"因法律保留义务暂缓"
    2. 写审计记录,标注 hold_id 与法律依据
    3. hold 解除后自动重新入队执行删除
禁止"表面接受删除请求、实际不删且不告知"。

21.5.4 跨境传输

默认    数据不出 residency_region(§20.5.3)
例外    仅允许传输路由元数据(tenant_id / shard 编号 / endpoint),不含正文与邮箱条目
运维    跨境的运维访问必须经跳板 + 审计 + 双人复核,且不得导出数据
高级档  客户自有 KMS(档位 C)时,平台侧即使物理持有密文也无解密能力,
        这是最强的跨境合规论证,应作为私有化的默认推荐配置

21.6 消息搜索与导出的边界

明确声明:服务端全文检索与数据导出为 **V2 能力**,本基线不实现。

但它们会反向影响存储模型,因此现在就必须预留与确认:

V2 能力 对基线的反向约束 基线阶段的动作
数据导出 需要按 (tenant, conversation) 顺序批量扫描全部历史 MessageRecord 的 seq_bucket 分桶天然对导出友好:桶宽固定 4096、无空洞、按 conversation_seq 有序,导出单位 = 归档单位 = 桶。基线阶段保持桶宽建表后不可变,不得引入按时间的动态分桶
服务端全文检索 需要倒排索引;索引词条本身就泄露正文 三选一,V2 设计前必须拍板:(a) 索引与正文同 DEK 加密,检索在受信执行环境内解密(性能代价高);(b) 可搜索加密 / 盲索引(功能受限,只支持精确词匹配);(c) 仅对非 E2EE 且未启用客户自有 KMS 的租户提供明文索引。无论哪种,索引必须随 DEK 销毁而失效,否则加密擦除被绕过
检索索引的删除 加密擦除对倒排索引不自动生效(词条是从正文派生的独立数据) 基线阶段写死:任何从正文派生的衍生结构(索引、摘要、embedding)都必须登记进 §21.4 的级联删除清单,新增此类结构时必须同步更新该清单,由代码评审强制
会话级导出(客户端参与) E2EE 会话服务端无明文 见 §22.4:E2EE 会话的导出必须由客户端解密后产出,服务端只提供密文与顺序保证

22. E2EE 能力边界与降级矩阵

v1 只有一句"E2EE 模式下服务端只保存密文"。本章给出适用范围、密钥体系概要与精确的降级边界。 本章的核心判断是:E2EE 的影响面被普遍夸大,必须逐项划清,避免为不存在的冲突做过度设计。

22.1 适用范围

会话类型 是否支持 E2EE 工程理由
单聊 支持(租户可默认开启) 2 个参与方,成员集合稳定,rekey 成本为常数
小群(成员数 ≤ e2ee_max_members) 支持 成员变更时 rekey 成本 O(N × D),N ≤ 1000 时可接受
大群(成员数 > e2ee_max_members) 不支持 见下方成本推导
聊天室 不支持 成员集合高频变化且无稳定成员表(§14 不维护全员持久成员关系),rekey 触发频率接近消息频率
系统会话 / 机器人会话 不支持 服务端与机器人必须读明文才能履行职责
e2ee_max_members = 1000(附录 B.6.1)

为什么大群 E2EE 不成立(量化):

设群成员 N = 100000,人均设备数 D = 2,即 200000 个接收端点。

1. 成员变更必须 rekey(前向保密/后向保密的基本要求):
   一次成员变更 → 新群密钥需向 200000 个端点分发,每个端点用其会话密钥单独加密
   → 一次成员变更的成本 ≈ 一条大群消息的完整 fanout + 20 万次非对称/对称密钥运算

2. 大群的成员变更频率并不低:
   即便按 membership_version_merge_window(附录 B.2)合并,
   活跃大群仍可能每 30 s 触发一次 rekey
   → 稳态下等价于额外增加 2 msg/min 的全量 fanout,且是 CPU 密集型的

3. §10.3 的核心优化被完全抵消:
   "公共正文只编码一次、ConnectionNode 只做轻量个性化帧头"依赖于**正文对所有接收者相同**。
   E2EE 下 sender key 虽可让正文密文共享,但密钥分发消息必须逐设备加密,
   且新成员加入后的每一次 rekey 都要重走逐设备路径。

结论:不是"难以实现",是**成本模型与本设计的 O(1) 正文承诺直接冲突**。
      因此在基线中明确不支持,而不是留一个永远无法开启的开关。

边界处理:群成员数增长到超过 e2ee_max_members 时,禁止自动降级为非 E2EE(会造成静默的安全等级下降)。正确行为是拒绝继续加人,返回 ERROR{code=PERMISSION_DENIED, detail=e2ee_member_limit},由群主显式选择"关闭 E2EE"或"停止扩员"。关闭 E2EE 必须向全体成员产生一条可见的系统消息。

22.2 密钥体系概要

身份层
  IdentityKey        每设备一对长期非对称密钥,私钥永不离开设备
  设备列表           每用户的设备公钥列表,由用户级签名密钥签名
                     新设备加入需已有设备确认,或走账号级密钥恢复流程

预共享层(支撑离线建会话)
  SignedPreKey       中期密钥,由 IdentityKey 签名,定期轮换
                     e2ee_signed_prekey_rotation_days = 7(附录 B.6.1)
  OneTimePreKey      一次性预共享公钥,服务端只存公钥、只发放一次
                     耗尽时降级使用 SignedPreKey(安全性略降,必须可观测)
                     客户端在剩余量低于 e2ee_prekey_low_watermark = 20(附录 B.6.1)时补充
  服务端存储结构 = DevicePreKeyBundle(§7.14);
                     上传与取用走 PREKEY_PUBLISH / PREKEY_FETCH(附录 A.3),
                     OneTimePreKey 耗尽由 ERROR{code=PREKEY_EXHAUSTED} 显式暴露(附录 A.6)

会话层(存储模型写死:单份正文密文 + 逐设备密钥分发,
        与 §21.3.1 "payload_or_ciphertext 是正文密文的唯一持久位置"及 §10.3 的
        O(1) 正文承诺一致)
  单聊与小群统一采用 sender key 式结构:
      正文密文**全端一份**,存 MessageRecord.payload_or_ciphertext;
      发送链密钥(sender key)经 pairwise 双棘轮会话**逐设备加密分发**。
  双棘轮(对称棘轮 + DH 棘轮)只用于加密逐设备的密钥分发消息
      (sender key 下发与成员变更 rekey),提供前向保密与后向恢复;
      正文本身不做逐设备加密,不存在多份按端点各异的正文密文。
  成员变更时全组 rekey(这正是 22.1 中大群不成立的成本来源)。

密钥分发消息的存储与投递(复用既有邮箱链路,不新增同步通道):
  密钥分发消息物化为 event_type=CONTROL 的 E2EE_KEY_DIST 邮箱事件,
      counts_unread=false、affects_session_order=false;
      物化时按目标 device_id 过滤,仅投递给目标设备,其余设备不可见。

多设备
  每设备独立参与 pairwise 棘轮,不共享私钥。
  发送方通过 E2EE_KEY_DIST 向自己的其他设备分发同一条 sender key,
  保证自发消息多端可见(正文密文全端一致,多端同步不需要逐设备正文副本)。

新设备的历史可见性(明确取舍,不留歧义):

默认:新设备**只能解密加入之后**的消息。加入前的历史在该设备上不可读,
      UI 必须显式展示"此设备加入前的消息已加密,不可读",
      不得表现为"消息丢失"或空白。

可选:由已有在线设备做端到端历史转移(受设备在线时长、电量与容量限制),
      转移范围与进度由用户控制,服务端只中转密文、不参与解密。

服务端职责边界:只存公钥与密文,永不持有私钥。
                启用客户自有 KMS 的私有化租户(§20.5.4)叠加 E2EE 后,
                平台在任何情况下都无解密能力——这一点必须在合同中与法务保留义务一并确认(§22.4)。

22.3 能力降级矩阵

这是本章最重要的一张表。左栏是真正受影响的能力,右栏是不受影响的能力——后者同样必须写明,否则实现方会为不存在的冲突做过度设计。

能力 是否受影响 具体行为
服务端消息预览 受影响 ConversationHead.preview_or_placeholder 一律写通用占位(如"[加密消息]"),此时不含正文片段、无需会话 DEK 加密(对照 §21.3.1 的非 E2EE 路径);SESSION_DELTA / SESSION_LIST_BATCH 的 preview 读时填充自该占位(§21.3.4,投影不持久存预览)。真实预览由客户端解密后本地生成并只存本地
离线推送内容 受影响 只推占位文案(§16)。aps.alert 不含正文;iOS 用 mutable-content 由客户端解密后本地改写通知
服务端全文搜索 受影响 不可用。E2EE 会话一律排除在 V2 服务端检索之外,只能做客户端本地搜索(§21.6)
服务端内容审核 受影响 跳过 §20.4.2 的挂载点 1。降级为:客户端举报(举报时由举报方上传明文与出处证明)+ 挂载点 2 的元数据频率风控
合规导出 受影响 服务端只能导出密文。明文导出必须由客户端参与解密(§21.6、§22.4)
新设备历史 受影响 加入前的历史不可解密,或走设备间历史转移(§22.2)
@提及的服务端识别 受影响(有解) MessageRecord.mention_targets(§7.1)在 E2EE 会话中保持明文。这是"元数据泄露"与"@功能可用"的显式取舍,必须在产品文档中披露
未读计数与排序标志 不受影响 counts_unread / affects_session_order 是 message_type / custom_type 的类型级契约(§13.3),由发送方在帧字段中声明、服务端按类型表判定,不需要解析明文
邮箱物化与同步 不受影响 前提:正文密文全端一致(§22.2 的单份正文密文模型)。UserMailboxEntry 只存引用与投递属性(§7.3),正文密文留在 MessageStore;物化路径完全不触碰正文。MAILBOX_BATCH / PUSH_EVENTS 中的正文是 MailboxNode 在读路径 join 出来的(附录 A.4.1),join 出来的就是 MessageRecord.payload_or_ciphertext 密文,服务端只做搬运与批内去重编码,不解密;body_included=false 时客户端走 PULL_HISTORY 补取,同样只拿到密文
会话列表投影与排序 不受影响 排序键是 last_activity_id / pin_rank / conversation_id(§6.9.3),全部为服务端生成的元数据;当前 qsession 消费 dispatch 元数据并从 canonical MessageStore 读取摘要,阶段二 MailboxNode 压缩器也只消费邮箱元数据,二者都不依赖理解正文(§12、§17.2)
投递可靠性 不受影响 mailbox_seq、lane 水位、dispatch_id 幂等、至少一次语义全部工作在引用层,与载荷是否加密无关
角标数字 不受影响 UserBadgeState(§7.7)聚合的是 unread_count / mention_count,二者均由类型级契约与 mention_targets 明文得出
合规删除 不受影响 §21.3 的加密擦除本来就是"销毁密钥"。E2EE 只是把密钥持有方从平台 KMS 换成端侧,删除语义不变,且强度更高
撤回与编辑 不受影响 撤回/编辑靠 target_conversation_seq 定位(§7.3、附录 A.3),服务端不需要读正文
限流与配额 不受影响 §20.4 的全部维度基于计数与字节数,不基于内容
多设备同步与已读 不受影响 已读同步是控制事件,携带 read_conversation_seq(元数据)
划界原则(写死,供后续文档引用):
  只有"服务端需要理解消息内容"的能力才受 E2EE 影响。
  所有基于**元数据与类型级契约**的能力都不受影响。
  实现方不得以"我们要支持 E2EE"为由,把未读、排序、投递、角标改成客户端计算。

22.4 与 §16、§20、§21 的衔接规则

22.4.1 与 §16 离线推送

1. 推送内容:E2EE 会话一律只推占位。占位文案按租户配置,默认 "你收到一条新消息"。
2. 角标数字:仍由 UserBadgeState 提供绝对值(§7.7),**不受 E2EE 影响**。
3. iOS:使用 mutable-content=1,Notification Service Extension 取本地密钥解密后改写标题与正文;
   解密失败(如密钥尚未同步)时保留占位,不得展示报错。
4. Android/FCM:data-only 消息,由客户端解密后本地构建通知。
5. 静音与免打扰的判定基于 UserConversationState(元数据),不受影响。
6. 推送 payload 缓存中禁止出现明文与密钥;缓存条目按 §21.4 第 16 行清理。

22.4.2 与 §20 风控

1. 挂载点 1(发送前内容检查)对 E2EE 会话**直接跳过**,不占用 moderation_sync_timeout_ms。
2. 挂载点 2(频率与行为异常)全量保留:发送速率、跨会话广播模式、被举报率、
   加群速率、被拉黑率均为元数据信号,E2EE 下完全可用。
3. 挂载点 3(新账号限制)全量保留。
4. 举报通道:客户端举报时上传明文片段 + 该消息的 message_id / conversation_seq +
   发送方设备签名,使 ModerationService 可以验证明文确由该发送方产生
   (否则举报可被伪造)。举报材料按 compliance_hold 规则单独留存。
5. 租户开通 E2EE 前必须在合同层确认:平台无法对该租户会话做主动内容治理。

22.4.3 与 §21 合规

1. 删除:语义不变,仍是加密擦除。E2EE 会话的服务端密钥(若采用平台托管的封装层)随之销毁,
   端侧密钥由客户端在收到删除指令后本地销毁。
2. 导出:服务端只能提供密文与顺序保证(conversation_seq / seq_bucket,§21.6);
   明文导出必须由客户端参与,导出流程需用户在设备上显式授权。
3. compliance_hold:**对 E2EE 只能保留密文**。
   若平台不持有密钥(尤其是客户自有 KMS + E2EE 的组合),法务保留在证据可读性上是无效的。
   这一点必须在开通 E2EE 时以合同条款显式确认,不得由实现层"尽力而为"。
4. 审计:E2EE 会话的审计记录只包含元数据(谁在何时向哪个会话发了多少字节),
   不包含也不可能包含正文。
5. 数据驻留:E2EE 不改变 §20.5.3 的驻留约束——密文同样受 residency_region 约束。

23. 投递模型与 Akka 对照

23.0 本章的作用与边界

服务端实现语言在 Rust 或 Go 中选择(不会是 Java/JVM),本文不引入 Akka、不引入 JVM 技术栈。本章的唯一目的是:用 Akka Cluster / Akka Persistence / Akka Reliable Delivery 这套已在大规模生产中被验证的模型,逐项校验本设计的 正确性、命名与边界,并把本设计有意偏离之处及其代价写在明面上,避免后续文档作者反复重开议题。

阅读方式:每一节的结构固定为「Akka 概念 → 本设计对应物 → 差异与理由」。凡是判定为"有意偏离"的, 都必须能说出偏离换来了什么、付出了什么。说不出代价的偏离一律视为设计缺陷。

本章不改变任何契约。所有序列、字段、帧、参数以 §6、§7、附录 A、附录 B 为准。

语言及各组件客户端库选型(分发日志幂等生产与 durable ACK、ScyllaDB 驱动、 ShardRegistry 租约客户端、RoaringBitmap 实现、异步运行时等)由 ADR-0006 决定(已落地):现行选择为 Rust + Redpanda;改变它须新开 ADR 并按迁移处理。 本文机制仍应优先依赖 Rust/Go 两个生态都具备的原语(单线程串行化、租约、幂等生产 + acks=all、source checkpoint、WriteBatch、前缀扫描),避免无必要地绑定单一生态特性; 库级候选清单放入该 ADR,不进入本文。

23.1 Actor / Entity 与单写者

Akka 本设计
Actor 单线程处理自身邮箱消息,天然串行化,无需锁 两类实体,各自单写
EntityTypeKey + entityId 唯一定位一个实体 (tenant_id, conversation_id) 与 (tenant_id, user_id)

本设计的两类实体:

会话实体 (tenant_id, conversation_id)
  单写者 = 该会话 Home Region 的 ConversationWriter
  串行化产物 = conversation_seq 与 last_activity_id 在同一临界区内分配(§6.3、§6.4)

邮箱实体 (tenant_id, user_id)
  单写者 = 持有该 MailboxShard 租约的 MailboxNode
  串行化产物 = UserMailboxEntry 的写入次序、materialized_watermark 的推进(§9.2)

这正是 §19.1「单会话顺序:Home Region 单写」的本质:conversation_seq 是实体内部状态的自增计数器, 不是分布式共识的产物。因此本设计全程不需要分布式锁、不需要每条消息一次 LWT/Paxos (ConversationHead 正常路径用 blind write,见 §7.4)。

差异与理由:

  • Akka 的实体是进程内对象,生命周期由 ShardRegion 管理;本设计的实体是分片内的键前缀, 生命周期由租约管理。粒度更粗,代价是单个实体不能独立迁移,收益是千万级实体不产生千万个对象。
  • Akka 用 Actor 邮箱做串行化;本设计的会话实体用租约 + 单写串行化,邮箱实体用 分发日志的分区顺序串行化。后者更强:日志顺序是持久的,实体重启后可重放;Actor 邮箱是易失的。

23.2 Cluster Sharding

Akka 本设计 对应章节
numberOfShards,集群建立后不可更改 virtual_bucket_count = 65536,建集群后不可变 §5.2、附录 B.1
ShardRegion(每节点一个,路由并宿主实体) MailboxNode / ConnectionNode §5.1
ShardCoordinator(Cluster Singleton + Lease) ShardRegistry(租约 + fencing_epoch) §5.1、§19.2
shard rebalancing / handoff 分片分裂与迁移 §5.5
handoff 期间该 shard 的消息被缓冲、暂停投递 双写窗口 + shard_epoch 递增 §5.5、§6.5
Sharding 的 use-lease:每个 Shard 启动实体前先取 per-shard lease,防同一 shard 在两节点双活(防 coordinator 双活的是 Singleton 的 lease,见上一行 ShardCoordinator 对照) 租约 + 失效等待 > 租约时长 §19.2

必须记住的 Akka 教训:numberOfShards 是 Akka Cluster Sharding 最容易选错、且在线无法更改的参数。 选小则分片粒度粗、节点间负载不均衡且无法再细分;选大则 coordinator 状态、rebalance 计算与 shard 启停开销显著上升。官方经验值是约为集群最大节点数的 10 倍。选错的唯一出路是全集群停机重建。

本设计对这条教训的回应不是"把数字选大一点",而是把 Akka 的一层拆成三层:

Akka:       numberOfShards          既是路由粒度,也是负载粒度,且不可变
本设计:
  第 1 层  virtual_bucket_count = 65536   路由粒度,不可变,只决定"用户属于哪个桶"
  第 2 层  mailbox_shard_count   = 256    负载粒度,可变,桶→分片映射版本化(§5.2、§5.5)
  第 3 层  lane_count            = 64     可见性粒度,建集群后不可变(§6.5.1、附录 B.1),只影响水位向量
  • 不可变的两层中承担路由的那一层被推到 65536,且不承担任何负载语义,因此选大不产生协调开销: 桶→分片映射是一张版本化的表,不是 65536 个活跃实体。
  • 可变的那一层(逻辑分片)通过 §5.5 的分裂在线扩容,且 shard_epoch 保证游标可换算。
  • 余量是被量化过的,不是拍脑袋:极限档上界 1024 由 min(virtual_bucket_count, connection_shard_count) 决定 (§5.2 推论 1),与 lane_count 无乘积关系;65536 的余量体现在桶→分片映射粒度上, 桶数选大不产生协调开销。

差异与理由:

  • Akka 的 handoff 期间消息进入 coordinator/region 的内存缓冲,缓冲满则丢弃或失败。 本设计不做内存缓冲:迁移窗口内双写新旧分片,客户端以 shard_epoch 判定归属, 越界时收到 SHARD_MOVED 并按 REDIRECT 重连(附录 A.6)。代价是迁移窗口内写放大一倍, 收益是迁移期不存在"缓冲溢出即丢消息"的路径。
  • Akka 的 shard 分配是动态的(coordinator 按策略随时挪动 shard)。本设计的桶→分片映射是 静态且版本化的,只有显式运维动作才改变。收益是本地缓存命中率与路由确定性(§2.2 已接受由此 带来的一定负载不均衡)。

23.3 Passivation 与 remember-entities

Akka 本设计
实体空闲超时自动 passivate(停止实体,释放内存) MailboxNode 的用户状态与缓存按 LRU/TinyLFU 淘汰(§18.3)
remember-entities=on 时 rebalance 后自动重建实体 冷用户投影按需一次分区读载入(§7.6,≤ 5000 行)
passivation 策略按实体个数计(active-entity-limit) 淘汰按字节加权(§18.3)

关键差异:淘汰的计量单位。 Akka 的 passivation 策略以实体数量为限,这在实体大小同质时是合理的。 IM 的实体大小相差数个数量级:

一个普通用户的 UserSessionProjection      ~ 数十 KiB
一个 10 万成员群的分片成员 RoaringBitmap   ~ 数十 KiB ~ 数百 KiB(且求交时需完整驻留)
一条大群公共正文的编码缓冲                  ~ 单条上限 max_frame_bytes(4 MiB,附录 B.5)

按个数淘汰会让"少量巨型对象撑爆内存"或"大量小对象被无谓驱逐"两种失败同时存在。因此 §18.3 明确: 按实际字节计容量,计入对象头、索引、编码缓冲与碎片,禁止写死"某 GiB 一定能缓存固定条数"。

另一处差异:Akka 的 remember-entities 之所以昂贵,是因为它要在 rebalance 后主动重建全部实体。 本设计不重建任何东西——邮箱实体的权威状态在 MailboxStore 中,节点内存只有缓存;接管后按需惰性载入, 接管时间只受检查点重放约束(§19.3),与用户数无关。

23.4 Event Sourcing:本设计与 Akka 最重要的一处有意偏离

Akka Persistence 本设计 对应章节
journal(事件日志) MailboxShard 分发日志 §6.5
snapshot 检查点 §19.3
recovery = snapshot + replay 加载最近检查点 + 重放分发日志 §19.3
persistenceId (mailbox_shard, lane) §6.5.1
sequenceNr(per-persistenceId,每实体一条独立日志与独立序号) per-shard 日志 offset(mailbox_seq) §6.5

这是全文最重要的一处有意偏离,必须讲清楚它换来了什么、付出了什么。

照搬 Akka 模型的后果:

若 persistenceId = (tenant_id, user_id),即每个用户一条独立 journal:

  一条 10 万成员群消息
    = 10 万次独立 append(10 万个不同 persistenceId)
    + 10 万个序号分配器各自做一次持久化自增
    + 10 万次 journal 写放大(每条都要写自己的键、索引、WAL)

  10 万人群按 2 msg/s(附录 B.5 的 per_conversation_msg_rate 上限)发送
    = 20 万 append/s,仅这一个群就吃掉一个中等集群

本设计的替代方案:

persistenceId = (mailbox_shard, lane)          日志实体数 = 256 × 64 = 16384,与用户数无关
mailbox_seq   = 复合序号 (shard_epoch, log_offset)  序号由日志分区天然产生,零竞争、零额外持久化
                编码与位宽以 §6.5 为准,本章不重述
个人队列      = 以 (tenant_id, user_id) 为分区键的前缀索引(§7.3)

一条 10 万成员群消息
  = S 条日志记录(S = 目标 MailboxShard 数,§25.0 目标档实测口径下 S_avg ≈ 23,大群 ≈ 256)
  + 各 MailboxNode 本地 WriteBatch 展开出的 N 条前缀索引(不产生跨服务 RPC,§10.3)

换来的:序号分配从 O(N) 次竞争降为 O(S) 次日志追加;"个人精准队列"的查询效果完全保留 (PULL_MAILBOX 只扫用户自己的前缀,§6.5 示例)。

付出的代价,必须写清楚:

1. 个人队列稀疏。用户 A 的 mailbox_seq 是 10008 / 10217 / 10491,差值无意义。
   → 因此 §6.10 必须明令禁止"用 mailbox_seq 差值判丢消息"。
   → 因此 §6.10.1 必须禁止把个人 last_applied_mailbox_seq 与分片水位比较
     (否则千万在线每心跳一次空拉,见 §24.1 的 heartbeat 指标组)。
2. 序号绑定分片。用户逻辑归属变更后 mailbox_seq 不可数值换算。
   → 因此 §5.5 的游标迁移是"按 EpochBoundary 换发签名令牌",不是数值映射。
   → 因此 §6.5 的 mailbox_seq 必须是复合序号,否则 epoch 变更后会静默回退。
3. 可见性耦合。一条日志记录的展开未完成,会挡住同分片后续记录的水位推进。
   → 因此需要 §6.5.1 的 lane(Akka 中不存在的第三层,见 23.11)。
4. 无法按单个用户重放。Akka 可以只重放一个 persistenceId;本设计重放的最小单位是
   (shard, lane)。这是接受的:邮箱数据的重建单位本来就是分片,不是用户。

一句话:本设计用"日志粒度粗化 + 索引粒度细化"替换了 Akka 的"日志粒度 = 实体粒度"。 这是为 10 万成员群做出的核心取舍,不是实现偷懒。

另外两处必须与 Akka 对齐的约束(否则重放不可用):

  • 重放必须确定性。主备 MailboxNode 消费同一日志必须物化出逐字节相同的索引,因此 event_id 是确定性哈希、created_at 取自 GroupDispatch.committed_at(§6.7、§7.8), 禁止随机 UUID 与本地墙钟。Akka 的 event handler 同样要求纯函数,理由完全一致。
  • 快照与日志的保留关系。log_retention_days(数值见附录 B.3)必须满足 §19.3.3 的下界不等式, 与 Akka"snapshot 之后的 journal 不可被裁剪"是同一条约束。该不等式的唯一规范在 §19.3.3,本节只引用。

23.5 Reliable Delivery(ProducerController / ConsumerController)

这是与本文投递链路同构度最高的部分。Akka 2.6 的 Reliable Delivery 解决的问题与 §9~§11 完全一致: 在不可靠网络与可重启进程之间,做到不丢、不乱序、有流控。逐项对照:

ProducerController 的 seqNr                  ↔ mailbox_seq(§6.5)
ConsumerController 的 confirmation            ↔ PULL_MAILBOX.acked_seq(附录 A.3,已合并 v1 的独立 ACK 帧)
demand-based 窗口与流控                       ↔ pull_mailbox_window + §11.3 双水位
重发与消费侧按 seqNr 去重                     ↔ 至少一次 + message_id/event_id 幂等(§3)
chunked messages(大消息自动分片)             ↔ MAILBOX_BATCH 的 max_bytes 软上限
                                                + 事件组不可切分(§9.3、§6.7)
                                                + 降级路径:单批正文总量超过 max_frame_bytes 时
                                                  条目回退为 body_included=false,客户端按
                                                  PULL_HISTORY 补取正文(附录 A.4.1),不得视为丢消息
durable producer(EventSourcedProducerQueue) ↔ 先可靠物化邮箱引用,再推送(§11.1)
ShardingProducerController                    ↔ FanoutCoordinator 按 MailboxShard 合并 dispatch(§10.1)
ShardingConsumerController                    ↔ MailboxNode 按 lane 展开子任务(§6.5.1、§10.1)

三处必须点明的一致立场:

  1. Akka 明确不承诺 exactly-once:它承诺的是 at-least-once + 消费侧按 seqNr 去重, 在此之上才能谈"效果上不重复处理"。本文 §3 的立场完全相同,禁止在任何文档中宣称 exactly-once。
  2. 确认是消费者驱动的:Akka 的窗口由 ConsumerController 的 confirmation 打开。 本文把确认合并进 PULL_MAILBOX.acked_seq(附录 A.3),省掉一个 RTT,语义不变。
  3. "已发送"不等于"已确认":Akka 的 ProducerController 在收到 confirmation 前保留未确认消息。 本文对应的是 §6.8 的不变量——设备游标只能由 MAILBOX_BATCH 连续推进, 实时 PUSH_EVENTS 不得越位推进游标;以及 §11.2 的六级到达层级 (COMMITTED / MAILBOXED / PUSHED / APPLIED / READ / NOTIFIED),禁止把 SEND_ACK 说成"对方已收到"。

一处有意差异:登录阶段推 vs 拉。

Akka:      ConsumerController 全程"服务端推 + 消费者确认",窗口由 demand 控制。
本设计:    登录阶段改为客户端主动拉(§9.3),在线阶段回到推(§11.1)。
            两种模式由 sync_to_seq 屏障切分(§9.4)。

理由(这是被移动端反复验证过的):

  • 移动端登录时可能有上万条积压。推模式下,服务端的发送速率由"窗口 + 网络"决定, 而客户端的消费速率由"解析 + 落本地库 + 建索引"决定,后者常慢一个数量级。
  • 结果是数据全部堆在服务端发送缓冲与内核发送队列里。千万连接同时登录时, 这部分内存是 连接数 × 窗口字节,会在客户端解析完成前耗尽服务端发送缓冲, 进而触发 §11.3 的硬水位断连,形成"越断越重连、越重连越积压"的正反馈。
  • 拉模式把速率控制权交给真正的瓶颈方:客户端处理完一批才发下一个 PULL_MAILBOX (最多 pull_mailbox_window = 4 个在途,附录 B.3)。服务端内存占用变成 连接数 × 在途批次,且可被 max_items/max_bytes 硬性封顶。

在线阶段之所以能安全回到推模式,是因为稳态速率远低于积压速率,且 §11.3 的双水位 + MAILBOX_DIRTY 兜底已经给出了溢出时的确定性降级路径(不丢持久消息,只丢在途正文)。

23.6 Projection(Akka Projection)

Akka Projection 本设计 对应章节
read-side projection UserSessionProjection §7.6
offset store projection_mailbox_seq §7.6、§12.3
at-least-once projection 要求 handler 幂等 SESSION_DELTA 由增量改为绝对值帧 §12.6
exactly-once projection = offset 与投影结果同事务提交 投影与 UserBadgeState 同一 WriteBatch §7.7

两条必须点明的因果:

  1. 为什么 SESSION_DELTA 必须是绝对值帧。 Akka Projection 的 at-least-once 模式下,同一事件可能被 handler 处理多次,因此官方硬性要求 handler 幂等。v1 的 unread_delta 是累加语义,在 MAILBOX_DIRTY 重放与连接替换丢帧这两条 正常路径上必然双加或少算,且误差永久累积、无自愈点。本版改为携带 unread_count / mention_count 绝对值 + projection_mailbox_seq 版本号, 客户端"版本更大才应用、绝对值直接覆盖",重复投递天然收敛。这不是优化,是 at-least-once 下的必要条件。
  2. 为什么投影与角标必须同批提交。 Akka Projection 的 exactly-once 保证来自"offset 与投影结果在同一个事务里提交"——offset 领先则丢更新, offset 落后则重复更新。本设计把 UserSessionProjection、UserBadgeState 与 badge_projection_mailbox_seq 放进同一次 WriteBatch、同一份检查点(§7.7), 否则会话列表与系统角标会在崩溃点撕裂,且撕裂不可检测(两边各自都自洽)。

一处差异:Akka Projection 通常按 persistenceId 或 tag 切分并行度。本设计的投影压缩器 顺序消费本节点邮箱写入流的 tail,在内存按 (user_id, conversation_id) 聚合后按 lane 并行 flush (§12.3)。明令禁止按用户主键全表扫描——那等于把"每次登录再计算"换成"常驻全量扫描",成本更差。

23.7 Split Brain Resolver 与 Lease

Akka 本设计
SBR keep-majority + lease-majority ShardRegistry 多数派 + 租约
akka.cluster.split-brain-resolver.stable-after 失效等待 > 租约时长(§19.2)
Cluster Singleton / Sharding 的 use-lease(两把 lease 保护对象不同:前者防 coordinator/singleton 双活,后者防单个 shard 双活) fencing_epoch + 会话/分片租约同时覆盖两层(§19.2)

Akka 的教训必须原样吸收:failure detector 判定 unreachable 不等于对方已经停止。 心跳超时可能来自 GC 停顿、网卡抖动、内核软中断风暴、虚拟机被挂起——这些情况下旧主仍在运行、仍可写入。 如果接管方以"心跳超时"为依据立即接管,就会出现双写。Akka 因此在 Cluster Singleton 与 Cluster Sharding 上引入 lease:即使 SBR 判定可以接管,也必须先拿到租约才允许启动新实例。

这正是 §19.2 切换流程要求"等待租约自然过期"而不是"心跳超时即接管"的理由:

接管的充分条件(三者全部满足,缺一不可):
  1. 旧持有者的租约已到期(wall-clock 上界,不是心跳判断)
  2. 接管方递增 fencing_epoch / shard_epoch,并在 ShardRegistry 上写入成功
  3. 接管方追平连续物化水位后才对客户端可见(§10.4、§19.3)

强制校验点:
  ConversationWriter 写 ConversationHead 时携带 fencing_epoch(§7.4)
  MailboxNode 写入日志与 MailboxStore 时携带 shard_epoch(§6.5)
  旧 epoch 的写入一律拒绝,客户端侧返回 REGION_FAILOVER / SHARD_MOVED(附录 A.6)

差异:Akka 的 fencing 主要防"两个单例同时活着",粒度是进程。本设计的 fencing 还要防旧写入落到新纪元的数据上, 因此 epoch 必须进入数据本身(mailbox_seq 的 shard_epoch 字段、ConversationHead.fencing_epoch; 位宽与取值范围以 §6.5 为准,本章不重述), 而不只是控制面的一个标志。这样即使旧主的写请求延迟很久才到达存储层,也会被无条件拒绝。

23.8 Distributed Data(CRDT):明确不采用

评估对象:用 Akka Distributed Data 的 ORSet / LWWMap 承载 PresenceDirectory(§5.4), 即把"谁在线、在哪个连接、session_epoch 是多少"做成集群内自动收敛的 CRDT。

评估结论:不采用。 理由不是"CRDT 不好",而是规模与传播机制不匹配:

1. DData 基于 gossip 复制,Akka 官方明确说明它面向**小规模数据**,
   并要求数据能完整驻留内存、单条 entry 不宜过大。
   本设计的 presence 条目规模:目标档 1000 万连接(§25.0),极限档 4000 万。

2. gossip 的传播开销正比于"节点数 × 条目变更率",且每个节点都要持有**全量**副本。
   千万级 presence 条目的全量驻留与增量 gossip 在内存、带宽、收敛时间三个维度同时不可接受。

3. 收敛时间不可控。presence 的消费方是投递热路径(§11.1 需要 connection_id 与 session_epoch),
   一个"最终一致但不知道多久收敛"的输入会把推送失败率变成不可预测量。

4. ORSet 的墓碑与 LWWMap 的时间戳依赖也带来额外问题:
   前者在高频上下线场景下墓碑累积,后者依赖时钟(与 §6.2 禁止裸墙钟的立场冲突)。

替代方案(§5.4 已采用):compacted topic + 分区订阅。

key      = (tenant_id, user_id, device_id)
分区键   = 与 MailboxShard 同源的 user_bucket
效果     = MailboxNode 只订阅"覆盖本地分片"的分区,不持有全量 presence
写入方   = 持有该 ConnectionShard 租约的 ConnectionNode(唯一写入方)
收敛     = epoch 不匹配则 ConnectionNode 回 PRESENCE_STALE,MailboxNode 失效缓存并按需回源

与 CRDT 的本质区别:gossip 是"人人都要知道一切",分区订阅是"只知道与自己相关的部分"。 IM 的 presence 恰好有天然的分区亲和性(收件人固定在某个 MailboxShard),因此这个亲和性必须被利用, 而不是交给一个与拓扑无关的收敛协议。

23.9 背压与有界邮箱

Akka 本设计 对应章节
Akka Streams 的需求驱动背压 §11.3 双水位 + 帧优先级 §11.3、附录 B.5
bounded mailbox + OverflowStrategy 超硬水位关闭连接 + mailbox_dirty 兜底 §11.3、§6.8

Akka Streams 的背压是端到端的:下游的需求一路回传到上游源头,源头据此减速。本设计在 PULL_MAILBOX 拉模式下是同构的(客户端的需求就是背压信号),但在在线推送这条链路上不是—— 群消息的源头是"另一个用户按了发送键",无法被单个慢连接反压。因此本设计的策略是 分级降级而非反压源头:

< conn_send_soft_watermark   (1 MiB / 2000 条)   正常推送完整事件
>= soft                                            停止推送积压正文,只发 MAILBOX_DIRTY
>= conn_send_hard_watermark  (4 MiB / 8000 条)   关闭连接
退出降级态                    < conn_send_low_watermark(256 KiB,滞回,避免抖动)
节点级                        node_send_buffer_budget 超出时按帧优先级丢弃:
                              控制流 > 单聊 > 小群 > 大群 > 聊天室

与 Akka 的 OverflowStrategy.dropHead/dropTail 的关键区别:本设计丢弃的一定是可重建的数据。 持久消息在被推送之前就已经物化进个人邮箱(§11.1),所以丢弃在途正文只损失延迟,不损失数据; 客户端凭 mailbox_dirty 重新 PULL_MAILBOX 即可全量恢复。Akka Streams 的 drop 策略没有这层保证, 丢掉就是丢掉。这也是为什么本设计必须坚持"先物化再推送"的顺序——它是所有降级策略成立的前提。

附录 A.5 的 stream_id 多路复用是这条策略的补充:控制流独立成流, 保证一个 4 MiB 的 MAILBOX_BATCH 不会阻塞 PONG 而造成心跳误判断连。

23.10 明确不采用的 Akka 机制

Akka 机制 不采用的判定理由 本设计的替代
per-persistenceId journal 与 sequenceNr 10 万成员群 = 10 万次独立 append + 10 万个序号分配器竞争(见 23.4) per-shard 日志 offset 作为 mailbox_seq + 用户前缀索引(§6.5、§7.3)
Distributed Data(ORSet/LWWMap)承载 presence gossip 面向小数据集;千万级条目的全量驻留、传播开销与收敛时间均不可接受(见 23.8) compacted topic + 分区订阅(§5.4)
Cluster gossip 做千万连接的成员/在线发现 gossip 的收敛时间与消息量随节点数上升,与"投递热路径需要确定性输入"不适配 ShardRegistry + 租约 + 版本化映射表(§5.1、§5.2)
Akka Cluster Client 让外部客户端参与集群拓扑发现,暴露内部拓扑且不适配千万级移动端;Akka 自身也已转向 gRPC 方案 路由令牌 + REDIRECT 重定向,单连接最多重定向 1 次、令牌 TTL ≤ 60 s 单次使用(§5.3、附录 B.1)
Akka 的 shard handoff 内存缓冲 缓冲溢出即丢消息;且缓冲量不可预算 迁移窗口双写 + shard_epoch 判定归属(§5.5)
按实体个数的 passivation 策略 IM 实体大小相差数个数量级(见 23.3) 字节加权 LRU/TinyLFU(§18.3)
直接引入 Akka / JVM 技术栈 服务端不使用 JVM 技术栈;引入 JVM 会带来 GC 停顿与故障检测的相互作用(GC 停顿 → 误判 unreachable → 见 23.7),且与既有运维体系不匹配 只借模型,不借实现;本章即为借用清单

23.11 小结:手写的、为 IM 特化的 Cluster Sharding + Reliable Delivery

本设计可以被完整地理解为一句话:

一套手写的、为 IM 特化的 Cluster Sharding + Reliable Delivery。

骨架与 Akka 同构(实体单写、分片路由、租约 fencing、日志+快照恢复、序号+确认+窗口的可靠投递、 read-side projection + offset store),三处特化是有意为之:

特化 替代了 Akka 的什么 为哪个目标 付出的代价
per-shard 日志替代 per-entity journal persistenceId 级 journal 与 sequenceNr 10 万成员群 个人队列稀疏(§6.10 必须禁止 seq 差值判丢);游标绑定分片(§5.5 换发令牌)
lane 水位替代单一顺序水位 Akka 中不存在的第三层;Akka 的顺序保证止于 persistenceId 10 万成员群 + 多租户隔离 水位由标量变向量,检查点与接管判定改用 min(W[0..K-1])(§6.5.1、§19.3)
登录拉取 + 在线推送双模式 ConsumerController 的全程推模式 千万连接 两套路径与一道 sync_to_seq 屏障,客户端复杂度上升(MAILBOX_BATCH 与 PUSH_EVENTS 条目结构因此被强制统一,附录 A.4.1)

这三处特化只服务于两个目标:10 万成员群与千万级同时在线连接。 任何后续文档若提出偏离本章模型的方案,必须先说明它如何在这两个目标下成立,否则不予采纳。


24. 可观测性

24.0 告警分级与总则

P1  立即呼叫值班(5 分钟内响应):正确性受损、大面积不可用、恒零指标非零
P2  工单(1 小时内处理):容量逼近上限、单分片/单租户降级
P3  日报(下一个工作日):趋势异常、成本异常

原则:
1. 每个指标必须有目标值或告警阈值,没有阈值的指标不进面板(v1 的八组指标全部无阈值,本版逐条补齐)。
2. 凡带 {tenant} 标签的指标必须同时暴露 per-tenant 与全局两条曲线,
   否则单租户异常会被平台总量稀释(§19 的租户隔离依赖这一点)。
3. 所有阈值中出现的参数值必须与附录 B 一致;本章不自建默认值汇总表。
4. 日志与追踪禁止输出 access_token、游标签名、密钥与明文消息正文(§20)。

24.1 核心指标

规范:本章目标值不得宽于 §2.4;两者冲突时以 §2.4 为准。 凡与 §2.4 同名的延迟类指标,必须使用同一测量点与同一分位数; 本章允许设更严的内部目标,但必须显式标注"内部目标严于 SLO",且告警阈值不得晚于 SLO 被违反时触发。

24.1.1 连接与认证

指标 口径 目标 告警 级别
connections_online{node} 单节点在线连接数 ≤ 20 万(per_node_connection_budget,数值见附录 B.7,待实测) > 80 % 预算持续 10 min P2
auth_success_rate 5 min 窗口成功/尝试 ≥ 99.9 % < 99.5 % → P2;< 99 % → P1 P1/P2
auth_latency_p99 AUTH 到 AUTH_OK(§2.4 未定义该分段,属本章内部目标) ≤ 300 ms > 800 ms 持续 5 min P2
online_ready_latency_p99{backlog} 客户端发出 AUTH → 收到 ONLINE_READY,按 pending_entry_count_hint 分档,测量点与分位数同 §2.4 P0 无积压 ≤ 2 s;1 万条积压 ≤ 10 s(§2.4 P0 SLO,本章不得放宽) 超对应目标持续 5 min → P2;超 2 倍 → P1 P1/P2
reconnect_rate 每分钟新建连接 / 在线连接 ≤ 0.5 %/min > 2 %/min 持续 5 min(重连风暴) P1
unauth_connection_count 已建立未完成 AUTH 的连接数 ≤ 1 % 在线连接 > 5 %(slowloris,对照 unauth_connection_timeout 10 s) P2
takeover_admit_duration 接管分片后全量放行耗时(takeover_admit_rate 5 %/s) ≤ 25 s > 60 s P2
kicked_total{reason} 按 replaced/admin/banned/token_revoked 分列 — token_revoked 突增 10× P2
session_epoch_reject_total 向旧 session_epoch 推送被 ConnectionNode 丢弃的次数 稳态 ≈ 0 > 在线连接数 0.1 %/min 持续 10 min(presence 未收敛) P2

24.1.2 消息提交

指标 口径 目标 告警 级别
send_to_ack_latency_p99 客户端发出 SEND_MESSAGE → 收到 SEND_ACK(同 region),测量点与分位数同 §2.4 P0 ≤ 150 ms(§2.4 P0 SLO,本章不得放宽) > 150 ms 持续 5 min → P2;> 500 ms 持续 5 min → P1 P1/P2
send_to_ack_latency_p999 同上,P99.9 分位(§2.4 未定义该分位,属本章内部目标) ≤ 800 ms > 3 s P2
conversation_seq_alloc_p99 分配 conversation_seq + last_activity_id 的临界区耗时 ≤ 5 ms > 50 ms P2
outbox_lag_seconds_p99 MessageRecord 提交到 Outbox 记录被消费 ≤ 1 s > 10 s P1
clock_unsafe_total 触发 clock_regression_reject_ms(5000)的次数 恒为 0 > 0 P1
send_reject_total{code} 按 RATE_LIMITED / FANOUT_QUOTA_EXCEEDED / PAYLOAD_TOO_LARGE / PERMISSION_DENIED 分列 — RATE_LIMITED > 1 % 发送量 P3
conversation_head_write_fail_rate ConversationHead 写失败率(不阻断 fanout,§7.4) ≤ 1e-4 > 1e-3 持续 5 min P2

24.1.3 Fanout 与分发

指标 口径 目标 告警 级别
fanout_lag_seconds_p99 提交到 GroupDispatch 写入分发日志 ≤ 1 s > 5 s 持续 5 min P1
fanout_target_shards_per_message_p99 每条消息的目标 MailboxShard 数 S 与 §25.0 的 S_avg 口径一致 P99 > mailbox_shard_count 的 90 % 且 QPS 上升 P3
fanout_entries_per_sec{tenant,shard} 邮箱条目产生速率 ≤ per_shard_entry_budget 的 50 % > 70 % → P2;> 90 % → P1 P1/P2
fanout_quota_rejected_total{tenant} 触发 tenant_fanout_quota 被拒条数 稳态 0 > 0 → P3 通知租户;持续 5 min → P2 P2/P3
dispatch_expand_duration_p99{shard,lane} 单个 lane 子任务的成员展开+落盘耗时 ≤ 500 ms(dispatch_expand_duration_p99_target,附录 B.5.1) > 3 s P2
dispatch_duplicate_suppressed_total 按 dispatch_id 去重掉的重复任务 稳态 > 0 属正常(重试) 突增 10× P3
membership_version_count_per_hour{group} 成员版本产生速率(对照 membership_version_merge_window 30 s) ≤ 120/h > 600/h(版本爆炸) P3
conversation_active_member_ratio{size_bucket} α = 活跃成员比例 = |active| / member_count,按会话规模分桶(≤20 / 21-200 / 201-1000 / 1001-10000 / >10000)统计 P50/P90 无目标值:这是 §10.2.3 准入判据的输入量,不是被控量 无告警。缺失该指标即无法回填 big_group_lazy_threshold —
mailbox_write_policy_switch_total{conversation} 会话在 always ↔ mention_only 之间切换次数 稳态 0(滞回生效) 单会话 24 h 内 > 1 → P3(滞回失效或阈值贴边) P3

24.1.4 邮箱物化与 lane 水位

指标 口径 目标 告警 级别
lane_watermark_stall_ms{shard,lane} materialized_watermark[lane] 距上次推进的时长。指标名与毫秒量纲为唯一规范(§20.5 引用本定义,另需按 {tenant_id, lane_id} 维度分组并标注贡献最大的租户)。指标标签中的 lane_id 必须复用 §6.5.1 公式的同一实现,禁止独立推导 P99 < 1 s > 5 s → P2;> lane_stall_alert(30 s,附录 B.5.1)→ P1 P1/P2
lane_watermark_skew_seq{shard} max(W[0..K-1]) - min(W[0..K-1]) < 10 万 > 100 万(某 lane 卡死或大群任务失衡) P2
materialized_lag_seconds_p99 now - committed_at(水位对应事件) ≤ 2 s > 15 s P1
mailbox_write_fail_rate 邮箱条目写入失败率 ≤ 1e-5 > 1e-4 持续 5 min P1
mailbox_replication_lag_p99 热备 MailboxNode 的追平差。仅阶段二(自研 LSM)且部署了热备时有效;一期与阶段一无主备,该指标不采集(§17.1) ≤ 1 s > 30 s(热备无法安全接管) P2
mailbox_trim_watermark_age_days mailbox_trim_watermark 对应事件的年龄 ≈ mailbox_retention_days(30) < 保留期 50 %(裁剪过早) P1

24.1.5 正确性恒零指标(本组任一非零即为设计不变量被破坏)

指标 口径 目标 告警 级别
mailbox_seq_regression_count 观察到 mailbox_seq 非单调(含跨 epoch)的次数 恒为 0 > 0 立即 P1
cursor_advanced_by_push_total 设备游标被 PUSH_EVENTS 越位推进的次数(违反 §6.8 不变量) 恒为 0 > 0 P1
silent_gap_detected_total 验收探针(§26)检出"服务端声称同步完成但存在缺口" 恒为 0 > 0 P1
event_id_nondeterminism_total 主备物化结果比对不一致的条目数(§6.7 确定性要求) 恒为 0 > 0 P1
mailbox_cursor_rebase_count 下发 CURSOR_REBASED 的次数 仅在 shard_epoch 变更后出现 无 epoch 变更事件却 > 0 P1
cursor_expired_total / login_total 下发 CURSOR_EXPIRED 占登录数比例 ≤ 0.1 % > 1 %(裁剪过早或裁剪过激) P2
cursor_invalid_total 游标签名无效或 seq 越界 ≤ 1e-5 登录数 突增(伪造尝试) P2

24.1.6 在线推送

指标 口径 目标 告警 级别
push_latency_p99 MAILBOXED → PUSHED(§2.4 未定义该分段,属本章内部目标) ≤ 150 ms > 500 ms 持续 5 min P2
push_e2e_latency_p99 COMMITTED → PUSHED(§11.2),测量点与分位数同 §2.4 P0 的"在线端到端投递时延(同区)" ≤ 300 ms(§2.4 P0 SLO,本章不得放宽) > 300 ms 持续 5 min → P2;> 2 s → P1 P1/P2
push_e2e_latency_cross_region_p99 同上,发送者与接收者 home region 不同 ≤ 800 ms(§2.4 P0 SLO) > 800 ms 持续 5 min → P2;> 3 s → P1 P1/P2
socket_send_queue_bytes_p99 单连接发送队列字节 < conn_send_soft_watermark(1 MiB) P99 > soft 持续 5 min P2
slow_connection_degraded_ratio 进入软水位降级态的连接占比 ≤ 0.1 % > 1 % P2
slow_connection_closed_total 触发 conn_send_hard_watermark(4 MiB / 8000 条)被关闭 ≤ 0.01 % 在线连接/min > 0.1 %/min P2
mailbox_dirty_sent_total 下发 mailbox_dirty 次数 ≤ 0.01 % 连接/min > 0.5 %/min(会引发拉取风暴) P1
push_drop_by_priority_total{class} 触发 node_send_buffer_budget 后按优先级丢弃的帧数 稳态 0 控制流 class > 0 立即 P1
push_batch_fanout_ratio 每个 PushBatch 平均覆盖的 Socket 数 ≥ 8(批处理有效) < 2 持续 10 min(合并失效,退化为逐 Socket RPC) P3
device_token_digest_lag MailboxNode 本地摘要缓存落后 device_token_digest topic 末端的时间(§16.6;滞后仅造成多推/少唤醒一次,由 §16.2 撤销兜底) P99 ≤ 1 s > 10 s 持续 5 min P2

24.1.7 心跳与连接活性

指标 口径 目标 告警 级别
heartbeat_pps 在线连接数 / 平均心跳间隔 × 2(§25.4) 目标档 ≈ 22 万 pps > 预算 150 %(说明大量连接回退到最小间隔) P2
ping_interval_effective 分布 服务端下发并被客户端采用的 next_ping_interval_ms P50 ≥ 90 s(对照 60/120/240 三档) P50 < 60 s 持续 30 min P3
ping_backoff_trigger_rate 触发 ping_backoff_factor(×0.8 锁定 30 min)的比例 ≤ 2 %/h > 10 %/h(网络或节点异常) P2
dead_connection_detect_ms_p99 链路不可用到客户端判定失效(send_ack_timeout 3 s + 探测 3 s) ≤ 7000 ms > 15000 ms P2
false_disconnect_ratio 有正常业务流量却被判超时关闭的连接占比,口径同 §2.4 P1 的"心跳误断连率" ≤ 0.05 %(内部目标严于 SLO;§2.4 P1 SLO 为 < 0.1 %) > 0.1 % 持续 1 h → P2(已触及 §2.4 SLO);> 0.2 % → P1 P1/P2
timewheel_tick_overrun_total 时间轮单 tick(1 s / 512 槽)超时次数 恒为 0 > 0 P2
empty_pull_ratio 返回 0 条的 PULL_MAILBOX 占比 ≤ 5 % > 30 %(§6.10.1 的非法判据被实现,全网空拉) P1

24.1.8 离线推送与角标

指标 口径 目标 告警 级别
offline_push_accepted_ratio APNs/FCM 网关接受率 ≥ 99 % < 95 % 持续 10 min P1
offline_push_arrival_ratio 客户端回执触达率(抽样) ≥ 95 % < 85 % P2
offline_push_latency_p95 MAILBOXED → NOTIFIED(§11.2,即 APNs/FCM 受理成功),测量点与分位数同 §2.4 P1 P95 ≤ 5 s(§2.4 P1 SLO,本章不得放宽) > 5 s 持续 10 min → P2;> 15 s → P1 P1/P2
badge_mismatch_ratio 抽样重算与 UserBadgeState.total_unread 不符的比例 ≤ 0.1 % > 1 % P2
device_token_invalid_ratio 网关返回无效 token 的比例 ≤ 1 % > 5 %(token 生命周期管理失效) P3

24.1.9 会话投影与未读

指标 口径 目标 告警 级别
projection_lag_seq{shard,lane} materialized_watermark[lane] - min(projection_mailbox_seq),按 {shard,lane} 分组取最差值。本行是该指标的唯一规范定义,§2.4 与 §12.3 均引用本定义(水位是 [lane_count] 向量,禁止写成标量减法,见 §6.5.1) 与 projection_lag_target_p99 对应(数值见附录 B.6) 见下行时间口径 —
projection_lag_seconds_p99 上式换算为时间 ≤ 5 min(projection_lag_target_p99) > 15 min → P2 P2
projection_lag_vs_retention 投影压缩滞后 / mailbox_retention_days ≤ 1/10 > 1/3 → P1(附录 B.3 硬性约束:保留窗口会删掉尚未合并进投影的增量) P1
unread_drift_ratio 抽样有界重算与 unread_count 不符的会话占比 ≤ 0.1 % > 1 % P2
projection_writes_per_sec / mailbox_entries_per_sec 即 1 / compaction_ratio(§25.3) ≤ 1(恒成立) > 1 → P1(投影写入超过邮箱写入,实现错误) P1
session_list_first_page_p99 客户端发出 PULL_SESSION_LIST → 第一页 SESSION_LIST_BATCH 可渲染,测量点与分位数同 §2.4 P0 ≤ 300 ms(内部目标严于 SLO;§2.4 P0 SLO 为 ≤ 500 ms,本行不得放宽到 500 ms 以上) > 300 ms 持续 10 min → P3;> 500 ms 持续 5 min → P2(已触及 §2.4 SLO 上限) P2/P3
projection_recompute_total 触发有界重算(unread_precise_limit 200)次数 — 突增 10× P3
session_delta_merge_ratio 合并窗口(100~200 ms)内被合并掉的帧比例 ≥ 50 %(高频群) < 10 % 持续 10 min(合并失效) P3

24.1.10 聊天室

指标 口径 目标 告警 级别
room_broadcast_latency_p99 RoomWriter 分配 room_seq 到写入 Socket ≤ 200 ms > 1 s P2
room_target_connection_shards_p99 每条房间消息的目标 ConnectionShard 数 — 接近 connection_shard_count(1024)且速率上升 P3
room_replay_gap_ratio 请求回放却超出 room_log_retention_minutes(30)窗口的比例 ≤ 0.5 % > 5 % P2
room_outbound_frame_rate_p99{conn} 合并后单连接出向帧率 ≤ 10 frame/s(附录 B.5) 超限即为限速器失效 P2
room_rate_limited_total{room} 触发 room_msg_rate(20 msg/s)的次数 — 单房间持续触发 10 min P3

24.1.11 缓存与存储

指标 口径 目标 告警 级别
body_cache_hit_ratio 大群展开时公共正文的 LRU 命中率 ≥ 95 % < 80 %(回源 MessageStore 放大 S 倍读) P2
member_bitmap_cache_hit_ratio 分片成员 Bitmap 命中率 ≥ 99 % < 95 % P2
presence_cache_hit_ratio PresenceDirectory 本地缓存命中率 ≥ 99.9 % < 99 %(推送路径出现同步远程调用) P1
presence_stale_total 收到 PRESENCE_STALE 的次数 稳态低 持续上升不收敛 P2
cache_bytes_used / cache_bytes_budget 字节加权占用率(§18.3) ≤ 90 % > 98 % 持续 10 min P2
mailbox_store_write_p99 MailboxStore.AppendBatch 延迟 ≤ 20 ms(同 §18.1 自研切换判据) > 20 ms 持续 1 h → 触发 ADR 复审 P2
checkpoint_age_seconds 最近检查点年龄 ≤ checkpoint_interval × 2 > × 5(RTO 失控) P2

24.2 追踪标识

以下 ID 必须贯穿提交、分发、邮箱、投影与推送全链路日志与 trace,缺一则跨阶段关联断裂:

trace_id                 一次端到端投递的贯穿 ID,由 ConversationWriter 在提交时生成
tenant_id
message_id / event_id
conversation_id
conversation_seq
dispatch_id
mailbox_shard
lane_id                  ← 本版新增:不带它无法定位是哪个 lane 卡住水位
mailbox_seq
shard_epoch              ← 本版新增:跨 epoch 的日志若不带它,offset 语义歧义
membership_version       ← 本版新增:定位"用了哪个成员快照",成员边界问题必需
connection_shard
connection_node_id       ← 本版新增
mailbox_node_id          ← 本版新增
device_id                ← 本版新增:多设备问题(游标、已读回声、角标)无法排查

采集规则:

1. 采样:正常路径按 trace_id 哈希采样 1 %;命中以下条件强制全采样(100 %):
   - 目标分片数 S > 64 的大群消息
   - 任一 24.1.5 恒零指标被触发的请求
   - 返回 ERROR 的请求
2. 大群 dispatch 的 trace 在 FanoutCoordinator 处按 target_mailbox_shard 分裂为 S 条子 span,
   不为每个收件人生成 span(否则一条消息产生 10 万 span)。
3. 日志与 trace 一律禁止输出 access_token、mailbox_cursor.signature、媒体下载凭证、
   payload_or_ciphertext 明文(§20)。preview_or_placeholder 也不得进入日志。

24.3 体验指标

本节的指标与 §2.4 的 SLO 一一对应。数值目标以 §2.4 为唯一来源,本节不复述——两处各写一份数字必然漂移。 本节定义的是口径、采集点与告警规则,缺了它们 SLO 无法被判定。

体验指标 计算口径 采集点 对应 SLO
消息端到端到达率 APPLIED 条目数 / MAILBOXED 条目数,按 message_id 在客户端回执侧去重后统计,观察窗口 24 h 客户端埋点 + 服务端 MAILBOXED 计数 §2.4 到达率
端到端延迟 P50/P99 发送端按下发送 → 接收端 UI 渲染完成(不是 PUSHED,含客户端解析与落库) 客户端双端埋点,按 message_id 对齐 §2.4 延迟
首屏会话列表耗时 连接建立 → SESSION_LIST_BATCH 首页渲染完成;冷启动与热启动分列 客户端埋点 §2.4 首屏
离线推送触达率 客户端回执数 / 服务端交付网关成功数,按 24 h 窗口;iOS/Android 分列 客户端回执 + 24.1.8 §2.4 离线触达
发送失败率 (未在 send_ack_timeout×3 内收到 SEND_ACK 的发送数 + ERROR 数) / 总发送数;用户主动取消不计入 客户端埋点 §2.4 发送成功率
误断连率 false_disconnect_ratio(24.1.7)+ 客户端侧"无网络变化却断连"计数 双端交叉验证 §2.4 连接稳定性

告警规则(多窗口燃尽率,倍率 slo_burnrate_fast / slo_burnrate_slow 见附录 B.5.3):

快窗:1 h 窗口 / 5 min 短窗,燃尽率 >= 14.4×    → P1
慢窗:6 h 窗口 / 30 min 短窗,燃尽率 >= 6×      → P2
两窗必须同时越线才告警,避免尖刺误报。

单独的硬性 P1(与燃尽率无关):
  消息端到端到达率任一 1 h 窗口 < 99.99 %
  —— 阈值取 §2.4 的 SLO 值(SLO 窗口为 24 h,此处收紧为 1 h 以便快速发现),
     不得放宽;到达率是本系统的存在理由,不接受错误预算平摊

口径纪律(三条,违反则指标失去意义):

1. 到达率的分母是 MAILBOXED,不是 PUSHED。用 PUSHED 做分母会把
   "服务端已放弃推送但邮箱里有" 记为成功,掩盖 §24.1.6 的降级。
2. 端到端延迟的终点是"UI 渲染完成",不是"写入 Socket"。
   §11.2 的六级到达层级中,PUSHED 与 APPLIED 之间的差值恰恰是客户端瓶颈,
   而 §23.5 的登录拉模式正是为这个差值设计的,不测它就等于没测。
3. 所有体验指标必须按 (平台, 网络类型, 客户端大版本) 三维切分。
   聚合值会掩盖单平台回归——这是移动端最常见的可观测性失败。

25. 容量模型

25.0 基线参数与三档容量

25.0.1 必须锁定的参数清单

容量推导前,以下参数必须由产品与运维共同锁定。未锁定的参数不得用默认值代入结论。

需求侧(产品锁定)
  DAU                        日活用户数
  C_peak                     峰值在线连接数
  d_online                   平均在线设备数 = C_peak / U_online_peak
                             **全文统一取 1.4**(附录 B.7),§2.2 与本章使用同一取值
  U_online_peak              峰值在线用户数
  online_ratio               峰值在线率 = U_online_peak / DAU
  M_day                      日持久消息数
  r_peak                     峰均比 = 峰值消息速率 / 日均消息速率
  b_payload                  消息正文均值(字节)
  b_row                      MessageRecord 行均值(含全部列、索引与开销,字节)
  f_mix                      fanout 构成:单聊/中群/大群的消息占比与平均成员数
  R_avg                      平均收件人数 = 每条持久消息平均产生的邮箱条目数(含控制事件摊销)
  S_avg                      每条消息平均目标 MailboxShard 数

系统侧(架构锁定,见附录 B)
  virtual_bucket_count       65536,不可变
  mailbox_shard_count        起步 64 / 目标 256 / 极限 1024
  lane_count                 64
  connection_shard_count     1024
  mailbox_replicas           3(RF=3)
  mailbox_retention_days     30

待实测(**数值一律见附录 B.7**,本章不另立默认值;回填前不得用于结论)
  entry_ondisk_bytes         邮箱条目磁盘实占(含键前缀压缩、索引、WAL、压缩后结果)
  lsm_space_amp              空间放大,leveled 约 1.1~1.3       ← 用于容量
  lsm_write_amp              写放大                              ← 用于寿命,不用于容量
  per_shard_entry_budget     单 MailboxShard 可持续 entry/s
  per_node_entry_budget      单 MailboxNode 可持续 entry/s
  per_node_connection_budget 单 ConnectionNode 连接数
  partition_dispatch_budget  单日志分区可持续 dispatch/s
  compaction_ratio           投影压缩比(§25.3)

25.0.2 R_avg 与 S_avg 的推导口径

R_avg 不是"群平均人数",而是每条持久消息平均产生多少条 UserMailboxEntry, 必须包含控制事件(v1 遗漏了这一项):

目标档 f_mix(示例口径,需按真实业务回填):
  单聊            占比 74 %,平均收件人 2(含发送者自身邮箱)  → 0.74 × 2    =   1.48
  中群 (<=1000)   占比 24 %,平均成员 80                      → 0.24 × 80   =  19.20
  大群 (>1000)    占比  2 %,平均成员 5000                    → 0.02 × 5000 = 100.00
                                                    消息类小计 = 120.68

控制事件摊销(已读同步、撤回、编辑、成员变更;已按 read_sync_merge_window=3s 合并后计):
  人均每天控制类邮箱事件 25 条 × DAU 5000 万 = 12.5 亿条/天
  摊到 M_day = 10 亿条持久消息                                 =   1.25

R_avg = 120.68 + 1.25 ≈ 122
S_avg(每条消息平均目标 MailboxShard 数,K = mailbox_shard_count):
  单个会话覆盖分片数 ≈ K × (1 - (1 - 1/K)^N)     N = 收件人数

  目标档 K=256:单聊 2;中群(80) ≈ 69;大群(5000) ≈ 256
  S_avg = 0.74×2 + 0.24×69 + 0.02×256 ≈ 23

25.0.3 三档容量表

以下全部是推导值,不是实测值。压测回填(§25.6)前,任何一格都不得用于采购、合同或容量评审结论。

维度 起步档 目标档 极限档
DAU 100 万 5000 万 2 亿
峰值在线连接 C_peak 20 万 1000 万 4000 万
平均在线设备 d_online 1.4 1.4 1.4
峰值在线用户 / 在线率 14.3 万 / 14.3 % 714 万 / 14.3 % 2857 万 / 14.3 %
日持久消息 M_day 2000 万 10 亿 50 亿
峰均比 r_peak 3 3 3
峰值消息提交速率 694 msg/s 34,722 msg/s 173,611 msg/s
R_avg 122 122 122
峰值邮箱写入 8.5 万 entry/s 约 424 万 entry/s 约 2118 万 entry/s
S_avg ≈ 14(K=64) ≈ 23(K=256) ≈ 40(K=1024)
峰值 dispatch 写入 约 1.0 万/s 约 80 万/s 约 694 万/s
峰值 Socket 写入 约 1.7 万/s 约 85 万/s 约 424 万/s
mailbox_shard_count 64 256 1024
lane_count 64 64 64
桶约束校验(§5.2 硬约束 1) 64 ≤ 65536 ✓ 256 ≤ 65536 ✓ 1024 ≤ 65536 ✓;分裂上界 = min(65536, connection_shard_count) = 1024(§5.2 推论 1)
MailboxNode(每节点 4 分片) 16 64 256
ConnectionNode(20 万/节点 + 冗余) 2 + 2 50 → 64 200 → 256
日志分区数(1:1 映射逻辑分片) 64 256 1024
邮箱驻留容量(30 天,RF=3,含空间放大) 约 37 TB 约 1.85 PB 约 9.2 PB
消息正文容量(365 天,RF=3,b_row=600 B) 约 16 TB 约 789 TB 约 3.9 PB
副本策略 RF=3 RF=3 RF=3

极限档说明:极限档 mailbox_shard_count = 1024 恰达分裂上界 min(virtual_bucket_count, connection_shard_count) = 1024(§5.2 推论 1),继续扩容须先提升 connection_shard_count(§5.2)。

25.1 邮箱存储

v1 的公式量纲是错的:它把 lsm_write_amp 当成了存储系数。写放大衡量的是"逻辑写 1 字节,设备实际写多少字节", 是寿命与带宽指标;决定驻留容量的是空间放大 lsm_space_amp(leveled 约 1.1~1.3)。 按写放大(10~30)估容量会把结果高估 10~30 倍,直接导致过度采购。本版拆成两式:

【式一】驻留容量(决定买多少盘)
驻留容量 = M_day × R_avg × entry_ondisk_bytes × lsm_space_amp × mailbox_replicas
           × mailbox_retention_days
【式二】设备写入带宽与寿命(决定买什么盘)
日节点设备写入 = M_day × R_avg × entry_ondisk_bytes × lsm_write_amp × mailbox_replicas
                 / mailbox_node_count

DWPD_实际 = 日节点设备写入 / 单盘可用容量

选盘约束:标称 DWPD >= 3,且 DWPD_实际 <= 标称值 / 3(保留 3 倍余量)

三项必须遵守的口径纪律:

1. R_avg 必须包含控制事件(已读同步、撤回、编辑、成员变更)。
   v1 遗漏此项。重度用户每天可产生数百条已读事件,即便按 read_sync_merge_window(3 s)
   合并后仍是可观的写入量(目标档摊销值 1.25 条/消息,见 §25.0.2)。
2. entry_ondisk_bytes 必须是压缩、索引、WAL 之后的**真实实占**,
   不是 §7.3 的逻辑字段大小(约 110 B)。二者的比值本身就是待实测项。
3. 式一用 space_amp,式二用 write_amp。**两个系数不得互换,也不得只用一个。**

目标档代入示例(推导值):

日邮箱条目 = 10 亿 × 122 = 1.22e11 条/天
取 entry_ondisk_bytes = 140 B(待实测占位)、lsm_space_amp = 1.2、RF = 3、保留 30 天

式一:1.22e11 × 140 B × 1.2 × 3 × 30 ≈ 1.85 PB
      每 MailboxNode(64 台)≈ 28.8 TB 可用,按 70 % 使用率需 ≈ 41 TB 裸容量

式二:取 lsm_write_amp = 12
      集群日设备写入 = 1.22e11 × 140 B × 12 × 3 ≈ 615 TB/天
      单节点 ≈ 9.6 TB/天;DWPD_实际 = 9.6 / 41 ≈ 0.23
      标称 DWPD 3 的企业级 SSD 余量约 13 倍 ✓

敏感度提示:mailbox_retention_days 是线性因子。30 天 → 7 天可把 1.85 PB 降到 432 TB。 阶段二 mailbox-tail 形态还受附录 B.3 的约束封底: mailbox_retention_days >= 投影压缩器最坏滞后 P99.9 × 3, 且下调会提高 CURSOR_EXPIRED 触发率(§24.1.5 监控该比例)。这是成本与体验的直接交换,需产品拍板。

25.2 在线推送

v1 的公式漏了两个乘数:只按"在线人数"计,既没有乘峰值在线率(把 DAU 当成在线数), 也没有乘平均在线设备数(人均 1.4 设备即低估 40 %,人均 2 设备即低估一倍)。本版:

峰值 Socket 写/秒
  = 峰值消息提交速率 × R_avg × 峰值在线率 × 平均在线设备数
  + Σ(房间数 × 限速后房间消息速率 × 房间在线人数 × 设备数)

峰值出向字节/秒
  = 峰值 Socket 写/秒 × (帧头与引用字段 + body_included=true 时的正文字节)

字节量必须按正文算,不能按引用算。 PUSH_EVENTS.events[] 与 MAILBOX_BATCH.entries[] 共用同一条目结构(附录 A.4.1):邮箱引用字段 + 读时 join 的正文。正文不存储在邮箱里, 由 MailboxNode 在读路径从 LRU 或 MessageStore join,同一 message_id 每批只 join、只编码一次; 但编码一次不等于发送一次——它仍要向每个在线连接各写一份。因此在线推送的出向字节按 b_payload 量级计,而不是按邮箱条目的引用大小(entry_logical_bytes 约 110 B,附录 B.7)计。 把字节量按引用大小估算会低估出向带宽与 TLS 加密量一个数量级。

目标档代入(推导值):

IM 部分:34,722 × 122 × 0.143 × 1.4 ≈ 84.8 万 次/秒
         分摊到 64 台 ConnectionNode ≈ 1.33 万 次/秒/节点

         出向字节(取 b_payload = 300 B,帧头 + 引用字段 ≈ 130 B,合计 ≈ 430 B/条目):
           84.8 万 × 430 B ≈ 365 MB/s ≈ 2.9 Gbps
           分摊到 64 台 ≈ 5.7 MB/s/节点 ≈ 46 Mbps/节点
         b_payload 是需求侧参数(§25.0.1),估错 2 倍则本项结论同时错约 2 倍。

聊天室部分(必须独立核算,见下):
         100 万房间在线连接
         稳态:合并后 3 frame/s/连接 → 300 万 frame/s
         上限:room_outbound_frame_rate = 10 frame/s/连接 → 1000 万 frame/s

结论一:聊天室必须与 IM 网关物理隔离。 聊天室的出向帧率(300 万~1000 万/s)比 IM 主链路 (85 万/s)高一个数量级,且完全由 room_msg_rate 与在线人数的乘积决定。共池会让一次房间热点 挤占 IM 的发送缓冲,触发 §11.3 的全局降级并引发重连风暴。按单节点 20 万 frame/s (per_node_frame_budget,附录 B.7,待实测)估算,目标档聊天室需独立网关池 ≈ 50 台。

结论二:批处理减少 RPC 与编码次数,但不能消除最终单播写入次数。

可以被消除的(§10.3):
  N 份正文存储、N 次跨服务 RPC、N 次正文编码、N 次正文 join
  → 一个 PushBatch 携带一份公共正文 + 多个轻量个性化帧头

不可能被消除的:
  O 次 Socket 写入(O = 在线连接数)—— 每个 TCP 连接必须各自收到字节
  O × 条目字节 的出向字节与 TLS 加密量 —— 条目 = 引用 + 读时 join 的正文(附录 A.4.1),
    正文只 join/编码一次,但必须被复制 O 份写出

唯一能压缩出向字节的路径:
  条目降级为 body_included=false(单批正文超过 max_frame_bytes 等三种成因,附录 A.4.1),
  客户端改走 PULL_HISTORY 补取 —— 这是把字节转移到拉取链路,不是消除字节,
  且会增加一次 RTT,因此不得作为常态容量手段。

因此以下三项必须单独核算,不能包含在"写入次数"里:

维度 核算方式 目标档单节点预算(附录 B.7,待实测)
syscall 次数 writev 合并同连接的多帧、10 ms 窗口聚合后仍需每连接一次 ≤ 15 万 syscall/s
TLS 加密 CPU 每个 Socket 的字节流必须各自加密,公共正文的编码复用不能复用加密结果 TLS 占用 ≤ 30 % CPU
网卡 pps / 线速 小帧场景瓶颈在 pps 不在带宽;GSO/writev 只降 syscall,不降 pps ≤ 60 万 pps,带宽 ≤ 线速 50 %

writev / GSO 的具体使用方式见 §10.3。注意 TLS 是这条链路上最硬的下界:公共正文只编码一次的优化 (§10.3)在加密层失效——每个连接的 TLS 会话密钥不同,O 次加密无法合并。这也是附录 A.1 规定 "完整性校验只覆盖帧头"的原因之一:若 CRC 覆盖整帧,则在加密之外再叠加 O 次全帧扫描。

25.3 会话投影

v1 的公式没有时间量纲("投影持久写入量 ≈ 活跃用户数 × 变化会话数",没说是每秒还是每天)。本版:

每压缩窗口投影持久写入量
  ≈ 窗口内活跃用户数 × 该用户在窗口内发生变化的不同会话数

窗口 = projection_compaction_window(附录 B.6,默认 5 s 或 512 条事件,先到者触发)

等价的速率形式(推荐用于容量规划):

projection_writes_per_sec = mailbox_entries_per_sec / compaction_ratio

compaction_ratio = 窗口内同一 (user_id, conversation_id) 的平均事件数

必须诚实说明 v1 自检断言的前提。 v1 写道:"它不应近似于『消息数 × 收件人数』;如果接近,说明合并窗口或投影实现错误。" 这句话只在 compaction_ratio > 1 时成立:

compaction_ratio 的取值:
  高频大群(5 s 内 10 条消息,同一成员的同一会话被更新 10 次)  → 约 10
  中频群(5 s 内 1~2 条)                                        → 1 ~ 2
  长尾场景(单聊、低频群,5 s 内同一 (user, conversation) 仅 1 条)→ 趋近 1

当 compaction_ratio = 1 时,projection_writes_per_sec 恒等于 mailbox_entries_per_sec。
此时"投影写入 ≈ 消息数 × 收件人数"是**模型的固有下界,不是实现错误**。

因此把 v1 的断言修正为可验收的形式(对应 §24.1.9 的指标):

恒成立不变量:compaction_ratio >= 1
             projection_writes_per_sec <= mailbox_entries_per_sec
若观测到 projection_writes_per_sec > mailbox_entries_per_sec,才是实现错误(P1)。

目标档代入(推导值):

峰值邮箱写入 424 万 entry/s
取整体 compaction_ratio = 2.5(由大群贡献拉高)
→ 投影持久写入 ≈ 170 万行/s

170 万行/s 的量级直接推出两条硬性架构约束(这是 §7.6/§7.7 要求的容量理由):

1. 投影必须与邮箱**同实例共置**、用本地 WriteBatch 批量落盘。
   若改为逐行远程写,170 万行/s 会额外增加一整套等量的跨网络写入,成本翻倍且无收益。
2. UserBadgeState 必须与 UserSessionProjection 在**同一 WriteBatch**(§7.7)。
   在 170 万行/s 下,任何"两次独立提交"的设计都会在崩溃点产生大量撕裂样本。

DEK unwrap 开销(§21.3.4 引入的读路径新增项,冷用户快照是最坏情形):

冷用户一次 SESSION_LIST_BATCH 快照 ≤ max_conversations_per_user = 5000 行,
preview 读时填充最多对应 5000 个不同会话 DEK 的解封(unwrap);
未命中 dek_cache(dek_cache_ttl = 5 min,附录 B.5.3)的部分需逐个向密钥服务 unwrap。

预算约束:
  dek_cache 稳态命中率与单次 unwrap 延迟为待实测参数(附录 B.7 口径),
      回填前不得据此得出容量结论;
  实现必须支持批量 unwrap 与并行预取,冷用户首屏不得被串行 unwrap 拖垮
      (该耗时计入 §2.4 首屏 SLO 的分解项)。

25.4 心跳与连接容量

心跳 pps = 在线连接数 / 平均心跳间隔 × 2        (上下行各一帧)

三档代入(平均心跳间隔取 90 s,介于 ping_interval_initial 60 s 与 ping_interval_max_foreground 120 s 之间):

档位 在线连接 心跳 pps 分摊到节点
起步 20 万 约 4,444 pps 4 台 → 1,111 pps/台
目标 1000 万 约 22.2 万 pps 64 台 → 3,472 pps/台
极限 4000 万 约 88.9 万 pps 256 台 → 3,472 pps/台

对照 v1 固定 30 s 心跳:目标档将是 1000 万 / 30 × 2 ≈ 66.7 万 pps,是自适应方案的 3 倍。 心跳自适应(附录 B.4)不是省电优化,它是容量项。

单 ConnectionNode 预算(per_node_connection_budget 与各单节点预算项数值见附录 B.7,均待实测):

项 预算 推导
per_node_connection_budget 20 万连接 目标档 1000 万 / 50 台,留 64 台冗余
文件描述符 ulimit -n ≥ 500,000 连接 20 万 × 1.2 + 后端连接 + 日志/监控句柄
每连接内存 ≈ 40 KiB 读缓冲 8 KiB + 写缓冲 8 KiB(池化,空闲降至 4+4)+ 连接状态 ≈ 1 KiB + TLS 上下文 ≈ 20 KiB(需启用 buffer pool,默认实现可达 34 KiB+)
连接内存总量 ≈ 8 GiB 20 万 × 40 KiB
发送缓冲 node_send_buffer_budget(附录 B.5) 与连接内存分开预算,超出按帧优先级丢弃
定时器 时间轮 tick 1 s / 512 槽 每 tick 扫 20 万 / 512 ≈ 390 个连接;超时用惰性校验 last_active_at,禁止每连接独立重型定时器
PONG 写出 10 ms 窗口批量合并 把 3,472 pps 的响应压成约 100 批/s

登录风暴预算(重连是心跳之外的第二大连接层成本):

单分片接管后按 takeover_admit_rate = 5 %/s 分批放行 → 全量放行约 20 s(§24.1.1 监控 ≤ 25 s)
AUTH_OK 下发 sync_delay_hint_ms(0~30000 随机)使拉取错峰
客户端 reconnect_backoff:1 s 起,×1.8,上限 120 s,±30 % 抖动
=> 全网瞬断后的 PULL_MAILBOX 峰值被摊到 30 s 以上,而不是集中在第 1 秒

25.5 分发日志容量(v1 完全遗漏)

分发日志的分区吞吐是独立于邮箱写入的第二条容量约束,v1 完全没有把它作为容量维度。 两者的量纲不同,必须分开算:

邮箱写入(entry/s)   = 展开后的收件人条目,落在 MailboxStore
分发日志(dispatch/s)= 展开前的分片级任务,落在日志分区

比值 = R_avg / S_avg:目标档 122 / 23 ≈ 5.3
      即每 1 条日志记录在 MailboxNode 内部展开为约 5.3 条邮箱条目
每 MailboxShard 分发事件/s
  = (峰值消息提交速率 × S_avg + 峰值控制事件速率) / mailbox_shard_count

三档代入(推导值):

档位 峰值 dispatch/s 分片数 每分区 dispatch/s 对 partition_dispatch_budget(3 万/s,附录 B.7)的利用率
起步 约 1.0 万 64 约 152 0.5 %
目标 约 80 万 256 约 3,125 10 %
极限 约 694 万 1024 约 6,780 23 %

据此代入逻辑分片数下界(公式本身不在本节:分片数下界的唯一规范公式在 §25.6, 本节只给出三档代入结果 —— 两条约束取大,再向上取 2 的幂,目标利用率 0.5 即预留 2 倍余量):

目标档:
  日志侧  80 万 / (3 万 × 0.5)  =  54
  邮箱侧 424 万 / (5 万 × 0.5)  = 170     ← 起决定作用
  → 向上取 2 的幂 = 256 ✓(与附录 B.1 的默认值一致)

极限档:
  日志侧  694 万 / 1.5 万 = 463
  邮箱侧 2118 万 / 2.5 万 = 847           ← 起决定作用
  → 向上取 2 的幂 = 1024 ✓

起步档:
  两侧下界均 < 4,分片数由**迁移粒度与节点数**决定而非吞吐 → 取 64

必须写进运维手册的一条硬约束:

单分区打满后,不能在线重新分区。

理由(§6.5 已锁定):
  逻辑 MailboxShard 与日志分区是 1:1 固定映射;
  生产者必须显式指定分区,禁止 key hash 分区器;
  mailbox_seq 的 log_offset 字段就是该分区的 offset(§6.5)。
  任何"加分区再哈希"都会让同一用户的历史 offset 与新 offset 落在不同分区,
  游标语义直接失效。

唯一出路:走 §5.5 的分片分裂 —— 新建分片 + 新 shard_epoch + 双写窗口 + 换发游标令牌。
因此 §24.1.3 的 fanout_entries_per_sec 必须在 70 % 就告警(P2),
给分裂流程留出足够的准备时间,而不是等打满。

25.6 验收口径

(一)逻辑分片数下界的唯一规范公式

本节是 mailbox_shard_count 下界的唯一规范推导。§10.5 只能引用本节,不得另立推导; 附录 B.7 已复述本式。 §25.5 只给出本式的三档代入结果。

输入(平台级峰值,口径见 §25.0.2 与 §25.0.3;单分片预算见附录 B.7)
  platform_fanout_entries_per_sec = 峰值消息提交速率 × R_avg          平台峰值邮箱条目写入/s
  platform_dispatch_per_sec       = 峰值消息提交速率 × S_avg          平台峰值分发日志写入/s
  per_shard_entry_budget          单 MailboxShard 可持续 entry/s      规划值 5 万,待实测
  partition_dispatch_budget       单日志分区可持续 dispatch/s         规划值 3 万,待实测
  target_utilization              目标利用率,默认 0.5(即预留 2 倍余量)

公式
  mailbox_shard_count
    >= max( platform_fanout_entries_per_sec / per_shard_entry_budget,
            platform_dispatch_per_sec       / partition_dispatch_budget )
       / target_utilization

  结果再向上取到 2 的幂(附录 B.1 要求 mailbox_shard_count 为 2 的幂)。

目标档代入(推导值)
  邮箱侧 424 万 / 5 万 = 84.8      ← 起决定作用
  日志侧  80 万 / 3 万 = 26.7
  max = 84.8,除以 0.5 得 169.6,向上取 2 的幂 = 256 ✓(与附录 B.1 默认值一致)

三条量纲纪律(违反其一即得出错误分片数):

1. 分子是**平台级**速率,分母是**单分片 / 单分区**预算。
   禁止代入 per_node_entry_budget:节点预算用于推导 MailboxNode 台数,不是分片数。
2. 两条约束必须**分别算完再取大**,不得把 entry/s 与 dispatch/s 相加。
   两者量纲不同,比值为 R_avg / S_avg(§25.5)。
3. target_utilization 不得省略。省略即等于按 100 % 利用率规划,
   分片打满后无法在线重新分区(见 §25.5 的硬约束),只能走 §5.5 的分片分裂。

当本式下界低于"迁移粒度与节点数"所要求的分片数时(起步档即如此),
分片数由迁移粒度决定而非吞吐,取 64。

(二)回填与验收清单

1. §25.0.3 的三档基线**全部是推导值**。在 docs/08-test-and-capacity-plan.md
   用压测实测值回填之前:
     - 不得用于采购、合同、SLA 承诺;
     - 不得写入任何对外文档;
     - 不得作为容量评审的结论依据。

2. 必须实测回填的参数(数值与说明一律见附录 B.7):
     entry_ondisk_bytes、lsm_space_amp、lsm_write_amp、
     per_shard_entry_budget、per_node_entry_budget、
     per_node_connection_budget、partition_dispatch_budget、
     compaction_ratio、checkpoint_bytes / checkpoint_interval / replay_rate

3. 必须由真实业务数据回填的需求侧参数:
     f_mix(fanout 构成)、R_avg 中的控制事件摊销、d_online、online_ratio、r_peak
   这五项由业务形态决定,不能用行业经验值代替 —— R_avg 一项估错 2 倍,
   §25.1 与 §25.2 的结论同时错 2 倍。

4. 回填后必须重新校验的四个不等式:
     mailbox_shard_count >= 本节(一)的下界公式
     mailbox_shard_count <= virtual_bucket_count 且
         connection_shard_count % mailbox_shard_count == 0(§5.2 硬约束 1/2);
         分裂上界 = min(virtual_bucket_count, connection_shard_count)(§5.2 推论 1)
     阶段二 mailbox-tail:mailbox_retention_days >= 投影压缩器最坏滞后 P99.9 × 3(附录 B.3)
     projection_writes_per_sec <= mailbox_entries_per_sec

5. 压测场景至少覆盖(详见 docs/08):
     10 万成员群按 per_conversation_msg_rate 上限持续发送时的 lane 水位偏斜;
     千万连接同时重连时的 PULL_MAILBOX 峰值与发送缓冲占用;
     单 lane 人为卡死时其余 lane 的可见性不受影响;
     分片分裂窗口内的双写放大与游标换发成功率。

26. 测试与验收

v1 的 24 条验收项里有 21 条只写了"不会丢失""能够恢复""不产生",无法判定通过与否。本章把每条重写为 场景 / 操作 / 通过判据(含数值) 三段式,每条判据必须能由一个自动化断言给出布尔结果。

26.0 本章总则

1. 每条判据必须落到一个可读取的量:计数器、集合相等、逐字段 diff、分位延迟。
   禁止出现"大致""基本""应当"这类词,出现即视为该条未完成。
2. 集合比对的标准三元组是 (mailbox_seq, event_ordinal, event_id)(§6.7)。
   "无重复"= 三元组多重集合中重复元素数 == 0;
   "无遗漏"= 期望集合 \ 实际集合 == ∅。
3. 所有参数默认值以附录 B 为唯一来源,本章只引用不重述,不另立"新增默认值汇总表"。
4. 延迟类判据与 §2.4 SLO 同源,冲突时以 §2.4 为准;§24 的告警目标值同样不得宽于 §2.4。
5. 标注【发布阻断】的用例失败即不得发布,不接受"已知问题"豁免。

需要为验收专门暴露的观测点(属于实现约束,不是可选项):

观测点 来源 用途
storage_keys_examined / storage_rows_returned MailboxStore 的 RangeScan 逐请求返回 26.1 精准队列断言
tombstones_scanned MailboxStore 26.1 排除墓碑扫描
projection_persist_writes 当前 qsession;阶段二 mailbox-tail 形态为 MailboxNode 26.4 投影持久写断言
per_user_rpc_count FanoutCoordinator 26.3 禁止逐用户 RPC
body_encode_count ConnectionNode 26.3 公共正文只编码一次
body_join_count / body_included_false_count MailboxNode 读路径 26.2 读时 join 只做一次、占位条目可补取
push_task_count{device_id} MailboxNode → NotificationService 26.5 离线推送按设备粒度判定
mailbox_seq_regression_count MailboxNode 26.6 / 26.8 恒为 0
cursor_advanced_by_push_total ConnectionNode 26.2 游标只由 MAILBOX_BATCH 推进;即 §24.1.5 恒零指标,验收断言与线上 P1 告警共用同一计数器

26.1 精准个人队列

用例 26.1.1  范围查询只访问本用户的键
场景:MailboxShard 事件范围 mailbox_seq ∈ [10001, 10500],共 500 个分发事件;
      用户 A 在该区间只有 3 条 UserMailboxEntry:10008 / 10217 / 10491;
      lane_watermark(A) = 10500;mailbox_trim_watermark = 9000。
操作:PULL_MAILBOX(after_seq=10001, up_to_seq=10500,
                   max_items=pull_mailbox_max_items, max_bytes=pull_mailbox_max_bytes)
通过判据:
  storage_keys_examined == 3          (精确等于 3,不接受"大致 3")
  storage_rows_returned == 3
  tombstones_scanned    == 0
  MAILBOX_BATCH.entries.len == 3
  MAILBOX_BATCH.covered_through_seq == 10500
  MAILBOX_BATCH.has_more == false
  该请求的跨分区读次数 == 1(只读 (tenant_id, user_id) 一个分区)
用例 26.1.2  无引用用户可安全推进到 lane 水位
场景:用户 B 在 [10001, 10500] 内无任何 entry;lane_watermark(B) = 10500;
      mailbox_trim_watermark = 9000(< after_seq)。
操作:PULL_MAILBOX(after_seq=10001, up_to_seq=10500)
通过判据:
  entries.len == 0
  covered_through_seq == 10500        (空洞被安全跳过)
  has_more == false
  storage_keys_examined == 0
  客户端 cursor.last_applied_mailbox_seq 推进到 10500
  本用例中 ERROR 帧出现次数 == 0

负向分支(同一用例的第二次执行):
  把 mailbox_trim_watermark 提升到 10100(> after_seq=10001)后重放同一请求
通过判据:
  必须返回 ERROR{code=CURSOR_EXPIRED, trim_watermark=10100, rebuild_required=true}
  返回 covered_through_seq=10500 的次数 == 0     (空洞不可跳过)
用例 26.1.3  早期任务未完成时客户端不得看到更高水位
场景:lane 3 上的 dispatch@10300 被人为阻塞 5 秒;10301..10500 的 entry 已写入 MailboxStore。
操作:lane 3 上的用户在阻塞期间发送 PULL_MAILBOX(after_seq=10001, up_to_seq=10500)。
通过判据:
  AUTH_OK / MAILBOX_BATCH 给出的 lane_watermark <= 10299
      (lane_watermark 只出现在这两个帧中,PONG 不携带任何水位字段)
  covered_through_seq <= 10299
  返回条目中 mailbox_seq > 10299 的条数 == 0     (存储里已存在也不得返回)
  阻塞解除后第二次拉取补齐 10300..10500
  两次拉取的三元组并集 == 全量基线集合,重复元素数 == 0
  min(W[0..lane_count-1]) 未出现在任何客户端可见帧中(协议一致性断言)
用例 26.1.4  全局兜底拉取 up_to_seq=0 必须回带最新 lane_watermark
场景:用户 C 已在线 6 小时,登录时 AUTH_OK.lane_watermark = 10500;
      此后其 lane 水位持续推进到 20000,期间 C 收到 20 条推送但未做任何拉取。
操作:客户端触发全局兜底通道(下拉刷新 / 网络恢复 / 定期自检),
      发送 PULL_MAILBOX(after_seq=cursor.last_applied_mailbox_seq, up_to_seq=0)。
通过判据:
  服务端不得拒绝该请求:ERROR 帧出现次数 == 0
  MAILBOX_BATCH.lane_watermark == 服务端当前 W[lane(C)] == 20000
      (up_to_seq=0 表示"拉到当前水位",由服务端填充,§6.10.2)
  MAILBOX_BATCH.lane_watermark > AUTH_OK.lane_watermark 的次数 == 1
      —— 证明客户端无需从 PONG 获知水位即可拿到最新上界
  拉取完毕(has_more == false)后 covered_through_seq == 20000
  区间 (10500, 20000] 内属于 C 的 entry 缺失数 == 0、重复数 == 0
  该请求受 client_resync_min_interval 限流(附录 B.3),窗口内重复发起次数 == 0
用例 26.1.5【发布阻断】读时 join 必须按会话分组,禁止逐 message_id 点查
场景:用户 D 的积压 500 条,构造两种分布:
      (a) 全部来自 1 个 5000 人大群(同一 conversation,conversation_seq 连续)
      (b) 来自 20 个单聊,每个 5 条;全部条目均为非内联(body_size 超 inline_body_max_bytes)
操作:清空 MailboxNode 正文 LRU,各执行一次 PULL_MAILBOX(max_items=500)。
通过判据:
  (a) 对 MessageStore 的读请求次数 <= 2      (1 次范围读,允许 1 次 seq_bucket 跨界)
  (b) 对 MessageStore 的读请求次数 <= 21     (20 组 + 1 容差)
  两种分布下"逐 message_id 单行点查"的次数 == 0
  同一 message_id 的 join 次数 == 1、编码次数 == 1(§10.3)
  内联条目(ADR-0005)进入 join 流程的次数 == 0
用例 26.1.6 强制裁剪:超条目上限时越过设备游标并显式暴露
场景:租户配置 max_mailbox_entries_per_user = 1000(便于测试),
      用户 E 有两台设备:E1 游标停在第 100 条,E2 已读完;
      向 E 持续投递至条目数达 1200。
操作:触发强制裁剪,随后 E1 发起 AUTH。
通过判据:
  裁剪后 E 的条目数 == 800                    (1000 × mailbox_entry_cap_hysteresis)
  被裁剪的是 mailbox_seq 最小的 400 条        (从旧到新,无乱序裁剪)
  effective_trim(E) 被抬高到第 400 条的 mailbox_seq
  E1 的 AUTH 返回 ERROR{code=CURSOR_EXPIRED, rebuild_required=true}
      —— 禁止退化为 AUTH_OK{has_offline=true}
  E1 完成 §9.6 REBUILD 后可正常收发,unread_exact 允许为 false
  mailbox_entry_cap_evicted_total{tenant} 增量 == 400 且触发 P2 告警
  对照组:未超上限、仅发生时间窗到期时,落后游标仍必须收到 CURSOR_EXPIRED,
          但 mailbox_entry_cap_evicted_total 增量 == 0 且不得触发条目上限 P2 告警

26.2 登录同步

用例 26.2.1  多条离线消息只触发一次 has_offline
场景:设备离线期间产生 100 条持久 entry。
操作:AUTH。
通过判据:
  AUTH_OK 帧数 == 1,AUTH_OK.has_offline == true
  pending_entry_count_hint 与真实值 100 的相对误差 <= 20%
      (pending_hint_error_ratio = 20%,估算值即可,不要求精确;附录 B.3)
  抓包中 opcode 属于 v1 独立同步通知帧(v1 在 AUTH 之后另发的两个登录同步信令,
      v2 已把它们合并进 AUTH_OK)的帧数 == 0
      —— 本判据的断言对象是运行时抓包流量,不是文档文本;禁止以对文档 grep 的方式执行
  登录到 ONLINE_READY 之间服务端主动 PUSH_EVENTS 的条目中,
      mailbox_seq <= sync_to_seq 的条数 == 0
用例 26.2.2  复合事件组中间的截断点【发布阻断】
场景:用户在 mailbox_seq = S 上存在一个 4 条的事件组(event_ordinal 0..3,覆盖全部 4 种 event_type);
      S-1 及以前已全部拉取完毕。
操作:PULL_MAILBOX(after_seq=S-1, up_to_seq=S+100, max_items=3)
      —— max_items 故意落在事件组中间。
通过判据:
  返回该组全部 4 条(软上限允许被整组突破,§9.3.3 规则 2;
      唯一硬约束是 max_frame_bytes),covered_through_seq == S
  返回 3 条(在组中间截断)的次数 == 0                     ← 本条是阻断判据
  返回 0 条且 covered_through_seq == S-1(空批次零进展)的次数 == 0
                                                          ← 本条是阻断判据(§9.3.3 规则 2)
  续拉直至 has_more == false 后,
      三元组集合 == 一次性无上限拉取得到的基线集合,
      重复元素数 == 0,缺失元素数 == 0

主备切换子场景:在两个批次之间对主 MailboxNode 执行 kill -9,由备节点服务后续批次。
通过判据:
  同一 mailbox_seq 下 (event_ordinal, event_id) 序列与切换前逐字节相同
      (event_id 是确定性哈希,§6.7)
  重复实验 1000 次,序列不一致次数 == 0
用例 26.2.3  1 万条积压的登录时延
场景:单设备积压 10000 条 entry;pull_mailbox_max_items=500、pull_mailbox_window=4
      (二者数值见附录 B.3)。
操作:1000 个设备并发登录并完整同步到 ONLINE_READY。
通过判据:
  AUTH -> ONLINE_READY 的 P99 <= 10 s,P50 <= 3 s
  PULL_MAILBOX 往返次数 <= 25(= 10000/500 + 5 的协议开销余量)
  ERROR 帧数 == 0
  三元组重复数 == 0,缺失数 == 0
  SYNC_COMPLETE.sync_to_seq != 服务端分配值 的情况下必须收到 SYNC_INCOMPLETE
      (单独注入一次篡改,断言服务端不放行屏障)
用例 26.2.4  实时队列溢出后 mailbox_dirty 恢复全部持久消息
场景:客户端停止读 socket,服务端连接缓冲越过 conn_send_hard_watermark(数值见附录 B.5)。
操作:持续发送 20000 条持久消息,随后客户端恢复读取。
通过判据:
  服务端置该连接 mailbox_dirty=true,且在下一个 PONG 中下发
  cursor_advanced_by_push_total == 0        (游标只能由 MAILBOX_BATCH 推进,§6.8)
  客户端在 1 个心跳周期内发起 PULL_MAILBOX
  恢复完成后:本地持久消息三元组集合 == 服务端 UserMailboxEntry 集合
  丢失条数 == 0,重复条数 == 0
  被丢弃的仅为内存正文,UserMailboxEntry 的写入失败数 == 0
用例 26.2.5  多设备游标互不吞噬
场景:同一用户 3 个设备(A 在线、B/C 离线),期间产生 100 条 entry。
操作:A 完整同步并携带 acked_seq;随后 B、C 分别登录同步。
通过判据:
  B、C 各自返回的 entries 三元组集合与 A 完全相同(两两相等,各 100 条)
  A 的 acked_seq 对 B/C 的 covered_through_seq 影响次数 == 0
  mailbox_trim_watermark 在本用例期间的推进次数 == 0
      (裁剪只按时间窗口,不以任何单设备游标为条件,§6.5.2)
用例 26.2.6  超过保留期的设备登录必须 CURSOR_EXPIRED【发布阻断】
场景:设备离线 mailbox_retention_days + 1 = 31 天(保留期数值见附录 B.3);
      其 cursor.last_applied_mailbox_seq < mailbox_trim_watermark。
操作:AUTH。
通过判据:
  必须收到 ERROR{code=CURSOR_EXPIRED, trim_watermark, rebuild_required=true}
  携带过期游标的 AUTH 收到 AUTH_OK{has_offline=false} 的次数 == 0  ← 阻断判据
      (§9.6.3 步骤 0 以空游标重新 AUTH 的冷启动响应不在此列)
  MAILBOX_BATCH{has_more=false} 在 REBUILD 之前出现次数 == 0  ← 阻断判据
  执行 §9.6 REBUILD 后:
    每个 membership_state=ACTIVE 的会话最近一页消息条数 >= 1,
        且与 MessageStore 中该区间逐条 message_id 相同
    本地按 message_id 去重后的重复气泡数 == 0
    新游标 == REBUILD 会话 AUTH_OK.sync_to_seq
    该设备随后一次正常断连重登,CURSOR_EXPIRED 出现次数 == 0
        (防游标钉死在过期边缘的回归项)
    窗口外会话的 unread_exact == false(允许不精确,但必须显式标注,不得伪装为精确)
  该用例即评审 001 的阻断级缺陷 B-1,回归套件必须每次发布前执行。
用例 26.2.7  body_included=false 的条目必须可补取且不计为丢消息
场景:某会话 200 条消息中,60 条的正文因三种成因不可内联——
      20 条已被治理删除、20 条已过 retention_class 保留期、
      20 条落在一个正文总量超过 max_frame_bytes(附录 B.5)的批次里;
      对应的 UserMailboxEntry 全部存在且完好。
操作:设备完整同步该区间,随后对 body_included=false 的条目调用 PULL_HISTORY 补取。
通过判据:
  MAILBOX_BATCH.entries 与 PUSH_EVENTS.events 的条目结构逐字段相同
      (同一结构,见附录 A.4.1),客户端解析器分支数 == 1
  entries.len == 200,其中 body_included == false 的条数 == 60
  body_included=false 的条目仍带完整邮箱引用字段
      (mailbox_seq / event_ordinal / event_id / message_id /
        conversation_id / conversation_seq / last_activity_id 缺失数 == 0)
  这 60 条被计入 covered_through_seq 与游标推进(未推进的条数 == 0)
  客户端把这 60 条判定为"丢消息"并触发 REBUILD 的次数 == 0     ← 本条是核心判据
  客户端对这 60 条发起 PULL_HISTORY 补取:
      因保留期/治理删除而不可补取的 40 条渲染为占位,占位渲染失败数 == 0
      因超帧长而未内联的 20 条补取成功率 == 100%,
          补取后正文与 MessageStore 中该 message_id 逐字节相同
  未读与排序不受 body_included 影响:
      unread_count / last_activity_id 与"全部 body_included=true"的对照组逐字段相同
  同一 message_id 在同一批次内的 join 次数 == 1、编码次数 == 1(§10.3)

26.3 大群

用例 26.3.1  10 万人群一条消息的成本边界
场景:成员 100000 的普通群,发送 1 条文本消息。
操作:SEND_MESSAGE 一次,等待所有分片 dispatch 完成。
通过判据:
  MessageStore 中 (tenant_id, conversation_id, conversation_seq) 行数 == 1
  ConversationHead 的写 mutation 次数 == 1
  UserMailboxEntry 新增条数 == 100000(精确,误差 0)
  存储层断言:MailboxStore 中任一 UserMailboxEntry 的正文类字段
      (payload_or_ciphertext / media_metadata)出现次数 == 0——
      邮箱只存引用,正文由 MailboxNode 在读路径 join(附录 A.4.1)
  单条 UserMailboxEntry 编码字节数 <= mailbox_event_group_max_bytes / 8
      (数值见附录 B.2)
  每个成员的该消息 entry 条数 == 1(无 @ 时不产生 MENTION 条目)
用例 26.3.2  中心层任务数与目标分片数同阶
场景:上述 10 万成员分布在 100 个 MailboxShard 上。
操作:同用例 26.3.1。
通过判据:
  FanoutCoordinator 产出的 GroupDispatch 记录数 == 100
  FanoutCoordinator 发起的跨服务调用(含日志追加)次数 < 200
  per_user_rpc_count == 0                    (禁止逐用户跨服务调用)
  body_encode_count <= 参与投递的 ConnectionNode 数 + 1
      (同一 message_id 每批只 join / 编码一次,附录 A.4.1)
  最终 Socket 写入次数 == 在线设备数(不是在线人数,按 d_online 核对,附录 B.7)
用例 26.3.3  分片写入中途崩溃后的恢复
场景:某目标 MailboxShard 展开到约 50% 时对 MailboxNode 执行 kill -9。
操作:恢复后按 DispatchProgress 续做,直至该 dispatch 完成。
通过判据:
  该分片最终 entry 条数 == 该分片成员数(缺失 0)
  重复 (tenant_id, user_id, mailbox_seq, event_ordinal, event_id) 数 == 0
  DispatchProgress.chunk_done_bitmap 与实际已写块逐位一致
  单分片恢复 RTO <= 10 min
  崩溃注入重复 100 次,丢失/重复恒为 0
用例 26.3.4  展开中途退群的边界
场景:成员 X 的 left_at_conversation_seq = L;在某分片 dispatch 展开过程中提交退群。
操作:对 conversation_seq ∈ {L-1, L, L+1} 的三条消息各发一次。
通过判据:
  X 的邮箱中 conversation_seq >= L 的 entry 条数 == 0
  X 的邮箱中 conversation_seq == L-1 的 entry 条数 == 1
  同一 dispatch 重放 10 次,X 的邮箱三元组集合逐次相同(重放确定性)
  X 的 UserSessionProjection 中该会话不因在途消息"复活"
      (断言 latest_conversation_seq < L)
用例 26.3.5  加群边界与加群前未读
场景:新成员 Y 的 joined_at_conversation_seq = J;加群前该群已有 5000 条消息。
操作:加群,随后拉取会话列表与首页历史。
通过判据:
  Y 的邮箱中 conversation_seq <= J 的 entry 条数 == 0(MEMBERSHIP 事件除外;加群不回填历史邮箱引用)
  Y 的邮箱中该会话的 MEMBERSHIP 事件条数 == 1
  会话列表展示的 latest_conversation_seq >= J,last_activity_id 为加群活动或其后活动
  Y 在该会话的 unread_count == 0,mention_count == 0
  Y 通过 PULL_HISTORY 读取 J 之前历史(群策略允许时)后,
      unread_count 仍 == 0(历史读取不增加未读)
用例 26.3.6  lane 水位隔离(同分片单聊用户不被大群阻塞)
场景:同一 MailboxShard 上,lane 0 有 10 万人大群 dispatch 正在展开(预期耗时 30 s);
      lane 1 上的用户 Z 只有单聊流量。
操作:在大群展开的全过程中,向 Z 持续发送单聊消息并测量可见延迟。
通过判据:
  Z 的 lane_watermark 推进延迟 P99 <= 2 s
      (lane_watermark_advance_p99 = 2 s;附录 B.5.1)
  该 P99 不随大群 dispatch 时长增长(把 dispatch 延长到 120 s 后 P99 变化 <= 20%)
  对照组(配置 lane_count=1,退回 v1 的标量水位)P99 > 30 s,用于证明本机制有效
  W[lane(Z)] 的推进等待 W[0] 的次数 == 0
  min(W[..]) 仅出现在检查点写入与备节点接管判定路径
  当前实现若仍由 finish_dispatch 把 64 lane 随整条 record 一起推进,本用例必须失败;
      禁止以“wm_blob 有 64 个槽位”代替独立推进的行为证明
用例 26.3.7  正文内联的判定一致性与预算边界
场景:固定 body_size = 600 B、inline_body_budget_bytes = 8 KiB
      (据此 N <= 13 内联,N >= 14 不内联)。
      构造三条消息:单聊(N=2)、13 人群(N=13)、14 人群(N=14),
      其中 13 人与 14 人群的成员分散在 >= 4 个 MailboxShard 上。
操作:各发一条,检查所有目标分片上物化出的条目。
通过判据:
  N=2 与 N=13:全部分片的条目 inline_payload_or_ciphertext 均非空,且 dek_id 非空
  N=14:      全部分片的条目均无内联正文,走读时 join
  同一条消息在不同分片的内联结果不一致的次数 == 0
      —— 判定来自 GroupDispatch.inline_body,MailboxNode 独立判定次数 == 0
  retention_class ∈ {ephemeral_24h, compliance_hold} 的消息内联次数 == 0(任意 N)
  body_size > inline_body_max_bytes 的消息内联次数 == 0(任意 N)
  客户端侧:内联条目与 join 条目的 Entry 字段集合完全相同(附录 A.4.1),
      客户端解析分支数 == 1
用例 26.3.8【发布阻断】表情回应不得产生邮箱写入
场景:10 万人群的一条消息,5000 个不同用户各发一次 REACT。
操作:全量执行,统计各层写入量。
通过判据:
  新增 UserMailboxEntry 条数 == 0                (回应不进邮箱,§13.6.1)
  任何成员的 unread_count / total_unread 变化次数 == 0
  任何成员的会话列表排序位次变化次数 == 0
  离线推送 PushTask 产生数 == 0
  REACTION_UPDATE 的下发对象全部为该会话当前在线成员,离线成员收到次数 == 0
  同一 conversation_seq 在 reaction_push_merge_window 内下发帧数 <= 1
  重复投递同一 REACTION_UPDATE 100 次,客户端 counts 恒等于最后一帧绝对值
  对照组:若实现把回应按 §13.3 自定义持久消息处理,本用例必须失败
      (将产生 5000 × 10 万 = 5 亿条邮箱条目)
用例 26.3.9  大群成员变更不广播、成员列表可分页
场景:10 万人群(member_count > membership_event_broadcast_max_members)。
操作:连续 100 次进群 / 退群;随后客户端调用 PULL_MEMBERS 遍历全部成员,
      并用 filter=search 做 @ 补全前缀查询。
通过判据:
  当事人以外的成员新增 UserMailboxEntry 条数 == 0
  当事人自己收到 event_type=MEMBERSHIP 条目数 == 1(每次变更)
  PULL_MEMBERS 单页返回 <= member_page_limit,遍历完整无重复无遗漏
  filter=search 且 query 长度 < member_search_min_prefix 时返回 PERMISSION_DENIED
      或空结果,服务端全表扫描次数 == 0
  对照组:member_count <= 阈值的群,成员变更正常广播给全体成员
用例 26.3.10【发布阻断】读扩散档的历史缺口必须可定位并补齐
场景:会话 G 启用 mailbox_write_policy = mention_only。
      成员 M 的活动序列:活跃期收到 seq 1..100(有邮箱条目)
      → 静默超过 active_window_days,期间会话推进到 seq 500(无邮箱条目)
      → M 重新活跃,收到 seq 501..520(恢复邮箱条目)
操作:M 登录并进入会话 G。
通过判据:
  SESSION_LIST_BATCH 中 G 的 write_policy == "mention_only"
      且 delivered_conversation_seq 非空
  客户端据此发起 PULL_HISTORY 补齐 (100, 500] 区间,补齐后本地
      conversation_seq 集合对 [1, 520] 的覆盖缺失数 == 0
  客户端使用"conversation_seq 差值 != 1"作为判缺口依据的次数 == 0
      —— 判据只能是与 latest_conversation_seq 比对(§6.10.1 例外条款)
  对照组一:write_policy == "always" 的会话,客户端不得发起该校验
  对照组二:若 SESSION_LIST_BATCH 不下发 write_policy,本用例必须失败
      (客户端无法区分"本地完整"与"中间缺 400 条",表现为历史永久缺失)
用例 26.3.11  档位切换滞回,不得在阈值附近抖动
场景:等效阈值 T(由 §10.2.3 判据反推)。构造会话成员数在 T-1 与 T+1 之间
      每小时往返一次,持续 48 h。
通过判据:
  mailbox_write_policy_switch_total{conversation} <= 2
      (进入一次 + 可能的退出一次;退出需 N < T × policy_switch_exit_ratio)
  任意两次切换的时间间隔 >= policy_switch_min_interval(24 h)
  切换期间该会话成员的邮箱条目不出现"有/无交替"的分段
      (断言:任一成员的条目序列中,缺口数 <= 实际策略切换次数)

26.4 会话列表与未读

用例 26.4.1  qsession durable projection 批量写与 checkpoint 顺序
场景:1000 人群连续发送 1000 条消息,记录实际产生的非空 dispatch record 数 D,
      以及这些 record 中去重后的 recipient 引用总数 R。
操作:qsession 独立消费全部 dispatch,记录 Redis pipeline 次数、ZADD 条目数与 checkpoint 推进。
通过判据:
  projection_batches_total == D(每条非空 dispatch 至多一次 Redis pipeline,不得逐用户往返)
  projection_entries_total == R;最终 `(user_id, conversation_id)` 集合缺失数 == 0、多余数 == 0
  任一 record 的全部 ZADD durable 前,该 partition 的 next_offset 推进次数 == 0
  重放同一 record 100 次后集合不变、活跃度不回退、额外会话数 == 0
  qsession 对本次实验的用户全表扫描次数 == 0;MailboxNode 的尽力 Projection 不得推进 durable checkpoint
用例 26.4.2  旧消息延迟到达不顶排序、不覆盖较新预览
场景:M1(conversation_seq=100, last_activity_id=A1) 先到并成为预览;
      M0(conversation_seq=99, last_activity_id=A0 < A1) 因重试晚到(在
      fanout_retry_max_window 内,数值见附录 B.2)。
操作:按 M1、M0 的顺序投递。
通过判据:
  会话列表中 last_activity_id 仍为 A1,位置排名不变
  preview_or_placeholder 仍为 M1 的预览(被覆盖次数 == 0)
  latest_conversation_seq 仍为 100(回退次数 == 0)
  若 M0 在可见区间内且 counts_unread,则 unread_count 增加 1
      —— 排序门控与内容门控分离(§12.4.2),未读不因排序门控被吞
  超过 fanout_retry_max_window 的晚到消息仍物化进邮箱(三元组缺失数 == 0)、
      会话位置排名变动次数 == 0、preview 覆盖次数 == 0、
      FanoutCoordinator 丢弃持久消息次数 == 0
用例 26.4.3【发布阻断】 qsession 投影重放与窗口外权威重建
场景:1000 个用户 × 20 个会话;qsession 已消费部分 dispatch。分别构造:
      A. 对某 record 的部分 recipients 已写 Redis、checkpoint 尚未推进时 kill -9;
      B. 删除用户 Redis 会话投影,并令其旧 checkpoint 早于 dispatch log low watermark。
操作:A 重启后按原 checkpoint 重放;B 走 `UserConversationState` + canonical
      MessageStore/ConversationHead 的全量权威重建,再追平当前 dispatch 高水位。
通过判据:
  A 中同一 record 的全部 recipients 写成功前,next_offset 推进次数 == 0;
      恢复后 `(user_id, conversation_id)` 集合缺失数 == 0、多余数 == 0、重复副作用 == 0
  B 中每个用户返回的 conversation_id 集合 == UserConversationState 中应展示的 ACTIVE 集合;
      缺失数 == 0、多余数 == 0
  会话头与定义式未读逐字段等于 canonical MessageStore + read state 的参考重算;差异字段数 == 0
  在权威兜底完成前 qsession ready 次数 == 0,禁止以空列表或残缺投影伪装成功
  当前实现若仍只从 `convs:{user}` 取集合,本用例必须失败并阻断发布
用例 26.4.4  SESSION_DELTA 绝对值幂等
场景:同一条 SESSION_DELTA(固定 projection_mailbox_seq = P,unread_count = 7)。
操作:向客户端重复投递 100 次;再乱序投递一条 projection_mailbox_seq = P-1、
      unread_count = 3 的旧帧。
通过判据:
  100 次重放后客户端 unread_count 恒 == 7(绝对值幂等,不是 7 的倍数)
  客户端 projection_mailbox_seq 恒 == P
  旧帧(P-1)被丢弃次数 == 1,应用次数 == 0
  客户端实现中不存在 unread 累加路径:代码级断言 SESSION_DELTA 处理函数
      不出现 "+=" 型未读更新;行为级断言同上
  合并窗口(session_delta_merge_window,数值见附录 B.6)内同一 conversation_id
      只保留最后一帧,累加型合并出现次数 == 0
用例 26.4.5  已读 / 置顶 / 静音 / 隐藏的多设备同步
场景:用户 3 设备(A、B 在线,C 离线)。
操作:A 上依次执行 MARK_READ、置顶、静音、隐藏;随后 C 上线。
通过判据:
  B 在 5 s 内(P99)收到并生效
  A 收到自己发出的已读回声次数 == 0(origin_device_id 过滤生效,§7.3)
  C 上线后从邮箱 CONTROL 事件恢复,UserConversationState 与 A 逐字段相同
  并发冲突:对 pin_rank / muted / archived / notification_policy 发起
      10 万次随机并发写,按 §7.5 逐字段合并规则收敛,
      read_conversation_seq / hidden_before_* / deleted_before_* 的回退次数 == 0
  已读事件按 read_sync_merge_window(数值见附录 B.3)合并,
      同一会话在一个该窗口内产生的邮箱 CONTROL 条数 <= 1
用例 26.4.6  5000 会话用户的首屏与分页
场景:用户拥有 max_conversations_per_user = 5000 个会话(数值见附录 B.5)。
操作:PULL_SESSION_LIST(limit=50) 拉首屏,随后翻完 100 页。
通过判据:
  首屏 P99 <= 500 ms(冷用户首次载入含一次分区读全量 <= 5000 行)
  100 页汇总的 conversation_id 集合大小 == 5000,重复数 == 0,缺失数 == 0
  snapshot_revision 不变期间,重复拉同一 page_cursor 的结果逐字节相同
  分页期间人为提升某会话到首位并递增 snapshot_revision:
      客户端重取第一页后,该会话出现次数 == 1,
      全量集合仍无遗漏("移动到前面的会话被静默漏掉"次数 == 0)
  snapshot_ttl(数值见附录 B.6)过期后使用旧 page_cursor 必须显式失败并重取第一页,
      静默返回错位结果的次数 == 0
用例 26.4.7  manual_unread_conversation_seq 跨设备同步且经投影重建后仍存在
场景:用户 3 设备(A、B 在线,C 离线);会话 K 已全部读完
      (read_conversation_seq == latest_conversation_seq,unread_count == 0)。
操作:1) A 上对会话 K 执行"标记未读",服务端在 UserConversationState 写入
         manual_unread_conversation_seq = U(= 标记时刻 head.latest_conversation_seq,§7.5);
      2) 删除该用户的 UserSessionProjection 并从检查点 + 邮箱增量重建;
      3) C 上线;
      4) A 上进入会话 K 并 MARK_READ,把 read_conversation_seq 前推到 >= U。
通过判据:
  第 1 步后:A、B 的会话列表中 K 均显示为未读(未读展示条数 >= 1),
      B 收到该状态的时延 P99 <= 5 s;显示为已读的设备数 == 0
  第 2 步后:重建出的投影中 K 仍显示为未读,
      manual_unread_conversation_seq 丢失次数 == 0        ← 核心判据
      (它是用户主动状态,存放在 UserConversationState 而非可重建视图中)
  第 3 步后:C 从邮箱 CONTROL 事件恢复,其 manual_unread_conversation_seq == U,
      与 A、B 逐字段相同,三设备不一致的字段数 == 0
  第 4 步后:manual_unread_conversation_seq 在三个设备上均自动失效(置空),
      K 显示为已读;需要用户再次手动清除的设备数 == 0
  全过程 read_conversation_seq 回退次数 == 0(只进不退,§7.5)
  UserBadgeState 的系统角标按 §7.7 聚合口径重算,
      标记未读带来的角标增量与"会话列表未读会话数增量"一致,差值 == 0

26.5 连接与聊天室

用例 26.5.1  idle_timeout 清理死连接且不误断
场景:10 万连接,其中 1 万条为死连接(对端内核静默丢弃,不回 RST);
      9 万条为只发业务帧、不单独发 PING 的活跃连接。
操作:运行 30 分钟。
通过判据:
  死连接在 idle_timeout(定义与数值见附录 B.4)之后
      的 1.5 倍时间内全部被清理(清理率 == 100%)
  活跃连接误断率 < 0.1%(被断开的活跃连接数 / 90000 < 0.001)
  任意通过鉴权与帧校验的业务帧都刷新 last_active_at
      (断言只发 SEND_MESSAGE 的连接被断次数 == 0)
  定时器实现断言:连接数 20 万时每 tick 扫描连接数 <= 512(时间轮 + 惰性校验),
      每连接独立重型定时器数 == 0
用例 26.5.2  旧 session_epoch 的推送不写入新会话
场景:设备重连产生新 session_epoch = E+1,旧连接尚未被回收。
操作:向旧 connection_id 发起一批 PushBatch。
通过判据:
  ConnectionNode 丢弃该批次并回 PRESENCE_STALE,丢弃率 == 100%
  写入新会话的旧 epoch 条目数 == 0
  旧连接收到 KICKED{reason=replaced, replaced_by_device},且随后被关闭
  MailboxNode 收到 PRESENCE_STALE 后失效本地 presence 缓存,
      并在 60 s 全量对账周期内收敛(残留过期条目数 == 0)
用例 26.5.3  PONG 字段修正验收(禁止全网空拉)【发布阻断】
场景:用户 24 小时无任何新消息;其所在 MailboxShard 的水位在此期间推进 100 万。
操作:该用户保持 2 个设备在线,正常心跳。
通过判据:
  该用户任一在线设备发起的 PULL_MAILBOX 次数 == 0        ← 阻断判据
  PONG.last_pushed_user_seq 恒 == 该连接的 last_applied_mailbox_seq
  协议一致性断言:24 小时抓包中 PONG 帧携带水位类字段的次数全部为 0——
      lane_watermark 出现次数 == 0
      materialized_watermark 出现次数 == 0
      trim_watermark 出现次数 == 0                       ← 阻断判据
      (水位类字段只允许出现在 AUTH_OK 与 MAILBOX_BATCH 中;
        trim_watermark 只随 AUTH_OK 下发,连接期内的裁剪由
        ERROR{CURSOR_EXPIRED} 暴露,§6.5.2)
  客户端实现断言:不存在"last_applied_mailbox_seq 与分片水位比较"的代码路径
  客户端在本连接期内需要最新上界时,只能走 PULL_MAILBOX(up_to_seq=0)(用例 26.1.4),
      从 PONG 读取上界的代码路径数 == 0

负向分支:服务端置该连接 mailbox_dirty = true。
通过判据:
  客户端必须在 1 个心跳周期内(测试固定 next_ping_interval_ms = 60000,即 <= 60 s)
      发起 PULL_MAILBOX,发起次数 == 1
  受 client_resync_min_interval(数值见附录 B.3)限流,窗口内重复发起次数 == 0
  拉取完成后 mailbox_dirty 被清除,后续心跳周期内再次发起次数 == 0
用例 26.5.4  100 万在线聊天室不产生持久邮箱引用
场景:单房间 100 万在线连接,按 room_msg_rate(数值见附录 B.5)持续广播 10 分钟。
操作:全程统计持久层写入。
通过判据:
  UserMailboxEntry 新增条数 == 0
  UserSessionProjection 新增/更新条数 == 0
  UserBadgeState 更新次数 == 0
  ROOM_BATCH 的目标 ConnectionShard 数 == 实际存在在线成员的分片数(不多发空批次)
  单连接出向帧率 <= room_outbound_frame_rate(合并后,数值见附录 B.5)
  回放:after_room_seq 落在 room_log_retention_minutes(数值见附录 B.3)窗口内时,
      补齐后 room_seq 缺口数 == 0;
      窗口外必须返回 latest_room_seq 并由客户端跳到当前水位,
      客户端停留在旧位置的次数 == 0
用例 26.5.5  单 ConnectionNode 故障后的接管
场景:稳态 200 万在线,kill 一个承载约 20 万连接的 ConnectionNode。
操作:观察接管节点 5 分钟。
通过判据:
  接管节点 CPU 使用率 <= 稳态 × 2
  AUTH 处理 P99 <= 稳态 P99 × 2
  max(单用户从断开到收到 AUTH_OK 的时间) <= 60 s(无用户超过 60 秒无法登录)
  takeover_admit_rate 生效(数值见附录 B.4):每秒放行连接数 <= 该分片总连接数 × 该比例
  sync_delay_hint_ms 生效(取值范围见附录 B.4):
      接管后 5 分钟内 PULL_MAILBOX 到达速率的峰谷比 <= 3
  未认证连接在 unauth_connection_timeout(数值见附录 B.4)内被清理,
      slowloris 占用连接数 == 0
  重连风暴期间 ERROR{RATE_LIMITED, retry_after_ms} 必须带 retry_after_ms,
      缺失该字段的响应数 == 0
用例 26.5.6  离线推送按设备粒度判定:手机离线 + 桌面在线【发布阻断】
场景:用户 U 有 2 台设备——手机 D1 完全离线(进程被杀、无长连接、
      PresenceEntry 已过 presence_lease_ttl 被清理),桌面 D2 在线且长连接正常。
      向 U 所在的一个单聊会话发送 1 条持久消息。
操作:等待 push_grace_window(数值见附录 B.5.2)过后统计推送侧与长连接侧的结果。
通过判据:
  D1 收到的离线推送条数 == 1                              ← 阻断判据
      ("该用户至少一个设备在线"不得取消 D1 的推送)
  D2 收到的离线推送条数 == 0(在线设备走 Socket,不推)
  D2 通过 PUSH_EVENTS 收到该条目的次数 == 1
  推送任务的键包含 device_id:键为 (tenant_id, user_id, device_id) 的任务数 == 2
      (D1 一条产生推送、D2 一条被在线证据取消),
      仅按 (tenant_id, user_id) 聚合的任务数 == 0            ← 阻断判据
  取消条件按设备判定:因"D2 已 PUSHED / APPLIED"而取消 D1 任务的次数 == 0
  重复 1000 次,D1 漏推次数 == 0、D2 误推次数 == 0
  扩展场景:U 增加第 3 台离线设备 D3,则离线推送条数 == 2(D1、D3 各一条),
      D2 仍为 0;漏推设备数 == 0
  角标一致性:D1 恢复在线后其角标与 D2 同源于 UserBadgeState(§7.7),
      两设备 total_unread 差值 == 0
  负向用例(僵尸连接不漏推):D1 在线,PUSH_EVENTS 写入 Socket 成功后
      D1 立即静默断网(不产生 FIN/RST),push_confirm_extended_window 到期后
      D1 收到的离线推送条数 == 1                              ← 阻断判据
用例 26.5.7  纯接收客户端对静默丢包必须限时判死重连【发布阻断】
场景:纯接收态设备(不发消息)的链路发生静默丢包
      (不触发系统网络回调、不产生 FIN/RST,模拟 NAT 映射静默回收)。
操作:注入静默丢包后观察客户端行为。
通过判据:
  客户端从最近一次 PING 发出起,
      <= next_ping_interval + ping_probe_timeout + 3 s 内发起重连    ← 阻断判据
      (PONG 缺失 → PING{probe=true} → ping_probe_timeout 判死,§15.1.2)
  期间客户端向死连接持续发周期 PING 的次数 <= 1(一次探测周期只允许一次 probe)

26.6 故障与容灾

用例 26.6.1【发布阻断】 ConversationWriter 切换不产生两个活动主写者
场景:对某会话的 Home Region 主写者发起故障切换,人为制造 10 s 的双活窗口。
操作:新旧实例在窗口内并发写 ConversationHead 与分配 conversation_seq。
通过判据:
  旧实例的条件更新 IF (fencing_epoch, head_version) < 新值 被拒绝次数 >= 1,
      成功次数 == 0
  同一 (conversation_id, conversation_seq) 对应的 message_id 唯一(重复分配数 == 0)
  ConversationHead.latest_conversation_seq 回退次数 == 0
  GroupDispatch 的编解码包含与 Writer 租约相同的 fencing_epoch,缺字段/值不符次数 == 0
  在新 epoch 的 dispatch 先被消费后,注入旧 epoch 的同会话 dispatch:
      MailboxNode 拒收次数 == 注入数,新增 UserMailboxEntry 数 == 0,
      stale_epoch_dispatch_dropped_total 增量 == 注入数
  当前实现若 GroupDispatch 尚未携带 fencing_epoch,本用例必须失败并阻断发布;
      禁止删改本判据或退化为仅验证 ConversationHead 条件更新
  窗口内客户端收到 ERROR{code=REGION_FAILOVER, retry_after_ms},
      静默失败(既无 SEND_ACK 也无 ERROR)的请求数 == 0
用例 26.6.2  Mailbox 主备接管的水位约束
场景:MailboxNode 主节点故障,备节点日志重放尚未追平。
操作:备节点在追平前后分别接受客户端请求。
通过判据:
  追平前(min(W[0..K-1]) 未达到接管判定线)客户端请求成功次数 == 0
  接管后各 lane 的 W[j] 回退次数 == 0,mailbox_seq_regression_count == 0
  接管前后同一 (user_id, mailbox_seq) 的 (event_ordinal, event_id) 序列逐字节相同
  备节点接管判定使用 min(W[..]),使用单 lane 水位判定的次数 == 0
用例 26.6.3  从检查点 + 日志恢复邮箱索引
场景:删除某 MailboxShard 的本地 MailboxStore 数据。
操作:加载最近检查点 + 重放分发日志。
通过判据:
  单分片 RTO <= shard_rto_target(数值见附录 B.5.3)
  恢复后 entry 三元组集合 == 故障前基线,缺失 0、多余 0
  replay_rate 满足 §19.3.2 的下界式(≥ per_node_entry_budget × 3,
      且覆盖 §19.3.2 的 RTO 推导式;本用例只引用不另立系数)
  log_retention_days 满足 §19.3.3 的良定义不等式(该不等式是全文唯一规范,
      本用例只校验不另立系数),不满足的配置组合数 == 0
  EpochBoundary / ShardSplitBoundary 随检查点一并恢复,缺失项数 == 0
用例 26.6.4  幂等键收敛
场景:同时注入三类重复。
操作:分发日志重复消费 3 遍;FanoutCoordinator 对同一 dispatch 重试 3 次;
      客户端用同一 client_message_id 重试 SEND_MESSAGE 5 次(24 h 内)。
通过判据:
  MessageRecord 行数 == 1
  ClientDedup 命中次数 == 4(= 重试 5 次 - 首次)
  UserMailboxEntry 三元组重复数 == 0,条数 == 期望收件人数
  客户端本地气泡数 == 1(按 client_message_id 原位升级,§6.9.1)
  SEND_ACK 均回带相同的 client_message_id 与 message_id
用例 26.6.5  序号回退恒为零【发布阻断】
场景:§26.8 的全部混沌场景。
操作:全程采集 mailbox_seq_regression_count。
通过判据:
  mailbox_seq_regression_count == 0(任一场景任一时刻)
  任一次 > 0 即判定 shard_epoch 递增规则(§6.5)或游标 rebase 流程有缺陷,发布阻断
用例 26.6.6  shard_epoch 上界与有符号存储介质的兼容性【发布阻断】
场景:把某测试 MailboxShard 的 shard_epoch 直接调到 §6.5 允许的最大值 0x7FFF,
      在该 epoch 下写入 1000 条 UserMailboxEntry(log_offset 覆盖 0、1、
      2^47-1、2^48-1 四类边界);同一用户在更早的 epoch 下另有 1000 条。
操作:a) 按 mailbox_seq 做全区间与分段范围查询;
      b) 让 ShardRegistry 尝试分配 shard_epoch = 0x8000。
通过判据(a):
  查询返回条数 == 2000,缺失数 == 0、重复数 == 0        ← 阻断判据
  全部 mailbox_seq 在存储介质中编码为非负值:bit63 == 1 的行数 == 0
  排序断言:按存储介质的聚簇顺序读出的序列与按 u64 无符号比较排序的序列
      逐元素相同,不一致元素数 == 0
  跨 epoch 边界(旧 epoch 最大值 → 0x7FFF 最小值)的范围查询漏数据条数 == 0
  mailbox_seq_regression_count == 0
通过判据(b):
  ShardRegistry 拒绝该分配,拒绝率 == 100%,成功分配次数 == 0   ← 阻断判据
  拒绝时产生 P0 告警,且分片进入只读而不是静默回绕;静默回绕次数 == 0
  重复尝试 1000 次,越界 epoch 被写入分发日志或游标令牌的次数 == 0
  同一断言对 room_seq 的 room_epoch(§6.6)重跑一遍,结果相同
用例 26.6.7【发布阻断】接管起始位点由水位推导,不受位点提交时机影响
场景:分片 7 上构造"位点已提交但物化未完成"的窗口——
      令 MailboxNode 在消费 dispatch D(mailbox_seq = E)后先提交消费位点到 E,
      再在写 UserMailboxEntry 之前 kill -9。
操作:ShardRegistry 等租约过期后把分片 7 授予新节点 N2(漂移形态,无热备)。
通过判据:
  N2 的起始位点 == min_j(W[j]) 对应 log_offset + 1,且 <= E
      —— 证明未采用消费者组已提交位点(该位点为 E+1)
  D 被完整重放,其全部收件人的 entry 缺失数 == 0
  重放产生的 (mailbox_seq, event_ordinal, event_id) 与崩溃前已写入部分逐字节一致
      (确定性同值覆盖,§6.7)
  W[lane(D 的收件人)] 最终越过 E,lane_watermark_stall_ms 峰值 < lane_stall_alert
  对照组:若实现改为直接使用已提交位点,本用例必须失败(W 永久停在 E-1)
用例 26.6.8  W_floor 下界校验:重算水位低于已发布值必须拒绝服务
场景:分片 7 的 W[3] 已推进到 10000 并通过 AUTH_OK 下发给客户端;
      人为损坏 DispatchProgress,删除 mailbox_seq ∈ (9000, 10000] 的块完成位。
操作:触发接管,新节点重算 W[3](将得到 9000)。
通过判据:
  新节点检测到 重算 W[3] (9000) < W_floor[3] (10000)
  对 lane 3 的 AUTH / PULL_MAILBOX 一律返回 ERROR{code=SHARD_MOVED}
  向客户端下发 W[3] < 10000 的次数 == 0        —— 水位对外永不回退
  触发 P1 告警,且不进入"自动重放追平"路径(属不可自愈故障,需人工介入)
  其余 lane(0~2、4~63)不受影响,正常服务

26.7 安全与合规

用例 26.7.1  越权拉取
场景:持有用户 U1 的合法令牌。
操作:构造 mailbox_shard_id 指向其他分片、user_id 指向 U2 的请求各 10000 次;
      对其他租户的 conversation_id 做 10000 次猜测性 PULL_HISTORY。
通过判据:
  全部返回 ERROR{code ∈ {CURSOR_INVALID, PERMISSION_DENIED}}
  成功读取到他人数据的次数 == 0
  服务端不因构造请求泄露"该 ID 是否存在"(存在与不存在的响应码、
      retry_after_ms 与响应耗时分布的 KS 检验 p > 0.05)
用例 26.7.2  游标不可伪造
场景:合法游标令牌。
操作:三类攻击各 10000 次——
      (a) 翻转 signature 的任意 1 bit;
      (b) 重放已过期令牌;
      (c) 令牌合法但把明文 last_applied_mailbox_seq 抬高到超过该 lane 当前 W[lane]。
通过判据:
  (a)(b) 全部返回 ERROR{code=CURSOR_INVALID},通过率 == 0
  (c) 服务端校验 seq <= 该用户 lane 当前 materialized_watermark W[lane](§6.8),
      抬高到超过 W[lane] 的请求全部返回 CURSOR_INVALID;
      抬高到 <= W[lane] 属自伤行为(至多跳过本设备自身未读),
      断言其不影响其他设备/用户的游标与数据可见性
  正向判据:合法登录流水线(§9.3.2)4 个子区间的首个 PULL_MAILBOX 全部被接受,
      CURSOR_INVALID 次数 == 0
  令牌中的 mailbox_shard_id / lane_id / shard_epoch 被客户端上行覆盖的次数 == 0
用例 26.7.3  删除用户后无残留【发布阻断】
场景:删除一个有 500 会话、10 万条消息、3 设备的用户。
操作:执行 §21.4 删除流程,随后对 §21.4 清单逐项断言。
通过判据(每一项的残留计数必须 == 0,缺任何一条断言即验收失败):
  UserMailboxEntry / UserSessionProjection / UserBadgeState /
  UserConversationState / ClientDedup / MemberSlotMap(仅置 released_at,
      slot_id 不复用)/ PresenceEntry / MailboxCursor 签发记录 /
  ConversationHead.preview_or_placeholder 中的该用户正文片段(按 §21.4
      用户删除主体规则改写占位,不销毁会话 DEK)/
  MediaService 中该用户的对象与缩略图 / NotificationService 的 device token /
  检查点与 S3 冷归档中的可解密副本 / 搜索索引 / 审计日志中的可识别字段(按策略脱敏)
  加密擦除路径断言:销毁该用户密钥后,用任何路径读取其历史密文的解密成功率 == 0
  受 retention_class = compliance_hold 保护的数据不被删除(误删数 == 0),
      且在合规清单中被显式列出为"依法保留"

26.8 混沌与压测场景清单

所有场景在同一套断言下运行,公共判据:持久消息丢失数 == 0(以邮箱层三元组集合比对)、 mailbox_seq_regression_count == 0、无静默成功(任何降级必须有对应错误码或标志位)。

场景 注入方式 观测 场景专属通过判据
节点 kill 对 MailboxNode / ConnectionNode / ConversationWriter 随机 kill -9,每 5 min 一次,持续 4 h 恢复时间、集合一致性 单分片 RTO ≤ 10 min;集合缺失 0、重复 0;期间 AUTH 成功率 ≥ 99%
网络分区 region 间断链 5 min;节点与分发日志间断链 5 min fencing、错误码 双活主写者数 == 0;分区侧返回 REGION_FAILOVER 且带 retry_after_ms;恢复后 conversation_seq 重复分配 == 0
磁盘满 MailboxStore 数据盘写至 95% 后继续写 写失败处理 邮箱写失败必须阻止对应 lane 水位推进(越位推进次数 == 0);不得回 MAILBOX_BATCH 成功;节点进入只读并告警
时钟回拨 (a) −3 s(< clock_regression_reject_ms);(b) −30 s HLC 行为 (a) 由 HLC 吸收,message_id 重复数 == 0、hlc_ms 回退次数 == 0;(b) 必须 ERROR{CLOCK_UNSAFE} 并 P1 告警,静默生成 ID 次数 == 0
日志分区不可用 停某分发日志分区 10 min 背压与恢复 发送侧返回 RATE_LIMITED 或排队,静默丢邮箱引用次数 == 0;恢复后按 dispatch_id 去重,重复条数 == 0
登录风暴 100 万连接在 60 s 内全部重连 准入与错峰 takeover_admit_rate 生效;AUTH P99 ≤ 稳态 × 2;无用户 > 60 s 无法登录;PULL_MAILBOX 峰谷比 ≤ 3
大群消息风暴 对 10 万人群按 per_conversation_msg_rate 上限的 3 倍输入 准入闸门 超限请求返回 RATE_LIMITED 或 FANOUT_QUOTA_EXCEEDED;静默丢弃数 == 0;同分片其他 lane 的水位推进 P99 ≤ 2 s
聊天室洪峰 100 万在线 + room_msg_rate 上限 3 倍 隔离性 房间流量不挤占控制流(PONG 延迟 P99 ≤ 1 s);因聊天室导致的持久会话连接断开数 == 0;UserMailboxEntry 新增 == 0

27. 演进与兼容

v1 完全没有这一章。缺少它的后果是:协议一改就全网不兼容,存储一改就无法回滚,客户端本地库 与服务端各自演化出两套事实。本章给出三条演进路径的强制规则。

27.1 协议演进

27.1.1 版本协商

第 1 层  TLS ALPN:qim/1(自定义帧)| http/1.1(WSS 升级)
         ALPN 只区分承载方式,不区分应用协议版本。

第 2 层  FrameHeader.version(附录 A.1):应用协议大版本。
         客户端首帧 AUTH 使用它支持的最高版本 Vc,并在 AUTH.client_version 中
         携带完整客户端版本串。
         服务端在 AUTH_OK 的帧头中回写生效版本 Ve = min(Vc, Vs)。
         客户端此后所有帧一律使用 Ve;服务端对 version != Ve 的帧按 27.1.2 处理。

第 3 层  AUTH.capabilities(附录 A.3):能力位,用于宣告可选能力
         (E2EE、压缩算法、聊天室、离线推送)。服务端把它落进
         PresenceEntry.capabilities(§7.10),投递侧据此决定是否下发某类事件。
  • 大版本只在帧头布局或语义不兼容时递增;新增帧、新增字段一律不递增大版本。
  • 服务端必须同时支持 当前版本与前一个大版本,双版本共存期 ≥ 2 个客户端强制升级周期。

27.1.2 未知 opcode 与未知字段

硬规则:未知内容必须可安全忽略,绝不允许导致整批失败或游标卡死。

情况 接收方动作 禁止行为
客户端收到未知 opcode 按 body_len 跳过整帧,计数 unknown_opcode_dropped,继续处理后续帧 断开连接、丢弃整个批次、停止推进游标
服务端收到未知 opcode 丢弃并计数;若该帧 request_id != 0,回 ERROR{code=UPGRADE_REQUIRED}(客户端版本过新时不会发生,出现即为实现缺陷) 断开连接、静默不响应导致客户端超时重连风暴
消息体中的未知字段 tag 按 tag-length 编码跳过并保留原始字节,重写该结构时原样回写 丢弃未知字节(会导致主备/新旧节点物化结果分叉)
MAILBOX_BATCH 中未知 event_type 的 entry 仍计入 covered_through_seq 与游标推进,只是不渲染;计数 unknown_event_skipped 因为不认识而不推进游标 —— 这会让老客户端永久卡在同一位置
未知 custom_type 按 §13.3 展示通用占位;counts_unread / affects_session_order 取自服务端下发的类型级契约,不需要解析载荷 整批同步失败
未知 ERROR.code 按 retry_after_ms 退避;无该字段则按 reconnect_backoff 处理 当作致命错误清空本地数据
  • event_id 的哈希输入元组(§6.7)永远不得包含新增字段,否则新旧版本节点会算出不同 event_id,主备物化结果分叉。这条是 27.1.2 与 27.2.1 的共同约束。

27.1.3 minimum_client_version 与强制升级

每个 custom_type 与每个新增能力都声明 minimum_client_version(§13.3)。
服务端在 AUTH 时比对 AUTH.client_version:

  client_version < 该能力的 minimum_client_version
      -> 不下发该能力相关事件;相关 entry 仍写入邮箱,客户端按 27.1.2 跳过
  client_version < 全局 minimum_client_version
      -> ERROR{code=UPGRADE_REQUIRED},不建立会话

全局 minimum_client_version 的提升流程(禁止一次性生效):
  1. 公告期 >= 90 天,期间在 AUTH_OK 之后下发提示(不阻断)
  2. 按租户灰度提升,单租户内按 user_bucket 分批
  3. 任一批次的 UPGRADE_REQUIRED 触达率超过 5% 即暂停并复评

27.1.4 灰度发布与回滚

灰度维度(按顺序放大):
  单节点 -> 一个 ConnectionShard -> 5% ConnectionShard -> 一个租户 -> 全量
邮箱侧灰度维度:单 lane -> 一个 MailboxShard -> 5% 分片 -> 全量

灰度期间必须同时满足(任一不满足立即回滚):
  mailbox_seq_regression_count == 0
  持久消息丢失数 == 0
  AUTH 成功率不低于基线 - 0.5%
  ONLINE_READY P99 不高于基线 × 1.2
  unknown_opcode_dropped / unknown_event_skipped 不高于预期值 × 2

回滚约束:
  协议变更必须"前向可回滚"——新版本写入的数据必须能被前一个大版本读取。
  凡是不可回滚的变更(改变 event_id 哈希输入、改变 mailbox_seq 位布局、
  改变分区键)一律走 27.2.3 的双写迁移,不走灰度发布。

27.2 存储演进

27.2.1 新增字段的默认值规则

1. 新增字段必须可省略,且缺省值在语义上等价于"该特性未启用"。
   禁止把"缺省"重新解释为一个业务含义(例如把缺省的 muted 解释为 true)。
2. 新增字段一律追加在结构末尾,禁止复用已废弃字段的 tag。
   废弃字段的 tag 永久保留(tombstone tag),只标注 deprecated。
3. 新增字段禁止进入以下确定性输入:
     event_id 的哈希输入元组(§6.7)
     dispatch_id 的哈希输入元组(§7.8)
     mailbox_seq / message_id / room_seq 的位布局(§6.2、§6.5、§6.6)
4. 读到未知 tag 的节点必须保留原始字节并在重写时回写(27.1.2)。
5. 新增字段若参与排序或未读计算,必须同时给出"老数据缺该字段时的确定性回退规则",
   并在用例 26.4.3 的逐字段 diff 中加入该字段。

27.2.2 projection_version 的用途

projection_version(§7.6、§7.13)只用于结构演进的兼容判定,不参与并发控制。

读取时:
  stored.projection_version == code.projection_version   -> 直接使用
  stored.projection_version <  code.projection_version   -> 按升级函数链惰性升级后使用,
                                                            并在下一次 flush 时写回新版本
  stored.projection_version >  code.projection_version   -> 拒绝写入、告警、
                                                            按 projection_mailbox_seq 从邮箱重建
                                                            (禁止用旧代码覆盖新结构)

升级函数必须是纯函数:输入 (旧结构, projection_mailbox_seq),输出新结构,
不得读取外部状态,否则重建结果不可重现。
若某次演进无法用升级函数表达,则把该用户的 projection_mailbox_seq 回退到
检查点位置并从邮箱重放(§12.7),代价是一次重放,不是数据丢失。

27.2.3 数据迁移:双写与影子读

阶段 1  双写      新旧两套结构同时写,读仍走旧结构。
                  双写失败策略:新结构写失败只告警不阻断主链路;
                  旧结构写失败按原路径处理。
阶段 2  影子读    读请求同时读新旧结构,返回旧结构结果,后台逐字段比对并采样上报。
                  放行判据:shadow_read_diff_ratio < 0.01% 且连续观察 >= 24 小时
                  (shadow_read_diff_ratio_max = 0.0001、
                   shadow_read_observe_hours = 24;两者见附录 B.5.3)
阶段 3  切主读    按 27.1.4 的灰度维度切换读路径;保留旧结构写入,随时可切回。
阶段 4  停旧写    切主读稳定运行 >= 7 天且回滚演练成功一次后,停旧写。
阶段 5  清理      再等待 >= mailbox_retention_days(数值见附录 B.3)后删除旧结构数据。

禁止跳过阶段 2 直接切读;禁止在阶段 4 之前删除旧数据。

27.2.4 建表后不可变项清单

以下项一经确定即不可在线变更;变更等价于新建集群 + 全量迁移(27.2.3):

不可变项 位置 变更后果
virtual_bucket_count 附录 B.1(含 blake3 取前 8 字节大端的算法规则,§5.2) 全部用户的桶归属改变,游标与分片映射全部失效;改哈希算法或取字节规则与改桶数后果等价:游标令牌、PresenceDirectory 分区订阅、成员 Bitmap 分片同时失效,等价重建集群
lane_count 附录 B.1 / §6.5.1 lane_id = blake3(tenant_id, user_id)[0] & (lane_count-1),哈希族与取首字节规则同为不可变项,改哈希与改 lane_count 后果相同:改变它会重排全部用户的 lane 归属,已发放游标中的 lane_id 与 lane 向量水位全部失效;这也是 lane_id 与 user_bucket、分片数解耦后仍不可在线变更的唯一维度
message_seq_bucket_width 附录 B.2 / §7.1 seq_bucket 计算改变,历史消息按旧宽度分桶后不可寻址
所有结构的分区键(§7.0) §7 数据物理位置改变,无法在线重排
mailbox_seq 位布局与 shard_epoch 取值上界 §6.5 已发放的游标不可比较;放宽 shard_epoch 上界会让 mailbox_seq 在有符号 64 位介质上编码为负数,范围查询静默漏数据(位宽与上界的唯一定义在 §6.5,本表只引用)
room_seq 位布局与 room_epoch 取值上界 §6.6 同上,影响 RoomRecord 的聚簇键排序(唯一定义在 §6.6)
message_id / last_activity_id 位布局与纪元 §6.2、§6.4 全局排序键不可比较
event_id / dispatch_id 的哈希算法与输入元组 §6.7、§7.8 主备物化结果分叉、幂等键失效
event_ordinal 的 event_type 优先级映射(MESSAGE=0/MENTION=1/MEMBERSHIP=2/CONTROL=3) §6.7 参与确定性物化与聚簇排序,改动导致主备重放分叉(event_id_nondeterminism_total > 0)
ScyllaDB 表的聚簇顺序(conversation_seq DESC 等) §7.0 读路径全部改写

lane_count 与 shard_epoch 上界的验收断言分别见用例 26.3.6 与用例 26.6.6。

以下项可变,但必须递增 shard_epoch 并触发游标 rebase(§6.5、ADR-0004):

mailbox_shard_count   (分片分裂/合并,§5.5;lane_id 不随之改变,§6.5.1)
分发日志的 topic / 分区重建
跨集群灾备切换

27.3 客户端本地存储与端上一致性

27.3.1 本地库必须保存什么

本地表 内容 保留策略
local_message 按 (conversation_id, conversation_seq) 组织的消息;含 message_id、state、timeline_anchor_seq、event_ordinal 按会话保留最近 N 页 + 用户置顶引用
local_pending 未确认的发送:client_message_id、client_send_ts、载荷 收到 SEND_ACK 后原位升级删除;超 client_pending_max_age(附录 B.5.1)标记失败
local_conversation 会话列表:last_activity_id、preview、unread_count、unread_exact、mention_count、projection_mailbox_seq、pin_rank、muted 全量保留(≤ max_conversations_per_user,数值见附录 B.5)
local_cursor MailboxCursor 令牌 + 明文 last_applied_mailbox_seq + lane_id 单条;只在 MAILBOX_BATCH 应用完成后推进
local_dedup 已应用的 message_id 与 (mailbox_seq, event_ordinal, event_id) 三元组 按 client_dedup_window_items 与 client_dedup_window_hours 双重上界淘汰,先到者生效(数值见附录 B.6)
local_media_cache 缩略图与已下载对象 按字节 LRU,与消息表解耦
local_meta local_schema_version、device_id、生效协议版本 Ve 常驻
  • local_dedup 的淘汰窗口必须大于 fanout_retry_max_window(附录 B.2)与一次重连的最坏 重放区间,否则会出现重复气泡。附录 B.6 的默认值必须始终满足该约束。
  • 客户端不得持久化任何服务端未下发的推导序号(例如自己给消息编号), 排序一律用 §6.9 的排序键。

27.3.2 本地与服务端冲突的解决规则

唯一规则:一律以服务端的 conversation_seq 与 projection_mailbox_seq 为准。

会话内消息内容与状态:
    同一 (conversation_id, conversation_seq),服务端版本覆盖本地版本。
    撤回/编辑按 target_conversation_seq 原地更新(§6.9.1),本地不新增时间轴行。

会话列表与未读:
    SESSION_DELTA / SESSION_LIST_BATCH 的 projection_mailbox_seq 更大者胜,
    绝对值整体覆盖;禁止任何累加型合并(用例 26.4.4)。
    本地乐观更新(点进会话立即清零未读)允许,但必须在收到服务端绝对值后无条件回正,
    即使回正会让未读数"变大"。

用户主动状态(置顶/静音/隐藏/已读):
    本地乐观生效 -> 上行 -> 以服务端 UserConversationState 的逐字段合并结果为准(§7.5)。
    水位类字段(read_conversation_seq 等)本地只进不退。

发送中的消息:
    本地 pending 恒排在已确认区之后;SEND_ACK 到达后按 client_message_id 原位升级。
    连接存续期间按退避盲重试同一 client_message_id,由幂等窗口兜底(附录 B.2)。
    断线重连后重发任何 pending 之前,必须先完成邮箱同步并按 client_message_id
    对账(ADR-0008):同步到的自发条目命中 pending → 原位升级为已确认,不重发;
    对账完成仍未命中 → 按原 ID 重发(未命中即未提交,重发不产生重复)。
    禁止重连后未对账即盲重发。
    超过 client_pending_max_age(附录 B.5.1)仍未确认的 pending 标记失败,
    用户手动重发时生成新的 client_message_id。

冲突不可解时的兜底:
    只允许"丢弃本地、以服务端为准",禁止"以本地为准覆盖服务端"。

27.3.3 本地数据版本迁移

local_schema_version 只前向迁移,逐版本执行迁移函数,不允许跨版本跳跃。

迁移失败或版本高于当前代码(降级安装)时:
    1. 丢弃 local_message / local_conversation / local_dedup
    2. 丢弃 local_cursor                      ← 关键:不得保留游标
    3. 按 §9.6 新设备流程重建
理由:保留游标 + 丢弃消息 = 服务端认为已同步、本地却没有数据,
      这正是评审 001 的 B-1 同类静默丢消息路径,必须从客户端侧一并封死。

local_pending 在迁移中必须保留:它是尚未提交到服务端的用户数据,
迁移后按 client_message_id 重试(重连场景,须先完成登录对账 §27.3.2;幂等窗口见附录 B.2 与 ADR-0008)。

27.3.4 卸载重装等价于新设备

卸载 -> 本地库与 device_id 一并销毁
重装 -> 生成新的 device_id -> 新设备(§9.6):
        1. 拉取会话列表快照(可带 projection_complete=false 先展示)
        2. 获取保留期内最近的个人邮箱窗口
        3. 每会话只加载最近一页消息
        4. 更早内容按 PULL_HISTORY 分页

约束:
  不得复用旧 device_id 以"继承"旧游标 —— 旧游标可能早于 mailbox_trim_watermark,
  继承后会走 CURSOR_EXPIRED,路径更长且语义更差。
  新 device_id 计入 max_devices_per_user(数值见附录 B.5);超限时按最久未活跃淘汰,
  被淘汰设备收到 KICKED{reason=replaced}。
  device_inactive_gc_days(数值见附录 B.3)后旧 device_id 的游标与 PresenceEntry 被回收。

27.4 能力开关与租户策略

所有开关有三个属性:作用域、生效方式、是否可回退。不可回退的开关必须在开通前 经过 ADR 评审。

能力开关 取值 作用域 生效方式 可否回退
e2ee_enabled on / off 租户 + 会话类型(单聊、≤N 人群) 会话创建时固化,存量会话不变更 不可回退(已有密文无法在服务端解密回明文)
mailbox_write_policy always(默认)/ mention_only 租户 + 会话规模档 按新消息生效,存量邮箱不回填也不删除 可回退(回退后新消息恢复全员写;回退期沉默成员的未读按 delivered_conversation_seq 估算)
search_enabled on / off 租户 开启后开始建索引,历史按需回填 可回退(关闭即停索引并按 §21.4 删除索引)
push_enabled on / off 租户 + 平台(APNs/FCM/自建) 立即生效 可回退
chatroom_enabled on / off 租户 立即生效 可回退(关闭后 ROOM_* 帧返回 PERMISSION_DENIED)
include_muted_in_badge true / false(默认值见附录 B.6) 租户 下一次角标聚合生效(§7.7);取 false 时静音会话的未读仍由 UserBadgeState.muted_unread 统计并随 BADGE_UPDATE 下发,只是不计入系统角标 可回退
mailbox_retention_days 取值范围与默认值见附录 B.3 租户 下调立即影响裁剪水位;上调只对新数据生效 下调不可回退(数据已删)
compliance_hold on / off 租户 + retention_class 立即生效,覆盖所有自动删除 可回退(解除后恢复正常保留策略)
开关变更的强制约束:
  1. 任何开关变更都必须写入审计日志,含操作者、时间、旧值、新值、生效范围。
  2. 影响存储格式或删除路径的开关(e2ee_enabled、mailbox_retention_days、
     compliance_hold)变更前必须先做一次全量备份可用性验证。
  3. 开关不得在同一租户内按用户细分(除 e2ee 按会话固化外),
     否则同一会话的成员会得到不一致的未读与可见性。
  4. mailbox_write_policy 的切换条件与最小改动集见 docs/adr/0003-large-group-fanout-policy.md,
     不得在专题文档中另立触发阈值。

28. 后续文档拆分

本文审核通过后按下表建立专题文档。每份文档只能细化实现,不得重新定义契约核心。

契约核心 = §5.1 实体与命名表、§6 标识/序列/游标、§7 核心数据模型、附录 A 协议帧与错误码总表、附录 B 默认参数表。 专题文档若需要改动契约核心,必须先修改本文或新增 ADR,再改文档与实现。直接在专题文档中 引入新序列、新帧、新错误码、新参数名的行为,一律按缺陷处理。

文档 内容边界 必须引用本文的哪些章节 明确不得重新定义
README.md 项目定位、能力清单、文档索引、快速上手路径 §1、§2、§28、§29 全部契约核心
docs/00-system-overview.md 系统总览、完整架构图、服务清单、请求全链路时序 §3、§4、§5.1、§5.4、§17、§23 §5.1 实体命名;只能使用 §5.1 表中的唯一写法,禁止使用该表"禁止别名"列中的任何写法
docs/01-connection-protocol.md opcode 字节值、帧头字节布局、编码格式、压缩、TLS/ALPN、WebSocket 承载、多路复用实现 §5.3、§6.8、§15、§27.1、附录 A(含 A.4.1 同步条目结构) 附录 A 的帧集合与语义、错误码含义、FrameHeader 字段集、PONG 不携带水位字段
docs/02-message-model-and-storage.md ScyllaDB 建表 DDL、压缩策略、二级查询、MessageStore 读写路径、媒体元数据编码 §6.2~§6.4、§7.0~§7.2、§7.4、§18、§25.1 §7.0 的分区键/聚簇键/单分区上界、retention_class 取值
docs/03-mailbox-and-group-fanout.md MailboxStore 接口与两种实现、lane 调度、dispatch 分块、Bitmap 与槽位、故障恢复算法 §6.5、§6.7、§7.3、§7.8、§7.9、§9、§10、§19.3 mailbox_seq 复合格式、lane 推导式、事件组上限、event_id 哈希输入
docs/04-session-list.md 投影压缩器实现、内存快照层、分页游标编码、未读重算、角标聚合 §6.9.3、§7.5~§7.7、§12、§25.3 SESSION_DELTA 的绝对值语义、未读权威定义式、projection_mailbox_seq 作为唯一版本源
docs/05-chatroom-and-control-message.md 房间广播实现、回放窗口、控制消息与自定义消息注册表、RTC 信令转发 §6.6、§7.12、§13、§14 room_seq 格式、瞬时消息不入邮箱的边界、自定义消息契约字段
docs/06-microservices-and-deployment.md 服务拆分与部署拓扑、扩缩容、主备、跨地域、重分片操作手册 §5.1、§5.5、§17、§19.2、§27.2.4 实体命名、不可变项清单
docs/07-reliability-security-operations.md fencing 实现、检查点参数、鉴权与令牌生命周期、限流实现、运维手册与告警响应 §8、§19、§20、§24 错误码语义、fencing_epoch 与 state_version 的并发规则
docs/08-test-and-capacity-plan.md 测试用例代码、压测脚本、混沌注入工具、容量参数实测回填 §25、§26、附录 B.7 §26 的通过判据数值(只能加严,不得放宽);附录 B 的默认值
docs/09-push-and-badge.md APNs/FCM/VoIP 接入、device token 生命周期、推送去重与频控、静音生效层、角标下发 §5.4、§7.7、§7.10、§12.5、§16、§22、附录 B.5.2 UserBadgeState 聚合口径(含 muted_unread)、total_unread 的唯一数据源、离线推送必须按设备粒度判定(§16.2)
docs/10-retention-deletion-compliance.md 加密擦除实现、删除清单执行器、导出与 WORM、审计与数据驻留 §7.1(retention_class、dek_id)、§18.3、§21、§22、用例 26.7.3 删除清单条目(只能增加,不得删减)、compliance_hold 语义
docs/adr/ 逐条重要决策的背景、决策、后果与复评条件 全文 ——

已建立的 ADR:

docs/adr/0001-mailbox-store-selection.md      邮箱存储分阶段选型(§18.1)
docs/adr/0002-client-transport.md             接入协议与回落链路(§5.3)
docs/adr/0003-large-group-fanout-policy.md    大群投递策略与降级档(§10.2)
docs/adr/0004-mailbox-seq-composition.md      mailbox_seq 复合序号与 epoch(§6.5)
docs/adr/0005-small-conversation-body-inline.md  小会话正文内联与 §3 禁令 4 收窄(§3、§7.3、§18.3.1)
docs/adr/0006-server-language-and-log.md          服务端语言 Rust + 日志 Redpanda(§7.9、§18.2、§23.0)
docs/adr/0007-phase-one-deployment-tier.md        一期简化形态与不可变项清单(§2.2、§7.12、§17.1、§18.1、§27.2.4)

后续新增 ADR 的触发条件:任何改动契约核心的提议、任何引入新外部依赖的提议、任何 改变投递语义或删除语义的提议,一律先出 ADR。


29. 当前默认决策

每条一句话,可作为快速索引;括号内是本文对应章节。

接入与连接

  • 客户端实时主链路为 TCP/TLS 自定义二进制协议,浏览器用 WebSocket 承载同一协议,端口固定 443 并用 ALPN 协商(§5.3.5、§5.3.6、ADR-0002)。
  • 回落链路顺序为 ALPN qim/1 → WSS 443 → 经系统代理 CONNECT 的 WSS 443,每级超时 5 秒;QUIC/WebTransport 列为二期(§5.3.2、§5.3.5、ADR-0002)。
  • L4 只做四层直通并透传源地址,分片亲和完全由应用层 REDIRECT 路由令牌达成,单连接最多重定向 1 次(§5.3.1、§5.3.2)。
  • 心跳自适应:起始 60 秒、前台上限 120 秒、后台上限 240 秒,idle_timeout = next_ping_interval × 2 + 10 s,由服务端下发(§15.1.2、§15.1.3、附录 B.4)。
  • PONG 回 last_pushed_user_seq 而非分片水位,且不携带任何水位字段;客户端严禁把个人游标与分片水位比较(§6.10.1、§15.1.1)。
  • lane_watermark 只出现在 AUTH_OK 与 MAILBOX_BATCH;全局兜底拉取用 PULL_MAILBOX(up_to_seq=0) 取"拉到当前水位",由服务端填充并回带最新上界(§6.10.2、附录 A.3、附录 A.4)。
  • mailbox_trim_watermark 只随 AUTH_OK 下发,连接期内的裁剪推进由 ERROR{CURSOR_EXPIRED} 暴露(§6.5.2)。
  • 发送侧独立超时:3 秒无 SEND_ACK 即探测,再 3 秒判链路失效,端到端不可用检测 ≤ 7 秒(§15.1.4)。

路由与在线

  • 用户经稳定虚拟桶固定到逻辑 MailboxShard 与 ConnectionShard,禁止按物理节点数取模(§5.2)。
  • 在线目录 PresenceDirectory 为独立权威来源,唯一写入方是持有租约的 ConnectionNode,经 compacted topic 发布,推送路径零同步远程调用;吊销/封禁复用同一 compacted topic 的控制记录(§5.4、§7.10)。
  • 在线用户 Bitmap 只作"至少一个设备在线"的快速过滤器,per-device 明细一律取自 presence 缓存(§5.4.3、§16.1.2)。
  • 离线推送按设备粒度判定:一个用户手机离线、桌面在线时手机必须收到推送(§16.2、用例 26.5.6)。

序列与游标

  • mailbox_seq 采用 (shard_epoch, log_offset) 复合序号,整体无符号比较,永不回退;位宽与 shard_epoch 取值上界的唯一定义在 §6.5(§6.5、ADR-0004)。
  • 物化水位由标量改为 lane 向量,lane_id = blake3(tenant_id, user_id)[0] & (lane_count-1) 与 user_bucket 及分片数完全解耦,对用户暴露的恒为 W[lane(user)],min(W[..]) 只用于检查点与接管判定(§6.5.1、附录 B.1)。
  • 新增 mailbox_trim_watermark:只有 after_seq >= trim_watermark 时空洞才可跳过,否则必须 CURSOR_EXPIRED(§6.5.2、§9.3)。
  • 游标令牌承载不可伪造部分,last_applied_mailbox_seq 明文传输并由服务端校验上界(§6.8)。
  • 设备游标只能由 MAILBOX_BATCH 连续推进,实时 PUSH_EVENTS 不得越位推进(§6.8)。
  • event_id 为确定性哈希,明令禁止随机 UUID;created_at 取自 dispatch 记录而非本地墙钟(§6.7)。
  • 会话内排序键为 (timeline_anchor_seq, event_ordinal, event_id),会话列表排序键为 last_activity_id;mailbox_seq 与 room_seq 禁止参与 UI 排序(§6.9)。
  • 禁止用 conversation_seq 或 mailbox_seq 的差值判丢消息,禁止按时间戳补拉(§6.10)。

存储

  • MailboxStore 是抽象接口,阶段一用 ScyllaDB 实现,达到四条判据之一后切换自研 LSM 实现(Go 生态用 Pebble,Rust 生态用 RocksDB)(§18.1、ADR-0001)。
  • MessageRecord 按 (tenant_id, conversation_id, seq_bucket) 分区,桶宽 4096 且建表后不可变(§7.1、§27.2.4)。
  • 每用户会话数上限 5000,每用户设备数上限 8(附录 B.5)。
  • 消息正文只存一份,邮箱只存轻量引用(entry_logical_bytes 见附录 B.7);正文由 MailboxNode 在读路径从 LRU 或 MessageStore join,body_included=false 时客户端走 PULL_HISTORY 补取且不得视为丢消息(§7.3、§10、附录 A.4.1)。
  • 图片、视频等原始大文件进对象存储,消息只保留缩略图与对象引用(§7.1、§18.2)。
  • 默认技术候选:ScyllaDB、Redpanda/Kafka、Pebble/RocksDB、S3/MinIO、RoaringBitmap,正式实施前必须基准测试(§18)。

投递语义

  • 至少一次投递 + 客户端按 message_id/事件三元组幂等去重,禁止宣称 exactly-once(§11.2)。
  • 大群默认全员写扩散 mailbox_write_policy = always,mention_only 作为预留降级档(§10.2、ADR-0003)。
  • 加群不回填历史邮箱引用,只写一条 MEMBERSHIP 事件,历史一律走 PULL_HISTORY(§10.1.3)。
  • 可见区间由序号边界定义:(max(joined_at, hidden_before, deleted_before), left_at ?? +∞)(下界为开区间,§7.5)。
  • 在线推送前必须先可靠物化邮箱引用;推送失败由邮箱恢复,不回滚(§11.1.2)。
  • MAILBOX_BATCH.entries[] 与 PUSH_EVENTS.events[] 共用同一条目结构,客户端复用同一套解析、去重与投影更新路径(附录 A.4.1)。
  • 分块进度与邮箱条目在同一原子提交内写入,或采用等价的"严格顺序(先条目后进度)+ 确定性幂等重放";阶段一的 ScyllaDB 实现必须走后者(§7.8、§10.4.1)。
  • 三层准入:会话级按群规模分档限速、发送者级 1 msg/3 s、租户级 fanout 令牌桶;超限显式拒绝,绝不静默丢弃(§8.2、附录 B.5)。

会话列表与未读

  • 三层模型:ConversationHead + UserConversationState + UserSessionProjection(§12.2)。
  • 目标态 SESSION_DELTA 为幂等绝对值帧,版本源为 projection_mailbox_seq,禁止任何累加型合并;当前 qsession 尚未按用户落地该版本源,恢复边界是 per-partition dispatch checkpoint(§12.6、§17.2)。
  • 未读以定义式为权威、unread_count 为缓存;超过 unread_precise_limit 时 unread_exact=false(§12.5、附录 B.6)。
  • "标记未读"由 UserConversationState.manual_unread_conversation_seq 承载,跨设备同步、投影重建后仍存在,read_conversation_seq 前推到 >= 该值时自动失效(§7.5、§12.9)。
  • 目标态全局角标由 UserBadgeState 提供绝对值;当前 UserBadgeState/NotificationService 链路未闭环,不得宣称已与投影同 WriteBatch(§7.7、§16.3)。
  • 当前投影与 checkpoint 归属 qsession:独立消费 dispatch,全部 recipients 的 Redis ZADD GT 成功后才 CAS 推进 per-partition next offset;MailboxNode 的 Projection 只作尽力低延迟补充(§17.2)。
  • 会话列表排序与快照发生在 qsession;未读由 canonical MessageStore + read state 在读路径定义式计算。完整会话集合的 UserConversationState 兜底仍是发布阻断(§9.6.2、§12.5、§17.2)。

聊天室与消息类型

  • 聊天室只用房间序列、实时广播与 room_log_retention_minutes 窗口内的短期回放,不建持久邮箱引用(§14、附录 B.3)。
  • 瞬时消息只发在线连接,不入邮箱、不影响会话列表(§13.2)。
  • 自定义消息必须声明类型级契约(counts_unread、affects_session_order、minimum_client_version 等),未知类型必须可安全忽略(§13.3、§27.1.2)。

可靠性、安全与合规

  • 单会话 Home Region 单写,切换用 fencing token 防双写;latest_conversation_seq 只进不退(§7.4、§19.2)。
  • MailboxNode 备节点只在 min(W[..]) 追平后接管;恢复流程为"检查点 + 日志重放",单分片 RTO ≤ shard_rto_target(§19.3、§26.6、附录 B.5.3)。
  • 分发日志保留期 log_retention_days 的下界只由 §19.3.3 的良定义不等式确定,其他章节不得另立系数(§19.3.3、附录 B.3)。
  • 当前 qsession 的 dispatch checkpoint 滞后必须小于 dispatch log recovery window;超过窗口时必须切到 UserConversationState 权威重建。mailbox_retention_days ≥ 投影压缩器最坏滞后 × 3 仅适用于 §12.3 的后续 mailbox-tail 形态。
  • 邮箱条目仅按时间窗口裁剪,不以任何单设备游标为条件(§6.5.2、§18.3)。
  • 令牌参数以 §15.3 为唯一规范:token_expiry_grace 为只读宽限,宽限期内允许 PING / PULL_MAILBOX / PULL_HISTORY,拒绝 SEND_MESSAGE / MARK_READ / RECALL / EDIT(§15.3、§20.3.1、附录 B.4)。
  • 每条密文可由 MessageRecord.dek_id(key_scope, key_id, key_version)定位到自己的 DEK 与密钥版本(§7.1、§21.3)。
  • 删除采用加密擦除路径,删除清单逐项可断言(§21.4、用例 26.7.3)。
  • E2EE 按租户 + 会话类型固化、不可回退;大群与聊天室的功能降级矩阵见 §22.3。
  • 帧完整性校验只覆盖帧头,body 完整性由 TLS 保证(附录 A.1)。
  • §24 的告警目标值不得宽于 §2.4 的 SLO,冲突时以 §2.4 为准(§2.4、§24.0)。
  • 分片数下界的唯一规范公式在 §25.6,其余章节只能引用不得另立推导(§25.6、附录 B.7)。

演进

  • 未知 opcode、未知字段、未知 event_type 一律可安全忽略,且不得阻止游标推进(§27.1.2)。
  • 存储迁移必须走"双写 → 影子读(差异率 < 0.01% 且观察 24 h)→ 切主读 → 停旧写"四阶段(§27.2.3)。
  • virtual_bucket_count、lane_count、message_seq_bucket_width、所有分区键、event_id 哈希输入、mailbox_seq / room_seq 位布局与 epoch 上界等为建表后不可变项(§27.2.4)。
  • 客户端本地库迁移失败时必须同时丢弃游标并按新设备重建,禁止"保留游标、丢弃消息"(§27.3.3)。
  • 客户端与服务端冲突一律以服务端 conversation_seq 与 projection_mailbox_seq 为准(§27.3.2)。

分片归属、裁剪与正文内联(本版新增)

  • mailbox_shard -> owner_node 的唯一权威是 ShardRegistry;读路径按订阅式本地缓存解析落点,日志消费用 assign() 静态指派,禁止消费者组自动 rebalance(§5.3.7)。
  • MailboxNode 接管起始位点 = min_j(W[j]) 对应的 log_offset + 1,不取消费者组已提交位点;W_floor[j] 持久化并在接管时校验(§10.4.2)。
  • 接管规则对热备与漂移两种形态同时适用,差别只是重放窗口长度(§10.4.2)。
  • 邮箱裁剪分两类:常规时间窗到期与超 max_mailbox_entries_per_user 的容量兜底。两者都可能 越过落后设备游标并必须显式返回 CURSOR_EXPIRED;区别是后者触发即 P2 告警,前者是正常 保留策略(§18.3.3)。
  • 读时 join 必须按 (conversation_id, seq_bucket) 分组批量读,禁止逐 message_id 点查——join 成本是 O(会话数) 不是 O(条目数)(§18.3.1)。
  • 小会话正文内联,判据为乘积字节预算 N × body_size <= inline_body_budget_bytes;判定由 ConversationWriter 一次性完成并写入 GroupDispatch.inline_body,MailboxNode 只执行不判定(§3、§7.3、§7.8、ADR-0005)。

大群客户端能力(本版新增)

  • 表情回应是第三类投递语义 ephemeral_aggregate:不产生 UserMailboxEntry、不计未读、不改排序、不触发离线推送;持久的是聚合结果 MessageReactionSummary 而非事件流(§7.15、§13.6)。
  • REACTION_UPDATE 与 SESSION_DELTA 一样是幂等绝对值帧,版本源为 summary_version(§13.6.2)。
  • 会话成员数超 reaction_detail_max_members 时停写回应明细,只累加聚合(§13.6.3)。
  • 成员变更事件默认只写当事人;成员数超 membership_event_broadcast_max_members 时禁止向其他成员广播,只在 PULL_MEMBERS 体现(§10.1)。
  • 10 万成员的成员列表必须分页拉取,@ 自动补全走服务端前缀搜索,禁止客户端本地过滤(附录 A.3 PULL_MEMBERS)。
  • Telegram 超级群的 per-channel 游标机制与本文 mention_only 档逐项同构,差别只在默认值与承诺;本文有意放弃"序号连续性判缺口"这一手段,改锚在邮箱层(§10.2.4)。
  • mention_only 的准入判据是 α 判据(N × (1-α) > read_diffusion_min_saving 且 α < read_diffusion_max_active_ratio),不是单一人数阈值;big_group_lazy_threshold 是反推出的等效值,属待实测(§10.2.3、ADR-0003 补充)。
  • 读扩散档的历史缺口由服务端显式告知:SESSION_LIST_BATCH 下发 write_policy 与 delivered_conversation_seq,客户端按 latest_conversation_seq 校验补齐。这是 §6.10.1「邮箱层是唯一锚点」的唯一例外,也是该档可默认开启的前置条件(§10.2.3)。
  • 档位切换有滞回(退出系数 0.8 + 最小保持 24 h),防止成员数贴阈值抖动造成邮箱条目分段(§10.2.3)。

实施决策(ADR-0006 / ADR-0007)

  • 服务端主语言 Rust(现行已接受决策),统一 tokio,禁止混合语言部署;提交与分发日志用 Redpanda,不使用 Apache Kafka(ADR-0006)。
  • Rust 日志客户端当前选用 rust-rdkafka(librdkafka C 绑定);其交叉编译与静态链接成本须计入交付计划,但它不在 §19.2.1 的 fencing 正确性路径上。纯 Rust 候选须先通过幂等生产、acks=all 与“dispatch durable 后才确认 source”的验证(ADR-0006、ADR-0012)。
  • 一期按简化形态交付:群上限 1000 人、MailboxStore 用 Redis(按天分桶 zset)、MailboxNode 无主备、聊天室进程内环形缓冲(ADR-0007、§18.1.2b、§7.12)。
  • 一期全部不可变项按目标档定死,尤以 lane_count = 64 为要——千人群完全用不上,但它是唯一一条今天不做以后再也做不了的大群准备(ADR-0007)。
  • 当前 Redis 仅支持 7.0.0+ standalone,必须 AOF + noeviction;版本不可证明或 Redis Cluster 均启动拒绝。 GroupMembership 跨槽、MessageStore 全局 {commit} 热槽和 ConnectionManager 无 Cluster 路由使局部 hash tag 不构成支持;未来只可在独立状态机/迁移工作后再评估(§18.1.2b)。
  • Redis 的 1 秒持久性缺口唯一兜底是 Redpanda 分发日志重放,二者是捆绑关系,不得单独简化(§18.1.2b)。
  • MailboxStore 三级演进:一期 Redis → 阶段一 ScyllaDB → 阶段二 自研 LSM(rust-rocksdb),切换按 §18.1.2 灰度 + 影子读,客户端无感。

附录 A 协议帧与错误码总表

本附录定义客户端可见的全部帧。docs/01-connection-protocol.md 可以定义 opcode 字节值、帧头布局、版本协商与编码细节,但不得新增或改变本表的语义。

A.1 帧头

FrameHeader {
    magic        u16    0x514D "QM"
    version      u8     协议大版本
    flags        u8     bit0 压缩  bit1 分片  bit2 加密载荷  bit3 需要 ACK
    opcode       u16
    request_id   u32    上行请求与下行响应配对;服务端主动帧为 0
    stream_id    u16    多路复用流标识,见 A.5
    body_len     u32    <= max_frame_bytes(默认 4 MiB)
    header_crc   u32    CRC32C,**只覆盖帧头**
}

完整性校验只覆盖帧头,不覆盖 body。 若覆盖整帧,一条 10 万人群消息会退化为 10 万次全帧扫描,与 §10.3 "公共正文只编码一次"的优化直接抵消。body 完整性由 TLS 记录层保证。

A.2 opcode 分段

0x0000 - 0x00FF   连接与会话     AUTH / AUTH_OK / PING / PONG / REDIRECT / KICKED / ERROR
0x0100 - 0x01FF   邮箱同步       PULL_MAILBOX / MAILBOX_BATCH / SYNC_COMPLETE / ONLINE_READY
0x0200 - 0x02FF   消息收发       SEND_MESSAGE / SEND_ACK / PUSH_EVENTS
0x0300 - 0x03FF   历史与会话列表  PULL_HISTORY / HISTORY_BATCH / PULL_SESSION_LIST /
                                 SESSION_LIST_BATCH / SESSION_DELTA / BADGE_UPDATE
0x0400 - 0x04FF   控制与状态     MARK_READ / RECALL / EDIT / TYPING / PRESENCE_SUB /
                                 READ_SYNC
0x0500 - 0x05FF   聊天室         ROOM_JOIN / ROOM_LEAVE / ROOM_REPLAY / ROOM_BATCH
0x0600 - 0x06FF   RTC 信令       RTC_SIGNAL
0x0700 - 0x07FF   媒体           MEDIA_TICKET
0x0800 - 0x08FF   E2EE 密钥      PREKEY_PUBLISH / PREKEY_FETCH
0x0900 - 0x09FF   表情回应       REACT / REACTION_UPDATE / PULL_REACTIONS / REACTION_LIST
0x0A00 - 0x0AFF   会话成员       PULL_MEMBERS / MEMBER_LIST_BATCH / CREATE_GROUP / LEAVE_GROUP
0xF000 - 0xFFFF   保留 / 厂商扩展

A.3 上行帧

帧 关键字段 说明
AUTH access_token, device_id, client_version, capabilities, mailbox_cursor 首包
PULL_MAILBOX after_seq, up_to_seq, max_items, max_bytes, acked_seq acked_seq 合并了原 MAILBOX_ACK,省一个 RTT。up_to_seq=0 表示"拉到当前水位",由服务端填充该用户 lane 的最新水位(§6.10.2)
SYNC_COMPLETE sync_to_seq 服务端校验其等于本次分配值
SEND_MESSAGE request_id, client_message_id, conversation_id, message_type, custom_type, payload, mention_targets, reply_to_conversation_seq custom_type 仅在 message_type=CUSTOM 时有意义,是业务自定义子类型的稳定标识(如 read_receipt / sys_notice / card.order)。
它必须是独立字段而不是编码进 payload:服务端与端上都需要在不解析正文的前提下按子类型分流(§13.3 类型级契约、离线推送文案选择、ephemeral_aggregate 类语义判定),而正文可能是 E2EE 密文——那时任何"从 payload 里读子类型"的方案都不成立。
长度上限 custom_type_max_bytes(附录 B.5);服务端只透传不解释,未知子类型照常投递。
PULL_HISTORY conversation_id, direction(older\|newer), anchor_conversation_seq, limit, max_bytes
PULL_SESSION_LIST snapshot_revision, page_cursor, limit
MARK_READ conversation_id, read_conversation_seq 只进不退
RECALL / EDIT conversation_id, target_conversation_seq, [new_payload] 必带定位坐标,正常路径不查 MessageIndex
TYPING conversation_id 瞬时,不入邮箱。双向帧,下行见 A.4
PRESENCE_SUB action(subscribe\|unsubscribe\|replace), targets[] 双向帧,下行见 A.4。订阅上限 presence_sub_max_targets
PING ping_id, client_time, last_applied_mailbox_seq, network_type, probe 不带 session_epoch(连接内不变)。probe=true 表示发送侧超时或 PONG 缺失触发的探测心跳(§15.1.4、§15.1.2),仅用于指标区分,服务端处理逻辑相同
ROOM_JOIN / ROOM_LEAVE / ROOM_REPLAY room_id, [after_room_seq]
RTC_SIGNAL conversation_id, target_user_id, signal_payload 只转发,不入邮箱
MEDIA_TICKET intent(upload\|download), object_id, bytes 换取签名 URL
REACT conversation_id, conversation_seq, reaction_key, action(add\|remove) 表情回应(§13.6)。幂等键 (tenant, conversation, seq, user, reaction_key),重复 add / 无效 remove 均返回成功
PULL_REACTIONS conversation_id, conversation_seq, reaction_key, offset, limit 拉"谁点了"的明细,仅用户主动查看时发起;limit <= reaction_detail_page_limit
PULL_MEMBERS conversation_id, filter(all\|admin\|banned\|search), query, offset, limit 成员列表分页拉取。10 万成员不可能全量下发,@ 自动补全必须走 filter=search 的服务端前缀匹配,禁止客户端本地过滤;limit <= member_page_limit
CREATE_GROUP group_id, member_ids[] 建群(建群即首次加群)。网关转发 writer 的 CreateGroup RPC(只创建不存在的群,与管理面 GroupAdd 分离);应答复用 MEMBER_LIST_BATCH(见 A.4),客户端按 request_id 关联即可确认建群成功。创建者由认证连接注入,member_ids 仅为邀请对象。建群初始成员 joined_at_conversation_seq = 0,建群本身不占序号
LEAVE_GROUP group_id 本人退群(0x0A04,ADR-0021)。退群者只取认证连接,帧内不携带用户。服务端提交一条成员控制消息(MESSAGE_TYPE_CONTROL、custom_type = qim.membership.v1),占用序号 J:left_at = J,J 及之后的消息对退群者零投递、历史与会话头不可见,J 之前的历史仍可读(§12.10 缺省策略)。应答复用 MEMBER_LIST_BATCH(members[] 为空、total_hint 为退群后人数),按 request_id 关联;非成员与群不存在不可区分,均返回 PERMISSION_DENIED。移除他人只开放受信管理面(GroupRemove),一期不开放群主踢人帧
PREKEY_PUBLISH identity_pub, signed_prekey{key_id, pub, signature}, one_time_prekeys[]{key_id, pub} 设备本人上传/补充 DevicePreKeyBundle(§7.14),整 bundle 覆盖写
PREKEY_FETCH target_user_id, [target_device_ids[]] 双向帧,下行见 A.4。取用目标设备预共享密钥以建立 E2EE 会话(§22.2)

A.4 下行帧

帧 关键字段 说明
AUTH_OK session_epoch, lane_id, lane_watermark, trim_watermark, sync_to_seq, has_offline, pending_entry_count_hint, pending_bytes_hint, total_unread, total_mention, muted_unread, badge_projection_mailbox_seq, projection_complete, preferred_endpoint, sync_delay_hint_ms, next_ping_interval_ms 合并了 v1 的 SYNC_REQUIRED/SYNC_EMPTY,登录风暴时每连接省一个 RTT
MAILBOX_BATCH entries[], covered_through_seq, lane_watermark, has_more 批次切分只能在 mailbox_seq 边界。lane_watermark 回带最新上界,使客户端无需从 PONG 获知水位。has_more = false 当且仅当 covered_through_seq == 请求的 up_to_seq(up_to_seq=0 时为服务端填充的该 lane W[lane]);客户端在 has_more=true 时必须以 covered_through_seq 为新 after_seq 续拉
ONLINE_READY — 解除登录屏障
READ_SYNC conversation_id, read_conversation_seq 跨设备已读水位同步。同一用户在任一设备 MARK_READ 且水位真的前进后,推给该用户的其他在线设备(不推给会话其他成员——那是已读回执,另一回事)。
刻意只带水位、不带 unread_count:带未读就要在写路径按定义式求值(§12.6),而未读是读路径的计算(projection.rs 头注释),搬到写路径等于每条消息多两次存储往返。收到的设备用本地定义式自行重算,零服务端成本。
是幂等绝对值帧:水位只进不退,重复/乱序/丢失都不影响最终值,因此不需要去重也不需要重传。
频率 = 用户读消息的频率,比消息到达频率低 1~2 个数量级。
PUSH_EVENTS events[], last_pushed_user_seq v1 从未命名此帧。条目结构与 MAILBOX_BATCH.entries[] 完全相同(见下方 A.4.1),客户端复用同一套解析与去重路径
SEND_ACK request_id, client_message_id, message_id, conversation_seq, last_activity_id 必须回带 client_message_id,否则 pending 气泡无法原位升级
HISTORY_BATCH messages[], latest_conversation_seq, earliest_available_conversation_seq, has_more 两个 *_conversation_seq 供客户端 O(1) 自检边界
SESSION_LIST_BATCH sessions[]{conversation_id, latest_conversation_seq, last_activity_id, last_message_id, preview_or_placeholder, unread_count, unread_exact, mention_count, mention_first_conversation_seq, pin_rank, muted, archived, **write_policy**, **[delivered_conversation_seq]**, **read_conversation_seq**}, snapshot_revision, next_page_cursor, has_more, projection_complete write_policy ∈ {always, mention_only} 告知客户端该会话的本地历史是否可能有洞;mention_only 时必带 delivered_conversation_seq 作为补齐锚点。缺此二字段客户端无法定位读扩散档的历史缺口(§10.2.3、§6.10.1)。
read_conversation_seq 是 §12.5.1 定义式的权威输入,user 级跨设备共享:客户端本地算未读必须用它当 base,不下发则换设备/重装后同步回来的历史全被计成未读(§12.5.1)。客户端只进不退合入;本地更高时回补 MARK_READ
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, mention_count, mention_first_conversation_seq 幂等绝对值帧,不再使用 unread_delta;driving_event_id 语义见 §12.6.2
BADGE_UPDATE total_unread, total_mention, muted_unread, badge_projection_mailbox_seq 在线走本帧,离线走 APNs/FCM 的 aps.badge,两者取值同源于 UserBadgeState(§7.7、§16.3)
PONG ping_id, server_time, echo_client_time, last_pushed_user_seq, mailbox_dirty, next_ping_interval_ms, idle_timeout_ms 不含 materialized_watermark,见 §6.10.1
REDIRECT route_token, connection_shard, endpoint_hint, exp 单次使用,TTL ≤ 60 秒
KICKED reason(replaced\|admin\|banned\|token_revoked), replaced_by_device v1 缺失,被替换连接无从告知
ROOM_BATCH room_id, room_epoch, events[]{room_seq, ...}, latest_room_seq, earliest_replay_room_seq, replay_truncated 兼作 ROOM_JOIN 与 ROOM_REPLAY 的应答。回放超出 room_log_retention_minutes 时返回空 events[] + replay_truncated=true,客户端直接跳到 latest_room_seq(不用 ERROR,因为这不是错误)
TYPING conversation_id, user_id, expires_in_ms 双向帧的下行方向(request_id=0)。合并窗口 typing_merge_window,有效期 typing_ttl
PRESENCE_SUB entries[]{user_id, state, last_active_at} 双向帧的下行方向。订阅应答与后续增量推送共用本帧,合并窗口 presence_sub_merge_window
PREKEY_FETCH bundles[]{device_id, identity_pub, signed_prekey, [one_time_prekey], one_time_exhausted} 双向帧的下行方向。one_time_prekey 每个只发放一次,发放即从 DevicePreKeyBundle 删除;耗尽时回带 SignedPreKey 并置 one_time_exhausted=true(§7.14、§22.2)
REACTION_UPDATE conversation_id, conversation_seq, counts, summary_version, [self_reaction_keys] 幂等绝对值帧(§13.6.2):counts 是全量映射不是增量,summary_version 更大才应用。语义与 SESSION_DELTA 完全一致,理由相同——推送通道至少一次且可丢弃,增量语义必然漂移。只推在线成员,永不触发离线推送
REACTION_LIST users[], total, has_more PULL_REACTIONS 的应答。会话成员数超 reaction_detail_max_members 时返回 total 与空 users[](明细已降级,§13.6.3)
MEMBER_LIST_BATCH members[]{user_id, role, joined_at_conversation_seq, display_hint}, total_hint, next_offset, has_more PULL_MEMBERS、CREATE_GROUP 与 LEAVE_GROUP(A.3)的应答。joined_at_conversation_seq 为该成员可见区间下界(开区间,入群事件序号减 1;建群初始成员为 0,ADR-0021)。total_hint 对大群为估算值(精确计数需全分片求和,不进同步路径)
ERROR code, retry_after_ms, detail, request_id + 按 code 携带 A.6 中为该 code 声明的附加字段 统一错误通道。附加字段示例:CURSOR_EXPIRED{trim_watermark, rebuild_required}、CURSOR_REBASED{new_cursor, replay_from_seq}

A.4.1 同步条目结构(MAILBOX_BATCH.entries[] 与 PUSH_EVENTS.events[] 共用)

Entry {
    -- 邮箱引用部分:逐字段对应 §7.3 的 UserMailboxEntry --
    mailbox_seq, event_ordinal, event_id, event_type,
    message_id, conversation_id, conversation_seq, last_activity_id, sender_id,
    visibility_floor_conversation_seq, flags, mention_type, created_at,
    [client_message_id] [origin_device_id] [target_message_id] [target_conversation_seq]
    [target_sender_id] [target_flags] [target_mention_type]

    -- 正文部分:内联条目直接取自存储(ADR-0005),其余由 MailboxNode 读时 join --
    body_included : bool
    [message_type] [custom_type] [schema_version] [payload_or_ciphertext] [media_metadata]
}
  • 线上态的形状与正文来源无关:内联条目与读时 join 的条目下发的是同一组字段, 客户端无法也不需要区分二者。内联只是让 body_included=true 多了一条为真的成因。
  • body_included=false 的三种成因:正文已被治理删除、已过 retention_class 保留期、 单批正文总量超过 max_frame_bytes。客户端据此走 PULL_HISTORY 补取或渲染占位,不得视为丢消息。
  • 同一 message_id 在一个批次内只 join 一次、只编码一次(§10.3)。
  • 两个帧使用同一结构,是为了让客户端的解析、去重、排序、投影更新走同一条代码路径—— 离线批量与在线推送的唯一区别是触发时机,不是数据形状。

A.5 多路复用

单个连接上使用 stream_id 做轻量多路复用,至少划分四条流:

stream 0   控制流:AUTH / PING / PONG / ERROR / KICKED —— 永不被业务帧阻塞
stream 1   实时流:PUSH_EVENTS / SEND_ACK / SESSION_DELTA / BADGE_UPDATE
stream 2   批量流:MAILBOX_BATCH / HISTORY_BATCH / SESSION_LIST_BATCH
stream 3   房间流:ROOM_BATCH

否则一个 4 MiB 的 MAILBOX_BATCH 会阻塞 PONG,导致心跳误判断连。

A.6 错误码

code 语义 客户端动作
CURSOR_EXPIRED 游标早于 mailbox_trim_watermark 走 §9.6 REBUILD
CURSOR_REBASED shard_epoch 落后但边界可解析(携带 new_cursor, replay_from_seq) 从 replay_from_seq 重放并幂等去重
CURSOR_INVALID 游标签名无效或 seq 越界 重新认证
MAILBOX_DIRTY 服务端已丢弃在途数据 重新 PULL_MAILBOX
SYNC_INCOMPLETE SYNC_COMPLETE 的 seq 与服务端不符 继续拉取
SHARD_MOVED 分片已迁移 按 REDIRECT 重连
REGION_FAILOVER Home Region 切换中 按 retry_after_ms 退避后重试;发送侧本地排队
RATE_LIMITED 触发用户级或会话级限流 按 retry_after_ms 退避
FANOUT_QUOTA_EXCEEDED 触发租户 fanout 配额 提示发送失败,不静默丢弃
TOKEN_EXPIRED 访问令牌过期 静默刷新后重连
TOKEN_REVOKED 远程登出或设备被吊销 清本地数据并回到登录页
UPGRADE_REQUIRED 低于 minimum_client_version 提示升级
CLOCK_UNSAFE 服务端时钟回拨超阈值 退避重试(服务端问题)
PAYLOAD_TOO_LARGE 超过 max_custom_payload_bytes 本地拒绝
PERMISSION_DENIED 租户/会话/角色鉴权失败 不重试
PREKEY_EXHAUSTED 目标设备的 OneTimePreKey 已耗尽且 SignedPreKey 降级被租户策略禁用(§7.14、§22.2) 提示稍后重试;目标设备上线补充后自动恢复

内部帧(不对客户端暴露):PushBatch(MailboxNode → ConnectionNode)、PRESENCE_STALE(ConnectionNode → MailboxNode)。


附录 B 默认参数表

所有数值均为默认值,可按租户配置。标注"待实测"的项在基准测试回填前不得用于容量评审结论或采购决策。

B.1 分片与路由

参数 默认值 约束
virtual_bucket_count 65536 建集群后不可变
bucket_hash blake3,user_bucket = be_u64(blake3(tenant_id||0x00||user_id)[0:8]) & (virtual_bucket_count-1) 建集群后不可变(与 §27.2.4 同级)
mailbox_shard_count 256(起步档 64) 2 的幂
connection_shard_count 1024 2 的幂
lane_count 64 2 的幂且 ≤ 256(lane_id 只取 blake3 输出首字节,§6.5.1)。建集群后不可变(lane_id 用 & (lane_count-1),改变它会重排全部用户)。lane_id 由独立哈希导出,与 virtual_bucket_count、mailbox_shard_count 无约束关系,见 §6.5.1
shard_lease_ttl 15 s
redirect_max_per_connection 1
route_token_ttl 60 s 单次使用

B.2 序列与幂等

参数 默认值
clock_regression_reject_ms 5000
conversation_seq_reserve_window 4096
message_seq_bucket_width 4096
client_dedup_ttl_seconds 7200(ADR-0008,配套 §27.3.2 登录对账义务)
mailbox_event_group_max_items 8
mailbox_event_group_max_bytes 8 KiB
fanout_retry_max_window 60 s(倒挂窗口上界与 local_dedup 淘汰窗口下界(§27.3),不是丢弃阈值)
membership_version_merge_window 30 s
slot_compaction_ratio 4

B.3 同步与保留

参数 默认值 说明
mailbox_retention_days 7 离线消息(邮箱)保留期,邮箱层容量的线性因子(ADR-0023,原 30)。日桶按 UTC 日绝对到期 (D + 7 + 1) × 86400 s,条目至少保留 7 整天;新设备(零游标)登录至少能拉回这 7 天的离线消息,更早的走 PULL_HISTORY
device_inactive_gc_days 60
room_log_retention_minutes 30
log_retention_days 1 d(24 h) 分发日志保留期。下界见 §19.3.3 的不等式
dispatch_progress_retention 7 d 必须 ≥ log_retention_days
membership_version_retention 8 d ≥ max(dispatch_progress_retention, log_retention_days) + 余量
client_resync_min_interval 5 s 全局兜底拉取限流
pull_mailbox_max_items 500 软上限,事件组不可切分
history_hot_window 7 d 近期历史留在 Redis 热层的时长(ADR-0018)。超过该时长且被归档水位覆盖(水位 − Outbox 恢复窗口 − 60 s 余量)的历史才从 Redis 裁剪;是容量参数(Redis 内存 ≈ 热窗口 × 写入速率 × 每条约 360 B),QIM_HISTORY_HOT_WINDOW_MS 可调,下限 1 h
default_history_retention_days 30 retention_class = default 的历史保留天数(ADR-0019)。QIM_DEFAULT_HISTORY_RETENTION_DAYS 可配,1..=default_history_retention_days_max(3650);writer(Redis 热层到期 GC)与 qim-archiver(ScyllaDB 逐行 TTL)必须同一值
history_archive_batch_max 512 HistoryArchiver 每批最多归档的 Outbox 记录数(ADR-0018),QIM_ARCHIVER_BATCH_MAX,1..=4096
history_max_empty_bucket_scan 8 PULL_HISTORY 向上翻页时允许连续扫描的空 seq_bucket 上限;超出即返回 has_more=false 并告警(防止 conversation_seq 的大段空洞退化为长扫描,docs/02)
pull_mailbox_max_bytes 512 KiB 软上限。开启内联(ADR-0005)后必须按实测内联比例重新标定:条目字节量从 110 B 升到最坏 inline_body_max_bytes + 110 B,本参数会先于 pull_mailbox_max_items 触顶而缩小批次条数
pull_mailbox_window 4 客户端并发在途请求数
read_sync_merge_window 3 s
trim_safety_margin 24 h 对应的时间跨度 裁剪安全余量;按时间而非 seq 数量取(§18.3.3)
pending_hint_error_ratio 20 % pending_entry_count_hint 允许的相对误差(估算值,§26.2.1)

阶段二 mailbox-tail 硬性约束:mailbox_retention_days ≥ 投影压缩器最坏滞后 P99.9 × 3。否则 TTL 到期会删掉尚未合并进投影的增量。当前 qsession 不使用该裁剪锚点, 其 checkpoint 必须留在 dispatch recovery window 内,窗口外切 UserConversationState 权威重建。 §24 的“投影压缩滞后 > 保留窗口 1/3”告警只适用于前述阶段二形态。

B.4 连接与心跳

参数 默认值
ping_interval_initial 60 s
ping_interval_max_foreground 120 s
ping_interval_max_background 240 s
ping_interval_step +30 s(连续 3 次成功后)
ping_backoff_factor ×0.8 并锁定 30 min(一次 PONG 缺失即触发)
ping_probe_timeout 3 s(PONG 缺失后 PING{probe} 的判死超时,§15.1.2;与 send_ack_timeout 后的探测超时同值)
idle_timeout next_ping_interval × 2 + 10 s
send_ack_timeout 3 s → 主动 PING{probe} → 再 3 s 判失效
tcp_user_timeout 15 s
tcp_keepalive idle 60 / intvl 10 / cnt 3
unauth_connection_timeout 10 s(slowloris 防护)
takeover_admit_rate 5 %/s(接管节点分批放行)
sync_delay_hint_ms 0 ~ 30000 随机
reconnect_backoff 1s 起,×1.8,上限 120s,±30% 抖动
presence_lease_renew 10 s
presence_lease_ttl 30 s
presence_propagation_target P99 ≤ 1 s
access_token_ttl 1 h
refresh_token_ttl 30 d
token_refresh_lead 5 min(过期前主动续期)
token_expiry_grace 5 min(只读宽限:允许 PING / PULL_MAILBOX / PULL_HISTORY,拒绝 SEND_MESSAGE / MARK_READ / RECALL / EDIT)
revocation_propagation_target P99 ≤ 3 s
authz_recheck_interval 5 min
transport_fallback_step_timeout 5 s(回落顺序每级超时,§4)
transport_choice_cache_ttl 7 d(上次成功传输方式的客户端缓存,§4)
heartbeat_wheel_tick 1 s(§15.1.4 心跳时间轮)
heartbeat_wheel_slots 512(§15.1.4 心跳时间轮)
pong_batch_window 10 ms(PONG 批量写出窗口,§15.1.4)
stream_scheduling_quantum 64 KiB(多路复用单次调度片,§15.4)
revocation_record_retention access_token_ttl + 安全余量(默认 2 h;吊销记录保留期,§15.3.3)

令牌相关参数以 §15.3 为唯一规范,§20.3 只引用不重复定义。

B.5 流控与限额

参数 默认值
max_frame_bytes 4 MiB
stream_write_chunk_bytes 64 KiB(多路复用的写出分片粒度;决定 stream 0 被低优先级大帧阻塞的时间上界,见 docs/01 §5.2)
frag_assembly_timeout 30 s(分片重组超时,超时丢弃并断连)
frame_compress_min_bytes 1 KiB(低于此值不压缩;已置 ENCRYPTED 标志的帧一律不压缩)
max_custom_payload_bytes 32 KiB
custom_type_max_bytes 64(SEND_MESSAGE.custom_type 的字节上限。取小值是刻意的:它是标识符不是内容,会随每条消息进邮箱条目与推送帧,放宽等于给每条消息加常驻开销)
media_thumbnail_max_bytes 32 KiB
conn_send_soft_watermark 1 MiB / 2000 条
conn_send_hard_watermark 4 MiB / 8000 条
conn_send_low_watermark 256 KiB(滞回,退出降级态)
node_send_buffer_budget min(节点可用内存 × 20 %, 8 GiB);单节点总发送缓冲上限,超出按优先级丢弃
帧优先级 控制流 > 单聊 > 小群 > 大群 > 聊天室
per_conversation_msg_rate N≤1000 → 20 msg/s;100010000 → 2 msg/s
per_sender_in_conversation_rate 1 msg / 3 s
per_user_msg_rate 20 msg/s
per_user_msg_rate_burst 40 条(§8.2 L1 连接本地令牌桶的容量,即允许的瞬时突发)。必须 ≥ per_user_msg_rate,否则稳态流量自身就会被限流。用令牌桶而非固定窗口计数:固定窗口在窗口边界允许 2 倍突发(窗口末尾用满 + 下个窗口开头再用满),令牌桶没有这个边界效应
tenant_fanout_quota entry/s 令牌桶,按合同配置
room_msg_rate 20 msg/s/房间
room_outbound_frame_rate 10 frame/s/连接(合并后)
max_devices_per_user 8
max_conversations_per_user 5000
per_ip_connect_rate 20 conn/s
per_user_history_pull_rate 5 req/s
per_user_media_upload_quota 200 次 / 2 GiB 每天
media_ticket_ttl 300 s
rtc_signal_max_bytes 8 KiB
quota_refill_interval 1 s
tenant_shard_share_limit 单租户占单分片写入预算上限 40%
tenant_quota_lease_interval 1 s(§8.2 配额令牌批量租借间隔)
per_ip_max_connections 200(企业 NAT 出口按租户白名单放大,§15.5)
at_all_rate_per_conversation 1 次 / 10 min / 会话(§11.4.2)
at_all_daily_quota_per_sender 10 次 / 天 / 用户(§11.4.2)
at_all_min_role 成员数 > large_group_member_threshold 时要求 ADMIN 及以上(§11.4.2)
moderation_sync_timeout_ms 300 ms(§20.5 发送前内容检查超时)
new_account_probation_hours 24 h(§20.5 新账号限制期)
media_orphan_grace 24 h(媒体引用计数归零后的物理删除宽限,§21.4)

B.5.1 邮箱与分发运行参数

参数 默认值
mailbox_writebatch_max_entries 2000
mailbox_writebatch_commit_p99 5 ms
mailbox_subtask_timeout 30 s
lane_stall_failover 120 s
lane_stall_alert 30 s
mailbox_takeover_rto_target 30 s
rebuild_concurrency 4
big_group_lazy_threshold 待实测回填(当前占位 10000)。它是 §10.2.3 两条准入判据反推出的等效人数阈值,不是独立可调项;回填依赖 conversation_active_member_ratio(§24.1.3)的分桶实测
read_diffusion_min_saving 待实测(单条消息进入读扩散档至少要省下的条目数)
read_diffusion_max_active_ratio 待实测(α 上界;超过则读扩散不划算)
policy_switch_exit_ratio 0.8(退出 mention_only 的滞回系数,§10.2.3)
policy_switch_min_interval 24 h(任一次档位切换后的最小保持时间)
active_window_days 7(mention_only 档的活跃判定窗口)
quota_sustained_window 15 min
push_coalesce_window 5 ms(Socket 写合并)
dedup_inflight_timeout 30 s
client_pending_max_age 24 h(与幂等窗口解除恒等,ADR-0008;重连后重发须先对账)
gc_grace_seconds 86400(防御性配置,不构成对修复周期的依赖:user_mailbox_entry 禁止显式 DELETE,§18.1.3)
membership_event_broadcast_max_members 500(超过则成员变更不向其他成员广播,只写当事人条目,§10.1)
dispatch_expand_duration_p99_target 500 ms(单 lane 子任务成员展开+落盘耗时 SLO,§24.1.3)
lane_watermark_advance_p99 2 s(lane 水位推进延迟 SLO,用例 26.3.6)
max_mailbox_entries_per_user 50000(强制裁剪触发阈值,§18.3.3;兜底而非常规,触发即 P2 告警)
mailbox_entry_cap_hysteresis 0.8(强制裁剪的目标水位系数,裁剪至阈值 × 该值)
inline_body_budget_bytes 8 KiB(N × body_size 的乘积上界,ADR-0005)
inline_body_max_bytes 2 KiB(单条正文上界;超过则一律不内联,防止撑大同步批次)

B.5.2 离线推送

参数 默认值
push_grace_window 3 s(物化后无 PUSHED/APPLIED 即补推;有 PUSHED 无 APPLIED 时转入 push_confirm_extended_window)
push_confirm_extended_window 18 s(= tcp_user_timeout 15 s + push_grace_window 3 s;PUSHED 后的延长确认窗口,§16.1.2)
push_merge_window 5 s
push_dedup_ttl 10 min
push_rate_per_user_per_min 10
push_rate_per_conversation_per_min 3
push_quiet_hours 租户配置,默认关闭
push_token_inactive_days 90
push_retry_max_attempts 3(退避序列 1 s / 4 s / 16 s,只对 5xx 与超时重试)
nse_pull_max_items 50(iOS Notification Service Extension 单次拉取上限)

B.5.3 一致性与容灾

参数 默认值
writer_lease_ttl = shard_lease_ttl(B.1,15 s)。同一参数的别名,禁止独立调参(§19.2.2:会话写入租约复用同一参数,独立调参会破坏防脑裂不等式)
writer_lease_renew_interval 5 s
writer_failover_wait 25 s(必须 > shard_lease_ttl,B.1)
writer_self_fence_deadline 12 s(自我隔离早于租约过期)
checkpoint_interval 15 min
checkpoint_full_multiple 每 8 次增量做一次全量
backup_retention_days 35 d
rpo_target ≤ 15 min
shard_rto_target ≤ 10 min
region_rto_target ≤ 60 min
home_region_migration_threshold 单会话 70% 活跃成员位于其他 region 且持续 7 天
hot_retention_days 30 d
dsar_response_days 30 d
user_deletion_grace 7 d(可撤销窗口)
region_failover_retry_after_ms 2000(±30 % 抖动,§19.2)
send_retry_budget 60 s(region 切换期发送重试总预算,§19.2)
takeover_catchup_lag_entries 1000(备节点接管追赶阈值,§19.3.2)
takeover_catchup_stable_window 5 s(§19.3.2)
disaster_rto_target ≤ 4 h(检查点与日志同时损坏场景,§19.3.4)
backup_full_interval 24 h(§19.3.5)
backup_incremental_interval 5 min(§19.3.5)
internal_cert_ttl 24 h(内部 mTLS 证书有效期,§20.4)
mailbox_store_shadow_read_ratio 1 %(ADR-0001 切换灰度的影子读比例,§17.4)
shadow_read_diff_ratio_max 0.01 %(§27.3 结构迁移放行判据)
shadow_read_observe_hours 24(§27.3 结构迁移观察窗口)
slo_burnrate_fast 14.4×(1 h 窗口 / 5 min 短窗 → P1,§24.2)
slo_burnrate_slow 6×(6 h 窗口 / 30 min 短窗 → P2,§24.2)
tenant_deletion_grace 30 d(退租宽限期,§20.6、§21.3)
tenant_key_rotation_days 365(TenantMasterKey 轮换周期,§21.2)
key_destroy_lag ≤ 24 h(冷却期结束 → 密钥销毁完成,§21.3;含"销毁 → 全部 DEK 缓存失效"分解项,§21.3.4)
dek_cache_ttl 5 min(读路径解密点的 DEK 进程内缓存 TTL,另订阅销毁事件主动失效,§21.3.4)
observability_log_retention_days 30 d(日志与追踪保留期;与附录 B.3 的分发日志保留期 log_retention_days 是两个不同参数,不得混用,§21.4)
audit_retention_years 5 年(WORM 审计保留期,法务可按辖区延长,§21.5)

B.6 会话列表与未读

参数 默认值
projection_compaction_window 5 s 或 512 条事件(先到者触发)
projection_lag_target_p99 5 min
session_delta_merge_window 100 ~ 200 ms
unread_precise_limit 200(超过则 unread_exact=false)
session_list_page_limit 50(服务端上限 200)
snapshot_ttl 5 min
include_muted_in_badge false(租户级;UserBadgeState 聚合口径,见 §7.7)
client_dedup_window_items 2000
client_dedup_window_hours 1
unread_audit_sample_rate 1 %/天("有未读且 24 h 无变更"会话的抽样重算比例,§12.5.3)
projection_lag_selfheal_threshold projection_lag_vs_retention > 1/2(自动扩容压缩并发度并执行 §12.3.2 降级,§12.3.3)
projection_write_ratio_max 0.05(持久投影写次数 / (消息数 × 用户数) 的上界,用例 26.4.4)

B.6.1 瞬时消息与聊天室交互

这些参数已进入协议行为(客户端可观测),因此收进契约核心而非留在各章。

参数 默认值
typing_merge_window 2 s
typing_ttl 5 s
presence_sub_max_targets 200
presence_sub_merge_window 2 s
max_rooms_per_connection 20
recall_self_window 2 min
edit_self_window 15 min
large_group_member_threshold 1000(区分小群/大群的推送与展示策略)
max_members_per_group 100000(§2.2 目标档);一期 1000(ADR-0007 简化形态)。超限的建群/加群必须整批拒绝并回 ERROR,禁止静默截断至上限——被截断掉的成员会永久收不到该群消息,且服务端与客户端都没有任何信号可循
e2ee_max_members 1000(§22.1 的 E2EE 适用上限)
rtc_voip_push_daily_quota 200 次/设备/天
rtc_ring_timeout 60 s(§13.5.3 呼叫振铃超时)
e2ee_signed_prekey_rotation_days 7(§22.2 SignedPreKey 轮换)
e2ee_prekey_low_watermark 20(§22.2 OneTimePreKey 补充水位)
reaction_push_merge_window 2 s(§13.6.3 聚合推送合并窗口;绝对值语义,合并即丢弃前帧)
reaction_detail_max_members 1000(会话成员数超此值即停写 MessageReaction 明细,只累加聚合)
reaction_detail_max_per_message 10000(单条消息的明细记录上限,超出后只累加聚合)
reaction_detail_page_limit 100(PULL_REACTIONS 单页上限)
per_user_reaction_rate 5 次/s(与 per_user_msg_rate 独立计量)
member_page_limit 100(PULL_MEMBERS 单页默认上限;客户端未指定 limit 时取此值)
member_page_hard_limit 200(服务端硬上限。客户端传再大也只放行到此值——10 万成员群一次请求就能打爆连接写缓冲。必须 ≥ member_page_limit)
member_search_min_prefix 2(filter=search 的最短前缀,防全表扫描)

B.7 待实测参数(禁止在回填前用于结论)

参数 说明
entry_logical_bytes 约 110 B(估算值)
entry_ondisk_bytes 含键前缀压缩、索引、WAL、压缩后真实结果
lsm_write_amp 用于设备寿命计算,不用于存储容量计算
lsm_space_amp 用于存储容量计算,leveled 约 1.1~1.3
mailbox_replicas 默认 3(ScyllaDB RF=3)
per_shard_entry_budget 单 MailboxShard 可持续的 entry/s(规划值 5 万)
per_node_entry_budget 单 MailboxNode 可持续的 entry/s(规划值 15 万)
per_node_connection_budget 单 ConnectionNode 连接数(规划值 20 万)
partition_dispatch_budget 单日志分区 dispatch/s(规划值 3 万)
compaction_ratio 投影压缩比 = 窗口内同一 (user, conversation) 的平均事件数
d_online 人均在线设备数,全文统一取 1.4
checkpoint_bytes / replay_rate 与 checkpoint_interval 一起决定 §19.3 的 RTO
checkpoint_load_time_budget 检查点加载时间预算(§19.3.2 / §19.3.3 的 T_recover 分项;推导用值 2 min,回填前不得用于容量结论)
per_node_frame_budget 聊天室网关单节点出向帧率(规划值 20 万 frame/s,§25.4)
per_node_syscall_budget ConnectionNode 单节点 syscall 预算(规划值 ≤ 15 万 syscall/s,§25.4)
per_node_tls_cpu_budget TLS 加密 CPU 占用预算(规划值 ≤ 30 %,§25.4)
per_node_pps_budget 网卡 pps 预算(规划值 ≤ 60 万 pps,带宽 ≤ 线速 50 %,§25.4)

分片数下界的唯一规范公式在 §25.6,其余章节只能引用、不得另立推导:

mailbox_shard_count >= max( platform_fanout_entries_per_sec / per_shard_entry_budget,
                            platform_dispatch_per_sec      / partition_dispatch_budget )
                       / 目标利用率(默认 0.5)