2738 字
约 9 分钟
14
S08-A18 · 事务里调外部接口:为什么"回滚了但消息发出去了"

一段代码在 @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. 自测

  1. 三种失败组合里,哪两种"真实存在且修法相反"?为什么只能选择而非同时消除?
  2. 为什么"丢事件"通常比"脏事件"更容易修?
  3. 本地消息表把什么放进了同一个事务?投递器如何保证可重试?
  4. 为什么"消费者必须幂等"不是优化而是前提?去重记录为什么要与业务变更同事务?
  5. 三条纪律分别是什么?"事务里调 HTTP、写缓存、发消息"各有什么后果?
  6. 判断"能否直接在事务后发消息"的那个问题是什么?
S08-A18 · 事务里调外部接口:为什么"回滚了但消息发出去了"
https://www.clxhxhhr.top/posts/6525/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。