跳转至

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 带来负载, 因而对提交侧天花板有真实收益,但扩实例已经解开该约束, 且扩实例可逆、删功能不可逆——先用可逆手段确认瓶颈位置。

已知缺陷与处置

  1. 中心 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),该差值不足以归因于本修复,按运行间波动对待。

  1. 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 同属「唯一藏身处」类指标: 总量健康时用于确认,总量异常时用于区分处方相反的两类原因。