「最终一致」需要一个数字
「最终一致」这个说法最容易骗人:它没有说「最终」是 50 毫秒还是 5 分钟,也没有说「一致」是条数一致、金额一致,还是业务状态一致。没有数字,就没法判断一次发布、一次扩容、一次故障之后系统到底有没有变差。
度量至少需要三样东西:
- 延迟分布:一条事件从产生到下游可见,通常要多久。只看平均值会被长尾骗,要看 P50、P95、P99 和最长窗口。
- 不一致窗口的告警:当前有没有超过 SLO 的延迟,持续了多久,影响了多少条。
- 对账:最终状态到底对不对,条数、金额、唯一键能不能对上。
三者回答不同的问题:延迟分布回答「正常有多快」,告警回答「现在是否异常」,对账回答「最后有没有错」。缺一个都会留下盲区。
三种延迟口径
先把「延迟」定义清楚,不同口径能差出几秒钟:
| 口径 | 起点 | 终点 | 回答的问题 | 本实验是否覆盖 |
|---|---|---|---|---|
| 传输延迟 | 生产者调用 send() 前 | 消费者 poll() 拿到 | 消息系统本身有多快 | 否 |
| 落库延迟 | 生产者调用 send() 前 | 下游写库提交 | 数据什么时候可查 | 是(到写库前一刻) |
| 业务可见延迟 | 生产者调用 send() 前 | 缓存失效、索引更新、对外接口可查 | 用户什么时候看到 | 否 |
本实验测的是第二种:producedAt 在 send() 之前取,consumedAt 在消费者执行 INSERT 之前取,两者相减。H2 内存库的 INSERT 是自动提交,耗时在亚毫秒级,所以「写库前一刻」和「写库提交」在本实验里差别可以忽略;换成真实数据库,应该在 commit() 返回后再取时间戳。
业务可见延迟通常才是用户感知的。一条借阅事件落库了,但详情页缓存还没失效,用户仍然看不到——这条延迟可能比落库延迟大一个数量级。要测它,得在缓存失效或接口可查的那一刻再打一个时间戳,形成三段式链路。本篇只做落库这一段,其余两段的测法相同。
测量陷阱:先生产还是先消费
最直觉的写法是「生产 2000 条,再启动消费者读回来,算差值」。本机第一次就是这么写的,输出(摘录,未改数值):
[produce] sent=2000 elapsed=418ms (单次运行,不作基准)
[consume] matched=2000 elapsed=17151ms (单次运行,不作基准)
[latency] sample=2000 expected=2000
[latency] min=4454ms p50=10800ms p95=16568ms p99=17076ms max=17190ms avg=10794.9ms
[latency] over_100ms=2000 (100.0%)
最小延迟 4.4 秒,中位数 10.8 秒。这个数字里混进了两种与「处理快慢」无关的东西:
- 消费组首次加入的等待。新建消费组后,broker 的
group.initial.rebalance.delay.ms默认是 3000 毫秒;消费者在这段时间里还没拿到分区,更不可能消费。本机后续测量到的加入耗时是 3126–3182 毫秒。 - 消费积压。生产者 418 毫秒就写完了 2000 条,消费者单线程处理,其中 10% 走了慢路径;消费速度低于生产速度,后面的消息一直在队列里等,延迟随位置线性增长。P50 的 10.8 秒主要是排队,不是处理。
这两个污染源在真实系统里也存在,但它们不该混进「单条事件的处理延迟」这个指标。要测稳态延迟,必须让消费者先就位,再开始生产。
正确的测量布置
本实验的布置是:
- 消费者线程先启动,订阅主题,轮询直到
assignment()非空,打印加入耗时。 - 主线程开始生产,每条之间
sleep(4ms),把 1000 条摊到约 5 秒,避免人为制造积压。 - 消费者边收边算
consumedAt - producedAt,写入下游表。 - 生产结束后等消费者追平,再算分位数与对账。
消息体用竖线分隔的紧凑编码,生产环境应换成 JSON 或 Avro;这里不引入 JSON 库,是为了让实验只依赖 JDK 与 kafka-clients。
eventId|loanId|amountCents|producedAt|slow
run-1793253100785-L-10-evt|run-1793253100785-L-10|250|1793253101922|1
slow 是故意注入的慢路径标记,每 10 条置 1。消费者看到它时睡 10–40 毫秒,模拟下游写库抖动;这个延迟是人为注入的,不是被测系统的真实性能。
测量代码
消费者线程的核心循环:
consumer.subscribe(List.of(TOPIC));
while (assigned.getCount() > 0 && System.currentTimeMillis() - joinStart < 20_000) {
consumer.poll(Duration.ofMillis(200));
if (!consumer.assignment().isEmpty()) {
assigned.countDown();
}
}
while (System.currentTimeMillis() - start < 180_000) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(200));
for (ConsumerRecord<String, String> r : records) {
String[] f = r.value().split("\\|", -1);
if (f.length != 5 || !f[1].startsWith(runId + "-L-")) {
continue; // 过滤历史运行的数据
}
long producedAt = Long.parseLong(f[3]);
if ("1".equals(f[4])) {
Thread.sleep(10L + (seq % 3) * 15L); // 注入的慢路径
}
long consumedAt = System.currentTimeMillis();
long latency = consumedAt - producedAt;
latencies.add(latency);
insertDownstream(f, producedAt, consumedAt, latency);
}
}
分位数用最近秩(nearest-rank),不插值:
static long pct(List<Long> sorted, int p) {
int idx = (int) Math.ceil(p / 100.0 * sorted.size()) - 1;
return sorted.get(Math.max(0, Math.min(idx, sorted.size() - 1)));
}
1000 个样本时,P99 落在排序后的第 990 个,只由最慢的十来条决定。样本越少,P99 越容易被单条抖动主导;如果 QPS 很低,按天聚合比分位更稳。
本机实测:基线与慢路径
环境:Temurin 25.0.5+8,kafka-clients 4.3.0,Kafka 4.3.0 KRaft 单节点(apache/kafka:4.3.0,1 分区),H2 2.3.232 内存库。命令:
$ export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$ javac --release 25 -cp kafka-clients-4.3.0.jar -d out src/ConsistencyLab.java
$ ./run.sh ConsistencyLab run 1000 0 # 基线,不注入慢路径
$ ./run.sh ConsistencyLab run 1000 10 # 每 10 条一条慢路径
基线
[topic] exists v2c9.consistency
[run] id=run-1793253100785 records=1000 slow_every=0 topic=v2c9.consistency
[consume] assigned after 3182ms (含消费组首次加入)
[produce] sent=1000 elapsed=5288ms (单次运行,不作基准)
[consume] matched=1000 elapsed=10574ms (单次运行,不作基准)
[latency] sample=1000 expected=1000
[latency] min=2ms p50=6ms p95=21ms p99=43ms max=80ms avg=8.5ms
[latency] over_100ms=0 (0.0%)
[recon] upstream rows=1000 sum=250150
[recon] downstream rows=1000 distinct=1000 sum=250150
[recon] missing=0 []
[recon] extra=0 []
[recon] duplicated=0 []
[recon] amount_diff=0 => PASS
注入 10% 慢路径
[run] id=run-1793253115611 records=1000 slow_every=10 topic=v2c9.consistency
[consume] assigned after 3126ms (含消费组首次加入)
[produce] sent=1000 elapsed=5351ms (单次运行,不作基准)
[consume] matched=1000 elapsed=10597ms (单次运行,不作基准)
[latency] sample=1000 expected=1000
[latency] min=2ms p50=14ms p95=46ms p99=77ms max=132ms avg=19.2ms
[latency] over_100ms=7 (0.7%)
[recon] upstream rows=1000 sum=250150
[recon] downstream rows=1000 distinct=1000 sum=250150
[recon] amount_diff=0 => PASS
两次对照:
| 场景 | min | P50 | P95 | P99 | max | >100ms |
|---|---|---|---|---|---|---|
| 基线 | 2 ms | 6 ms | 21 ms | 43 ms | 80 ms | 0 |
| 10% 慢路径 | 2 ms | 14 ms | 46 ms | 77 ms | 132 ms | 7(0.7%) |
所有数字都是单次运行,随本机调度波动,不作基准。有价值的不是绝对值,而是相对变化:10% 的慢路径把 P95 拉高了一倍多,P99 接近翻倍,最大值从 80 毫秒涨到 132 毫秒。
慢路径只占 10%,为什么 P50 也从 6 涨到 14?因为消费者是单线程顺序处理的:一条慢消息会挡住它后面排队的消息。100 条慢消息每条多花 10–40 毫秒,合计约 2.5 秒的多余处理时间,这些时间会以阻塞的形式压在后续消息上。如果分区数和消费者线程数更多,阻塞只影响同一分区,尾延迟会小一些;单分区单消费者时,一条慢消息就是整条链路的队头阻塞(head-of-line blocking)。
分位数怎么读
| 指标 | 回答的问题 | 容易被怎么误用 |
|---|---|---|
| 平均值 | 总体资源消耗 | 被长尾掩盖,8.5 ms 和 19.2 ms 看起来都不大 |
| P50 | 大多数请求的体验 | 只盯 P50 会漏掉慢用户 |
| P95 / P99 | 尾延迟,SLO 的主力 | 样本少时抖动大,要配合样本数与时间窗 |
| max | 最坏窗口,用于事故复盘 | 单条异常就能刷新,不适合直接做告警阈值 |
| 超过阈值的条数 | 有多少用户受影响 | 比单纯分位数更贴近业务影响 |
一个实用做法是同时看两族指标:分位数看趋势,超阈值计数看影响面。例如 SLO 是「99% 的事件在 1 秒内可见」:
- P99 ≤ 1 s 说明达标;
count(latency > 1s)说明这次窗口里有多少条违约;max告诉你最坏的那条等了多久,用于判断是否触发人工排查。
只报 P99 不报样本数,等于没说。1000 条样本的 P99 只由 10 条决定;10 条样本的 P99 就是最大值。
不一致窗口的告警
延迟指标要变成可执行的告警,至少覆盖四类:
| 告警 | 示例阈值 | 说明 |
|---|---|---|
| P99 超 SLO | P99 > 1 s 持续 5 分钟 | 整体变慢,先看消费 lag 与下游耗时 |
| 最长窗口 | max > 30 s | 单条卡住,可能是毒消息或分区热点 |
| 违约比例 | latency > 1s 占比 > 0.1% | 影响面扩大,准备扩容或降级 |
| 按业务键的年龄 | 同一 bookId 最老未处理事件 > 60 s | 某个聚合卡住,比全局指标更早定位 |
最后一类最容易被忽略。全局 P99 正常,不代表每个业务键都正常:某个热点 bookId 的事件可能全部堆在一个分区后面。按 key 聚合「最老未落库事件的年龄」,能直接指出是哪个聚合出了问题。
告警阈值不要拍脑袋。先跑一周基线,取 P99 的 2–3 倍作为初始阈值,再根据误报率调整。本实验的基线 P99 是 43 毫秒,如果照搬这个数字设 50 毫秒,任何一次 GC 都会触发告警;应该按业务 SLO 设,比如 1 秒。
对账任务:条数与金额
延迟和对账是两件事。延迟再低,也可能少一条;对账就是回答「最终状态对不对」。
最小对账比四样东西:
- 上游条数 vs 下游条数;
- 上游金额合计 vs 下游金额合计;
- 上游有、下游没有的键(缺失);
- 下游有、上游没有的键(多余)以及重复键。
核心 SQL 是三个反连接:
-- 缺失:上游有、下游没有
SELECT u.loan_id
FROM upstream u
LEFT JOIN (SELECT loan_id FROM downstream WHERE run_id = ? GROUP BY loan_id) d
ON u.loan_id = d.loan_id
WHERE u.run_id = ? AND d.loan_id IS NULL;
-- 重复:下游同一键出现多次
SELECT loan_id, COUNT(*) c
FROM downstream
WHERE run_id = ?
GROUP BY loan_id
HAVING COUNT(*) > 1;
生产环境里 upstream 通常是业务库的借阅表,downstream 是消费后写入的宽表或数仓表;两边可能在不同存储,不能指望一条 SQL 跨库 join。常见做法是把两边各自聚合成「键 + 金额 + 条数」的中间结果,落到同一张对账表再比。
干净数据的对账结果
上面两次延迟实验的对账都是:
[recon] upstream rows=1000 sum=250150
[recon] downstream rows=1000 distinct=1000 sum=250150
[recon] missing=0 []
[recon] extra=0 []
[recon] duplicated=0 []
[recon] amount_diff=0 => PASS
条数、去重后的键数、金额三者都相等,缺失、多余、重复都为空。注意这里同时报了 rows 和 distinct:如果只比总条数,一条缺失加一条重复会互相抵消,账面仍然是 1000,问题被掩盖。
故意制造差异
tamper 模式让上游记 1000 笔账,但漏发第 999、1000 条,并把第 1 条重发一次:
[tamper] id=run-tamper-1793253132132 upstream=1000 sent=999
[tamper] matched=999
[recon] upstream rows=1000 sum=250150
[recon] downstream rows=999 distinct=998 sum=249550
[recon] missing=2 [run-tamper-1793253132132-L-1000, run-tamper-1793253132132-L-999]
[recon] extra=0 []
[recon] duplicated=1 [run-tamper-1793253132132-L-1x2]
[recon] amount_diff=-600 => FAIL
金额差 -600 可以逐笔还原:
| 项 | 金额(分) |
|---|---|
缺失 L-999 | -350 |
缺失 L-1000 | -400 |
重复 L-1(多算一次) | +150 |
| 合计 | -600 |
这正是对账的价值:它不仅说「不一致」,还能把差异拆成缺失、重复、金额三类,直接对应三种修复动作——按 ID 补发、按幂等键去重、金额不符转人工。
对账的工程细节
| 问题 | 做法 |
|---|---|
| 在途数据造成误报 | 只对账「水位线之前」的数据,比如产生时间早于 5 分钟的记录 |
| 两边时钟不一致 | 用业务日期或分区边界做窗口,不依赖单机时钟 |
| 晚到数据 | 窗口保留缓冲期,或按事件时间重新跑历史窗口 |
| 对账本身很重 | 按天分区、增量聚合,不要每次全表扫 |
| 修复动作重复 | 补发按事件 ID 幂等,去重靠下游唯一键 |
| 对账结果没人看 | 差异数进告警,PASS 也要留一条心跳指标 |
还有一条原则:对账只读,修复另起任务。对账任务顺手改数据,会让「差异报告」和「修复动作」互相污染,出问题时分不清是原发差异还是修复引入的差异。对账输出差异清单,修复任务消费清单并记录每次修复的结果。
三件工具的分工
| 工具 | 回答 | 频率 | 发现的问题 |
|---|---|---|---|
| 延迟指标 | 正常情况下要多久 | 持续采集 | 变慢、尾延迟、队头阻塞 |
| 不一致告警 | 现在是否超出 SLO | 分钟级 | 故障期、扩缩容后的退化 |
| 对账任务 | 最终状态是否相等 | 小时/天级 | 丢失、重复、金额错、状态错 |
延迟低不代表对账平:一条消息可能 5 毫秒就落库,但落错了表;对账平也不代表延迟达标:数据可能一小时前就到齐了,只是用户看不到。三者要一起看,才能把「最终一致」从一个形容词变成一组可验收的数字。
落地清单
- 每条事件带
producedAt,由生产者在send()前生成,不要用 broker 追加时间替代。 - 测量时消费者先就位,把消费组加入延迟与稳态处理延迟分开统计。
- 同时报 P50/P95/P99、max、超阈值条数,并标注样本量与时间窗。
- 按业务键统计最老未落库年龄,定位单聚合卡住。
- 对账比条数、去重键数、金额、缺失、多余、重复,只比总数会掩盖一缺一重。
- 对账只看水位线之前的数据,避免把在途数据当成丢失。
- 对账只读、修复另起任务,差异分类后分别处理。
本节判据
- 为什么不能只报平均延迟? 答:平均值被长尾掩盖,10% 的慢路径就能把 P95 拉高一倍,而平均值只从 8.5 涨到 19.2 毫秒。
- 先生产后消费测出的延迟混入了什么? 答:消费组首次加入的等待(本机约 3.1 秒)和消费积压,二者都不是单条事件的处理耗时。
- 单分区单消费者时,慢消息为什么影响 P50? 答:顺序处理导致队头阻塞,一条慢消息会挡住它后面所有消息。
- 对账为什么不能只比总条数? 答:一条缺失加一条重复会让总数仍然相等,必须同时比去重键数、金额和差异清单。
- 延迟达标是否等于最终一致? 答:不等于。延迟只说明快慢,对账才能发现丢失、重复与金额错误,两者回答不同问题。
阅读导航:上一节 9.2 补偿事务的幂等 | 下一节 10.1 顺序的粒度与代价
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。