《Java 消息中间件·事件驱动架构》4.3 补偿与最终一致性

「最终一致」里的「最终」必须是一个可度量的分布,不是一句安慰。本文给出一致性窗口的度量代码:事件产生时打时间戳,下游写库提交后再打一个,按分位统计。本机两段负载实测显示窗口随积压线性增长,慢消费者下 p99 从 81ms 变成 4387ms。据此讨论补偿的触发条件、补偿失败的人工出口,以及一致性窗口该怎么承诺。

「最终」到底是多久

4.1 把最终一致选成了大多数业务的默认方案,代价是「窗口」。4.2 用对账兜住了「永远不到」的情况。这一节补上中间那块:窗口本身有多长,怎么量,怎么承诺,超了怎么办。

「最终一致」这个说法最容易骗人的地方,是它没有主语也没有时限。「系统最终会一致」——最终是 10 毫秒还是 10 分钟,取决于积压、消费者数量和那一刻的机器负载。不把它变成分布,这句话在事故复盘时没有任何信息量。

所以先给一个可操作的定义:

一致性窗口:一个事件从「业务侧产生」到「下游读模型可见」之间的时间。

两个端点都要打时间戳,而且必须打在同一条事件的生命周期上:

  • 起点:业务事务提交后、事件对象构造完成的那一刻,写进事件体;
  • 终点:消费者把这条事件写进读模型、事务提交返回之后的那一刻。

终点不能打在「消费到消息」或者「开始处理」,那两个时刻下游还读不到数据。也不能打在「写库语句执行完」,因为事务没提交之前,别的连接看不到。

度量代码

度量本身不复杂,难的是不打错位置。下面这段是 4.3 实验里的核心逻辑,生产者和消费者在同一个 JVM 里,用同一个 H2 内存库做读模型:

// 生产者:时间戳在 send 之前打进消息体
long now = System.currentTimeMillis();
String value = eventId + "|" + now + "|ON_LOAN";
producer.send(new ProducerRecord<>(TOPIC, key, value));

// 消费者:写库、提交,之后再取「可见时刻」
String[] f = record.value().split("\\|");  // eventId | producedAt | status
long producedAt = Long.parseLong(f[1]);
long procStart = System.currentTimeMillis();
insert.setString(1, f[0]);
insert.setString(2, phase);                // paced / burst
insert.setLong(3, producedAt);
insert.executeUpdate();
db.commit();                               // 提交返回,下游才可见
long visible = System.currentTimeMillis();
update.setLong(1, visible);
update.setLong(2, visible - producedAt);   // 一致性窗口
update.setLong(3, visible - procStart);    // 处理耗时,单独记
update.setString(4, f[0]);
update.executeUpdate();

三个时间戳分别回答不同问题:

时间戳含义用途
producedAt业务事件产生窗口起点
procStart消费者开始处理这条记录拆出「排队时间」
visible读模型提交完成窗口终点

visible - producedAt 是窗口,procStart - producedAt 是排队时间,visible - procStart 是处理时间。三者一拆,就能回答「窗口变长是因为积压还是因为消费变慢」——这两个问题的处置方式完全不同。

本机实测:两段负载

实验在单节点 KRaft(apache/kafka:4.3.0,主题三分区、副本数 1)上跑两段负载:

  • paced:每秒 100 条,持续 15 秒,共 1500 条;
  • burst:不节流连发 3000 条。

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 单节点 KRaft / H2 2.3.232 内存库 / 2026-10-29):生产者与消费者在同一 JVM,读模型为内存库,提交无磁盘 fsync。以下为原样输出。

paced: 1500 条,用时 15002 ms,折算 100.0 条/秒
burst: 3000 条,发送用时 17 ms,折算 176470.6 条/秒
burst 全部可见用时:73 ms(发送结束到最后一条可见)
consumed: 4500 / 4500

[paced] n=1500  期望=1500
  min=2  p50=4  p90=10  p99=78  p99.9=160  max=169  avg=7.1  (ms)
[burst] n=3000  期望=3000
  min=4  p50=52  p90=75  p99=81  p99.9=82  max=82  avg=49.5  (ms)

