《Java 消息中间件·事件驱动架构》12.3 一致性边界

一致性不是全局属性,而是由聚合边界、消费者归属和关键程度共同决定的局部属性。本文给出一张判定表和一段可运行的 H2 代码,按「是否跨聚合消费」「是否关键聚合」两维推导每个聚合的一致性级别,输出明确的 STRONG 与 EVENTUAL 判定报告。

一致性不是全局开关

事件驱动架构里最危险的一句话是「我们用最终一致性」。说这话的人通常没回答一个前置问题:哪部分数据可以最终一致,哪部分必须强一致?

12.1 讲了用 outbox 保证「写侧不丢」,12.2 讲了量「多久能一致」。但这一节解决更前置的问题:一致性边界画在哪里。 边界画错了,outbox 和度量工具做得再漂亮,也只是把错误放大了。

判定需要三个输入:

  1. 聚合根清单:哪些聚合在业务上需要强一致(如支付、库存);
  2. 消费者归属:哪些消费者写这个聚合的数据;
  3. 跨聚合信号:有没有来自其他聚合的消费者在写它。

第三个输入最容易被忽略。支付状态如果只被支付服务自己消费,可以强一致;但如果订单服务也消费支付事件去改自己的订单状态,支付聚合就同时被两个服务写,强一致的代价是引入分布式锁或 2PC——通常不值得。

判定表

聚合是否关键是否有跨聚合消费者一致性级别原因
YNSTRONG关键且自我封闭,本地事务即可保证
YYEVENTUAL跨服务写入,锁的范围超出单进程,成本过高
NNEVENTUAL非关键,强一致的边际收益不足
NYEVENTUAL非关键,且跨聚合,自然选最终一致

这张表的意思是:跨聚合消费者是强一致的否决项。 不是因为技术做不到,而是因为一旦聚合的数据被外部服务修改,本地事务的隔离性就被打破了——你保得了本地写入的原子性,保不了外部写入的顺序和时机。

Inventory 聚合就是一个典型例子:库存服务自己扣库存,同时订单服务也监听支付成功事件去「预占释放」。Inventory 被两个服务写,强一致需要协调两个进程,而库存超卖的业务风险通常用「预占 + 定时释放」解决,不需要实时强一致。

数据结构

实验用两张 H2 表模拟判定输入:

CREATE TABLE aggregateRoots (
    name      VARCHAR(32) PRIMARY KEY,
    ownerSvc  VARCHAR(32) NOT NULL,
    critical  CHAR(1)     NOT NULL    -- Y/N
);

CREATE TABLE externalEvents (
    eventId   VARCHAR(32) PRIMARY KEY,
    srcTopic  VARCHAR(32) NOT NULL,
    consumer  VARCHAR(32) NOT NULL,   -- 哪个服务在消费
    writesTo  VARCHAR(32) NOT NULL,   -- 消费后写哪个聚合
    crossAgg  CHAR(1)     NOT NULL    -- Y=来自外部聚合
);

crossAgg 是人为判定的字段。它的判定规则:如果事件的源主题属于另一个聚合根(如 order-created 事件被 inv-svc 消费后写 Inventory),则标为 Y;如果事件和目标聚合属于同一业务上下文(如 item-viewed 被 rec-svc 消费后写 Recommendation),则标为 N。

判定代码

ResultSet rs = conn.createStatement().executeQuery(
    "SELECT name, critical FROM aggregateRoots ORDER BY name");
while (rs.next()) {
    String agg = rs.getString("name");
    boolean critical = "Y".equals(rs.getString("critical"));

    boolean hasCrossAgg = false;
    boolean hasSameAggConsumer = false;
    ResultSet sub = conn.createStatement().executeQuery(
        "SELECT consumer, crossAgg FROM externalEvents WHERE writesTo = '" + agg + "'");
    while (sub.next()) {
        if ("Y".equals(sub.getString("crossAgg"))) hasCrossAgg = true;
        else hasSameAggConsumer = true;
    }

    String boundary;
    String reason;
    if (!critical) {
        boundary = "EVENTUAL";
        reason = "non-critical aggregate, eventual-consistent acceptable";
    } else if (!hasCrossAgg && hasSameAggConsumer) {
        boundary = "STRONG";
        reason = "critical and same-aggregate consumer dominates";
    } else if (hasCrossAgg) {
        boundary = "EVENTUAL";
        reason = "cross-aggregate consumer exists, cannot hold strong consistency";
    } else {
        boundary = "STRONG";
        reason = "critical aggregate, no external cross-agg consumer";
    }
    // 写入结果表...
}

代码逻辑与判定表完全对应:先判断关键程度,再看跨聚合信号,最后看是否有同聚合消费者兜底。hasSameAggConsumer 的作用是处理一种边缘情况——某个聚合有关键消费者,但这些消费者都来自同一个聚合上下文,此时仍可以强一致。

本机实测:五个聚合的边界判定

实验输入:

