跳转至

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 字典翻倍」):

  1. 状态量级:幂等键每消息一个,24 h 窗口在目标速率(15k msg/s)下是 13 亿键/天, 单 Redis 内存不可承载;即便阶段一换 ScyllaDB,行数也是消息量级的常数倍索引。
  2. 键速率的副作用:主 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 已同步)。