先说清楚 176470.6 条/秒 这个数字:它是 send() 调用返回的速度,不代表 broker 的写入能力,更不代表链路吞吐。3000 条先堆在生产者缓冲区里,真正的投递发生在之后。这类数字在别处经常被当成「吞吐量」引用,是错的。

稳定的 100 条/秒下,p50 是 4 毫秒、p99 是 78 毫秒。突发 3000 条时 p50 涨到 52 毫秒,p99 只到 81 毫秒——因为消费者够快,73 毫秒就把 3000 条全部追平了。

把 burst 段按「可见顺序」切成三段,能看见窗口在积压过程中的漂移:

[burst 按可见顺序分三段]
  第 1 段 n=1000  p50=26  p99=36  max=36 (ms)
  第 2 段 n=1000  p50=52  p99=62  max=62 (ms)
  第 3 段 n=1000  p50=73  p99=81  max=82 (ms)

三段的中位数几乎等距:26 → 52 → 73。这不是巧合,是排队论的直接结果:队列在匀速排空,越靠后的事件等得越久。

慢消费者:窗口等于积压除以速率

把消费者人为放慢——每条记录 sleep 1ms——再跑一次同样的负载:

paced: 1500 条,用时 15001 ms,折算 100.0 条/秒
burst: 3000 条,发送用时 15 ms,折算 200000.0 条/秒
burst 全部可见用时:4426 ms(发送结束到最后一条可见)

[paced] n=1500  期望=1500
  min=3  p50=4  p90=5  p99=22  max=49  avg=4.9  (ms)
[burst] n=3000  期望=3000
  min=6  p50=2283  p90=3978  p99=4387  max=4433  avg=2244.7  (ms)

[burst 按可见顺序分三段]
  第 1 段 n=1000  p50=700  p99=1545  max=1559 (ms)
  第 2 段 n=1000  p50=2283  p99=2973  max=2986 (ms)
  第 3 段 n=1000  p50=3707  p99=4420  max=4433 (ms)

paced 段几乎没变(p50 还是 4 毫秒),因为 100 条/秒远低于消费者的 680 条/秒能力,队列根本堆不起来。burst 段则彻底变了:p50 从 52 毫秒变成 2283 毫秒,p99 从 81 毫秒变成 4387 毫秒。

两组数据放在一起,得到这一节最重要的一条关系:

窗口 ≈ 队列里等待的事件数 ÷ 消费速率

验证一下:慢消费者每条 1 毫秒加上开销,实测约 680 条/秒;积压 3000 条,3000 ÷ 680 ≈ 4.4 秒,与实测的「全部可见用时 4426 毫秒」和 max 4433 毫秒吻合。三段中位数 700/2283/3707 也不是巧合——它们分别对应队列里第 500、1500、2500 条事件的等待时间:500 ÷ 680 ≈ 0.74 秒、1500 ÷ 680 ≈ 2.2 秒、2500 ÷ 680 ≈ 3.7 秒,三个都落在实测值附近。

这条关系决定了三件事:

  1. 窗口不是一个常数,是负载的函数。 说「我们的一致性窗口是 50 毫秒」,必须补上「在多少条/秒以下」。
  2. 消费者扩容是唯一能缩短窗口的手段。 加消费者提高分母;分母上不去,窗口就会随积压线性变长。
  3. 积压是窗口的先导指标。 想提前知道窗口要变长,盯 lag 比盯延迟更早。

窗口的承诺怎么写

把上面的关系翻译成 SLO,需要三个条件同时写清楚,缺一个就无法验收:

承诺项示例写法违约信号
稳态窗口在持续 ≤ 200 条/秒、lag < 1000 时,p99 ≤ 500 毫秒连续 5 分钟 p99 超阈值
突发窗口单次突发 ≤ 5000 条时,全部可见时间 ≤ 10 秒突发后 10 秒仍有未可见事件
降级条件lag > 10000 时窗口承诺失效,转为「尽力而为」并告警进入降级状态本身

三条里最容易被省略的是第三条。没有降级条款,SLO 在大积压时会被动违约,团队只能在事故复盘时解释「当时积压太高」——这等于没有承诺。

