为什么微服务让事务变得如此棘手
很多团队从单体应用拆到微服务后,第一个迎面撞上的难题就是“下单成功了,库存怎么没扣?”。在单体时代,一个数据库本地事务(ACID)就能搞定订单、库存、积分的连环操作。但到了微服务架构下,数据跟着服务被拆散到不同的数据库实例里,这个原本简单的操作就变成了一个需要协调多个独立数据源的分布式事务问题。
核心矛盾在于CAP定理的约束:在分布式系统中,网络分区(P)无法避免,我们只能在一致性(C)和可用性(A)之间做选择。对大多数互联网业务而言,为了高可用和性能,最终一致性成为了更务实的选择。但这并不意味着我们只能“摆烂”,而是需要一套机制,确保数据在经历一段可能的不一致状态后,最终达成一致。
强一致性的“重武器”:2PC/XA
当业务对一致性要求达到“分毫不差”的级别时,比如传统金融的核心账务,两阶段提交(2PC)及其标准化协议XA常被提及。它的工作模式很像一个谨慎的会议主持人:第一阶段(Prepare)询问所有参与者(即各个服务的数据库)“能否提交?”,所有人都回复“可以”后,才进入第二阶段(Commit)下达正式提交指令。
在Java生态中,你可以通过JTA(Java Transaction API)配合支持XA协议的数据库(如MySQL)来实现。代码侵入性小,框架帮你做了大部分协调工作。
// 简化的JTA使用示意(通常由容器或框架管理)
UserTransaction ut = getUserTransaction();
ut.begin();
// 执行跨多个XA数据源的操作
orderService.createOrder();
inventoryService.deductStock();
ut.commit(); // 框架将执行两阶段提交协议
然而,2PC的缺点在微服务的高并发场景下被放大了:同步阻塞导致响应慢、资源锁定时间长影响吞吐量,且协调者存在单点故障风险。因此,它更像一件适用于特定场景的“重武器”,在普遍的电商、社交等业务中已较少被采用。
业务层控制的柔性事务:TCC模式
为了避免数据库层的长锁,TCC(Try-Confirm-Cancel)模式将事务控制上提到了业务层。它要求开发者将一个业务操作拆解成三个可补偿的阶段:
- Try:检查并预留资源(例如,冻结库存100件,而不是直接扣减)。
- Confirm:确认执行,使用预留的资源(正式扣除冻结的100件库存)。
- Cancel:取消执行,释放预留的资源(解冻那100件库存)。
这带来了很高的灵活性,性能也更好,但代价是显著的开发成本。你需要为每个参与事务的服务设计并实现这三组接口。更麻烦的是要处理各种边缘情况,比如网络超时导致的“空回滚”(Try没执行却收到了Cancel)和“悬挂”(Cancel先于Try到达)。
阿里开源的Seata框架的TCC模式能帮你管理事务上下文和重试,但业务逻辑的三段代码仍需自己实现。它比较适合对一致性要求较高且业务模型容易设计“预留”动作的场景,比如支付、秒杀库存扣减。
长流程的解决方案:Saga模式
对于“下单->支付->发货->确认收货”这类跨越多服务、耗时长的业务流程,Saga模式是一个更自然的选择。它的核心思想很简单:将一个大事务拆成一串本地小事务,每个小事务完成后立即提交。如果中间某一步失败了,就依次执行前面已成功步骤的“补偿操作”。
实现Saga主要有两种方式:
| 实现方式 | 工作原理 | 优点 | 缺点 |
|---|---|---|---|
| 编排式 (Orchestration) | 由一个中心化的Saga协调器(通常是一个状态机)负责按顺序调用各个服务,并在失败时触发补偿。 | 流程集中管理,易于监控和调试。 | 协调器可能成为单点,增加了架构复杂度。 |
| 协同式 (Choreography) | 各个服务通过事件(如消息队列)进行通信,每个服务监听上游事件并执行操作,失败时发布补偿事件。 | 去中心化,服务耦合度低。 | 流程分散,链路追踪和问题排查困难。 |
在Java项目中,你可以使用Axon Framework或Seata的Saga模式来实现编排式,也可以用Spring Cloud Stream配合Kafka/RabbitMQ来实现协同式。Saga的最大挑战是“隔离性缺失”:在流程执行过程中,中间状态(比如订单已创建但未支付)是暴露的,可能需要通过“语义锁”(如订单状态字段)在业务层面避免脏读。
简单可靠的异步保障:本地消息表与事务消息
如果业务可以接受短暂的延迟一致,那么基于消息的最终一致性方案往往是最具性价比的选择。这类方案在Java社区中有两种主流实现。
本地消息表是一个“草根”但极其有效的模式。它的做法是,在发起方执行本地业务操作时,在同一数据库事务中,向一张本地消息表插入一条记录。然后由一个后台定时任务轮询这张表,将消息发送到消息队列,由下游服务消费。
// 订单服务中,创建订单并记录消息
@Transactional
public void createOrder(Order order) {
// 1. 本地业务操作
orderDao.insert(order);
// 2. 在同一事务中写入消息记录
messageDao.insert(new Message(order.getId(), "ORDER_CREATED", "pending"));
}
// 独立的定时任务
@Scheduled(fixedDelay = 5000)
public void pollAndSendMessage() {
List pendingMsgs = messageDao.selectPending();
for (Message msg : pendingMsgs) {
rocketMQTemplate.send(msg);
messageDao.updateStatus(msg.getId(), "sent");
}
}
这种方式实现简单,不依赖特定中间件,保证了消息必达。缺点是引入了数据库压力和维护消息表的成本。
RocketMQ事务消息 则是对上述模式的封装和升级。生产者先发送一个“半消息”,接着执行本地事务,然后根据本地事务的成功与否,向MQ提交或回滚该消息。如果MQ长时间未收到确认,还会回调查询本地事务状态。这省去了自建消息表的麻烦,可靠性由MQ保障。
如何选择:没有银弹,只有权衡
面对这么多方案,团队选型时常常陷入纠结。其实关键在于厘清业务场景的真实诉求。
- 追求强一致,且并发压力不大:考虑2PC/XA,但务必清楚其性能代价和运维复杂度。
- 高并发,业务可设计补偿:TCC模式是优选,尤其适合资金、库存等核心资源操作。
- 流程长,涉及服务多:Saga模式(尤其是编排式)能清晰管理复杂流程。
- 接受最终一致,追求简单可靠:本地消息表或RocketMQ事务消息是最务实的选择,适用于绝大多数订单、通知类场景。
一个常见的误区是,为了技术的“完备性”而选择最复杂的方案。实际上,很多业务场景中,用户对几秒内的延迟是不感知的。例如,下单后积分稍后到账,通常不会引起投诉。这时采用基于消息的最终一致性,用很低的开发成本就能获得很高的系统吞吐量和可用性。
落地时必须守住的底线
无论选择哪种方案,有几个工程原则是必须遵守的,否则任何漂亮的架构设计都可能崩塌。
1. 幂等性设计是第一道防线。网络重试、消息重复投递是常态。所有服务的接口,尤其是Confirm、Cancel、消息消费者,都必须支持幂等。通常的做法是利用业务唯一ID(如订单号)在执行业务前先查询状态。
2. 完善的监控与可观测性。分布式事务链路长,必须通过分布式追踪(如SkyWalking)、业务日志和监控大盘,清晰地看到每个事务的流转状态、卡点及失败原因。没有可视化,排查问题就是大海捞针。
3. 设计人工兜底通道。再健壮的系统也可能遇到无法自动修复的极端情况(如补偿逻辑连续失败)。需要有一个后台管理系统,能根据事务ID查询状态,并支持手动触发重试或补偿,这是保证数据最终一致的“保险丝”。
最后,一个更高层次的思考是:能否通过业务或架构设计,避免或简化分布式事务?例如,将频繁关联查询的数据适当冗余,或者将强关联的服务合并部署共享数据源。很多时候,治本之道在于简化问题本身,而不是在复杂问题上叠加更复杂的解决方案。
原创文章,作者:,如若转载,请注明出处:https://fczx.net/wiki/123