ADR-0008 登录对账与幂等窗口收窄¶
状态¶
已接受(2026-08-15)。对应 docs/PLAN.md §7.2、§7.3、§8.3、§27.3.2、附录 B.2/B.5.1;
docs/02 §2(client_dedup DDL);一期 Redis 实现 qim-store 条目编码(尾部追加字段)。
背景¶
§8.3 曾把幂等窗口定为 24 小时,并用 client_pending_max_age ≡ client_dedup_ttl_seconds
的恒等约束保证"服务端已忘记、客户端还在重试"的窗口不存在。这个取舍在一期 Redis
形态下暴露出两个实测问题(2026-08-15 压测归因,见 README「180 秒稳态与 Redis 字典翻倍」):
- 状态量级:幂等键每消息一个,24 h 窗口在目标速率(15k msg/s)下是 13 亿键/天, 单 Redis 内存不可承载;即便阶段一换 ScyllaDB,行数也是消息量级的常数倍索引。
- 键速率的副作用:主 keyspace dict 与 expires dict 以消息速率增长, 在 2^20/2^21 键数处翻倍 rehash,180 s 稳态端到端 P99 被推到 727 ms(预扩 dict 后 116 ms 达标,机制已实锤)。窗口长短不改变键速率,但决定稳态键总量, 进而决定翻倍事件的规模与频次。
同时,§7.3 早已要求"仅发送者自己的邮箱条目回带 client_message_id"(多设备
去重回显),§27.2 要求登录后必须批量同步邮箱——对账所需的锚点与时机在契约里
都已存在,只差把两者连成一条客户端义务。
决策¶
1. 登录对账(新增客户端义务,§27.3.2)¶
重连/登录后,客户端在重发任何 local_pending 之前,必须先完成个人邮箱同步
(PULL_MAILBOX 拉到重连时刻的 lane 水位)并按 client_message_id 对账:
同步到的自发条目.client_message_id ∈ local_pending
→ 原位升级为已确认(等价于收到 SEND_ACK),不重发
对账完成后仍未命中的 local_pending
→ 按原 client_message_id 重发(消息未提交,重发不会产生重复)
服务端前提是把 §7.3 的既有要求真正贯通:UserMailboxEntry 在收件人等于发送者
时携带 client_message_id,经 MAILBOX_BATCH / PUSH_EVENTS 下发。
其他收件人的条目不携带(16 字节/条的开销只花在有用处的地方)。
2. 幂等窗口 24 h → 2 h(附录 B.2)¶
client_dedup_ttl_seconds:86400 → 7200。
对账生效后,服务端窗口只需覆盖"已提交但客户端尚不可见"的暴露期,其构成:
| 分量 | 量级 | 说明 |
|---|---|---|
| 在线退避重试 | 秒级 | retry_after_ms 指数退避,连接内盲重发 |
| 提交后物化在途 | 正常毫秒级 | 条目落盘前对账不可见,重发由窗口兜底 |
| 物化水位停滞(异常) | 运营时限 1 h | 水位卡住时条目不可见;窗口取其 2 倍余量 |
| 对账同步耗时 | 分钟级上界 | 积压登录 P99 ≤ 10 s(§T-BACKLOG),留裕量 |
残余风险(接受并记录):水位停滞超过 2 h 且期间客户端完成对账并重发,
会产生重复气泡(回到窗口外语义,不丢消息、不破坏序号)。水位停滞本身是
告警项(materialize_error_total、水位停止推进),修复时限为运营 SLO。
收益:稳态幂等键总量 12 倍下降(15k msg/s 下 13 亿 → 1.08 亿/天);
dict 翻倍事件的规模同比缩小;client_dedup 表(阶段一)行数与 TWCS
窗口同步缩短。
3. 解除 client_pending_max_age 与窗口的恒等约束(附录 B.5.1)¶
原恒等约束的目的(边界处不产生重复气泡)由对账接管,且对账给出更强的保证: 窗口外重发只要以对账未命中为前提就是安全的。因此:
client_pending_max_age保持 24 h(产品语义不变:断网一天内重连, 未发出的消息仍自动补发);- 盲重发(未对账)仅允许在连接存续期间的退避重试中发生;
- 断线重连后的任何重发都必须以"对账完成且未命中"为前提—— 这是新的硬性客户端义务,取代旧的恒等约束成为重复气泡的防线。
4. 一期 Redis 条目编码变更¶
qim-store 的 zset member 编码在尾部追加 client_message_id:16
(全零 = 无)。放在尾部使旧条目可解码(缺失即无),7 天保留期滚动后自然收敛。
后果¶
- 客户端实现复杂度上移:必须实现"先对账后重发"的顺序纪律;参考实现在
qim-loadgen(验收项 15)。 - 幂等兜底范围缩小到 2 h,窗口外的正确性依赖客户端对账义务——不合规的 客户端在窗口外重发会产生重复气泡(服务端不丢消息、不破坏不变量)。
- ScyllaDB 阶段一的
client_dedup表按 7200 s TTL 与 TWCS 窗口重算 (docs/02 §2 已同步)。