还有一条实践中的写法:把窗口 SLO 绑定到具体业务动作,而不是全局。 「借阅创建后 1 秒内库存可查」比「系统 p99 延迟 500 毫秒」有用得多,因为前者能直接回答用户投诉,后者只能在监控面板上讨论。

补偿的触发条件

有了窗口的度量,补偿才有触发条件。补偿不是「感觉不对就修」,而是由四个明确的信号触发:

触发条件观测方式典型场景
超时未完成事件在 outbox 停留超过阈值;或消费端处理超时中继卡住、下游服务不可用
对账差异4.2 的三方对账报出差异事件丢失、投影写错
业务规则拒绝消费时发现前置条件不满足库存不足、账户已冻结
人工发现客服工单、审计抽查前三种都没覆盖到的角落

四个条件里,前三个可以自动化,第四个必须有人。设计补偿时要把「人工发现」当成一等公民,而不是异常——它会一直存在。

需要强调的是:窗口内不补偿。 一个事件产生几十毫秒还没到读模型,不是故障,是正常在途。补偿的触发阈值必须大于窗口的 p99,否则补偿会和正常投递打架,制造出「补偿一次、原事件又到一次」的重复。这也是为什么必须先量窗口,再定补偿阈值——顺序反了,阈值只能拍脑袋。

阈值的推导可以用两条约束夹出来:

下限:阈值 > 窗口 p99 × 2        (留出一次正常波动的余量)
上限:阈值 < 业务能容忍的最长不一致时间

两条约束如果打架——比如窗口 p99 已经 30 秒,而业务只能容忍 10 秒——说明问题不在补偿阈值,在窗口本身:该扩消费者或者拆分区,而不是把阈值调到 5 秒然后靠补偿硬扛。阈值不是用来掩盖窗口的。

补偿的三种形态

补偿动作按「能不能自动做完」分成三层,每层的失败出口不同:

形态做什么适用失败后
重试重新投递或重新处理同一条事件瞬时故障:网络抖动、下游重启有界重试 + 退避,超限进死信
反向操作冲正、释放预占、退款、回滚状态业务已产生副作用且不可重放反向操作本身失败 → 进补偿任务表
人工处置工单、对账差异裁决、数据修复需要业务判断「哪边是对的」升级、暂停相关业务

重试是最便宜的一层,也是最容易被滥用的。无限重试不是补偿,是把故障藏起来。 一条永远失败的事件如果无限重试,会占满消费者、拖高整个分区的 lag,最后把局部故障放大成全局故障。正确的做法是有界重试(比如 3 次,指数退避),超限后进死信主题,由下一层接手。

反向操作要满足两个条件才安全:

  1. 可重复执行——同一个补偿执行两次,结果和执行一次一样;
  2. 有业务凭证——冲正要留下「为什么冲、冲了哪一笔、谁批准的」,否则账对不上时无法追溯。

第二条在技术文档里经常被忽略,但它是财务和生产运维真正需要的。一次没有凭证的自动冲正,事后和一次数据错误没有区别。

补偿失败的人工出口

补偿本身会失败,这是设计时必须承认的前提。所以补偿不能是一段「执行完就结束」的代码,而要有一张表来承载它的状态:

CREATE TABLE compensation_task (
  task_id      VARCHAR(32) PRIMARY KEY,
  event_id     VARCHAR(32) NOT NULL,
  action       VARCHAR(32) NOT NULL,   -- RETRY / REVERSE / MANUAL
  state        VARCHAR(16) NOT NULL,   -- PENDING / RUNNING / DONE / FAILED / GAVE_UP
  attempts     INT         NOT NULL,
  last_error   VARCHAR(512),
  created_at   TIMESTAMP   NOT NULL,
  updated_at   TIMESTAMP   NOT NULL
);

有了这张表,人工出口才有落点。一个可用的三级升级路径:

级别触发动作谁来做
L1 自动首次失败退避重试,最多 3 次补偿任务自己
L2 工单L1 用尽或 GAVE_UP生成工单,附事件体、差异报告、已尝试的动作值班工程师
L3 阻断同类失败在时间窗内超过阈值暂停相关主题的消费或相关业务入口值班 + 业务负责人

