跳转至

ADR-0018:历史分层——Redis 热窗口同步提交,ScyllaDB 异步批量归档

  • 状态:已接受(2026-09-26,由项目负责人决定;实测依据见“背景”)
  • 相关:ADR-0007(一期部署档,其“Redis 承载正文与历史”否决项由本 ADR 修订)、 ADR-0008(幂等窗口)、ADR-0012(Fanout 不用 Kafka 事务)、docs/PLAN.md §7.1、§17.1、附录 B.3

背景

docs/PLAN.md §17.1 把 MessageStore(正文、历史、ClientDedup)定为 ScyllaDB,ADR-0007 否决 “Redis 承载正文与历史”(理由:正文按 365 天保留、体量远大于邮箱)。实现中二者曾被一个全局 QIM_STORE_BACKEND 绑定,部署只能“全 Redis”(永久历史进入 Redis 内存与 AOF)或“全 Scylla”。

2026-09-26 把 MessageStore 切到 Scylla(邮箱仍在 Redis)实测,同机三节点、每节点 1 核:

测量 结果
200 msg/s 的 SEND_ACK P50 / P99 184 / 252 ms(SLO 150 ms)
每条消息的 Scylla 操作 约 6.5 次 LWT + 8 次读 + 3 次普通写,全部串行在提交路径上
每条消息每节点的服务端工作量 约 33 次写(含 system.paxos)、23 次读、3.7~4.7 ms CPU
单次 LWT(消费级盘 fsync 6.5 ms) 空闲 19.5 ms,负载下 31.5 ms
单次 LWT(--unsafe-bypass-fsync 模拟服务器 SSD) 3.1 ms;但单核节点在 200 msg/s 即 CPU 饱和

同步写 Scylla 让提交热路径背负 Paxos 的全部代价,而这份代价换来的只是“历史第一时间 在库里”——近期消息的读取本来就由热层满足:客户端靠邮箱引用拿到新消息,非内联正文通过 PULL_HISTORY 立即读取;只有超出热窗口的旧消息才需要归档层。

决策

1. 提交热路径只写 Redis

writer 的提交状态机、ClientDedup、conversation_seq 分配与近期历史仍由 Redis MessageStore 在一次融合 Lua 中原子完成(reserve_and_record → Outbox → mark_published_and_committed), SEND_ACK 仍只在 Outbox 持久化后返回。提交路径上没有任何 Scylla 操作。

2. 归档:独立消费 Outbox,批量普通写入 Scylla

新增 HistoryArchiver(qim-archiver):

  • 数据源是 Redpanda Outbox,不是 Redis。 Outbox 记录(CommittedEnvelope)携带完整 canonical MessageRecord,不论正文大小;acks=all、多副本、可重放。Redis AOF everysec 崩溃时最多丢最近 1 秒,不能作为归档的重放来源。
  • 独立消费组、静态 assign 单个 Outbox 分区;起点规则与 fanout 相同:已提交位点须在 [low, high] 内,无位点时只允许 low == 0 全量起步,否则拒绝启动(需走回填)。
  • 每批至多 history_archive_batch_max 条:先并发写全部 message_record,再写对应 history_index(读路径把“有索引无记录”判为损坏,顺序不可调换);全部取得 LOCAL_QUORUM 确认后才同步提交消费位点。任一写失败则不提交、原批重放。
  • 全部是普通 INSERT:主键 (tenant, conversation, seq_bucket, conversation_seq) 由 writer 唯一 分配,同一记录重复写入同值幂等;Outbox 的重复记录与乱序(恢复器补发)均无害。 Ephemeral24h 按剩余保留期 USING TTL 写入,迟到的归档照样按原时刻到期。
  • 提交位点后发布归档水位 T:取批开始前的时刻 T0 与当时的 Outbox 高水位 H0, 当已提交位点 ≥ H0 时水位推进到 T0。含义:所有在 T0 之前完成 COMMITTED 的消息都已 归档(COMMITTED 晚于 Outbox 确认,而 T0 之前确认的记录 offset 必小于 H0)。

3. 热窗口裁剪:到期且已归档才删

  • history_hot_window(默认 7 天)。每条消息进入 COMMITTED 时登记裁剪候选,分值为 committed_at + history_hot_window。
  • 裁剪条件:分值 ≤ min(now, T + history_hot_window − outbox_recovery_window − 安全余量), 即除到期外还要求归档水位越过“该消息提交时刻 + 恢复窗口”。后一项保证同会话中序号更低、 因恢复而更晚提交的消息也已归档。归档器停摆时水位停住,Redis 只涨不删——安全失败, 由归档滞后告警驱动处置,绝不以“到期”为由删除未归档的历史。
  • 每会话维护只增不减的热层下界 F:裁剪 (conversation, seq) 时 F = max(F, seq)。 seq ≤ F 以 Scylla 为准,seq > F 以 Redis 为准。
  • 裁剪按批在 Lua 中执行(一次调用处理多条),吞吐必须高于峰值写入速率;不得再出现 “每分钟固定 256 条”这类低于写入速率的回收。

