跳转至

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_previous CAS 连续成功后,才把持久 64-lane W 推进到 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 是派生值还是持久状态」才是原因——一旦它是持久状态,就必须在每次物化时更新, 而更新频率正好落在最热的那条路径上。

旧架构的水位实现全貌

职责分在三处,写路径上没有任何水位计算:

  1. 分配时:INCR mbseq:{shard} 取号,ZADD mbpend:{shard} 登记在途
  2. 写路径(complete_seq.lua,每消息每收件人一次):只做 ZREM 销账
  3. 读路径(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)

不采纳为通用方案。 它保证的是发送侧语义,而连续水位解决的是接收侧问题,两者正交。

关键推演(三段,缺一不可):

  1. shard 级序号 + 同步 ACK 仍不成立:请求并发执行,seq=102 已落盘不蕴含 seq=101 已完成。要使其成立需全局串行分配与写入,吞吐退化为 1/RTT。
  2. shard 级序号 + 原子分配写入仍不成立:单个操作原子,但操作之间无顺序保证。 用户看到 {100, 102} 时,仍无法区分 101 是「不属于我」还是「正在写给我」。
  3. 只有 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_manifest 25 us + uniform_blob 19 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) 可见区间、租户保留策略、 CommitAuditFact orphan 审计、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 是当前硬上限。
  • 剩余成本已量化:HSET 8 字段约 47 us、canonical_manifest 64 次变长拼接约 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 三级演进)