L3 是最不情愿但必须存在的出口。「暂停」听起来很重,但相比「带着错误继续跑」,暂停往往是损失更小的选择——尤其当错误会写进账务或库存时。

还有三个细节决定这套机制能不能用起来:

  • 补偿任务必须可查询。 至少要能按 event_id、按时间段、按状态查,否则工单里没法引用。
  • 补偿必须有开关。 一个全局开关加上按主题的开关,出问题时能立刻停掉自动补偿,避免补偿风暴。
  • 补偿必须被监控。 FAILED 与 GAVE_UP 的数量、最老一条的滞留时长,这两个指标比补偿成功率更能反映真实状态。

度量本身的三个坑

上面的数字能成立,前提是打点位置正确。换到真实的多机环境,有三处会系统性地把窗口量错:

第一,时间戳打在「消费到」而不是「提交后」。 这是最常见的错法:消费者一拿到消息就记一个时间,然后写库、提交。这样量出来的是「投递延迟」,不是「可见延迟」。写库慢、事务排队、数据库主从延迟,全都被漏掉了,而这几项恰恰是窗口变长的主因。

第二,跨机器直接相减。 生产者在 A 机、消费者在 B 机,两台机器的时钟哪怕只差 30 毫秒,量出来的窗口就整体偏移 30 毫秒——而稳态窗口本身可能只有几毫秒。此时的 p50 只是一个时钟偏移量,毫无意义。可用的做法有三种:统一 NTP 源并持续监控偏移量;改用「事件里的 broker 接收时间」做单侧估算;或者在事件体里带上生产者的单调时钟读数,只在同一台机器内比较。

第三,只看平均值。 这次实测里 paced 段的 avg 是 7.1 毫秒,看起来很好;但 p99 是 78 毫秒,max 是 169 毫秒。对用户体验而言,被长尾击中的那 1% 才是投诉来源。窗口的承诺必须落在分位上,平均值只能用来做趋势观察。

还有一条容易被漏掉:度量代码本身会影响被度量的对象。 每条事件多两次 UPDATE 和一次 commit,在内存库里可以忽略,在真实数据库上就是实打实的写入放大。生产环境通常改成采样(比如每 100 条抽一条)或者异步批量落表,代价是分位数的精度下降——这个取舍要写进度量方案,而不是假装没有。

窗口、补偿与对账的分工

三节内容到这里可以合成一张图:

环节负责什么失败时的表现兜底手段
outbox + 中继正常路径不丢重复投递消费端幂等
对账发现「永远不到」差异报告4.2 的四类处置
补偿修正已发生的错误补偿任务积压三级升级
窗口度量回答「现在一致吗、多久一致」SLO 违约扩容、降级

这四件事缺一不可,而且顺序不能颠倒:先能度量窗口,才能定补偿阈值;先有对账,才知道该补偿什么。 反过来做——先写补偿、再补对账——通常的结果是补偿逻辑覆盖了一堆想象出来的场景,真正的问题一条都没接住。

小结

  • 一致性窗口是「事件产生」到「读模型提交可见」的时间,两个时间戳都有明确的打点位置。
  • 本机实测:100 条/秒稳态下 p50 4 毫秒、p99 78 毫秒;突发 3000 条时 p50 52 毫秒、p99 81 毫秒。
  • 把消费者放慢后,同一个突发负载的 p99 从 81 毫秒变成 4387 毫秒,p50 从 52 毫秒变成 2283 毫秒。
  • 窗口 ≈ 积压 ÷ 消费速率;实测 3000 ÷ 680 ≈ 4.4 秒,与「全部可见 4426 毫秒」吻合。
  • 窗口承诺必须带条件:稳态速率、突发规模、降级阈值,三者缺一不可。
  • 补偿由四类信号触发,阈值必须大于窗口 p99;窗口内的在途不是故障。
  • 补偿分层:有界重试、幂等反向操作、带状态表的人工出口;无限重试是反模式。
  • 顺序是「先度量、再对账、后补偿」,反过来做会写出一堆接不住真实问题的补偿逻辑。

阅读导航:上一节 4.2 本地消息表与对账 | 下一节 5.1 事件顺序与业务顺序

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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