聚合归属服务关键外部消费事件
Orderorder-svcYpay-success(支付服务写 Order)
Paymentpay-svcY无外部消费者
Inventoryinv-svcYorder-created(订单服务写 Inventory)
Recommendationrec-svcNitem-viewed(内部上下文)
LogEntrylog-svcNorder-created(订单服务写日志)

本机实测(Temurin 27.0.1.0.3 / H2 2.3.232 内存库 / 2026-10-29):单进程内跑完判定逻辑,不涉及网络。以下为原样粘贴。

=== phase 2: compute consistency boundaries ===
  Inventory -> EVENTUAL | cross-aggregate consumer exists, cannot hold strong consistency
  LogEntry -> EVENTUAL | non-critical aggregate, eventual-consistent acceptable
  Order -> EVENTUAL | cross-aggregate consumer exists, cannot hold strong consistency
  Payment -> STRONG | critical aggregate, no external cross-agg consumer
  Recommendation -> EVENTUAL | non-critical aggregate, eventual-consistent acceptable

=== phase 3: decision report ===
  [critical aggregates with STRONG boundary]
    Payment — critical aggregate, no external cross-agg consumer

  [aggregates with EVENTUAL boundary]
    Inventory — cross-aggregate consumer exists, cannot hold strong consistency
    LogEntry — non-critical aggregate, eventual-consistent acceptable
    Order — cross-aggregate consumer exists, cannot hold strong consistency
    Recommendation — non-critical aggregate, eventual-consistent acceptable

  [action summary]
    EVENTUAL = 4 aggregates
    STRONG = 1 aggregates

五个聚合里只有一个(Payment)被判定为 STRONG。这个结果看起来反直觉——Order 是关键聚合,为什么也是 EVENTUAL?因为订单服务消费了支付事件(pay-success)回写 Order 状态,Order 被支付服务间接写了。想让 Order 强一致,就要在支付服务下单时锁住订单表——锁跨越了两个服务,代价超过收益。

Inventory 也是同样的问题。订单服务监听 order-created 去预占库存,Inventory 被订单服务写了。库存超卖的风险用「预占 + TTL 释放」解决,比分布式锁更实际。

判定的使用方式

这段代码不是跑一次就完事的。它有两个持续使用场景:

  1. 设计评审时:把聚合清单、消费者清单输进去,当场输出边界判定。评审会上可以直接问:「Order 被判定为 EVENTUAL,是因为支付服务会回写它。这个回写能不能去掉?如果去掉,订单状态只能从自己聚合内的事件推导。」
  2. 架构演进时:新增一个消费者后重跑判定,如果某个聚合从 STRONG 滑到 EVENTUAL,就是一次需要记录的架构决策(见附录 B 的 ADR 模板)。

不建议把它做成自动化的「架构检测 CI」,因为 crossAgg 的判定一半靠代码分析(消费者写哪个表),一半靠业务判断(这个回写算不算跨聚合)。自动化只能报出「哪些消费者写了哪些聚合」,边界判定本身仍需人做。

常见错误

错误表现后果
把所有聚合都标成 STRONG设计图上一片绿色系统上线后锁争用爆炸,延迟远高于 EVENTUAL
忽略间接消费者只看到直接订阅,漏掉通过投影表回写的链路强一致承诺在间接回写时 silently 失效
用数据库事务替代一致性判定「我有本地事务所以是强一致」本地事务保不了跨聚合的最终读者
判定表从来不更新上线后加了新消费者,判定表还是旧的架构决策与代码实际脱节

一致性边界的常见设计误区

除了上表中的错误,还有三个更深层的误区:

误区一:用「同步RPC」替代事件,认为这样就能保证强一致。 同步 RPC 确实在调用链内是强一致的,但调用链一旦断开(服务重启、网络超时),系统状态就不可恢复了。事件的优势在于状态可以重放和重建——代价是一次性的事件乱序和延迟。

误区二:把所有读操作都通过事件驱动,拒绝任何同步查询。 有些场景下同步查询是更简单的方案,比如管理后台的报表查询。报表不需要实时一致性,按天生成即可,这时候用事件驱动反而增加复杂度。

误区三:一致性边界画好后从不更新。 系统设计变了,新消费者加入,旧消费者下线,这些变化都会影响一致性边界。建议每个季度重跑一次判定代码,与当前的消费者拓扑做对比。

一致性边界如何影响技术选型

一致性边界画好后,技术选型就有了依据:

一致性级别推荐存储消息语义监控重点
STRONG关系型数据库 + 本地事务至少一次事务超时、锁等待
EVENTUAL任意(文档/列族/时序)至少一次消费延迟、投影 lag

STRONG 级别下,关系型数据库的事务能力是必要的。EVENTUAL 级别下,存储选择更灵活,可以用更适合查询模式的存储引擎。

跨聚合一致性的替代方案

