一致性不是全局开关
事件驱动架构里最危险的一句话是「我们用最终一致性」。说这话的人通常没回答一个前置问题:哪部分数据可以最终一致,哪部分必须强一致?
12.1 讲了用 outbox 保证「写侧不丢」,12.2 讲了量「多久能一致」。但这一节解决更前置的问题:一致性边界画在哪里。 边界画错了,outbox 和度量工具做得再漂亮,也只是把错误放大了。
判定需要三个输入:
- 聚合根清单:哪些聚合在业务上需要强一致(如支付、库存);
- 消费者归属:哪些消费者写这个聚合的数据;
- 跨聚合信号:有没有来自其他聚合的消费者在写它。
第三个输入最容易被忽略。支付状态如果只被支付服务自己消费,可以强一致;但如果订单服务也消费支付事件去改自己的订单状态,支付聚合就同时被两个服务写,强一致的代价是引入分布式锁或 2PC——通常不值得。
判定表
| 聚合是否关键 | 是否有跨聚合消费者 | 一致性级别 | 原因 |
|---|---|---|---|
| Y | N | STRONG | 关键且自我封闭,本地事务即可保证 |
| Y | Y | EVENTUAL | 跨服务写入,锁的范围超出单进程,成本过高 |
| N | N | EVENTUAL | 非关键,强一致的边际收益不足 |
| N | Y | EVENTUAL | 非关键,且跨聚合,自然选最终一致 |
这张表的意思是:跨聚合消费者是强一致的否决项。 不是因为技术做不到,而是因为一旦聚合的数据被外部服务修改,本地事务的隔离性就被打破了——你保得了本地写入的原子性,保不了外部写入的顺序和时机。
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 的作用是处理一种边缘情况——某个聚合有关键消费者,但这些消费者都来自同一个聚合上下文,此时仍可以强一致。
本机实测:五个聚合的边界判定
实验输入:
| 聚合 | 归属服务 | 关键 | 外部消费事件 |
|---|---|---|---|
| Order | order-svc | Y | pay-success(支付服务写 Order) |
| Payment | pay-svc | Y | 无外部消费者 |
| Inventory | inv-svc | Y | order-created(订单服务写 Inventory) |
| Recommendation | rec-svc | N | item-viewed(内部上下文) |
| LogEntry | log-svc | N | order-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 释放」解决,比分布式锁更实际。
判定的使用方式
这段代码不是跑一次就完事的。它有两个持续使用场景:
- 设计评审时:把聚合清单、消费者清单输进去,当场输出边界判定。评审会上可以直接问:「Order 被判定为 EVENTUAL,是因为支付服务会回写它。这个回写能不能去掉?如果去掉,订单状态只能从自己聚合内的事件推导。」
- 架构演进时:新增一个消费者后重跑判定,如果某个聚合从 STRONG 滑到 EVENTUAL,就是一次需要记录的架构决策(见附录 B 的 ADR 模板)。
不建议把它做成自动化的「架构检测 CI」,因为 crossAgg 的判定一半靠代码分析(消费者写哪个表),一半靠业务判断(这个回写算不算跨聚合)。自动化只能报出「哪些消费者写了哪些聚合」,边界判定本身仍需人做。
常见错误
| 错误 | 表现 | 后果 |
|---|---|---|
| 把所有聚合都标成 STRONG | 设计图上一片绿色 | 系统上线后锁争用爆炸,延迟远高于 EVENTUAL |
| 忽略间接消费者 | 只看到直接订阅,漏掉通过投影表回写的链路 | 强一致承诺在间接回写时 silently 失效 |
| 用数据库事务替代一致性判定 | 「我有本地事务所以是强一致」 | 本地事务保不了跨聚合的最终读者 |
| 判定表从来不更新 | 上线后加了新消费者,判定表还是旧的 | 架构决策与代码实际脱节 |
一致性边界的常见设计误区
除了上表中的错误,还有三个更深层的误区:
误区一:用「同步RPC」替代事件,认为这样就能保证强一致。 同步 RPC 确实在调用链内是强一致的,但调用链一旦断开(服务重启、网络超时),系统状态就不可恢复了。事件的优势在于状态可以重放和重建——代价是一次性的事件乱序和延迟。
误区二:把所有读操作都通过事件驱动,拒绝任何同步查询。 有些场景下同步查询是更简单的方案,比如管理后台的报表查询。报表不需要实时一致性,按天生成即可,这时候用事件驱动反而增加复杂度。
误区三:一致性边界画好后从不更新。 系统设计变了,新消费者加入,旧消费者下线,这些变化都会影响一致性边界。建议每个季度重跑一次判定代码,与当前的消费者拓扑做对比。
一致性边界如何影响技术选型
一致性边界画好后,技术选型就有了依据:
| 一致性级别 | 推荐存储 | 消息语义 | 监控重点 |
|---|---|---|---|
| STRONG | 关系型数据库 + 本地事务 | 至少一次 | 事务超时、锁等待 |
| EVENTUAL | 任意(文档/列族/时序) | 至少一次 | 消费延迟、投影 lag |
STRONG 级别下,关系型数据库的事务能力是必要的。EVENTUAL 级别下,存储选择更灵活,可以用更适合查询模式的存储引擎。
跨聚合一致性的替代方案
如果业务上确实需要跨聚合强一致,有三个替代方案:
- 合并聚合:把两个聚合合并成一个,消除跨聚合消费。代价是聚合变大,维护成本增加。
- Saga 模式:用补偿事务保证跨聚合的最终一致。代价是复杂度增加,需要设计补偿逻辑。
- 接受延迟:业务上允许短暂的不一致,用异步通知替代同步回写。代价是用户体验上的延迟。
选择哪个方案取决于业务对一致性的真实需求。很多情况下,「必须强一致」只是惯性思维,实际业务可以容忍秒级的不一致窗口。
小结
- 一致性边界由三个输入决定:聚合关键程度、消费者归属、是否有跨聚合写入。
- 跨聚合消费者是强一致的否决项。不是技术做不到,是锁跨越进程后的成本超过收益。
- 判定结果是「起点」不是「终点」。评审时用它提问,演进时用它检测漂移。
- 五个聚合实测:Payment STRONG(无外部消费者),其余四个 EVENTUAL。
- 判定代码需要业务输入(crossAgg 标记),不能完全自动化;CI 能检测消费者拓扑,边界仍需人判。
一致性边界的持续维护
一致性边界不是画一次就完事的。系统设计变化时,边界也会变化。维护的关键是:
- 每次新增消费者都要重新判定受影响的聚合一致性级别。
- 每次架构评审都要有判定表作为输入材料。
- 每季度重跑一次判定代码,检查是否有漂移。
一致性边界与代码结构
一致性边界应该在代码结构中得到体现。STRONG 级别的聚合应该有独立的模块或服务边界,EVENTUAL 级别的聚合可以更灵活。代码结构应该反映一致性边界,而不是反过来。
具体来说:STRONG 聚合的代码应该集中在一个模块内,所有修改这个聚合的操作都通过这个模块。EVENTUAL 聚合的代码可以分散在多个消费者中,每个消费者维护自己的投影。
这种结构的好处是:当你需要改变一个聚合的一致性级别时,你知道需要修改哪些代码。如果代码结构混乱,一致性级别的变更就会变成全系统的重构。
一致性边界如何指导测试策略
一致性级别直接决定了测试策略:
| 一致性级别 | 测试重点 | 测试工具 |
|---|---|---|
| STRONG | 并发修改的正确性 | 单元测试 + 集成测试 |
| EVENTUAL | 消费延迟的容忍度 | 混沌测试 + 负载测试 |
STRONG 级别的测试重点是并发场景:两个消费者同时修改同一个聚合,结果是否正确。EVENTUAL 级别的测试重点是延迟场景:消费者在最大延迟窗口内能否完成处理。
本节判据
- 一致性边界不是全局属性,是由聚合边界、消费者归属和关键程度决定的局部属性。
- 判定需要三个输入:聚合根清单、消费者归属、跨聚合信号。
- 跨聚合消费者是强一致的否决项。
- 判定结果是评审时的提问工具,不是不可更改的合同。
- 常见错误包括:把所有聚合标成 STRONG、忽略间接消费者、用数据库事务替代一致性判定。
- 一致性边界需要持续维护,不是画一次就完事。
- 用同步 RPC 替代事件不能解决根本问题,调用链断开后状态不可恢复。
- 把所有读操作都通过事件驱动有时会过度复杂化,管理后台报表等场景用同步查询更简单。
- 一致性边界应该在代码结构中得到体现,STRONG 聚合应集中在独立模块内。
一致性边界如何影响团队分工
一致性边界还影响团队分工。STRONG 级别的聚合通常由核心团队维护,因为它的变更影响面广、一致性要求高。EVENTUAL 级别的聚合可以由多个团队并行开发,因为它们之间的耦合度低。
团队分工的原则是:按一致性边界划分团队职责。不是按技术栈划分,而是按一致性需求划分。这样每个团队都清楚自己维护的聚合的一致性承诺,不会在不知情的情况下破坏强一致性。
一致性边界与事件溯源
事件溯源(Event Sourcing)要求所有状态变化都通过事件记录。在事件溯源系统中,一致性边界的概念更加重要,因为所有数据都来源于事件。
在事件溯源中,一致性边界决定了哪些聚合的事件可以并行回放,哪些必须顺序回放。STRONG 级别的聚合的事件必须顺序回放,EVENTUAL 级别的聚合的事件可以并行回放。如果边界判断错误,并行回放可能导致状态不一致。
事件溯源不是本卷的重点(卷一有详细讨论),但一致性边界的概念在事件溯源中同样适用,而且更加关键。
阅读导航:上一节 12.2 最终一致性 | 下一节 13.1 libraryd 领域模型
一致性边界与微服务边界
一致性边界与微服务边界有相似之处,但不完全相同。
| 维度 | 一致性边界 | 微服务边界 |
|---|---|---|
| 关注点 | 数据一致性 | 服务自治 |
| 划分依据 | 聚合根和一致性需求 | 业务能力和团队结构 |
| 变更频率 | 较低 | 较高 |
一致性边界通常比微服务边界更稳定,因为数据模型比组织结构调整得更慢。
- 一致性边界通常在微服务边界之下,同个微服务内可能有多个一致性级别。
- 事件溯源系统中一致性边界的重要性更高,因为所有数据来源于事件。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。