ADR-0013 Redis 横向分片:邮箱按 user、提交按 conversation 桶¶
状态¶
已接受(2026-08-25)。补充 ADR-0001(MailboxStore 三级演进)与 ADR-0007(一期部署档位);
不改变 ADR-0011 的成本归因结论,也不引入 Redis Cluster。
一句话结论:Redis 是单线程的,单实例吞吐上限即单核上限。经实测, 把 MailboxStore 按 user、MessageStore 按 conversation 桶分片到 4 实例后, 提交容量由 5,760 msg/s 提升到 14,234 msg/s(2.47 倍),提交侧不再是瓶颈。
背景¶
问题¶
ADR-0011 完成五轮实现层优化后,投递侧仍封顶于约 3,200 dispatch/s,
而实测显示瓶颈是 Redis 主线程 100% 饱和——单实例只用得上 1 个核,
而目标硬件有 28 核闲置 27 个。
继续削单次成本已被证明收益递减(ADR-0011 第 6 条量级判据),
横向分片是取得数量级的唯一路径。
关键判断:CLAUDE.md 记录的两个前置条件不适用于本方案¶
CLAUDE.md 曾记录 Redis 横向扩展的前置条件为「跨槽 GroupMembership」与
「全局 {commit} 热槽」。核实后确认:这两条针对的是「整个系统 Redis Cluster 化」,
不适用于本方案。
系统实际上有两组 Redis:
core(QIM_REDIS_ADDR) |
mailbox(QIM_MAILBOX_REDIS_ADDR) |
|
|---|---|---|
| 使用方 | writer、fanout、session | 仅 mailbox |
| 内容 | MessageStore、GroupMembership、convs/read |
邮箱条目、水位、DispatchProgress、V4 proof |
- GroupMembership 在 core,不在 mailbox —— 因此不阻碍 mailbox 分片
{commit}是 MessageStore 的 hash tag —— 它是本 ADR 要解决的对象,不是前提
决策¶
1. MailboxStore 按 user 分片¶
mailbox Redis 上的全部 key 要么按 shard 分组(dp: / dpcomplete: / mbpend: /
mbseq: / wm:),要么按 user 分组(mailbox: / mbmeta: / mbdays: /
mbseqbucket:),而 user -> shard 是 routing::route 的确定映射。
因此同一 shard 的全部 key 必定落在同一实例,不存在跨实例操作, 无需任何 key 布局变更。
配置:QIM_MAILBOX_REDIS_ADDR 支持逗号分隔多地址。
2. MessageStore 按 conversation 提交桶分片¶
原实现把全部 key 套上固定字面量 hash tag {commit},理由(原注释)是
「同一条原子 reserve 所需的幂等 hash、会话序号计数器和恢复索引处于同一 slot」。
核实 reserve_commit.lua 的 KEYS 后确认:msghistory / msgrecord 不在原子
reserve 内,它们是 RESERVED 之后独立的一步;而它们的 key 早已带
{tenant}:{conversation},{commit} 是被顺带套上的。
改为按会话分桶:
b = blake3(domain ‖ tenant ‖ conversation)[0..4] % 256
msgcommit:{b}:{tenant}:{conv}:{sender}:{cmid}
msgcseq:{b}:{tenant}:{conv}
msgrecord:{b}:{tenant}:{conv}
msghistory:{b}:{tenant}:{conv}
msgrecover:{b} / msgcommitted:{b} / msghistorygc:{b}
同一 conversation 的全部 key 仍在同一桶 → reserve 的单槽原子性一点未损失。
必须过哈希:direct_conversation_id(a,b) 是 (min<<64)|max 的直接拼接,
顺序分配的 user_id 会让取模严重倾斜。
配置:QIM_MESSAGE_STORE_ADDRS(逗号分隔的额外实例),未设置时退化单实例。
3. 桶数固定、桶→实例映射可变¶
COMMIT_BUCKET_COUNT = 256,建集群后不可变(改变会重排全部 key 归属);
桶→物理实例的映射可变,扩容只需迁移部分桶。
同理 mailbox 侧 MAILBOX_SHARD_COUNT = 64 固定。
当前实现使用 bucket % 实例数,这是已知缺陷:实例数变化会重排绝大多数桶
(4→5 实例时 256 桶中约 205 个换实例,等于全量迁移)。
正确做法是范围映射或显式映射表,使倍增扩容时每个实例只交出一半。
该缺陷在生产扩容前必须修复,见「遗留」。
4. 三个原全局索引改为 per-bucket,扫描遍历¶
msgrecover / msgcommitted / msghistorygc 由「全局一个」变为「每桶一个」。
scan_recoverable_commits、gc_committed_auxiliary、gc_expired_history
遍历全部 256 桶,起点随时间轮转——否则低编号桶长期独占配额、高编号桶饿死。
恢复必须覆盖所有桶:漏掉任一桶,其中的未完成 intent 将永远无人续做。
recovery member 携带完整 redis_key(含桶标签),因此 load_by_redis_key
可自解析路由,不需要额外上下文。
5. cmid 归属检测:按 cmid 自身分片¶
按 conversation 分桶后,换会话即换桶,本桶查不到某 cmid 不代表它未被用过,
这会使 CLAUDE.md 的明文契约「不同消息复用同 cmid 必须拒绝」在跨桶时失效
(该缺陷由既有用例 按请求读取拒绝同_cmid_的不同稳定事实 立即捕获)。
引入独立的归属键:
cb = blake3(cmid-owner-domain ‖ tenant ‖ sender ‖ cmid) % 256
cmidown:{cb}:{tenant}:{sender}:{cmid} -> conversation_id (SET NX PX 2h)
按 cmid 自身哈希分片——检测对象是「同一个 cmid」,因此同一 cmid 必然落在同一 实例,这不是单点,而是按 cmid 分片,与提交桶(按 conversation)相互独立。
用单条 SET NX 完成「检查并占用」,避免并发下两个会话同时通过检查。
它不参与原子提交,漏检不会丢消息或产生重复。保留期与提交辅助状态一致(2 小时),
与 client_dedup_ttl_seconds 的窗口语义一致。
6. 分片不豁免任何一台的安全探测¶
每个实例仍独立通过 standalone 启动探测(版本 ≥ 7.0.0、cluster_enabled=0、
AOF、appendfsync、maxmemory-policy=noeviction)。
RedisMessageStore::readiness 逐实例 PING——任一实例不可用都会让落在它上面的
会话整体失败,不能只探一台就报健康。
本方案不引入 Redis Cluster,每个实例都是 standalone。
实测结果¶
隔离环境,60 秒窗口,1000 客户端。
| 配置 | committed | 说明 |
|---|---|---|
| 全单实例 | 5,760 msg/s | core Redis 100% 饱和 |
| mailbox 4 片 + core 单实例 | 5,760 msg/s | 瓶颈仍在 core |
| mailbox 4 片 + core 4 片 | 14,234 msg/s | 2.47 倍,完全跟上 15,000 |
分片后 core Redis 实例 CPU 降至 83%,不再饱和。
mailbox 侧(5000 msg/s 口径,4 片):delivered 由 2,896 提升至 4,760 msg/s(100% 跟上),
端到端 P99 由 27.4 s 降至 285.9 ms,materialize 由 4.72 ms 降至 1.95 ms。
与旧架构的对照¶
历史 TCP dispatch 架构曾实测 15,000 msg/s(单实例)。但该架构
没有 MessageStore、没有提交状态机、没有重放源——git grep 'msghistory|msgcommit|msgrecord' HEAD
返回空。因此这不是同一功能集的对比:现在的 14,234 msg/s 是在多做了
canonical history、四态提交状态机、DispatchProgress 与 V4 proof 的前提下取得的。
后果¶
收益¶
- 提交侧不再是瓶颈;瓶颈转移至投递侧(mailbox 4 片上限约 8,900 dispatch/s), 继续加 mailbox 实例即可
- 不改 wire 契约、不改
mailbox_seq/conversation_seq语义、不引入 Cluster reserve的单槽原子性完全保留
遗留与风险¶
bucket % 实例数映射必须在生产扩容前改为范围/显式映射,否则扩容等于全量迁移。 这是本 ADR 已知且未修的缺陷。- 桶数(256)与 shard 数(64)建集群后不可变;
MAILBOX_SHARD_COUNT = 64同时是 dispatch topic 分区数,意味着 mailbox 侧物理实例上限为 64。 按当前单实例约 2,200 dispatch/s 估算,天花板约 14 万 dispatch/s—— 若目标高于此,需在一期发布前重新评估该取值(topic 重建成本随时间上升)。 convs:{user}/read:{user}仍留在 core Redis。它们是纯 per-user 数据, 划分上应随 mailbox 走;但实测其成本仅占 core 的约 2.5%, 属架构正确性问题而非性能问题,未在本 ADR 处理。- 跨桶 cmid 检测新增每条新消息一次
SET NX。按分片后 core Redis 83% 的余量可以承受, 但它是热路径上的新增操作,后续若 core 再度接近饱和需重新评估。
参考¶
ADR-0011(邮箱热路径成本归因与实现层裁决)ADR-0012(Fanout 不使用 Kafka 事务)ADR-0007(一期部署档位;LANE_COUNT/MAILBOX_SHARD_COUNT不可变项)- 实现:
crates/qim-store/src/redis/store.rs(mailbox 分片)、crates/qim-store/src/message/redis.rs(提交桶分片与 cmid 归属)