4. 分层读取

  • PULL_HISTORY:锚点与方向落在 (F, latest] 内只读 Redis;跨越或低于 F 的部分读 Scylla, 按 conversation_seq 合并;earliest_available_conversation_seq 在 F > 0 时取自 Scylla。
  • 会话列表定义式未读:所需区间 (read_seq, latest] 全在 F 之上时只读 Redis;否则 (read_seq, F] 读 Scylla、(F, latest] 读 Redis 后合并计数。任一层所需记录缺失或损坏整页失败, 不得以零未读或部分结果代替。

5. 后端取值

QIM_MESSAGE_STORE_BACKEND:tiered(默认,一期生产形态:本 ADR)、redis(仅开发与测试: 无归档,历史永久留在 Redis)、scylla(提交路径同步写 Scylla,保留作对照与阶段一评估)。

为什么不是……

方案 否决理由
提交路径同步写 Scylla(原 §17.1 形态) 实测每条消息约 6.5 次串行 LWT;ACK 时延被 Paxos 与刷盘主导,吞吐受 Scylla CPU 封顶
归档器从 Redis 扫描读取 Redis 不是可靠重放源(AOF 1 秒缺口),且扫描需要全量键遍历;Outbox 已是有序、多副本的提交事实
到期即删,不看归档水位 归档器停摆期间到期的历史被永久删除
用 LWT 写归档防覆盖 把本 ADR 要移出热路径的 Paxos 成本原样搬到归档器;序号回退应在源头(分配器)防住

后果

正面

  • 提交热路径回到全 Redis 的时延与吞吐;Scylla 只承担批量、可并发、无 Paxos 的写入。
  • Redis 内存从“历史永久累积”变为“热窗口 × 写入速率”(实测约 360 B/条的记录与索引)。
  • 归档与投递解耦:Scylla 不可用只推迟归档与裁剪,不影响收发。

负面与约束

  • Redis 需容纳 history_hot_window 的历史:按实测约 360 B/条,1,000 msg/s 平均速率 7 天约 218 GB。热窗口是容量参数,需按租户速率配置。
  • 超出热窗口的读取延迟取决于 Scylla;归档滞后期间,旧历史仍由 Redis 提供(未裁剪)。
  • 新增一个服务与一个消费组。Outbox 保留期必须覆盖归档器可容忍的最长停摆; 超过时从 Redis 回填(Redis 在归档前绝不裁剪,数据仍在)。
  • 序号回退风险(已处置,2026-09-26):Redis AOF everysec 只在宿主崩溃/断电时丢约 1 秒, 但丢的这 1 秒会让 msgcseq、HLC 窗口与热层记录一起回退,新消息复用已归档消息的 conversation_seq,归档的普通 INSERT 会静默覆盖。处置:writer 连接的 Redis(默认 core 实例与 全部 MessageStore 分片)启动时强制 appendfsync always 且 no-appendfsync-on-rewrite no (RedisDurability::CommitFacts,不满足拒绝启动),回复即持久;邮箱实例的丢失窗口由 dispatch 日志重放补回,保持 everysec。未采用"检测回退并按 Outbox 重新播种":崩溃前已预留、崩溃后才 发布的记录在扫描时可能尚不可见,且热层丢失的记录还需另行补回,竞态面远大于一次 fsync。
  • always 的代价取决于盘的刷盘时延:Redis 每轮事件循环回复前统一 fsync,而单连接每轮最多 读 16 KB 请求(提交脚本约 1~2 KB),故 writer 对每个实例维持连接池 (QIM_MESSAGE_STORE_CONNECTIONS,默认 8)以扩大组提交。本机消费级盘(单次刷盘 13~17 ms, 多实例争用)实测:单连接时 10k msg/s 提交塌到 1,873/s;8 连接时 5k msg/s 全量提交但 ACK P50/P99 129/184 ms,超 SLO。生产必须使用带掉电保护的服务器 SSD(刷盘约 0.05~0.2 ms), 与 ScyllaDB 的要求相同;消费级盘上的结果不可作为容量依据。
  • 升级前已存在于 Redis、但不在 Outbox 保留期内的历史,需要一次性回填到 Scylla 后才能裁剪。

实施顺序

  1. 归档器(Outbox → Scylla 批量写、位点、水位、指标)与 tiered 后端开关。
  2. 热层下界、批量裁剪与分层读取(PULL_HISTORY、会话列表未读)。
  3. 序号与 HLC 回退防护;回填工具。