如果业务上确实需要跨聚合强一致,有三个替代方案:

  1. 合并聚合:把两个聚合合并成一个,消除跨聚合消费。代价是聚合变大,维护成本增加。
  2. Saga 模式:用补偿事务保证跨聚合的最终一致。代价是复杂度增加,需要设计补偿逻辑。
  3. 接受延迟:业务上允许短暂的不一致,用异步通知替代同步回写。代价是用户体验上的延迟。

选择哪个方案取决于业务对一致性的真实需求。很多情况下,「必须强一致」只是惯性思维,实际业务可以容忍秒级的不一致窗口。

小结

  • 一致性边界由三个输入决定:聚合关键程度、消费者归属、是否有跨聚合写入。
  • 跨聚合消费者是强一致的否决项。不是技术做不到,是锁跨越进程后的成本超过收益。
  • 判定结果是「起点」不是「终点」。评审时用它提问,演进时用它检测漂移。
  • 五个聚合实测:Payment STRONG(无外部消费者),其余四个 EVENTUAL。
  • 判定代码需要业务输入(crossAgg 标记),不能完全自动化;CI 能检测消费者拓扑,边界仍需人判。

一致性边界的持续维护

一致性边界不是画一次就完事的。系统设计变化时,边界也会变化。维护的关键是:

  1. 每次新增消费者都要重新判定受影响的聚合一致性级别。
  2. 每次架构评审都要有判定表作为输入材料。
  3. 每季度重跑一次判定代码,检查是否有漂移。

一致性边界与代码结构

一致性边界应该在代码结构中得到体现。STRONG 级别的聚合应该有独立的模块或服务边界,EVENTUAL 级别的聚合可以更灵活。代码结构应该反映一致性边界,而不是反过来。

具体来说:STRONG 聚合的代码应该集中在一个模块内,所有修改这个聚合的操作都通过这个模块。EVENTUAL 聚合的代码可以分散在多个消费者中,每个消费者维护自己的投影。

这种结构的好处是:当你需要改变一个聚合的一致性级别时,你知道需要修改哪些代码。如果代码结构混乱,一致性级别的变更就会变成全系统的重构。

一致性边界如何指导测试策略

一致性级别直接决定了测试策略:

一致性级别测试重点测试工具
STRONG并发修改的正确性单元测试 + 集成测试
EVENTUAL消费延迟的容忍度混沌测试 + 负载测试

STRONG 级别的测试重点是并发场景:两个消费者同时修改同一个聚合,结果是否正确。EVENTUAL 级别的测试重点是延迟场景:消费者在最大延迟窗口内能否完成处理。

本节判据

  • 一致性边界不是全局属性,是由聚合边界、消费者归属和关键程度决定的局部属性。
  • 判定需要三个输入:聚合根清单、消费者归属、跨聚合信号。
  • 跨聚合消费者是强一致的否决项。
  • 判定结果是评审时的提问工具,不是不可更改的合同。
  • 常见错误包括:把所有聚合标成 STRONG、忽略间接消费者、用数据库事务替代一致性判定。
  • 一致性边界需要持续维护,不是画一次就完事。
  • 用同步 RPC 替代事件不能解决根本问题,调用链断开后状态不可恢复。
  • 把所有读操作都通过事件驱动有时会过度复杂化,管理后台报表等场景用同步查询更简单。
  • 一致性边界应该在代码结构中得到体现,STRONG 聚合应集中在独立模块内。

一致性边界如何影响团队分工

一致性边界还影响团队分工。STRONG 级别的聚合通常由核心团队维护,因为它的变更影响面广、一致性要求高。EVENTUAL 级别的聚合可以由多个团队并行开发,因为它们之间的耦合度低。

团队分工的原则是:按一致性边界划分团队职责。不是按技术栈划分,而是按一致性需求划分。这样每个团队都清楚自己维护的聚合的一致性承诺,不会在不知情的情况下破坏强一致性。

一致性边界与事件溯源

事件溯源(Event Sourcing)要求所有状态变化都通过事件记录。在事件溯源系统中,一致性边界的概念更加重要,因为所有数据都来源于事件。

在事件溯源中,一致性边界决定了哪些聚合的事件可以并行回放,哪些必须顺序回放。STRONG 级别的聚合的事件必须顺序回放,EVENTUAL 级别的聚合的事件可以并行回放。如果边界判断错误,并行回放可能导致状态不一致。

事件溯源不是本卷的重点(卷一有详细讨论),但一致性边界的概念在事件溯源中同样适用,而且更加关键。

阅读导航:上一节 12.2 最终一致性 | 下一节 13.1 libraryd 领域模型

一致性边界与微服务边界

一致性边界与微服务边界有相似之处,但不完全相同。

维度一致性边界微服务边界
关注点数据一致性服务自治
划分依据聚合根和一致性需求业务能力和团队结构
变更频率较低较高

一致性边界通常比微服务边界更稳定,因为数据模型比组织结构调整得更慢。

  • 一致性边界通常在微服务边界之下,同个微服务内可能有多个一致性级别。
  • 事件溯源系统中一致性边界的重要性更高,因为所有数据来源于事件。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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