3. 核心概念:本地事务与远程调用、双写问题、最终一致性、幂等性
好,咱们进入正题。这一章要聊的四个概念,可以说是整个 Outbox Pattern 的基石。你如果没搞懂它们,后面写代码一定会踩坑。我自己带团队的时候,发现很多线上事故,根源就是对这几个概念的理解不够深。
说白了,这章就是帮你打好地基。咱们一个一个来。
3.1 本地事务与远程调用
先问个问题:什么是本地事务?
很简单,就是在一个数据库连接里,通过 BEGIN、COMMIT、ROLLBACK 控制的一组操作。要么全成功,要么全失败。这是 ACID 里的 A(原子性)。
那远程调用呢?就是跨网络调另一个服务,比如发 HTTP 请求、写消息队列、调 RPC 接口。
问题来了——本地事务和远程调用,天然不兼容。
为什么?因为本地事务的提交,和远程调用的成功,是两个独立的事情。你没法用一个 COMMIT 去保证远程接口一定被调用了。反过来,远程调用成功了,本地事务也可能回滚。
核心矛盾:本地事务的边界在数据库内,远程调用的边界在网络外。两者之间没有全局事务管理器,所以必然存在不一致的窗口期。
我举个例子。你在一个事务里做了两件事:
- 更新订单状态为“已支付”
- 调用库存服务扣减库存
如果第二步的 HTTP 请求超时了,怎么办?事务回滚,订单状态恢复。但库存服务那边可能已经扣了(因为请求其实到达了,只是响应丢了)。这就是典型的分布式事务问题。
嗯,这里要注意:不要把远程调用放在本地事务中间。这是很多新手容易犯的错。我曾经在一个项目里看到有人把发短信、发邮件、调第三方支付全部塞在一个事务里,结果数据库连接池被活活拖死。
3.2 双写问题
双写问题,说白了就是:你的业务数据要写数据库,同时还要写消息队列(或者缓存、搜索引擎等)。这两个写操作,没法保证原子性。
你想想看,代码可能是这样的:
// 伪代码:双写
@Transactional
public void createOrder(Order order) {
orderDao.insert(order); // 写数据库
messageQueue.send(orderEvent); // 写消息队列
}
这段代码有什么问题?
- 如果
send成功了,但事务提交失败——消息发出去了,订单没落库。消费者收到消息后去查订单,查不到,报错。 - 如果事务提交成功了,但
send失败——订单落库了,消息没发出去。下游服务永远不知道有新订单。
这两种情况,都是数据不一致。而且你很难排查,因为错误发生在不同的系统里。
避坑指南:我曾经在一个电商项目中,因为双写问题导致库存超卖。订单库显示卖了100件,但消息队列只发了80条,库存服务只扣了80件。最后对账才发现差了20件。排查了整整两天,最后定位到是消息队列的写入超时被吞掉了异常。
双写问题的本质是什么?是两个独立的资源管理器(数据库和消息队列)之间没有分布式事务。你没法用 XA 协议去协调它们,因为消息队列通常不支持 XA,或者性能代价太高。
那怎么解决?Outbox Pattern 就是答案之一。咱们后面会详细讲,这里先记住:双写问题必须通过架构手段解决,不能靠 try-catch。
3.3 最终一致性
聊完双写,咱们说说最终一致性。这个词你肯定听过,但理解到位的人不多。
最终一致性,是 CAP 理论里 AP 模式的必然结果。它说的是:在没有新更新的前提下,经过一段时间,所有副本最终都能达到一致的状态。
注意几个关键词:
- 没有新更新:如果一直在写,那永远达不到一致。最终一致性只保证在静默期后收敛。
- 一段时间:这个时间不确定,可能是毫秒级,也可能是分钟级。取决于系统设计。
- 最终:不是强一致,不是读写一致,而是最终能对上。
在 Outbox Pattern 里,最终一致性体现在哪里?
你看,业务数据先写入数据库的 outbox 表,然后 CDC 组件(比如 Debezium)异步读取 outbox 表,再发送到消息队列。这个过程是有延迟的。
所以,在 outbox 记录被消费之前,数据库和消息队列是不一致的。但最终,CDC 组件会把所有 outbox 记录都发出去,两边就一致了。
我的经验:最终一致性不是“最终再说”,而是“最终必须”。你要设计好补偿机制和监控告警。比如,如果一条 outbox 记录超过5分钟还没被消费,就应该触发告警。我习惯在 outbox 表里加一个 created_at 和 status 字段,定期扫描是否有长时间处于 PENDING 状态的记录。
你可能会问:那业务上能接受这种延迟吗?
答案是:大部分场景可以。比如订单创建后发通知、用户注册后发欢迎邮件,延迟几秒钟用户感知不到。但有些场景不行,比如支付扣款,必须强一致。那你就不能用 Outbox Pattern,得用 TCC 或者 Saga。
3.4 幂等性
最后一个核心概念:幂等性。
定义很简单:同一个操作,执行一次和执行多次,结果一样。
为什么在分布式系统里幂等性这么重要?因为网络是不可靠的。你发一个消息出去,可能对方收到了,但响应丢了。这时候你重试,对方就会收到两条相同的消息。
如果消费者不处理幂等,就会出现重复扣款、重复发货、重复创建订单等严重问题。
实现幂等性的常见方式:
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 唯一键约束 | 数据库表加唯一索引,重复插入报错 | 创建类操作(订单、用户) |
| 去重表 | 用一张去重表记录已处理的消息 ID | 通用场景,适合所有操作 |
| 状态机 | 业务状态只能向前流转,不能回退 | 订单状态、审批流程 |
| 版本号 | 乐观锁,更新时比对版本号 | 更新类操作 |
在 Outbox Pattern 里,幂等性尤其重要。因为 CDC 组件可能会重复读取同一条 outbox 记录(比如重启后重新读取),消息队列也可能重复投递。所以消费者必须做好幂等处理。
我的建议:每个消息都带一个全局唯一的 event_id(比如 UUID)。消费者在处理前,先去去重表查一下这个 event_id 是否已经处理过。如果处理过,直接跳过。这是最通用、最可靠的幂等方案。
嗯,这里还要注意一点:幂等性和事务边界要配合好。比如,你在消费者里先查去重表,再执行业务逻辑,再写入去重表。这三个步骤必须在同一个事务里,否则可能出现并发问题。
我曾经见过一个案例:消费者先查去重表,发现没有记录,然后执行业务逻辑(扣款),但在写入去重表之前,另一个线程也处理了同一条消息,也查去重表(也没有记录),然后也扣了款。结果就是重复扣款。这就是典型的“先检查后执行”的并发问题。解决方案是:把“检查+执行+记录”放在一个事务里,或者用数据库的唯一键约束来保证。
3.5 知识体系总览
好了,四个核心概念都讲完了。我画了一张图,帮你把它们的逻辑关系串起来。
这张图你看懂了吗?从上往下看:
- 本地事务与远程调用的矛盾,是问题的根源。
- 这个矛盾导致了双写问题——你没法原子性地写两个系统。
- 解决双写问题,我们选择最终一致性,而不是强一致性。因为强一致性的代价太高。
- 但最终一致性带来了消息重复的风险,所以必须用幂等性来兜底。
这四个概念,环环相扣。你理解了它们,就理解了 Outbox Pattern 为什么存在,以及它要解决什么问题。