ADR-0014:mailbox Redis 实例数是容量参数,不是调优旋钮¶
- 状态:已接受
- 日期:2026-08-26
- 相关:ADR-0011(邮箱热路径表示)、ADR-0012(fanout 去事务)、ADR-0013(Redis 横向分片)
背景¶
ADR-0013 落地按用户分片后,mailbox Redis 从 1 个实例扩到 4 个,提交侧吞吐 从 5,760 提到 14,234 msg/s。但端到端延迟在 10,000 msg/s 上仍然是 P50 2,940ms / P99 5,208ms,且投递率 100%、零丢失——这是典型的 「吞吐够、但系统运行在 ρ≈1 上」的形态:队列在 60 秒窗口内线性堆积, max(5,646ms)≈ 2×P50(2,940ms)。
问题在于没有任何指标指向那个队列:
| 段 | 均值 |
|---|---|
| SEND_ACK | P99 34.8ms |
| Outbox lag | 15.3ms |
| broker→enqueue | 0.094ms |
| push_transit(mailbox→gateway) | 248µs |
| gateway 写 socket | 17µs |
| mailbox FIFO 排队 | 47.1ms |
| materialize | 4.44ms |
全部加起来约 100ms,而端到端 P50 是 2,940ms。
两个观测缺陷¶
一、qim_mailbox_dispatch_broker_to_enqueue_us 名不副实。
它观测的是 received_at.elapsed(),而 received_at 是
consumer poll 返回的时刻,不是 broker 时间戳。因此它测的是
「poll 返回 → 入 shard FIFO」,完全测不到 mailbox 落后 Redpanda 多少。
积压恰好堆在它测不到的那一段:Redpanda dispatch topic 自身。
二、qim_materialize_latency_us 只有总量。
总量在 4ms 量级时无法区分「Redis 排队」与「本地 CPU」——
两者的处方相反(前者要减少往返或加实例,后者要减少编码/哈希工作)。
本次为此新增 qim_materialize_stage_*_us 六个分段。
分段实测(10,000 msg/s,4 实例)¶
| 分段 | 均值 | 占比 |
|---|---|---|
| normalize(64 lane manifest) | 3.4µs | 0.08% |
| encode_durable + blake3 | 4.3µs | 0.10% |
| build_entry | 0.3µs | — |
| W cache 读/写 | 0.1µs | — |
| store_call(Redis 往返) | 4,387.9µs | 98.8% |
Rust 侧全部工作合计 8.1µs。此前基于总量的推断「95% 在 Rust 侧 (encode_durable + blake3 + tokio 调度)」被此数据证伪,实际是 0.18%。
根因¶
四个 mailbox Redis 实例 CPU 分别为 103%/98%/96%/104%——单线程 Redis 打满。 evalsha 服务时间 237µs,而客户端观测的往返是 4,388µs,18 倍排队, 正是 ρ≈1 的表现。
传导链是热点 shard 堵死中心 consumer:
- 单 shard 串行上限 = 1/materialize = 1/4.42ms = 226/s;
- 实测 shard 19 与 39 达到 250/s,已越过该上限,其 FIFO 填满;
- 中心 consumer 在
sender.reserve()上 await(mailbox/main.rs), 一个 shard 队满就让另外 63 个分区一起停止消费; - 于是 64×226=14,479/s 的理论聚合只跑出 10,480/s,多出来的全部堆在 Redpanda 里——即上面那个没有指标的位置。
倾斜本身是 1,000 用户散列到 64 shard 的固有二项方差: 最热 shard 15,000 样本、最冷 4,800,3.1 倍。
决策¶
mailbox Redis 实例数按 ρ = 实例承担的 evalsha 速率 × 单次服务时间 反推,
而不是按「够用就行」选取。 目标 ρ ≤ 0.65。
集成环境内置实例数从 4 提到 8(QIM_MAILBOX_REDIS_SHARDS 值域 1..8;
64 个 shard 必须被实例数整除)。
实测结果(10,000 msg/s)¶
| 指标 | 4 实例 | 8 实例 |
|---|---|---|
| 端到端 P50 | 2,940.4ms | 78.5ms |
| 端到端 P99 | 5,208.2ms | 178.7ms |
| SEND_ACK P99 | 34.8ms | 109.9ms |
| 投递率 | 100% | 100% |
| mailbox Redis CPU | 96~104% | 48~64% |
| FIFO 排队均值 | 47.1ms | 14.4ms |
| store_call 均值 | 4,388µs | 3,028µs |
端到端 P99 下降 96.6%。注意吞吐两侧相同——这不是「变快了」, 而是从 ρ>1(积压持续增长)回到 ρ<1(稳态)。这也是为什么 mailbox 内部 只有 51ms 却能产生 2,940ms 端到端:差额全部是 HOL 堵死造成的隐形积压。
SEND_ACK 反而从 34.8ms 升到 109.9ms:mailbox 不再堵住后压力前移, 而 MessageStore 的 core Redis 仍是 4 实例、CPU 67~75%。它是下一个约束, 并已在 12,000 msg/s 上显形——见下节。
提交侧同样受 ρ 约束(core Redis)¶
QIM_MESSAGE_STORE_SHARDS 同步扩到 8(256 个提交桶必须被实例数整除)。
同一台机器、1,000 客户端、60 秒:
| 速率 | 配置 | emitted | committed | SEND_ACK P99 | 端到端 P99 |
|---|---|---|---|---|---|
| 10,000 | 8 + 4 | 9,498.6 | 9,498.6(100%) | 109.9ms | 178.7ms |
| 12,000 | 8 + 4 | 11,330.5 | 9,647.0(封顶) | 4,644.6ms | 4,710.8ms |
| 12,000 | 8 + 8 | 11,309.2 | 11,309.2(100%) | 254.9ms | 343.6ms |
| 15,000 | 8 + 8 | 14,093.3 | 12,120.3(封顶) | 2,884.2ms | 3,212.1ms |
「封顶」指 committed < emitted:提交侧拒绝接收更多, 不是投递侧丢消息(两行的 delivered 都等于 committed)。
结论与 mailbox 侧同构:提交吞吐上限由 core Redis 实例数决定, 4 实例约 9,650/s、8 实例约 12,120/s,近似线性。
本轮容量结论(同一台 28 核机器、隔离环境、60 秒窗口):
| 配置 | P99 < 300ms 的达标速率 | 提交封顶 |
|---|---|---|
| 4 + 4 | 9,000(P99 296.5ms) | ~10,000 |
| 8 + 4 | 10,000(P99 178.7ms) | ~10,150 |
| 8 + 8 | 10,000~12,000 之间(12,000 时 P99 343.6ms,略超标) | ~12,760 |
这些数字是 60 秒短压,不是容量结论。 附录 B 与发布判据仍要求 目标硬件上 180 秒 + T-HEAVY + 混沌门禁,未跑完之前不得用于采购或发布。
会话列表、未读与投影未做删减¶
上述全部实测均在会话列表、未读计数、SessionProjection 全部开启下取得
(10,000 msg/s 那次:projection_entries_total 662,471、
mark_read_total 30,000、projection_dropped_total 0、
会话列表查询均值 0.90ms)。
曾评估「为性能删除未读/投影/会话列表」,结论是不删:
瓶颈的 98.8% 在 mailbox 物化的 Redis 往返上,而投影是 durable 物化完成后的
try_send 尽力而为出口,不在该路径。投影确实给 core Redis 带来负载,
因而对提交侧天花板有真实收益,但扩实例已经解开该约束,
且扩实例可逆、删功能不可逆——先用可逆手段确认瓶颈位置。
已知缺陷与处置¶
- 中心 consumer 的 HOL 堵死——已修复(2026-08-26)。
enqueue_received 曾对每一条 record 在其目标 shard 的队列上
sender.reserve().await。中心消费者是单循环,因此一个 shard 队满就停掉
整条读取循环,另外 63 个空闲 shard 一起饿着。把 broker_to_enqueue 的
sum 除以墙钟即可看出饱和度:
| 配置 | consumer 待在 enqueue 内的总时长 |
端到端 P50 |
|---|---|---|
| 4 实例 | 59.4s / 60s = 99% | 2,940ms |
| 8 实例 | 0.1s / 60s = 0% | 78.5ms |
修法:队满时 try_reserve 失败即只暂停该 Kafka 分区
(DispatchLogConsumer::pause_shard,librdkafka pause/resume),
把该条 record 交还并暂存,其余分区继续流动;worker 腾出位置后先入队挂起
记录、再恢复分区(顺序不可颠倒,否则分区内更新的记录会抢先入队)。
分区暂停后不再投递,故每 shard 至多一条挂起,上限即 shard 数。
溢出留在 Redpanda 是安全的——那本来就是可重放的持久位置。
实测(4 实例 10,000 msg/s,即修复前崩溃的那个配置):
| 修复前 | 修复后 | |
|---|---|---|
| 端到端 P50 | 2,940.4ms | 110.2ms |
| 端到端 P99 | 5,208.2ms | 15,275.4ms |
| 窗口内投递 | 100% | 98.1% |
| dispatch 总数 | 628,786 | 616,722 |
这不是性能修复,是隔离修复,必须如实理解:它不增加任何容量,
只把痛苦从「全体均摊 2.9 秒」变成「80% 的用户 110ms、过载 shard 上的
用户 15 秒」。聚合吞吐还因 28,546 次暂停/恢复调用略降 1.9%。
价值在诊断:partition_paused_total 精确指向 19/64 个过载 shard
(4、19、39、11、27、23、24、21…),而此前只能观察到「整体变慢」。
不过载时完全惰性:8 实例下暂停 0 次。同配置(8 mailbox + 4 core、
10,000 msg/s)A/B 为 P99 178.7ms → 113.2ms、ACK P99 109.9ms → 40.1ms,
但暂停为 0 时两版代码路径几乎相同(唯一实际差别是每条 record 不再构造
select! 与两个 future),该差值不足以归因于本修复,按运行间波动对待。
broker_to_enqueue名不副实,且其均值会主动误导。2.broker_to_enqueue名不副实,且其均值会主动误导。 计时起点是enqueue入口的Instant::now(),即 consumer 已读到该 record 之后。它确实覆盖了上面那段reserve()阻塞,但不覆盖 record 在 Redpanda 中等待被读取的时间——名字里的 "broker" 使人误以为 覆盖了 broker 滞后。
更要记的是它如何骗过一次排查:均值 0.094ms,看上去完全健康, 据此被排除;而同一份数据的 sum/墙钟是 99%。97.61% 的 enqueue 确实 ≤100µs,但剩余 2.39% 吃掉了整条循环 99% 的时间。 串行资源的饱和度不能看单次均值,必须看总时长占墙钟的比例。
分区级暂停落地后该比值不再可用:它现在含暂停期等待,且多个 shard
可同时挂起,实测为 186%。消费者饱和度改看 partition_paused_total。
仍缺的指标是「record 的 broker 时间戳 → 物化完成」的真实端到端滞后。
3. 热路径 evalsha 的成本由「写」主导,不是校验、也不是解释器。
曾据 evalsha_usec − Σ内层命令 cmdstat_usec = 39.87s / 50.06s 得出
「80% 是 Lua 解释器开销」。该结论错误,此处更正。 那个减法把
cmdstat 不归属给命令的两类成本都算进了「解释器」:
- Lua↔C 桥接:实测 1.11µs / 次
redis.call(含 40 次 HGET 的脚本 83.56µs,减空脚本 17.14µs 与内层执行 22.1µs); - 写命令的效果传播:单次 HSET 使空脚本从 19.09µs 升到 38.24µs, 即约 19µs/写,而同一命令的 cmdstat 只记 5.36µs。
隔离微基准(独立 Redis,方法自检:uniform_blob 实测 5.20µs
对 ADR-0011 记录的 5.48µs)给出 237µs 的实际构成:
| 组成 | 耗时 | 占比 |
|---|---|---|
| 9 个写命令 | ~123µs | 52% |
| 32 个读命令(校验,含桥接) | ~53µs | 22% |
| W blob 的 Lua 运算 | 21.3µs | 9% |
| EVAL 基础开销 | 17µs | 7% |
| 139 个 ARGV 编组 | 15µs | 6% |
dp_fields + 64 次 manifests 循环 |
2.8µs | 1% |
合计 232µs,与实测 237µs 吻合。真正的解释器运算只有 10%。
由此两个曾被认真考虑的方向都被证伪: 「信任状态、跳过校验」上限只有 22%;「packed W blob 的 Lua 操作是大头」 实测 9%。ADR-0011 第三轮「合并 HGETALL」零收益也由此解释—— 它省的是读,而读本就只占 22%。
上述微基准的写侧结论后被 perf 与真实压测双双证伪,见下节。
Redis 侧 profile(perf,2026-08-26)¶
前面对「成本在哪」的推断连续失败四次,故改用 perf record -F 999 -g
直接采样 mailbox Redis 主进程 30 秒(10,000 msg/s、4 实例、31,486 个采样)。
符号解析需要未 strip 的二进制:redis:7.4.1-alpine 与 redis:7.4.1 的
build-id 一致,把 Debian 版二进制加入 perf buildid-cache 即可符号化。
| 类别 | 占比 |
|---|---|
| Lua 解释器 | 34.50% |
| libc(memcpy/memcmp)+ 内核 + 未分类 | 40.57% |
| Redis 数据结构(zslInsert/dictFind/siphash) | 9.60% |
| jemalloc | 8.79% |
| RDB 快照(fork 子进程) | 5.58% |
| AOF | 0.05% |
Lua 内部:luaV_execute 10.90%、luaS_newlstr 6.59%、
luaV_gettable 2.48%、luaH_get 2.17%、luaD_precall 1.62%。
下一个杠杆是 luaS_newlstr(Lua 建串 + 驻留哈希)。 它由
uniform_blob 对 1216 字节 blob 的两次 string.sub 驱动——每次构造一个
约 1197 字节的新串并哈希。把 64 lane 全同的情形压成 19 字节存储可以让
string.sub 的对象降到接近零,但那要改 wm_layout,需独立 ADR 与
「fence 旧 owner 后离线转换」的迁移路径,不在本 ADR 范围内。
四次被证伪的推断(方法记录)¶
同一个问题上连续四次「看似合理的推断」被实测推翻,逐条记录以免重犯:
| # | 推断 | 实测 | 错因 |
|---|---|---|---|
| 1 | 80% 是 Lua 解释器开销 | perf 实测 34.5% | evalsha_usec − Σ内层 cmdstat 把桥接与写传播都算进了「解释器」 |
| 2 | 1216B packed W blob 的 Lua 运算是大头 | 21.3µs,占 9% | 只测了单项就外推 |
| 3 | 写侧冗余可省 24% | evalsha −2.5%、CPU −4% | 微基准每次迭代写新键,量到的是建键成本;生产是更新既有键 |
| 4 | RDB 快照浪费 5.58% | 关闭后 evalsha 226.97 vs 225.35µs,CPU 99.9% vs 99.7%,零收益 | bgsave 在 fork 子进程、另一个核上;perf -p 跟随 fork 采到了它,但采样占比 ≠ 关键路径占比 |
共同教训:单线程热路径的成本归因必须直接采样,不能由聚合指标做减法。
cmdstat 不归属桥接与效果传播;perf 采样不区分是否在关键路径上;
微基准的键分布必须与生产一致。
已落地的写侧削减(保留)¶
尽管收益远小于预期,materialize_fresh_single_recipient.lua 中四处
可证明的冗余写已消除,且不引入任何新信任假设、不改 packed-v1 / V4 格式:
| 写 | 处置 | 依据(脚本上文已有的读) |
|---|---|---|
EXPIREAT KEYS[3] |
TTL 已等于 expiry 时跳过 | 已有的 EXPIRETIME KEYS[3] 校验 |
EXPIREAT KEYS[5] |
同上 | 已有的 EXPIRETIME KEYS[5] 校验 |
ZADD KEYS[6] |
稳态整条跳过 | ZRANGE 已证明账本恰为 [day, expiry] |
HSET KEYS[4] |
7 字段降为 2 | 其余 5 个已逐一验证等于将写入的值 |
实测(同实例、10,000 msg/s):expireat 325,200 → 5,214 次、
zadd 649,200 → 488,478 次、evalsha 231.10 → 225.35µs、CPU 103.9% → 99.7%。
收益约 2.5~4%,但每分钟少约 32 万次 Redis 写与相应 AOF 写放大,且零风险。
RDB 关闭未保留:无实测收益,不留无收益的持久化配置变更。
4. bucket % 实例数 映射在扩容时全量迁移(ADR-0013 已记录),
实例数从 4 改到 8 会让既有 mailbox 键全部落到错误实例。
本次实测均为全新隔离环境,不构成对在线扩容的验证。
观测补强¶
新增 qim_materialize_stage_{normalize,encode_digest,entry_build,cache_read,
store_call,cache_commit}_us。它们与 read_gap_us 同属「唯一藏身处」类指标:
总量健康时用于确认,总量异常时用于区分处方相反的两类原因。