《Java 消息中间件·可靠消息与消费治理》9.3 最终一致性的度量

最终一致不是一个形容词,而是一组可以画在面板上的数字。本篇给每条事件带上产生时间戳,在下游落库时计算端到端延迟,本机跑出基线 P50/P95/P99 与最长窗口,再注入 10% 的慢路径看尾延迟怎么变;最后写一个对账任务,故意漏发两条、重发一条,验证它能把条数、金额和重复同时报出来。

「最终一致」需要一个数字

「最终一致」这个说法最容易骗人:它没有说「最终」是 50 毫秒还是 5 分钟,也没有说「一致」是条数一致、金额一致,还是业务状态一致。没有数字,就没法判断一次发布、一次扩容、一次故障之后系统到底有没有变差。

度量至少需要三样东西:

  1. 延迟分布:一条事件从产生到下游可见,通常要多久。只看平均值会被长尾骗,要看 P50、P95、P99 和最长窗口。
  2. 不一致窗口的告警:当前有没有超过 SLO 的延迟,持续了多久,影响了多少条。
  3. 对账:最终状态到底对不对,条数、金额、唯一键能不能对上。

三者回答不同的问题:延迟分布回答「正常有多快」,告警回答「现在是否异常」,对账回答「最后有没有错」。缺一个都会留下盲区。

三种延迟口径

先把「延迟」定义清楚,不同口径能差出几秒钟:

口径起点终点回答的问题本实验是否覆盖
传输延迟生产者调用 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 秒主要是排队,不是处理。

这两个污染源在真实系统里也存在,但它们不该混进「单条事件的处理延迟」这个指标。要测稳态延迟,必须让消费者先就位,再开始生产。

正确的测量布置

本实验的布置是:

  1. 消费者线程先启动,订阅主题,轮询直到 assignment() 非空,打印加入耗时。
  2. 主线程开始生产,每条之间 sleep(4ms),把 1000 条摊到约 5 秒,避免人为制造积压。
  3. 消费者边收边算 consumedAt - producedAt,写入下游表。
  4. 生产结束后等消费者追平,再算分位数与对账。

消息体用竖线分隔的紧凑编码,生产环境应换成 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

两次对照:

场景minP50P95P99max>100ms
基线2 ms6 ms21 ms43 ms80 ms0
10% 慢路径2 ms14 ms46 ms77 ms132 ms7(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 超 SLOP99 > 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 秒。

对账任务:条数与金额

延迟和对账是两件事。延迟再低,也可能少一条;对账就是回答「最终状态对不对」。

最小对账比四样东西:

  1. 上游条数 vs 下游条数;
  2. 上游金额合计 vs 下游金额合计;
  3. 上游有、下游没有的键(缺失);
  4. 下游有、上游没有的键(多余)以及重复键。

核心 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 毫秒就落库,但落错了表;对账平也不代表延迟达标:数据可能一小时前就到齐了,只是用户看不到。三者要一起看,才能把「最终一致」从一个形容词变成一组可验收的数字。

落地清单

  1. 每条事件带 producedAt,由生产者在 send() 前生成,不要用 broker 追加时间替代。
  2. 测量时消费者先就位,把消费组加入延迟与稳态处理延迟分开统计。
  3. 同时报 P50/P95/P99、max、超阈值条数,并标注样本量与时间窗。
  4. 按业务键统计最老未落库年龄,定位单聚合卡住。
  5. 对账比条数、去重键数、金额、缺失、多余、重复,只比总数会掩盖一缺一重。
  6. 对账只看水位线之前的数据,避免把在途数据当成丢失。
  7. 对账只读、修复另起任务,差异分类后分别处理。

本节判据

  1. 为什么不能只报平均延迟? 答:平均值被长尾掩盖,10% 的慢路径就能把 P95 拉高一倍,而平均值只从 8.5 涨到 19.2 毫秒。
  2. 先生产后消费测出的延迟混入了什么? 答:消费组首次加入的等待(本机约 3.1 秒)和消费积压,二者都不是单条事件的处理耗时。
  3. 单分区单消费者时,慢消息为什么影响 P50? 答:顺序处理导致队头阻塞,一条慢消息会挡住它后面所有消息。
  4. 对账为什么不能只比总条数? 答:一条缺失加一条重复会让总数仍然相等,必须同时比去重键数、金额和差异清单。
  5. 延迟达标是否等于最终一致? 答:不等于。延迟只说明快慢,对账才能发现丢失、重复与金额错误,两者回答不同问题。

阅读导航:上一节 9.2 补偿事务的幂等 | 下一节 10.1 顺序的粒度与代价

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收