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.5visible(u,c)); 历史翻页、定义式未读与会话头都按区间裁剪,退群者仍可读退群点之前的历史。§7.5UserConversationState一期只实现会话集合子集(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
图注:
①是单向写:ConversationWriter 只写 MessageStore,不从 MessageStore 读取以完成提交路径。②是读正文,起点是 MailboxNode,不是 FanoutCoordinator。 MailboxNode 每个GroupDispatch只读一次正文,且仅在本地 LRU 未命中时读取 (§10.3)。v1 把这条读箭头画在 FanoutCoordinator 上,会误导实现者在中心层引入 与成员数无关但完全不必要的正文读取,并让中心层持有正文缓存。CommitLog是基础设施(Redpanda/Kafka),不是独立服务,见 §18.1。 图中出现两次:一次承载 Outbox,一次承载 MailboxShard 分发日志;两者是同一套基础设施的不同 topic。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 关键原则¶
- 消息提交、收件人索引和 Socket 投递是三个独立阶段,各自有独立的失败域与重试语义。
- 消息正文只存一次,用户邮箱中插入指向正文的轻量引用。
- 用户固定到逻辑 MailboxShard 和 ConnectionShard,经稳定虚拟桶推导,不经远程查询(§5.2)。
- 所有可能重试的步骤都有稳定幂等键:
client_message_id、message_id、dispatch_id、event_id。 - 在线推送失败不回滚邮箱;客户端通过邮箱游标恢复。
- 设备游标只能由
MAILBOX_BATCH连续推进;实时推送(PUSH_EVENTS)不得越位推进游标。 否则实时推送越过尚未拉取的区间,会造成静默丢消息(§6.8)。 - 所有参与物化的输入必须确定性:
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 再允许写入:
- MailboxShard 主备接管;
- 逻辑分片分裂或合并;
- 分发日志 topic/分区重建或截断后重建;
- 跨集群灾备切换。
日志与逻辑分片的映射约束(避免 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;1000 |
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 设计目标¶
会话列表要同时满足三个互相冲突的约束:
- 新消息到达后,在线用户的会话列表应在百毫秒级更新。
- 10 万人大群不能每条消息同步写 10 万条持久会话记录。
- 在邮箱被裁剪之后,会话列表仍然必须正确——即会话列表不能是"邮箱增量的纯累加结果",
它必须存在一个不依赖历史邮箱的权威事实源,否则
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_usersRoaringBitmap(与 §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 行为准,本节不重复定义数值。
四条缓解手段:
- Home Region 按群主要成员分布选择:建群时按创建者 region 落定;
GroupMembership每 24 h 统计成员 region 分布,主导 region 占比超过home_region_migration_threshold(数值见附录 B.5.3)且与当前 Home Region 不同时,产生迁移建议。 - Home Region 可迁移:迁移复用 §19.2.3 的切换流程(停写窗口 ≤
writer_failover_wait),迁移后region_id变化只影响新分配的message_id(§6.2 的region_id位段),不影响任何历史。 - 发送侧本地确认不提前:禁止在跨区提交完成前返回
SEND_ACK。伪造的低延迟会破坏 §11.2 的到达层级定义。客户端用 pending 气泡承载这段延迟。 - 只有分配点跨区,分发不跨区串行:
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;1000 |
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 做租户隔离。
因此写死三条策略:
- 超大租户(
tenant_fanout_quota占单分片per_shard_entry_budget的比例超过tenant_shard_share_limit,数值见附录 B.5 与 B.7)必须启用档位 B 独立分片。这是硬性准入条件,不是建议。 - 档位 A 下,
tenant_fanout_quota必须小于等于per_shard_entry_budget × tenant_shard_share_limit,由 FanoutCoordinator 在签约时校验,不允许超卖。 - §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)
三处必须点明的一致立场:
- Akka 明确不承诺 exactly-once:它承诺的是 at-least-once + 消费侧按 seqNr 去重, 在此之上才能谈"效果上不重复处理"。本文 §3 的立场完全相同,禁止在任何文档中宣称 exactly-once。
- 确认是消费者驱动的:Akka 的窗口由 ConsumerController 的 confirmation 打开。
本文把确认合并进
PULL_MAILBOX.acked_seq(附录 A.3),省掉一个 RTT,语义不变。 - "已发送"不等于"已确认":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 |
两条必须点明的因果:
- 为什么
SESSION_DELTA必须是绝对值帧。 Akka Projection 的 at-least-once 模式下,同一事件可能被 handler 处理多次,因此官方硬性要求 handler 幂等。v1 的unread_delta是累加语义,在MAILBOX_DIRTY重放与连接替换丢帧这两条 正常路径上必然双加或少算,且误差永久累积、无自愈点。本版改为携带unread_count / mention_count绝对值 +projection_mailbox_seq版本号, 客户端"版本更大才应用、绝对值直接覆盖",重复投递天然收敛。这不是优化,是 at-least-once 下的必要条件。 - 为什么投影与角标必须同批提交。
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 或MessageStorejoin,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.3PULL_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;1000 |
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)