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)携带完整 canonicalMessageRecord,不论正文大小;acks=all、多副本、可重放。Redis AOFeverysec崩溃时最多丢最近 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 后才能裁剪。
实施顺序¶
- 归档器(Outbox → Scylla 批量写、位点、水位、指标)与
tiered后端开关。 - 热层下界、批量裁剪与分层读取(
PULL_HISTORY、会话列表未读)。 - 序号与 HLC 回退防护;回填工具。