ADR-0011 邮箱热路径的成本归因与实现层裁决¶
状态¶
已接受(2026-08-25),作为热路径归因与优化演进记录保留。对应 docs/PLAN.md §6.5、
§18.1.2b,以及 ADR-0004(mailbox_seq 组成);现行 packed 格式与水位推进条件以
ADR-0015 及当前实现为准。
本 ADR 裁决实现层事项,不改动 mailbox_seq 语义、LANE_COUNT 取值或任何 wire 契约。
历史模型边界:本文出现的
W = min(在途) - 1、分配时登记与完成时销账,均在 明确讨论旧 TCP/直写序号模型或曾评估但未采纳的回归方案。现行日志驱动模型没有该 分配登记点:同一 dispatch partition 顺序处理,record 的全部 lane/chunk/entry durable, 且expected_previousCAS 连续成功后,才把持久 64-laneW推进到observed_log_offset。不得从本文摘取旧公式描述当前实现。
一句话结论:投递容量从 15,000 msg/s 降到 1,258 dispatch/s 与 Kafka 无关。
主因是连续水位 W 从「读时计算的派生值」变成了「每次物化都要更新的持久状态」,
次因是同一套机制的存储表示由紧凑标量/bitmap 展开为 1216 字节向量与 64 个字段。
背景¶
触发问题¶
Redpanda 链路上线后,投递侧稳态容量为约 1,258 dispatch/s;而历史 TCP dispatch 架构 曾实测 15,000 msg/s。差距约 12 倍,一度被归因为「引入 Kafka 导致」。
本 ADR 记录该归因的完整证据链,因为结论与直觉相反,且不写下来必然重演。
归因证据(隔离环境实测,60 秒窗口,split Redis)¶
四次同口径测量,工件 /tmp/qim-attrib/run*:
| 目标速率 | committed | delivered | 端到端 P99 | fanout 处理量 | Redis 主线程 CPU |
|---|---|---|---|---|---|
| 1000 | 1000 | 1000 | 181.9 ms | 60,000(100%) | 81% |
| 2000 | 2000 | 1197 | 27.0 s | 120,000(100%) | 108% |
| 5000 | 5000 | 1140 | 50.2 s | 299,998(100%) | 108% |
| 2000 + inflight=48 | 2000 | 1207 | 26.7 s | — | 102% |
三次饱和运行的 cmdstat_evalsha 总 CPU 恒定在 58.1~58.4 秒 / 60 秒窗口,
与输入速率和 inflight 上限均无关——Redis 单线程是唯一的墙。
Kafka 不是瓶颈:5000 msg/s 下 fanout 处理了全部 299,998 条 source 一条未积压,
batch 自适应 12.1 → 86.3(上限 128 仍余 33%),事务成本摊薄至 0.19 ms/条,
SEND_ACK P99 仍达标(71.1 ms / 上限 150 ms)。Kafka 全链路固定成本约 25~35 ms,
在 300 ms 端到端预算中占约 10%。
提高并发无效:DISPATCH_INFLIGHT_LIMIT 16 → 48 时吞吐 1326 → 1336 dispatch/s
(+0.75%),而 materialize 均值 12.89 → 28.78 ms(等比劣化),
permit_wait 16.74 → 0.42 ms。等待只是从信号量转移进 Redis 内部。
ADR-0007 时代「不提高 inflight 默认值」的结论在饱和场景下同样成立。
成本的真实构成¶
将 evalsha 总耗时减去其内部全部 redis.call 耗时:
evalsha 总耗时 58.36 s (102,346 次)
内部 redis.call 合计 6.38 s (11%)
纯 Lua 解释器开销 51.98 s (89.1%)
每次调用 570 us = Redis 操作 62 us + Lua 计算 508 us
与旧架构逐项对比:
| 旧(15,000 msg/s) | 新(归因时) | 倍数 | |
|---|---|---|---|
redis.call 执行 |
~44 us | ~67 us | 1.5× |
| 纯 Lua 计算 | ~0 | ~508 us | — |
多做的持久化动作只贵了约 50%。成本爆炸全部来自在 Redis 单线程的 Lua 解释器里处理大对象。
决定性发现:这些机制不是 Kafka 引入的¶
git show HEAD:crates/qim-store/src/redis/store.rs(无 Kafka、15,000 msg/s 的一期形态)
的键设计:
mailbox:{tenant}:{user}:{day} zset score = mailbox_seq
wm:{shard}:{lane} hash W(原子推进)
dp:{shard}:{dispatch_id} hash lane -> chunk_done_bitmap
mailbox_seq、MailboxShard、LaneId、连续水位 W、DispatchProgress
在旧架构中全部存在。分片来自「用户经稳定虚拟桶固定到 MailboxShard」这一
水平扩展基础(docs/PLAN.md §6.5),与日志实现无关。
Kafka 改变的是同一套机制的实现方式:
| 旧 | 新 | |
|---|---|---|
mailbox_seq 来源 |
INCR 分配 + 在途 zset |
Kafka partition offset |
水位 W 的性质 |
派生值(读时用纯函数算) | 持久状态(每次物化 CAS 推进) |
| 水位存储 | 分片级标量(仅作观测/缓存) | 1216 字节 64-lane blob |
dp 的 lane 进度 |
lane -> chunk_done_bitmap |
64 个 m:N 字段 + manifest |
而旧实现的注释已经写明了标量表示的依据:
/// 一期是**分片级**标量(等价于 lane 向量各分量相同):
/// 阶段二做 lane 级隔离时才需要按 lane 拆开
「lane 向量各分量相同」这一性质在一开始就被认识到并加以利用,在重构中丢失了。 本轮第一次优化所做的,正是加一条运行时快路径去重新检测它。
最根本的原因:水位从派生值变成了持久状态¶
上表第二行才是差距的主因。表示形式(标量 vs 向量)只是结果, 「W 是派生值还是持久状态」才是原因——一旦它是持久状态,就必须在每次物化时更新, 而更新频率正好落在最热的那条路径上。
旧架构的水位实现全貌¶
职责分在三处,写路径上没有任何水位计算:
- 分配时:
INCR mbseq:{shard}取号,ZADD mbpend:{shard}登记在途 - 写路径(
complete_seq.lua,每消息每收件人一次):只做ZREM销账 - 读路径(
watermark_seq.lua,仅PULL_MAILBOX时):计算W
读路径脚本的全部有效代码只有五行:
local pend = redis.call('ZRANGE', KEYS[1], 0, 0)
if #pend == 0 then
return { tonumber(redis.call('GET', KEYS[2]) or '0'), 0 }
end
local oldest = tonumber(pend[1])
return { oldest - 1, oldest }
2 次 redis.call,零 Lua 计算。
决策理由(旧 complete_seq.lua 注释原文)¶
-- 只做销账,**不写水位**:水位改为读时计算(见 watermark_seq.lua)。
-- 理由是 QPS 差着数量级——销账在 fanout 热路径上每条消息一次,
-- 而水位只在 PULL_MAILBOX 时读取。把 HGET+HSET 从写路径挪到读路径,
-- 在单线程 Redis 已饱和的前提下直接决定端到端时延能否达标。
--
-- 正确性不受影响:`W = min(在途) - 1` 是纯函数,读时算与写时算结果相同,
-- 且天然单调——在途集合的最小值只会随销账变大,新分配的序号恒大于已有的。
「差着数量级」的实测验证¶
本轮在隔离环境实测了写路径与读路径的调用比:
| 目标速率 | 物化(写路径) | PULL_MAILBOX(读路径) |
比值 |
|---|---|---|---|
| 2000 | 126,000 次 | 11,974 次 | 10.5× |
| 5000 | 161,807 次 | 14,557 次 | 11.1× |
写路径是读路径的 10 倍以上。 把水位维护从写路径挪到读路径, 等于把该项成本除以 10。且该比值只会随消息速率上升而扩大—— 消息速率由业务驱动,拉取频率由客户端在线数与拉取策略决定,两者不同步增长。
正确性为何不受影响¶
W = min(在途) - 1 是在途集合上的纯函数,读时算与写时算必然同值。
单调性由构造保证,无需额外的 max 比较:
- 在途集合的最小值只随销账变大;
- 新分配的序号恒大于所有已分配的(
INCR保证),进入在途不会拉低最小值; - 在途由空变非空时,新最小值 = 已分配上限 + 1,故
W不变。
新架构为何改成持久状态¶
不是设计倒退,而是序号来源变化的连带后果:
- 旧:序号由
INCR分配 → 存在明确的「分配」时刻 → 天然可以在该时刻ZADD登记在途 - 新:序号 = Kafka dispatch 记录的 partition offset → 没有「分配」这一步 →
没有在途集合 → 无法用
min(在途) - 1推导
失去派生来源后,唯一的选择是把「已连续物化到哪」作为持久状态维护, 于是它落回写路径,并随 64 lane 展开为 1216 字节向量。
成本对照¶
| 旧(写路径每次) | 新(写路径每次) | |
|---|---|---|
| 水位相关操作 | ZADD 登记 + ZREM 销账 |
HGET 1216 字节 + 解码 + CAS + HSET 1216 字节 |
| Lua 计算 | 无 | uniform 判定 + lane 编解码 |
| 量级 | 约 8 us(zset 为 O(log N)) | 归因时约 280 us,优化后约 25~40 us |
提炼出的原则¶
在单线程存储上,派生值不要变成持久状态。引入任何「每次写入都要更新」的 状态之前,先测它在写路径与读路径上的 QPS 比。
这条原则独立于本项目的具体机制,适用于任何在单线程存储上维护聚合量的场景。
旧脚本写死过、而新实现违反了的纪律¶
旧 append_batch.lua 的头注释:
-- 本脚本在 fanout 热路径上按**收件人数**调用,因此每多一次 redis.call
-- 都会乘以收件人数压在单线程 Redis 上。实测(15000 msg/s):
-- evalsha 曾占 Redis 总 CPU 的 66%,是全系统唯一饱和的资源。
反推:0.66 s ÷ 15,000 = 44 us/消息,与上表一致。
但这条纪律指错了方向,见「后果」一节。
决策¶
1. 已实施:全同快路径(第一轮)¶
materialize_fresh_single_recipient.lua 与 advance_log_watermarks.lua 各增加一条
全同快路径:稳态下 64 个 lane 记录逐字节相同(每次物化把所有落后 lane 推进到同一
canonical offset,只有某个 lane 存在未完成 chunk 时才分叉),因此先用一次 C 层
blob == string.rep(sub(blob,1,19), 64) 判定「全同」,命中后只解码一个 lane,
写回用 string.rep。
判定逻辑提取为 lane_outcome(watermark, previous, target),该函数与 lane 下标无关
——这是快路径成立的前提,修改前必须确认该性质仍成立。
不命中即回落逐 lane 循环,正确性不依赖命中率。存储格式一字节未改。
2. 已实施:稀疏 lane manifest(第二轮)¶
manifest 字段(DPM\1)本就含全部 64 lane 的规范信息,m:N 只是给 Lua 按 lane
廉价访问的展开副本。空 lane 的 m:N 没有任何读取方需要——所有读取点
(final_lane、待标记 lane)按定义必然非空。
改为只写非空 lane,HSET 字段数从 71 降到 8。读取侧把「字段缺失」与「空串」
同等视为空 manifest,旧记录无需迁移。
注意 HGET/HMGET 对缺失字段返回 false 而非 nil,读取点必须 or '' 规范化,
否则 #manifest 会在 boolean 上崩溃(该缺陷由隔离 Redis 用例在实施中捕获)。
3. 已实施:周期性 uniform 判定(第三轮)¶
uniform_blob 由 blob == string.rep(sub(blob,1,19), 64) 改为
sub(blob,20) == sub(blob,1,-20)。二者等价:后者成立
⟺ blob 的周期整除 LANE_BYTES ⟺ 64 个 lane 记录逐字节相同。
微基准 11.768 us → 5.482 us(2.15 倍)。但见下条——端到端无可测收益。 保留的理由是同等复杂度的一行替换且微基准占优,不是因为它带来了性能提升。
4. 已实施:manifest 校验降为 O(1)(第四轮)¶
原实现在入口处重建整个 64-lane manifest 再与 ARGV[2] 逐字节比对
(canonical_manifest,微基准 28.9 us,占纯 Lua 计算约 15%)。
改为 O(1) 结构校验,语义等价:
328 = 4(头) + 64*5(每 lane 的 lane 字节 + count) + 4*sum(count)
=> 长度 328 蕴含 sum(count) == 1
因此「长度 328」+「target lane 的 count==1 且 chunk 值正确」
即可推出其余 63 个 lane 的 count 全为 0,无需任何循环。
canonical_manifest 函数随之删除(同时省去每次执行重建该闭包的开销)。
实测(5000 msg/s 口径):
| evalsha | 物化吞吐 | delivered | |
|---|---|---|---|
| 第三轮后 | 245.9 us | 2,694.8/s | 2,446.7 msg/s |
| 实验版(仅校验长度+头,不安全,仅用于测上限) | 215.9 us | 3,065.8/s | 2,781.6 msg/s |
| 第四轮 O(1) 校验 | 216.3 us | 3,002.9/s | 2,726.6 msg/s |
O(1) 版取得实验上限 98% 的收益,且校验强度与原实现等价。 2000 msg/s 下端到端 P99 218.7 ms,达标。
5. 已实施:快路径压缩返回(第五轮)¶
全同快路径下 64 个 lane 的判定必然一致,原实现仍构造并返回 65 元素表。
改为成功时返回 {2, outcome},由 Rust 侧展开为 64 个相同结果;
慢路径仍返回 {1, o0..o63},两种形式由首元素区分。
脚本经 include_str! 编译进二进制,Lua 与 Rust 恒为同版本,不存在协议错配。
实测(5000 msg/s):evalsha 216.3 -> 205.8 us, 物化吞吐 3,002.9 -> 3,192.7/s(+6.3%),delivered 2,726.6 -> 2,896.3 msg/s。
微基准预测 24 us(返回 65 元素表 21.0 + 64 次表赋值 3.2),实测 10.6 us,稀释约 2.3 倍。
6. 微基准可靠性的量级判据¶
四轮实测得到一条可复用的经验:微基准的可信度与被测项的量级强相关。
| 优化项 | 微基准预测 | 端到端实测 | 结论 |
|---|---|---|---|
HGETALL 合并(削调用次数) |
省 20~60 us | 0 | 完全不可信 |
| 周期性 uniform 判定 | 省 12.6 us | 约 1.5 us | 稀释约 8 倍 |
| 快路径压缩返回 | 省 24 us | 10.6 us | 稀释约 2.3 倍 |
| 移除 manifest 重建 | 省 28.9 us | 31 us | 准确 |
判据(四轮实测标定):
| 微基准量级 | 端到端实际兑现 |
|---|---|
| > 25 us | 基本全额兑现 |
| 10 ~ 25 us | 稀释约 2 倍 |
| < 10 us | 基本无效 |
该固定开销已实测(EVAL 口径):
| 项 | 单次 |
|---|---|
| EVAL 基础(10 ARGV) | 13.49 us |
| 139 个 ARGV 的额外成本 | 14.65 us |
12 个 local function 每次重建闭包 |
6.17 us |
| 返回 65 元素表 | 21.03 us |
因此优化必须先按微基准挑出 >20 us 的大项,再逐项实测验证; 小项累加式的优化(第三轮)已被证明无效。
7. 本 ADR 裁决:原则保留,但停止为性能继续实施¶
原则本身成立,作为未来改动的指导:
稳态下各分量相同的向量,应以标量存储,仅在真实分叉时展开为向量。
但不再为性能实施 wm 标量化与 dp bitmap 化。
理由是第三轮的实测反证。周期性判定在微基准上省 6.3 us、脚本内调用 2 次, 预期省约 12.6 us(250 → 237 us,-5%)。实际测得:
| evalsha | 物化吞吐 | |
|---|---|---|
第二轮(string.rep 版) |
250.24 us | 2,700.3/s |
| 第三轮(周期性版,重复 1) | 248.69 us | 2,696.9/s |
| 第三轮(周期性版,重复 2) | 245.88 us | 2,694.8/s |
evalsha 降幅 0.6~1.7% 处于噪声边缘,而物化吞吐没有改善(2,700.3 → 2,696.9/2,694.8)。
微基准收益在完整脚本中被稀释一个数量级。 推定原因:evalsha 的
usec_per_call 含大量固定开销(139 个 ARGV 的组装、脚本查找、返回值构造),
且 Redis 主线程已 102~103% 饱和,瓶颈不再由单项 Lua 操作主导。
按同一稀释比例外推,wm 标量化的微基准收益约 13 us
(string.rep 生成 1216 字节 7.824 us + 省去 uniform 判定 5.482 us)
在端到端同样将不可测,而它是跨 5 个脚本 + Rust 解码侧的存储格式变更,
需要新增 wm_layout 值、双格式读兼容,且旧版本读到新格式会失败闭合(回滚需停机)。
风险收益比不成立。 注意这与第四轮的成功并不矛盾:
按第 6 条的量级判据,wm 标量化属于 <20 us 的小项(会被稀释),
而第四轮移除的 manifest 重建是 28.9 us 的大项。
触发条件:仅在以下任一情况出现后重新评估—— mailbox Redis 分片完成使瓶颈转移出单线程; 或出现新的量化证据表明水位读写的体量重新成为主导项。
8. 不采纳(附触发条件)¶
8.1 去掉 Kafka¶
不采纳。 实测证明它不占瓶颈,而它修复的是旧架构的真实缺陷:
writer→mailbox 的进程内 TCP 队列曾被当作可靠 Outbox,物化失败后只剩序号、
没有 payload 或重放源,会永久卡住连续水位。旧架构的 15,000 msg/s 是在
没有重放源的前提下取得的。
8.2 同步 ACK(等 mailbox 写入完成才返回 SEND_ACK)¶
不采纳为通用方案。 它保证的是发送侧语义,而连续水位解决的是接收侧问题,两者正交。
关键推演(三段,缺一不可):
- shard 级序号 + 同步 ACK 仍不成立:请求并发执行,
seq=102已落盘不蕴含seq=101已完成。要使其成立需全局串行分配与写入,吞吐退化为1/RTT。 - shard 级序号 + 原子分配写入仍不成立:单个操作原子,但操作之间无顺序保证。
用户看到
{100, 102}时,仍无法区分 101 是「不属于我」还是「正在写给我」。 - 只有 per-user 序号成立:
{1, 2}连续,「能读到 2」蕴含「1 已写完」, 因为 2 由该用户自己的计数器发出,且发号与写入在同一原子操作内。
此外群扇出规模使其不可行:1000 人群同步写 1000 个 mailbox,
SEND_ACK 要等最慢一次。
触发条件:若一期产品上限下调至纯单聊/小群,可重新评估「小会话同步扇出 + 大群日志异步」的双路径方案,代价是两套代码路径。
8.3 per-user 连续序号¶
本期不采纳,但方向正确。 它能删掉 W、W_floor、W_recomputed、64 lane、
packed W 与 pull.rs 的水位校验,并使客户端可用 local + count == seq 一个算术式
检测缺口(Telegram pts 形态)。
不采纳的理由:
- 性能收益不足以支撑契约变更。第一轮快路径已削去 packed W 的大部分成本,
当前可删部分仅约 59 us(
canonical_manifest25 us +uniform_blob19 us + lane 相关 ARGV 10 us + 水位字段 5 us),即 250 → 190 us,吞吐 +30~40%, 距 15,000/s 仍差 4 倍。 - 影响面为契约级:
mailbox_seq属docs/PLAN.md§6 五种序列,LANE_COUNT = 64是ADR-0007定死的一期不可变项;涉及 152 处引用、25+ 文件、MailboxCursor的lane_id/shard_epoch作废、SDK 三个绑定的冻结表面。 - 一期尚有更硬的发布阻断项:
[joined,left)可见区间、租户保留策略、CommitAuditFactorphan 审计、180 秒 + T-HEAVY + 混沌门禁。
触发条件:阶段一更换 ScyllaDB MailboxStore 时一并评估——届时存储层本就要重做, 改动边际成本最低,且已有真实群规模分布数据可用于定「小群/大群」分界点。
一个附带的功能收益值得记录:lane 的设计意图是可见性隔离, per-user 序号下隔离粒度从 1/64 变为 1/N,是净改进,不依赖性能论证。
8.4 水位回归读时计算(已评估,不采纳)¶
方案:物化开始 ZADD(offset) 登记在途、完成 ZREM(offset) 销账(并入现有原子 Lua),
读路径按 min(在途) - 1 计算,即恢复旧架构做法。
评估结论:两个预设障碍均不成立,但收益已被第一轮优化吃掉,不采纳。
障碍一不成立:Redis 侧从未有过独立的交叉校验¶
三个写入路径全部把同一个值写三遍:
advance_log_watermarks.lua:312 encode_u48(observed) x3
advance_log_watermark.lua:217 encode_u48(observed) x3
materialize_fresh_single_recipient.lua:370 encode_u48(canonical) x3
Redis 分支上 W ≡ W_floor ≡ W_recomputed 恒等。
19 字节的 lane 记录中有 12 字节是重复数据。
由此得到一个必须记录的事实:pull.rs 的两条校验
state.watermark != state.recomputed_watermark // 恒假
watermark < floor // 恒假
在 Redis 分支上是恒假的空保护,只能检出字节级损坏,检不出任何逻辑错误。 未来若有人基于「我们有水位交叉校验」这一假设做安全决策,该假设是错的。
Scylla 分支不同:recomputed_watermark(scylla/mailbox.rs:977)是真重算,
从 lane_dispatch 表扫描连续 complete=true 前缀——它本身就已经是读时计算。
障碍二可解:W_floor 在该方案下自然不需要¶
在途集合只保留未完成项,GC 删除已完成证明不影响 min(在途),
因此不需要「不可回退锚点」——这正是旧架构只有 W 而无 floor 的原因。
崩溃语义安全:未销账条目使 W 卡住(而非跳过),由日志重放收敛。
不采纳的真正理由:收益已被第一轮吃掉¶
本 ADR 早先估计「省 20~30 us」,那是以归因时的 280 us 为基数。 第一轮全同快路径已将水位成本削至约 25~40 us,重算净收益为:
| 项 | 现状 | 在途集合方案 |
|---|---|---|
| uniform 判定 | 5.5 us | 0 |
string.rep 生成 1216 字节 |
7.8 us | 0 |
HGET/HSET 1216 字节的 IO |
数 us | 0 |
新增 ZADD + ZREM |
0 | 约 8 us |
净省约 5~15 us(2~6%),再按第三轮实测的稀释比例,端到端很可能不可测。
而它需要新建在途集合、改动登记时机、调整 pull.rs 校验,
且 Redis 与 Scylla 两分支形态不同需分别设计。
风险收益比不成立。水位这条线到此挖尽。
附带记录(不单独实施)¶
若未来因其他原因变更水位 layout,可顺带利用三值恒等把 lane 记录由 19 字节
压至 7 字节(presence + 单个 u48),blob 由 1216 字节降至 448 字节。
但不值得为此单独发起一次格式变更。
8.5 mailbox Redis 分片¶
未在本 ADR 裁决,是取得数量级的唯一路径(2,700 → 万级)。
前置条件已记录于 CLAUDE.md:跨槽 GroupMembership、全局 {commit} 热槽、
Cluster 路由与迁移。需独立设计与 ADR。
后果¶
已验证的收益¶
三阶段实测(5000 msg/s 口径):
| 阶段 | Lua 单次 | 物化吞吐 | materialize 均值 |
|---|---|---|---|
| 归因基线 | 603 us | 1,258/s | 13.52 ms |
| ① 全同快路径 | 321 us | 2,090/s | 7.22 ms |
| ② 稀疏 manifest | 250 us | 2,700/s | 5.47 ms |
| ③ 周期性判定 | 246~249 us | 2,695~2,697/s | 5.31 ms |
| ④ O(1) manifest 校验 | 216 us | 3,003/s | 4.81 ms |
| ⑤ 快路径压缩返回 | 206 us | 3,193/s | 4.72 ms |
累计:603 -> 206 us(-66%),1,258 -> 3,193 dispatch/s(+154%)。 收益来自 ①②④⑤;③ 无可测收益(见「决策」第 7 条)。
2000 msg/s 从塌陷(delivered 1,197/s、端到端 P99 27.0 s) 变为完全跟上(delivered 1,900/s、端到端 P99 130.3 ms,达标)。 5000 msg/s 下 delivered 由 1,140 提升至 2,454/s。
全量 613 项测试通过,clippy -D warnings、check-contract.sh、fmt 均通过。
纪律修正¶
旧 append_batch.lua 的纪律「热路径每多一次 redis.call 都乘以收件人数」
指错了方向,必须修正为两条,且第一条优先级更高:
一、派生值不要变成持久状态。引入任何「每次写入都要更新」的状态之前, 先测它在写路径与读路径上的 QPS 比。
二、若该状态确实必须持久化,则 Redis 单线程上热路径的成本在单次操作的 体量,不在调用次数。
两条的层级不同:第一条决定要不要做,第二条决定怎么做。 最便宜的操作永远是不执行的那个——旧架构把水位挪到读路径省掉的是 10 倍调用, 而本轮三轮实现层优化合计只拿回 115%,且第三轮为零。 先问第一条,再问第二条。
证据是第三轮的失败实验:将 mbmeta(7 字段被访问 10 次)与水位 hash(3 字段被访问
4 次)合并为各一次 HGETALL,redis.call 由 41 降至 28,
而 evalsha 250.47 → 250.24 us、物化吞吐 2700.2 → 2700.3/s,零收益,已回滚。
原因:内部 redis.call 仅占 evalsha 的 11%,小 listpack hash 上的单字段 HGET
是亚微秒级;省下的 13 次调用恰被 2 次 HGETALL 的 table 构造抵消。
有效的两轮都不是在削调用次数,而是在削单次操作的体量:
①把 64 次逐字节编解码降为 1 次,②把 HSET 从 71 字段降为 8 字段。
遗留风险¶
- Redis 主线程在 5000 msg/s 下仍为 103% 饱和,2,700 dispatch/s 是当前硬上限。
- 剩余成本已量化:
HSET8 字段约 47 us、canonical_manifest64 次变长拼接约 25 us、uniform_blob两次约 19 us、139 个 ARGV 约 15 us,其余为 V4 proof 与邮箱写入的 固有开销。单项均在 10~25 us 量级,不会再有数量级变化。 - 全同快路径的正确性依赖
lane_outcome与 lane 下标无关这一性质。 任何引入 lane 相关分支的修改都会静默破坏它——快路径仍会命中,但结果错误。 该约束已写入两个脚本的注释。
附带发现:端到端 P99 门禁不稳定(根因已在 ADR-0012 定位并消除)¶
2000 msg/s 下端到端 P99 在多次重复测量中剧烈波动 (125.5 / 222.3 / 351.6 / 351.9 / 455.7 ms),而同批 P50、P95 稳定。 按 CLAUDE.md 纪律,这类「会随机红」的门禁最终必然被当成「本来就飘」而失效。
根因已查明:Kafka 事务两阶段提交的长尾。 见 ADR-0012。
去掉事务后三次重复测量 P99 为 91.5 / 92.7 / 91.5 ms,方差不足 1.5 ms。
因此本 ADR 早先「需改为多次取中位数或将判据移至 P95」的建议作废—— 当前 P99 已是可靠判据。
另需更正:CLAUDE.md 中记录的 692.6 ms「最好成绩」为 10 秒窗口结果;
同口径 60 秒窗口下 P99 为 181.9 ms、max 735.5 ms。结合 ADR-0012 的发现,
该数值同时受短窗口放大与事务长尾影响,不得再作为稳态基线使用。
微基准:为什么「削调用次数」是错的直觉¶
在隔离 Redis 上对 uniform 判定的几种写法计时(各 20,000 次,稳态全同必须跑完全程):
| 实现 | 单次 |
|---|---|
| 空循环基线 | 0.015 us |
blob == string.rep(sub(blob,1,19), 64) |
11.768 us |
| 分段 sub 比较(63 次 19 字节) | 10.230 us |
周期性:sub(blob,20) == sub(blob,1,-20) |
5.482 us |
逐字节 string.byte(1197 次) |
203.135 us |
string.rep 生成 1216 字节 |
7.824 us |
标量 string.format('%d', u48) |
0.376 us |
逐字节版本比两次 C 层 sub 慢 37 倍。 按「减少 redis.call/减少操作次数」的
旧直觉去优化,正好落到最差解上。这是「成本在单次操作的体量」这一原则最直接的证据。
周期性判定的正确性:blob[20..] 与 blob[..-20] 相等
⟺ blob 的周期整除 LANE_BYTES ⟺ 64 个 lane 记录逐字节相同。
调用方需保证 #blob == BLOB_BYTES(valid_blob 的长度检查在前)。
参考¶
- 归因数据均已记录于本文正文。原始工件位于临时目录
/tmp/qim-attrib/(12 次隔离运行的 commandstats、分段指标与 loadgen 摘要), 该目录已被系统清理,不可复查;后续实测需重新建立基线。 - 旧实现:
git show HEAD:crates/qim-store/src/redis/store.rs、git show HEAD:crates/qim-store/lua/append_batch.lua - 相关:
ADR-0004(mailbox_seq组成)、ADR-0007(一期部署档位)、ADR-0001(MailboxStore 三级演进)