从一次写入到一致状态:事务、缓存、消息与纠错
创建订单后,数据库里可能已经出现 CREATED(v1),Outbox 仍等待投递,查询投影还停在旧版本,客户端却因为响应超时再次发送同一个请求。一次写入会同时推动多份状态,它们各有提交点、版本和失败窗口:
创建订单命令 + 幂等键
├─ 请求结果:已拒绝 / 已接受 / 客户端未收到结果
├─ 权威状态:订单不存在 / CREATED(v1) / CONFIRMED(v2)
├─ 传播状态:Outbox 待投递 / 已投递 / 重试中
├─ 派生状态:缓存、读模型、搜索索引、报表各自处于某个版本
├─ 消费状态:未处理 / 已处理 / 重复到达 / 失败待处置
└─ 业务终态:完成 / 拒绝 / 补偿后完成 / 需要人工处理一个 HTTP 状态码容纳不了这些状态。200 或 201 记录服务端的响应选择,连接超时则说明客户端在期限内没有拿到结果;事务可能已经提交。系统需要明确决定结果的记录与业务不变量,规定重复请求的处理方式、派生副本允许落后的时间,以及无法自动收敛时的修复责任。
后面的可运行实验使用 Java、Spring Boot 与 PostgreSQL;事务、幂等、Outbox、消费去重、版本水位和对账所表达的状态关系同样适用于其他语言与框架。
一次写入会产生哪些状态
先区分命令、事实、事件和派生状态
“创建订单”首先是一条命令,系统可以因参数无效、幂等冲突、库存不足或并发冲突拒绝它。订单记录成功提交后,“订单已创建”才成为可以传播给其他副本的事实。
CreateOrder command
│ 表达意图:希望创建 order-1001
│
├─ reject ───────────────→ 没有产生订单事实
│
└─ commit
├─ orders: CREATED, version=1 权威事实
└─ outbox: ORDER_CREATED, version=1 待传播事实
│
├─ cache 派生副本
├─ order_projection 派生读模型
├─ search index 派生索引
└─ downstream effect 下游业务副作用几个对象的职责不同:
| 对象 | 表达什么 | 能否作为冲突时的最终依据 |
|---|---|---|
| 命令 | 调用方希望系统做什么 | 不能,命令可能被拒绝或重复 |
| 权威事实 | 所有者已经接受并提交的业务状态 | 能,是修复其他副本的来源 |
| 领域事件 | 某项事实已经发生,供其他处理者感知 | 通常不能替代所有者当前状态 |
| 派生状态 | 为查询、性能或下游用途复制和转换的数据 | 不能默认作为真相源,应能校验或重建 |
| 业务终态 | 一项跨步骤业务最终落入的可接受结果 | 由业务状态机定义,不等同于某次请求结束 |
事件与命令也不能互换。ConfirmOrder 可以被拒绝;ORDER_CONFIRMED 则表示确认事实已经成立。一个以过去时命名的事件如果可能在提交前发出,会让消费者无法判断它究竟是事实还是愿望。
先确定哪份记录决定业务结果
订单可以同时出现在订单库、Redis、Elasticsearch、数据仓库和消息日志中。发生冲突时,副本数量无法决定正确值,需要先回答:
业务对象:order-1001
├─ 所有者:订单模块
├─ 权威记录:orders(order_id='order-1001')
├─ 当前版本:2
├─ 不变量:金额大于 0;订单号唯一;状态只能按允许路径变化
├─ 派生副本:order_projection、缓存、搜索索引
└─ 修复方向:orders → 派生副本,而不是任意多数副本覆盖 orders如果两个服务都能直接写同一业务字段,或者冲突时没有指定最终来源,对账只能发现差异,无法决定正确值。所有权要先落到具体服务和存储,数据库、消息中间件与缓存算法再围绕它工作。
不变量决定事务里必须共同成败的内容
不变量是系统在每个可观察提交点都必须守住的规则:
订单写入不变量
├─ order_id 在订单集合中唯一
├─ amount_cents 必须大于零
├─ 一个幂等键只绑定一个规范化请求
├─ 订单版本只能递增
└─ 订单事实与对应 Outbox 同时出现或同时消失其中一部分规则适合在应用层表达,另一部分应落到数据库约束。应用层的“先查后插”会留下并发窗口:两个事务都查到不存在,然后同时插入。唯一约束会让数据库在最终写入点裁决冲突。PostgreSQL 的约束文档分别说明了非空、检查、唯一、主键和外键约束的语义。
CREATE TABLE idempotency_record (
idempotency_key VARCHAR(120) PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
order_id VARCHAR(80) NOT NULL,
event_id UUID NOT NULL,
result_amount_cents BIGINT NOT NULL,
result_status VARCHAR(24) NOT NULL,
result_version BIGINT NOT NULL
);
CREATE TABLE orders (
order_id VARCHAR(80) PRIMARY KEY,
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
status VARCHAR(24) NOT NULL,
version BIGINT NOT NULL CHECK (version > 0)
);数据库约束负责守住能够结构化表达的不变量,应用继续解释更完整的业务规则并返回清晰错误。即使并发请求或旁路程序绕过某段应用校验,违反唯一性、引用关系或检查条件的数据仍无法提交。
原子性解决共同成败,不解决所有一致性问题
数据库事务把一组读写组织成一个提交单元:全部提交,或者回滚到事务开始前的可见状态。典型边界如下:
BEGIN
├─ 校验当前权威状态
├─ 写幂等记录
├─ 写订单
├─ 写 Outbox 事件
└─ COMMITACID 四个字母容易被误读:
| 性质 | 解决的问题 | 不自动保证什么 |
|---|---|---|
| Atomicity 原子性 | 事务内操作共同提交或共同回滚 | 不能把数据库和任意远程 API 自动包成一个事务 |
| Consistency 一致性 | 每次提交都满足已定义的约束和事务规则 | 数据库不知道全部业务规则,也不保证所有副本立即相同 |
| Isolation 隔离性 | 控制并发事务相互可见的方式 | 级别过低仍可能出现业务竞态,级别更高可能要求重试 |
| Durability 持久性 | 成功提交后,数据按存储系统承诺保留 | 不等于缓存、消息消费者和异地副本都已经追平 |
Java 方法执行到末尾时,事务仍可能尚未成功提交。自动提交模式下,一条语句可以自成事务;显式事务中,多条语句要等 COMMIT 成功后才形成共同结果。连接断开、数据库故障或提交响应丢失时,调用方还可能得到“结果未知”。
隔离级别规定并发读写怎样相遇
多个请求同时修改同一对象时,要检查数据库实际允许哪些并发现象。PostgreSQL 默认的 Read Committed 会让同一事务内两条普通查询看到不同的已提交快照;Serializable 尝试让成功提交的事务等价于某种串行执行,但可能以 40001 拒绝其中一个事务,应用必须整体重试。具体行为和数据库实现有关,应以 PostgreSQL 事务隔离文档为准。
常用控制方式并不互相替代:
并发目标
├─ 禁止结构性重复 → UNIQUE / PRIMARY KEY
├─ 修改前必须独占当前行 → SELECT ... FOR UPDATE
├─ 仅当仍是旧版本才更新 → UPDATE ... WHERE version = :expected
├─ 让一组事务满足串行等价结果 → SERIALIZABLE + 对 40001 整体重试
└─ 多个热点对象必须固定顺序 → 统一锁顺序,缩短持锁时间行锁会等待,也可能形成死锁。数据库检测到死锁后会中止一个事务;应用应记录被中止的事务、涉及对象和重试结果,并为等待设置期限。无限延长超时无法消除环形等待。锁模式和死锁行为可查阅 PostgreSQL 显式锁文档。
乐观并发通常把版本放进条件更新:
UPDATE orders
SET status = 'CONFIRMED', version = version + 1
WHERE order_id = :orderId
AND status = 'CREATED'
AND version = :expectedVersion;受影响行数为 1 表示本次转换成立;为 0 表示记录不存在、状态不允许或版本已变化。应用必须把这些情况区分为可理解的 404、409 或重试,而不能仍然返回“确认成功”。
Spring 事务边界是代理调用边界
在常见 Spring 应用中,@Transactional 由代理拦截外部方法调用,在方法进入前开启或加入事务,在方法正常返回后提交,在符合回滚规则的异常离开方法时回滚。默认传播级别是 REQUIRED,默认隔离使用底层数据源设置;这些默认值和可选项见 Spring 声明式事务注解文档。
HTTP Controller
│ 调用 Spring 代理
▼
TransactionInterceptor
├─ begin / join transaction
├─ OrderService.create(...)
│ ├─ insert idempotency_record
│ ├─ insert orders
│ └─ insert outbox_event
├─ commit 或 rollback
▼
方法结果返回 Controller这带来几个直接边界:
| 调用或异常 | 实际边界 |
|---|---|
同一对象内部执行 this.otherMethod() | 通常不会再次经过事务代理 |
| 新线程、异步任务或远程服务 | 不自动加入当前线程的数据库事务 |
| 在数据库事务中调用网络服务 | 会延长持锁时间,但不会让远端资源自动回滚 |
| 运行时异常离开事务方法 | 默认触发回滚 |
| 受检异常离开事务方法 | 默认不回滚,除非显式配置 |
完整异常匹配规则见 Spring 回滚规则。
事务边界要结合真实调用路径验证。方法上的注解只是配置入口,还要确认调用经过代理、使用预期数据源,并且异常符合回滚条件。
副本、消息和重试怎样改变一致性
提交结果和客户端结果可能分离
服务端完成事务提交后,还要序列化响应、写入 socket,经代理和网络返回客户端。任意一段中断,都可能出现下面的第三种结果:
客户端观察
├─ 明确成功:收到可验证的成功响应
├─ 明确拒绝:请求在提交前被确定拒绝
└─ 结果未知:超时、连接重置、代理断开、响应丢失
├─ 服务端可能未接收
├─ 事务可能已回滚
└─ 事务也可能已经提交结果未知不能直接转换成“失败后再创建一次”。安全做法是使用稳定业务标识或幂等键查询原操作结果;如果允许重试,同一个逻辑操作必须携带同一个键。
HTTP 规范把幂等定义为:多次相同请求的预期效果与一次相同。PUT、DELETE 和安全方法具有幂等语义,POST 默认不具有;但这不表示每个 PUT 实现都正确,也不表示 POST 不能通过业务契约实现幂等。方法语义与自动重试边界可查阅 RFC 9110 的幂等方法章节。
业务幂等需要绑定请求身份与结果
一个可用的写入幂等协议至少包含:
Idempotency-Key: create-order-1001
│
├─ 规范化业务请求 → SHA-256 request_hash
├─ 数据库唯一键 → 同一 key 只能有一条记录
├─ 绑定业务对象 → order-1001
└─ 保存结果快照 → initial status / version / event_id处理规则应明确:
| 到达情况 | 处理结果 |
|---|---|
| 新键、新请求 | 在业务事务内记录键并执行写入 |
| 已有键、相同请求指纹 | 返回原业务结果,不再执行副作用 |
| 已有键、不同请求指纹 | 返回冲突,不能把一个键重新解释成另一个操作 |
| 键记录存在但结果处于处理中 | 返回明确的处理中状态,或等待同一操作完成 |
进程内 Set 会在实例重启时丢失,多实例之间也无法共享;如果它与业务提交分离,还会产生新的窗口。缓存完整响应同样会在淘汰后失去防重能力。幂等记录应与订单写入处在能够共同成败的持久边界内,并按业务重试窗口设置保留期。
PostgreSQL 的 INSERT ... ON CONFLICT 可以在唯一冲突时原子地选择不写或更新,但它只是实现工具,不会自动判断两个业务载荷是否等价。语法与并发保证见 INSERT 官方文档。
缓存保存可重建的派生状态
Cache Aside 的基本过程是应用先读缓存,未命中时读权威数据库并回填;写入时先更新权威数据,再删除或更新缓存。Redis 的 Cache Aside 说明给出了该模式及 TTL 的基本用法。
读:client → cache hit ───────────────→ value
│ miss
▼
database → populate cache → value
写:client → authoritative database commit → invalidate/update cache即使采用常见顺序,数据库提交与缓存失效仍是两个资源上的操作:提交后进程退出,旧缓存可能暂时保留;失效后并发旧读回填,也可能把旧值重新写入。正确性不能建立在“缓存通常很快更新”上,而应明确下面的关系:
缓存契约
├─ 来源:缓存键对应哪条权威记录和哪个版本
├─ 时限:旧值允许存在多久,TTL 是否真是正确性上界
├─ 强读:哪些查询必须绕过缓存或执行版本校验
├─ 修复:失效失败怎样重试,是否还有事件或对账路径
└─ 重建:缓存整体删除后能否从权威来源恢复缓存穿透、热点键击穿和大量键同时过期主要造成容量故障,可以用空值策略、请求合并、随机 TTL 和限流缓解。缓存值发生冲突时,则要根据来源记录、版本和允许陈旧窗口判断哪份数据可用。
读模型、搜索和报表也是副本
缓存只是派生状态的一种。CQRS 读模型、Elasticsearch 索引、报表表、数据仓库和数据库只读副本都可能落后:
authoritative version = 42
├─ cache version = 42
├─ order_projection = 41
├─ search document = 39
└─ report watermark = event offset 78120“最终会一致”必须变成可测量的承诺,例如投影 99.9% 在 30 秒内追平,超过 5 分钟进入告警和重放队列。没有版本、事件位点、更新时间或待处理最老年龄,系统只能知道副本不同,却无法知道落后方向、持续时间和修复进度。
读己之写也是独立承诺。订单创建接口刚返回后,查询接口若读取异步投影,可能暂时得到 404。可选处理包括:在短窗口内读权威库、让写入响应携带足够结果、把最低可接受版本传给读接口,或者明确返回“处理中”。选择哪一种取决于业务契约,不应假装所有副本都能立即同步。
数据库写入和消息发送存在双写窗口
下面两种顺序都不能用一个本地数据库事务自动关闭窗口:
方案 A:先写数据库,再发消息
database commit ── process crash ──X message publish
方案 B:先发消息,再写数据库
message publish ── database rollback
└──────────── consumer 看到了尚未成立的事实把消息发送代码写进 @Transactional 方法,不会让普通 Broker 自动加入数据库事务。即使使用 Spring 的事务绑定事件监听器,也要理解监听发生在哪个事务阶段;Spring 事务绑定事件文档说明了 BEFORE_COMMIT、AFTER_COMMIT、AFTER_ROLLBACK 和 AFTER_COMPLETION 等阶段。AFTER_COMMIT 可以避免在数据库提交前处理事实,但监听器所在进程在提交后崩溃时,仍可能没有完成外部发送。
Outbox 把“待传播事实”纳入同一本地提交
Transactional Outbox 不要求数据库与 Broker 参加同一个分布式事务。业务行和 Outbox 行写入同一数据库事务,由独立发布器持续扫描或通过 CDC 捕获已提交的 Outbox:
业务事务
BEGIN
├─ INSERT orders(... version=1)
├─ INSERT outbox_event(... aggregate_version=1)
COMMIT
│
▼
publisher / CDC
├─ publish(event_id)
├─ mark delivered / advance position
└─ retry until acknowledged or moved to failure handling这样可以守住“不出现只有订单、没有待传播事实”的不变量,但不会消除所有问题:发布器仍可能在 Broker 已接收、数据库尚未标记成功时崩溃,因此同一事件可能再次发布。Outbox 表还需要索引、保留与归档、积压告警、失败出口以及多发布器并发领取方案。Debezium 的 Outbox Event Router 文档展示了用 CDC 路由 Outbox 记录时的标准字段和事件键设计。
消息确认决定丢失和重复窗口
消息系统中的“已发送”至少可能指本地客户端接受、Leader 写入、达到副本确认条件或事务提交;“已消费”也可能指已拉取、业务副作用已完成,或消费位点已经提交。这些时点必须分开:
broker delivery
│
▼
consumer receives event
├─ business effect commits
├─ process crashes before ack/offset commit
└─ broker delivers the same event again如果先确认再执行业务副作用,进程在两者之间退出会丢处理;如果先完成副作用再确认,确认失败会产生重复。Kafka 的 消息投递语义设计文档说明了 at-most-once、at-least-once 和 exactly-once 的基本边界。
“Broker 支持 exactly-once”不能直接推出“扣款、发邮件、调用第三方 API 恰好发生一次”。端到端语义取决于消费位点、业务状态和外部副作用是否处在同一个可协调边界。对于不能参与 Broker 事务的数据库或远程系统,仍要依赖业务唯一键、幂等接口、Inbox 去重或补偿。
Inbox、唯一键和版本共同吸收重复
消费者可把事件 ID 与业务写入放入同一本地事务:
BEGIN
├─ INSERT projection_inbox(consumer, event_id)
│ ON CONFLICT DO NOTHING
├─ 若本次确实插入:应用业务副作用
└─ COMMIT同一事件再次到达时,唯一键使它成为已知重复。对于可能乱序的事件,还要比较聚合版本:
INSERT INTO order_projection(order_id, status, version, last_event_id)
VALUES (:orderId, :status, :version, :eventId)
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status,
version = EXCLUDED.version,
last_event_id = EXCLUDED.last_event_id
WHERE order_projection.version < EXCLUDED.version;这里的两个条件分别解决不同问题:事件 ID 防止同一事件重复产生副作用,版本条件防止旧事件覆盖新状态。若业务天然有唯一键,例如“订单 1001 的首次付款”,业务唯一约束通常比短期保存事件 ID 更强,因为它直接约束真正不能重复的效果。
跨资源协调方式取决于需要守住的承诺
Outbox 适合“本地事实提交后异步传播”的场景。选择方案前,还要确认参与者是否支持同一种事务协议、中间状态能否被业务接受、补偿是否真实可行,以及调用返回前是否必须得到共同结果。
| 方式 | 核心做法 | 适合的承诺 | 主要代价与边界 |
|---|---|---|---|
| 单一所有者 + 本地事务 | 相关不变量收回一个数据库事务 | 必须共同提交的强不变量 | 要求数据和写入责任能够位于同一事务资源 |
| Outbox + 发布器或 CDC | 权威事实与待传播事实共同提交,异步投递 | 允许短暂落后、要求最终可恢复 | 可能重复,必须有消费幂等、积压和修复能力 |
| Broker 事务 | 原子写入 Broker 内受支持的记录与位点 | 消息系统范围内的原子处理 | 不自动涵盖普通数据库、邮件或第三方 API |
| XA / 2PC | 协调器让支持协议的资源共同 prepare、commit 或 rollback | 多个事务资源必须共同决定提交 | 参与者支持、协调日志、阻塞窗口、故障恢复和运行复杂度更高 |
| TCC | 业务资源显式提供 Try、Confirm、Cancel | 可以预留资源并明确确认或释放 | 每个参与者都要实现幂等、空回滚、悬挂防护和资源超时 |
| Saga | 多个本地事务依次提交,失败后执行补偿动作 | 可接受中间状态,已提交步骤可业务补偿 | 不是原地回滚;补偿也会失败、重复或需要人工处理 |
| 对账与人工修复 | 周期比较权威状态与外部结果 | 主链之外的最终安全网 | 收敛较慢,必须有确定权威、隔离和审计 |
选择顺序通常从收紧业务边界开始:能在单一权威事务内守住的不变量,不要先做成分布式协调;允许异步可见时,Outbox 与幂等消费通常比全局提交协议简单;必须预留或补偿外部业务资源时,再评估 TCC 或 Saga。所谓补偿必须是业务上成立的新动作,例如退款或释放额度,而不是假设已经发出的邮件、外部扣款和用户看到的状态都能像数据库行一样回滚。
无论采用哪种方式,结果未知仍然存在。协调器、Broker 或远端参与者超时后,调用方需要 operation ID、状态查询、稳定重试和人工出口,不能把分布式协议误当成网络永不丢失。
一致性承诺要说明一次读取允许看到什么
“一致”可以拆成多种面向调用方的承诺:
| 承诺 | 调用方可以依赖什么 | 常见实现方向 |
|---|---|---|
| 线性一致读写 | 成功写入后,后续读取不会像写入尚未发生 | 读取同一权威副本、共识复制或满足相应读屏障 |
| 读己之写 | 当前会话能读到自己刚刚成功写入的最低版本 | 会话版本令牌、短期读权威库、等待投影水位 |
| 单调读 | 同一会话一旦见过版本 42,之后不会退回 41 | 粘性路由、最低版本条件、等待副本追平 |
| 有界陈旧 | 返回值最多落后明确时间或版本差 | 副本 lag 门限、超限回源或拒绝服务 |
| 最终一致 | 停止新写入且故障可恢复时,各副本最终收敛 | 重试、版本、Outbox/日志、对账和失败出口 |
一个系统可以按接口提供不同承诺:订单提交页要求读己之写,公开搜索允许分钟级陈旧,结算和库存校验必须读取权威状态。承诺必须进入接口语义和监控,不能只写在架构图上。
最终一致必须包含截止时间和失败出口
一个可运行的一致性承诺至少包含六项:
| 项目 | 示例 |
|---|---|
| 目标状态 | 订单投影最终达到权威订单的同一版本和状态 |
| 正常延迟 | 99.9% 的事件在 30 秒内应用 |
| 可观察水位 | 权威版本、投影版本、Outbox 最老待处理年龄、消费位点 |
| 重试边界 | 指数退避,最多 N 次后进入失败队列 |
| 独立修复路径 | 按权威订单对账,支持重放或重建投影 |
| 人工责任 | 超过 5 分钟且自动修复失败时由值班人员处置 |
对账从最终来源枚举对象,比较版本或业务摘要,只修复落后或错误的派生状态,并记录修复前后值。重放从事件历史重新执行消费者;重建通常先清空可再生投影再全量生成;补偿则产生新的业务动作来抵消已经成立、无法原地回滚的事实。四种操作使用不同输入,也承担不同风险。
发现不一致
├─ 派生数据可删除重建 → rebuild
├─ 历史事件完整且处理器兼容 → replay
├─ 权威状态可逐对象比较 → reconcile
├─ 外部副作用已经不可撤销 → compensating action
└─ 无法自动判断正确值 → quarantine + manual decision事件 Schema 不兼容、修复程序本身有缺陷或权威来源已经损坏时,重复重试不会带来收敛。此时应隔离失败对象、停止扩大副作用,并转入版本兼容、备份恢复或人工判定流程。
用 Java 与 PostgreSQL 验证提交、重复和收敛
环境、身份和实验边界
实验基线为 Java 25、Maven 3.9.12、Spring Boot 4.1.1 与 PostgreSQL 18.6。Spring Boot 4.1.1 支持 Java 17 至 26,并要求 Maven 3.6.3 或更高版本;版本变化时先核对 Spring Boot 系统要求。
在装有 Docker Engine、Docker Compose v2、curl 和 jq 的 Linux 主机上,以拥有本机 Docker 权限的普通用户执行。实验不需要 root、云账号或生产数据。PostgreSQL 仅连接专用 Compose 网络,不向宿主发布端口;应用只绑定 127.0.0.1:18085。
Dockerfile 的 Maven 构建阶段使用官方构建镜像的默认 root 身份;最终应用镜像使用固定的 10001:10001。构建身份和运行身份是两个独立事实,不应由多阶段构建自动类推。
故障注入、手工投递和对账接口位于 /lab,只用于本机实验。生产系统必须使用独立管理面、身份认证、授权、审计和并发保护,不能公开这些入口。
下载完整示例工程,在下载目录执行:
unzip state-consistency-lab.zip
cd state-consistency-lab
LAB_DIR="$PWD"
BASE_URL='http://127.0.0.1:18085'
test -f "$LAB_DIR/compose.yaml"
test -x "$LAB_DIR/mvnw"
docker version
docker compose version
curl --version
jq --versiontest 没有输出且退出码为 0 表示目录与 Wrapper 权限正确。若 mvnw 不可执行,先确认解压工具是否保留 POSIX 权限,再执行 chmod u+x mvnw;若 Docker 返回 permission denied,应先让当前用户获得本机 Docker 权限,而不是把后续命令全部改成 sudo。
工程中的状态表形成下面的对应关系:
state-consistency-lab
├─ orders 权威订单与单调版本
├─ idempotency_record 幂等键、请求指纹和原结果定位
├─ outbox_event 与权威写入共同提交的待传播事实
├─ projection_inbox 消费者已经处理的 event_id
└─ order_projection 可由权威事实修复的派生读模型构建、测试和启动
docker compose config --quiet
docker compose build --pull
docker compose up --detach --wait --wait-timeout 120
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$BASE_URL/actuator/health/readiness" | jq .构建日志应包含:
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS健康检查预期为:
{
"status": "UP"
}若构建阶段不能写 Maven 缓存,应检查宿主挂载目录的 UID/GID;本例 Dockerfile 的构建阶段默认使用 root,不要据此推断运行容器身份。启动超时后先执行:
docker compose ps
docker compose logs --no-color --tail 120 app databasedatabase 未健康时检查卷初始化和凭据是否一致;app 退出时从第一条异常向上判断数据库连接、Schema 初始化或端口配置。不要仅提高 --wait-timeout 掩盖确定性启动错误。
确认最终应用进程身份:
docker compose exec -T app sh -c \
'id && test "$(id -u)" = 10001 && test "$(id -g)" = 10001'预期第一行包含 uid=10001(app) gid=10001(app),且命令退出码为 0。
先证明提交前异常会整体回滚
全新数据库中的计数应全部为 0:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$BASE_URL/lab/counts" | jq .{
"orders": 0,
"idempotencyRecords": 0,
"outboxEvents": 0,
"inboxEvents": 0,
"projections": 0
}现在让服务在订单、幂等记录和 Outbox 都已执行插入后抛出运行时异常:
ROLLBACK_REQUEST='{"orderId":"order-rollback","amountCents":9950}'
ROLLBACK_BODY_FILE='/tmp/state-consistency-rollback.json'
ROLLBACK_STATUS="$(curl -q --noproxy '*' --silent --show-error \
--output "$ROLLBACK_BODY_FILE" \
--write-out '%{http_code}' \
--request POST \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: key-rollback' \
--data "$ROLLBACK_REQUEST" \
"$BASE_URL/orders?failBeforeCommit=true")"
printf 'HTTP %s\n' "$ROLLBACK_STATUS"
jq . "$ROLLBACK_BODY_FILE"
test "$ROLLBACK_STATUS" = '500'预期输出包含:
HTTP 500
{
"code": "INJECTED_BEFORE_COMMIT",
"message": "injected failure before transaction commit"
}再次查询计数,五项仍应为 0。只看到 HTTP 500 只能证明请求返回失败;计数结果才证明这次注入异常没有留下订单、幂等记录或待发布事件。
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$BASE_URL/lab/counts" | jq .若任一计数增加,优先检查事务方法是否经过 Spring 代理、三个写入是否使用同一数据源和连接、异常是否被方法内部吞掉,以及回滚规则是否覆盖实际异常类型。
创建订单并观察传播前后的状态
CREATE_REQUEST='{"orderId":"order-1001","amountCents":9950}'
CREATE_KEY='key-1001'
CREATE_RESPONSE="$(curl -q --noproxy '*' \
--fail-with-body --silent --show-error \
--request POST \
--header 'Content-Type: application/json' \
--header "Idempotency-Key: $CREATE_KEY" \
--data "$CREATE_REQUEST" \
"$BASE_URL/orders")"
printf '%s\n' "$CREATE_RESPONSE" | jq .
EVENT_V1="$(printf '%s' "$CREATE_RESPONSE" | jq -r '.eventId')"
test "$(printf '%s' "$CREATE_RESPONSE" | jq -r '.outcome')" = 'created'
test -n "$EVENT_V1"响应中的动态 UUID 每次不同,其余关键字段应为:
{
"outcome": "created",
"orderId": "order-1001",
"amountCents": 9950,
"status": "CREATED",
"version": 1,
"eventId": "<dynamic-uuid>"
}在投递事件前读取完整状态:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$BASE_URL/lab/state/order-1001" | jq .此时应同时看到 authoritative.version=1 和一个 outbox 事件,而 projection=null、inboxCount=0。订单及待传播事件已经共同提交,读模型仍在等待消费。
手工投递一次,再重复投递同一事件:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--request POST "$BASE_URL/lab/deliver/$EVENT_V1" | jq .
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--request POST "$BASE_URL/lab/deliver/$EVENT_V1" | jq .第一次和第二次的关键差异应为:
first: applied=true, duplicate=false, projectionVersion=1
second: applied=false, duplicate=true, projectionVersion=1重复投递会增加 deliveryAttempts,投影版本仍保持不变。传输尝试次数与业务版本分别记录两种变化,第二次送达同一事件不应再次推进订单状态。
验证相同重试和冲突重试
使用相同幂等键与相同请求体再次调用:
REPLAY_RESPONSE="$(curl -q --noproxy '*' \
--fail-with-body --silent --show-error \
--request POST \
--header 'Content-Type: application/json' \
--header "Idempotency-Key: $CREATE_KEY" \
--data "$CREATE_REQUEST" \
"$BASE_URL/orders")"
printf '%s\n' "$REPLAY_RESPONSE" | jq .
test "$(printf '%s' "$REPLAY_RESPONSE" | jq -r '.outcome')" = 'replayed'
test "$(printf '%s' "$REPLAY_RESPONSE" | jq -r '.eventId')" = "$EVENT_V1"返回值应是原订单和原事件 ID,不能新增订单或 Outbox。然后复用同一键但改变金额:
CONFLICT_REQUEST='{"orderId":"order-1001","amountCents":9951}'
CONFLICT_BODY_FILE='/tmp/state-consistency-conflict.json'
CONFLICT_STATUS="$(curl -q --noproxy '*' --silent --show-error \
--output "$CONFLICT_BODY_FILE" \
--write-out '%{http_code}' \
--request POST \
--header 'Content-Type: application/json' \
--header "Idempotency-Key: $CREATE_KEY" \
--data "$CONFLICT_REQUEST" \
"$BASE_URL/orders")"
printf 'HTTP %s\n' "$CONFLICT_STATUS"
jq . "$CONFLICT_BODY_FILE"
test "$CONFLICT_STATUS" = '409'预期错误码为 STATE_CONFLICT,说明该键已经绑定另一个请求。若服务返回原结果,会把两个不同意图错误地视为同一操作;若重新执行,会破坏幂等键的唯一含义。
验证客户端超时后的结果未知
下一个入口会在事务服务返回、事务代理已经提交之后延迟 HTTP 响应。用 1 秒客户端超时调用 3 秒延迟:
TIMEOUT_REQUEST='{"orderId":"order-timeout","amountCents":3300}'
TIMEOUT_KEY='key-timeout'
TIMEOUT_BODY_FILE='/tmp/state-consistency-timeout.json'
set +e
TIMEOUT_HTTP="$(curl -q --noproxy '*' --silent --show-error \
--max-time 1 \
--output "$TIMEOUT_BODY_FILE" \
--write-out '%{http_code}' \
--request POST \
--header 'Content-Type: application/json' \
--header "Idempotency-Key: $TIMEOUT_KEY" \
--data "$TIMEOUT_REQUEST" \
"$BASE_URL/orders?delayResponseMs=3000")"
CURL_EXIT=$?
set -e
printf 'curl_exit=%s http=%s\n' "$CURL_EXIT" "$TIMEOUT_HTTP"
test "$CURL_EXIT" = '28'
test "$TIMEOUT_HTTP" = '000'预期得到 curl_exit=28 http=000:客户端在截止时间内没有收到 HTTP 响应,数据库结果仍未知。等待服务端结束延迟,然后用原键和原请求重试:
sleep 3
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--request POST \
--header 'Content-Type: application/json' \
--header "Idempotency-Key: $TIMEOUT_KEY" \
--data "$TIMEOUT_REQUEST" \
"$BASE_URL/orders" | jq .预期 outcome 为 replayed、版本为 1,证明第一次请求已经提交。若返回 created,说明第一次请求在提交前结束;这两种结果都可能与客户端看到的超时相容,因此调用方必须依赖幂等协议或状态查询,而不能猜测。
如果实验没有得到退出码 28,先确认 -q 位于 URL 之前、--noproxy '*' 没有被 shell 展开,并检查请求是否意外经过代理。也应确认本机没有其他程序占用 18085,以及 delayResponseMs 仍为 3000。
验证版本落后、对账和旧事件保护
先把权威订单推进到版本 2,但暂不投递新事件:
CONFIRM_RESPONSE="$(curl -q --noproxy '*' \
--fail-with-body --silent --show-error \
--request POST "$BASE_URL/orders/order-1001/confirm")"
printf '%s\n' "$CONFIRM_RESPONSE" | jq .
EVENT_V2="$(printf '%s' "$CONFIRM_RESPONSE" | jq -r '.eventId')"
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$BASE_URL/lab/state/order-1001" | jq .状态应显示:
authoritative: status=CONFIRMED, version=2
projection: status=CREATED, version=1
outbox v2: delivered=false订单模块已经提交版本 2,投影仍停在版本 1,差异来自传播落后。执行对账:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--request POST "$BASE_URL/lab/reconcile/order-1001" | jq .预期为:
{
"orderId": "order-1001",
"beforeVersion": 1,
"afterVersion": 2,
"repaired": true
}再投递版本 2 事件,并重投旧的版本 1 事件:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--request POST "$BASE_URL/lab/deliver/$EVENT_V2" | jq .
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--request POST "$BASE_URL/lab/deliver/$EVENT_V1" | jq .
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$BASE_URL/lab/state/order-1001" | jq .版本 2 事件第一次到达,因此 duplicate=false;但投影已由对账修到版本 2,所以版本条件使 applied=false。版本 1 事件已经处理过,返回 duplicate=true。最终权威状态和投影都保持 CONFIRMED(v2),旧事件不能让状态倒退。
如果投影回到版本 1,应立即停止继续投递,检查 upsert 是否缺少版本条件、事件是否携带正确聚合版本,以及对账与消费者是否使用同一个版本排序规则。
清理实验资源
docker compose down --volumes --remove-orphans
rm -f \
/tmp/state-consistency-rollback.json \
/tmp/state-consistency-conflict.json \
/tmp/state-consistency-timeout.json
unset LAB_DIR BASE_URL \
ROLLBACK_REQUEST ROLLBACK_BODY_FILE ROLLBACK_STATUS \
CREATE_REQUEST CREATE_KEY CREATE_RESPONSE EVENT_V1 \
REPLAY_RESPONSE CONFLICT_REQUEST CONFLICT_BODY_FILE CONFLICT_STATUS \
TIMEOUT_REQUEST TIMEOUT_KEY TIMEOUT_BODY_FILE TIMEOUT_HTTP CURL_EXIT \
CONFIRM_RESPONSE EVENT_V2down 应删除本实验的容器、网络和数据库卷;本地应用镜像仍会保留。需要回收时精确执行:
docker image rm local/state-consistency-lab:1.0.0不要使用 docker system prune --all --volumes 清理单个实验,它会把本次实验没有创建的镜像、缓存和卷一并纳入清理范围。
该实验覆盖本地事务、幂等、Outbox、Inbox、版本保护和对账之间的关系。跨数据库原子提交、真实 Broker 高可用、CDC 运维、跨地域复制和生产容量还需要对应环境验证;分析这些场景时,仍要追踪最终来源、提交点、重复位置、版本推进、允许延迟以及失败退出方式。
从第一份状态事实定位故障
不要先用“数据不一致”概括现象
故障定位应从一个稳定业务标识开始,例如订单号、幂等键或事件 ID,再沿状态层次寻找最后一个可靠事实:
order_id / idempotency_key / event_id
│
├─ 请求入口
│ ├─ 是否到达正确服务与版本
│ ├─ 是否通过认证、校验和限流
│ └─ 是否返回明确拒绝,还是连接结果未知
│
├─ 权威提交
│ ├─ idempotency_record 是否存在,指纹是否相同
│ ├─ orders 的状态与版本是多少
│ └─ 事务已提交、已回滚,还是提交结果未知
│
├─ 传播事实
│ ├─ outbox_event 是否与权威行共同出现
│ ├─ pending age / attempts / delivered 状态
│ └─ 发布器是否运行,Broker 是否确认
│
├─ 消费处理
│ ├─ inbox 是否已有 event_id
│ ├─ 业务副作用是否提交
│ └─ ack / offset 是否在正确时点提交
│
├─ 派生可见
│ ├─ 投影、缓存、索引分别是什么版本
│ ├─ 当前水位与积压差距是多少
│ └─ 旧事件是否被版本条件拒绝
│
└─ 业务终态
├─ 是否仍在允许时间内推进
├─ 是否进入补偿或失败出口
└─ 对账、重放或人工修复是否成功这棵树能把相似表象拆成两条路径。“查询不到订单”可能因为订单事务没有提交,也可能因为订单已提交而读模型尚未消费。第一种情况没有事件可重放,第二种情况重新创建订单反而可能扩大问题。
在本地实验中,可以把同一订单的四层事实一次查出。若前面已经清理容器,先重新执行启动命令并创建、投递示例订单;生产环境则应换成受限只读诊断身份,只按单个业务 ID 查询,避免无条件扫描热表。
ORDER_ID='order-1001'
curl -q --noproxy '*' --fail-with-body --silent --show-error \
"$BASE_URL/lab/state/$ORDER_ID" | jq .
docker compose exec -T database \
psql --no-psqlrc --set ON_ERROR_STOP=1 \
--username state_app --dbname state_lab \
--set "order_id=$ORDER_ID" <<'SQL'
SELECT 'authority' AS layer, status AS state,
version::text AS version, order_id AS reference
FROM orders WHERE order_id = :'order_id'
UNION ALL
SELECT 'idempotency', result_status, result_version::text, event_id::text
FROM idempotency_record WHERE order_id = :'order_id'
UNION ALL
SELECT 'outbox', event_type, aggregate_version::text, event_id::text
FROM outbox_event WHERE aggregate_id = :'order_id'
UNION ALL
SELECT 'projection', status, version::text,
COALESCE(last_event_id::text, 'reconciled')
FROM order_projection WHERE order_id = :'order_id'
ORDER BY layer, version;
SELECT e.aggregate_version, i.event_id
FROM projection_inbox i
JOIN outbox_event e ON e.event_id = i.event_id
WHERE e.aggregate_id = :'order_id'
ORDER BY e.aggregate_version;
SQL正常完成创建、确认和投递后,authority 与 projection 都应位于版本 2,Outbox 应包含版本 1 和 2,Inbox 也能关联相应事件。若 authority 缺失,故障仍在订单提交之前;订单存在而 Outbox 缺版本,就检查同一事务内的事件写入。Outbox 完整但 Inbox 缺失时再转向发布和消费,Inbox 已有记录而投影仍落后,则检查消费事务、版本条件和后续覆盖。查询本身失败时,先处理数据库身份、连接和 SQL 权限,“无法观察”不能当作“记录不存在”。
幂等冲突先比较键、指纹和业务对象
出现 409 时,检查顺序如下:
| 顺序 | 检查对象 | 判断 |
|---|---|---|
| 1 | 调用方的逻辑操作 | 同一次操作是否稳定复用同一个键 |
| 2 | 服务端请求指纹 | 规范化字段是否稳定,是否误把时间戳或字段顺序纳入业务差异 |
| 3 | 已有幂等记录 | 绑定的业务对象和原结果是否可查询 |
| 4 | 记录保留期 | 是否覆盖上游最大重试时间 |
| 5 | 所有写入入口 | 是否共享同一唯一约束,而不是各自维护内存集合 |
相同键绑定不同指纹时应拒绝并保留原结果,不能由“最后一次请求覆盖”。大量冲突若集中在少数客户端版本,通常是键生成或重试逻辑错误;若集中在单个业务对象,则要检查上游是否并行发起了本应串行的命令。
慢写入要区分锁等待、资源等待和远程等待
事务耗时增长时,先分解:
transaction duration
├─ connection acquisition
├─ SQL execution
├─ row/table lock wait
├─ application computation inside transaction
├─ remote call accidentally inside transaction
└─ commit / WAL flush锁等待应结合阻塞者、被阻塞者、锁对象和事务年龄判断;连接池等待应看活动连接与排队时间;远程调用应移出不必要的持锁区间。仅扩大 HTTP 超时会让更多请求同时占用连接和锁,可能把局部慢请求放大为全局拥塞。
发生死锁或 Serializable 冲突时,重试单位是整个事务,不是从失败 SQL 后继续。重试必须有上限、退避和稳定幂等身份;否则高冲突时会形成重试风暴。
Outbox 积压先判断是没有产生、没有发布还是没有确认
| 观察 | 更可能的层次 | 下一步 |
|---|---|---|
| 权威行存在,Outbox 行不存在 | 双写没有共同提交,或历史数据绕过标准入口 | 停止继续假设事件可恢复,按权威数据补建并修复写入路径 |
| Outbox 待处理数量和最老年龄增长 | 发布器停滞、领取锁竞争、Broker 不可用 | 检查发布器实例、错误分类、重试和 Broker 确认 |
| 尝试次数增长但永不成功 | 永久格式错误、权限错误或目标不存在 | 进入失败出口,不能无限快速重试 |
| Broker 已有事件但 Outbox 未标记 | 确认丢失或标记失败 | 允许重复发布,由消费者去重吸收 |
只监控待处理总数不够。低流量系统即使只有一条消息,最老待处理年龄也可能已经超过业务时限;高流量系统则可能数量很多但水位持续前进。数量、年龄和吞吐需要一起看。
消费积压要区分容量不足和毒消息
所有分区水位同步落后,通常指向整体容量、下游数据库或网络;只有一个分区停滞,可能是热点键、分区再均衡或特定事件反复失败。消费者日志至少应携带 topic/queue、partition、offset 或 message ID、event ID、aggregate ID、aggregate version、attempt 和最终错误分类。
可重试错误与永久错误必须分开。短暂连接失败可以退避重试;Schema 不兼容、必填字段缺失、业务对象永不存在等错误应进入隔离或死信处理。没有失败出口的“至少一次”最终会变成“无限次阻塞后续消息”。
扩容消费者前还要检查分区上限、下游写容量和同一业务键的顺序要求。并发提高可能缩短积压,也可能把数据库锁冲突和乱序放大。
缓存陈旧先比较权威版本和缓存版本
遇到旧值时记录同一业务对象的:
authoritative_version
cached_version
cache_ttl_remaining
last_invalidation_event
consumer_watermark订单版本没有前进时检查写入侧;版本已前进而失效事件缺失时检查事务与 Outbox;事件存在但未投递时检查传播;事件已经消费而缓存仍旧时检查键映射、条件更新和多级缓存。直接删除一个键可以暂时恢复读取,后续新写入仍要验证失效链路。
当缓存故障导致大量请求回源时,先用限流、请求合并或降级保护权威库,再处理一致性原因。权威库一旦被回源流量压垮,原本可通过删缓存恢复的问题会升级为写入和读取同时不可用。
版本倒退通常来自乱序、错误覆盖或双权威
投影版本从 42 回到 41 时,重点检查:
版本倒退来源
├─ 更新语句无条件覆盖,缺少 current_version < incoming_version
├─ 不同事件类型没有共享同一可比较版本
├─ 重建任务使用旧快照覆盖在线增量
├─ 多个写入者分别生成不可比较的版本
└─ 手工修复绕过版本条件与审计字段修复前先阻止继续倒退,并保存受影响对象、事件和写入来源。若两个系统都声称自己是权威,不能靠选择较大版本自动判定业务正确值;需要先恢复所有权和冲突规则。
对账失败时保护权威来源和修复幂等性
对账程序应只读权威来源,以条件更新修复派生状态,并能对同一对象安全重复执行。推荐记录:
reconcile_run_id
object_id
authoritative_version
derived_version_before
derived_version_after
action = no-op / repaired / quarantined
reason如果差异数量突然增加,先暂停自动覆盖,确认是否发生 Schema 变更、版本算法变化或权威数据损坏。修复速度也必须受控,避免全量扫描和回写抢占在线事务资源。对于可完全重建的投影,分批重建并校验水位通常比逐行猜测更可靠。
恢复后,订单版本与派生版本重新满足约定,积压年龄回到阈值内,失败对象进入明确出口,新的写入也能持续前进。重试任务结束只是其中一个过程信号。
权威资料与规范地址
以下地址对应文中的协议语义、事务行为、数据库并发、缓存模式和消息投递边界。版本升级或切换实现时,应先按实际版本复核差异。
HTTP 与框架事务
Spring Boot:System Requirements
Spring Framework:Using @Transactional
Spring Framework:Rolling Back a Declarative Transaction
Spring Framework:Transaction-bound Events
PostgreSQL 事务与约束
PostgreSQL:Transaction Isolation
