一段代码在 @Transactional 里先写库、再发一条 MQ 消息。业务抛出异常,事务回滚了——而消息已经发到 broker,消费者拿着一条"不存在的订单"去做事。修法是"把发消息移到事务之外",但接下来出现了新问题:事务提交成功、发消息失败,订单存在而下游不知道。
事务与外部调用的组合,本质是一个没有分布式事务的分布式问题。这个"不可能"来自一个事实:数据库事务的边界止于数据库,broker、缓存、HTTP 都在边界之外。所以"加一个注解让它一致"是做不到的——只能接受不一致,并把它变成"可发现、可修复"。
而它的解法不是"加事务",而是"承认两者不可能原子"。
1. 三种失败组合
| 组合 | 后果 |
|---|---|
| 库成功、消息失败 | 下游不知道(丢事件) |
| 库失败、消息成功 | 下游知道一个不存在的事件(脏事件) |
| 都失败 | 一致(但业务失败) |
第一项与第二项都真实存在,而它们的修法相反:"先发消息再写库"会让脏事件变多,"先写库再发消息"会让丢事件变多。
关键认识是:这两项不可能同时消除,只能选择"哪一种更容易修复"。而通常答案是"丢事件更容易补(可以对账),脏事件更难清(要回滚下游)"。选择"丢"还有一个原因:丢事件是"下游少做一件事",而脏事件是"下游做错一件事"。前者补上即可,后者可能需要撤销、退款、通知用户。
2. 四种解法与代价
| 解法 | 代价 |
|---|---|
| 事务内发消息 | 脏事件(回滚后已发) |
| 事务外发消息 | 丢事件(提交后发失败) |
| 本地消息表 | 需额外表与投递器 |
| 事务消息(MQ 支持) | 依赖中间件能力 |
第一项还有个"部分感知"的版本:有人在事务里发消息后加了 afterCommit 回调,看起来修好了——但 afterCommit 里发失败时异常无法回传给事务,于是"提交成功、消息丢失"静默发生。
第一项是最常见的错误:"事务里发消息"看起来一致,实际是"消息不受事务管"——broker 不在数据库事务里。
第三项是主流正解:把"要发的消息"和"业务数据"写在同一事务里第三项的代价要算清:多一张表、多一个投递器、多一段运维(积压监控、重试策略、清理已发记录)。所以它适合"一致性要求高"的场景,而不是所有消息。
,再由一个独立的投递器扫描并发送(可重试、可幂等)。
-- 本地消息表:业务写入与消息写入在同一事务
BEGIN;
INSERT INTO orders(id, amount, status) VALUES ('O-1', 100, 'CREATED');
INSERT INTO outbox(id, topic, payload, status) VALUES ('M-1', 'order.created', '{...}', 'PENDING');
COMMIT;
-- 独立的投递器:SELECT ... WHERE status='PENDING' FOR UPDATE SKIP LOCKED
-- 发送成功后 UPDATE status='SENT';失败则重试(消费者需幂等)
3. 为什么"消费者必须幂等"
| 现象 | 原因 |
|---|---|
| 同一条消息处理两次 | 投递器重试 |
| 顺序错乱 | 并发投递 |
| 部分成功 | 消费者内部失败 |
第一项是"至少一次"投递的必然结果:只要涉及重试,就必须假设会重复。所以幂等不是"优化",而是这套方案的前提。
第二项的解法除了分区,还有"按聚合串行投递":同一个 orderId 的消息由同一个投递线程处理。代价是吞吐下降,收益是下游不需要处理乱序。
第二项在"同一聚合的多条消息"上很关键:投递器并发发送会导致乱序,解法是按聚合键分区、或单线程投递同一 key。
// 消费者幂等:用唯一键去重(数据库唯一索引最可靠)
try {
processedRepo.insert(eventId); // 唯一索引,重复则抛
} catch (DuplicateKeyException e) {
return; // 已处理,直接返回
}
applyBusinessChange(event);
// 关键:去重记录与业务变更在同一事务里
4. 事务边界与"外部调用"的三条纪律
| 纪律 | 原因 |
|---|---|
| 事务内只做数据库操作 | 其它都会被"回滚不掉" |
| 外部调用放事务后 | 用消息/重试补齐 |
| 长事务拆短 | 减少锁与连接占用 |
第一项最容易被违反:事务里调 HTTP、写缓存、发消息,三项都不受事务控制,于是"回滚"只回滚了数据库。
第三项与"发消息"的取舍有关:把"写库 + 发消息"拆成两个短事务,会有中间态第二项(外部调用放事务后)有一个"补偿"要求:放事务后就必须有重试或对账,否则"放出去"只是把问题从"脏事件"转成"丢事件"。
;而把它们放一个长事务,会拉长锁时间。折中是"本地消息表 + 短事务"。
投递器的实现有几个"必须做对"的细节,否则 outbox 会变成新的问题源:
// 投递器的四个要求
1 取任务时加锁:SELECT ... FOR UPDATE SKIP LOCKED(避免多实例重复取)
2 发送成功与"标记已发"之间可能失败 → 标记失败就重发(消费者幂等)
3 有超时与批次上限,避免一次捞太多占住连接
4 死信:重试 N 次仍失败 → 移到死信表并告警,不要无限重试
// 反例:无 SKIP LOCKED → 多实例互相等锁,吞吐归零
5. 观测:怎么知道"消息丢了"
| 指标 | 用途 |
|---|---|
| outbox 表积压 | 投递是否滞后 |
| 投递失败次数 | 是否有系统性故障 |
| 消费端重复率 | 幂等是否生效 |
| 业务对账差额 | 最终一致性 |
第一项最直接:PENDING 数量持续增长,说明投递器卡住或 broker 不可用。
第三项(消费端重复率)在"幂等生效"后应当接近 0 影响:重复消息会被去重,但"重复率"这个数仍要监控——它反映投递器是否在疯狂重试。
第四项是"最终一致性"的兜底:每天对账一次,找出"库有而下游无"与"下游有而库无"。这类对账任务是这套方案的必备部分,而不是可选项。
-- 日对账:找出下游缺失的订单(下游表在另一个库/服务)
SELECT o.id FROM orders o
LEFT JOIN downstream_orders d ON d.order_id = o.id
WHERE o.created_at >= CURDATE() - INTERVAL 1 DAY AND d.id IS NULL;
-- 结果非空 → 有丢事件,需补发或人工处理
还有一种"跨服务"的变形:两个服务各自有库,靠消息同步。此时"一致性"不是二元真假,而是"延迟多久"。所以需要定义"允许的延迟"(如 5 秒),并监控它。
// 一致性延迟的观测(比"是否一致"更有用)
t_write → 订单写入(服务 A)
t_consume→ 下游处理完成(服务 B)
延迟 = t_consume - t_write
// 监控 P99 延迟;超过阈值(如 30s)告警 → 说明投递或消费出问题
6. 排错:五种"消息与数据不一致"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 回滚后消息已发 | 事务内发消息 | 改 outbox |
| 订单存在但下游无 | 提交后发失败 | 投递器重试 |
| 下游处理两次 | 无幂等 | 加唯一键去重 |
| 消息顺序乱 | 并发投递 | 按 key 分区 |
| 积压持续增长 | 投递器异常 | 监控 + 告警 |
第一项最典型,也是这条经验最容易被反复踩的:"事务里发消息"在测试里完全正常,因为正常路径不抛异常。第四项(顺序乱)的排查方式是"看同一聚合键的消息时间戳是否单调"。如果同 key 的消息出现逆序,说明投递是并发的,而消费者可能需要"按版本号丢弃旧消息"的兜底。
最后一个"测试"上的建议:专门测"事务回滚后消息是否发出"。这个用例在多数项目里没有,而它恰好是这类 bug 的专属复现方式。
// 测试:业务异常 → 事务回滚 → 断言"没有消息被发出"
@Test
void 回滚时不发消息() {
assertThrows(BusinessException.class, () -> service.create(badOrder));
assertTrue(mq.sentMessages().isEmpty(), "回滚后不应有消息");
}
// 用 outbox 实现时:断言 outbox 无 PENDING 记录
7. 边界:什么情况可以直接在事务后发
| 场景 | 为什么 | 做法 |
|---|---|---|
| 消息丢失可接受(如埋点) | 不要求一致 | 直接发 |
| 有强对账兜底 | 差额可修 | 直接发 + 日对账 |
| 下游可主动拉取 | 不依赖推送 | 不给消息,让下游轮询 |
| 单库单服务(无跨系统) | 同事务内可完成 | 不需要消息 |
最后一条与"组织"有关的边界:对账任务往往没人认领,因为它是"防备性工作",平时不产出价值。所以它要么被写进"上线检查单",要么会被无声地省掉——而省掉之后,问题只在"真的丢了一次"时才暴露。
判断口径一句话:这条消息丢了,会不会导致"两个系统说的不一样且没人发现"。会,就必须用 outbox + 对账;不会(埋点、可重算的衍生数据),直接发即可。
8. 自测
- 三种失败组合里,哪两种"真实存在且修法相反"?为什么只能选择而非同时消除?
- 为什么"丢事件"通常比"脏事件"更容易修?
- 本地消息表把什么放进了同一个事务?投递器如何保证可重试?
- 为什么"消费者必须幂等"不是优化而是前提?去重记录为什么要与业务变更同事务?
- 三条纪律分别是什么?"事务里调 HTTP、写缓存、发消息"各有什么后果?
- 判断"能否直接在事务后发消息"的那个问题是什么?