面试官问“订单和库存是两个服务怎么保证数据一致”如果你脱口而出“加个Transactional”我建议你先把这篇文章看完。分布式事务这道题面试官真正想听的从来不是一个注解而是你对事务边界的理解哪些操作能放在同一个本地事务里哪些只能靠最终一致性兜底。很多人把Transactional当成“万能事务开关”在跨服务调用、多数据源、发消息的场景里随手一加线上数据不对了第一反应还是“是不是我事务粒度不对”。问题不在粒度而在方向。一句话给出本文判断Transactional只能管理本地数据库事务它管不了跨服务、跨库、跨消息的一致性。真正能解决“订单和库存分布式事务”的是本地消息表、消息事务、TCC、SAGA这类分布式事务方案。本文会把原理、典型错误场景、订单库存实战代码、面试答题框架和生产排错清单一次讲清楚。1. 先看结论为什么Transactional不是分布式事务的答案先明确一个容易混淆的概念什么才算分布式事务。一个Service方法里写了三个Mapper虽然代码里有“多张表、多个操作”但它们最终都指向同一个数据库处于同一个数据库连接中。这不是分布式事务这是典型的本地事务。Transactional可以管理它问题是“本地事务能不能被管住”而不是“分布式事务能不能被管住”。真正的分布式事务至少要满足下面之一数据分散在多个数据库实例操作跨越多个服务进程一个业务流程既写数据库又发MQ消息一个业务流程调用第三方HTTP/RPC接口并且需要保证多方数据一致。订单场景就是典型订单数据在订单库库存数据在库存库中间还要经过网络调用。两个库各有一个独立事务Transactional在订单服务里能管的只有订单库。库存服务提交或回滚和订单服务本地事务是两个完全独立的过程。所以从结论上看分布式事务问题的本质是多个独立资源之间的一致性无法靠单个数据库的事务管理器去协调。你越早接受“Transactional不是分布式事务答案”这个事实越能在面试和线上问题排查里少走弯路。那Transactional到底能做什么下一节把它讲透。2. Transactional的原理与边界2.1 它是怎么生效的Spring的Transactional本质是AOP代理。方法进入之前代理通过PlatformTransactionManager开启一个数据库事务方法正常退出代理提交事务方法抛出异常代理回滚事务。具体到最常用的DataSourceTransactionManager它的逻辑是把一个数据库连接绑定到当前线程。同一个线程内后续执行的SQL都拿的是这个连接。所以本地事务的边界其实是一个数据库连接。多个SQL要么一起提交要么一起回滚。示例代码本地事务的正确用法// 文件路径src/main/java/com/example/order/service/OrderService.java Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Transactional(rollbackFor Exception.class) public void createLocalOrder(OrderDO order, ListOrderItemDO items) { // 订单表和订单明细表在同一数据库 orderMapper.insert(order); for (OrderItemDO item : items) { orderItemMapper.insert(item); } } }这段代码里订单表和明细表的写入要么全部成功要么全部回滚。为什么能做到因为两个Mapper用的是同一个DataSource同一个数据库连接处于同一个本地事务里。2.2 它管不住什么用同样的方式扣库存就不行了// 文件路径src/main/java/com/example/order/service/OrderService.java Transactional(rollbackFor Exception.class) public void createOrderWithRemote(OrderDO order) { // 本地库插入订单 orderMapper.insert(order); try { // 远程调用库存服务这里是另一个数据库连接和另一个事务 inventoryService.deductStock(order.getSkuId(), order.getCount()); } catch (Exception e) { log.warn(扣库存失败但订单本地事务仍然可能提交, e); } }这段代码有几个致命问题inventoryService.deductStock是远程调用它走的是另外一条链路库存服务有自己的事务管理器和当前方法的事务管理器不是同一个。即使把异常catch住再抛出Spring能回滚的也只是订单库本地事务库存服务已经扣减的库存不会自动回滚。如果catch住异常不放出去代理根本不知道发生了异常本地事务照样提交。订单提交了库存没扣数据不一致。这里真正容易踩坑的是第三点很多人以为“只要远程调用的异常能传回调用方事务就会回滚”。实际上无论异常有没有传回跨服务的一致性都不会因为一个本地事务注解而解决。最坏的情况是远程库存扣减成功本地订单插入失败回滚导致库存被白白扣了。2.3 一个容易被追问的失效场景Transactional还有一个经典失效点同一个类内部调用。Service public class OrderService { Transactional public void outerMethod() { // 这里走的不是代理是this对象 this.innerInsert(); throw new RuntimeException(触发回滚); } public void innerInsert() { orderMapper.insert(new OrderDO()); } }Spring的AOP代理只对从外部进入的调用生效。outerMethod内部调用innerInsert时调用的是当前对象的方法不会重新走代理所以Transactional不会生效。面试里经常把这个包装成“自调用导致事务失效”本质上还是没理解“代理”这两个字。3. 跨服务场景为什么管不住从CAP到最终一致性3.1 网络是不可靠的跨服务的第一道坎是网络。远程调用有可能成功有可能失败有可能“实际上成功但响应超时”。订单服务调用库存服务扣库存如果响应超时订单服务无法确定库存到底扣没扣。此时无论订单服务怎么回滚都不一定能把库存状态改回去。这个不确定性是本地事务不存在的数据库连接上的SQL成功与否数据库自己知道但网络另一端的服务调用方无法直接感知。3.2 CAP和分布式事务的代价分布式系统绕不开CAP一致性Consistency、可用性Availability、分区容错性Partition tolerance。网络分区必然存在所以CP和AP之间必须做取舍。选择强一致性往往要牺牲可用性。XA两阶段提交就是这种模式所有参与者先prepare全部成功后coordinator再发commit期间锁资源、阻塞事务。选择高可用就只能接受最终一致性。先把请求落库异步通知下游下游成功了整个流程成功失败了通过重试和补偿慢慢纠正。很多业务的正确姿势是后者允许中间状态存在但最终状态是对的。比如订单先创建状态“待扣库存”再过几百毫秒库存扣减成功订单状态变成“已下单”。用户感受到的是下单成功不需要感知中间过程。3.3 一致性级别再补充一个面试高频概念分布式一致性不是只有“有”和“没有”两级而是有强弱之分。强一致性数据一旦写入任何线程、任何服务立刻能读到最新值。XA可以近似做到但成本高。弱一致性写入后不保证立刻读到只承诺后期会一致。最终一致性弱一致性的一种系统保证“如果没有新的更新经过一段时间后数据会一致”。这是大多数异步消息方案的目标。订单和库存的选择业务上通常允许“先下单后扣库存”所以最终一致性就够了。这也是为什么面试官听你说出“最终一致性”之后会接着追问那你要怎么保证最终一致4. 四个典型的错误姿势面试和生产里的高频翻车点4.1 错误一Transactional RPC调用这是最常见的错误。前面代码里已经演示过。核心问题是把远程调用的不确定性包装进本地事务产生“假事务”。更合理的做法是本地事务只负责写库写完之后通过消息或定时任务异步通知下游而不是在事务里面直接等远程结果。4.2 错误二Transactional 多数据源有些同学觉得“我配置了多个DataSourceTransactional加上去两个库就是同一个事务了”。这是对Spring事务管理器的误解。Spring默认的事务管理器一次只绑定一个DataSource。如果Service方法里操作了DataSourceA和DataSourceBTransactional只能管到由事务管理器指定的那一个数据源。另一个数据源的操作要么在自己的连接里自动提交要么根本不参与事务。要让多个库参与同一个事务需要引入JTAJava Transaction API或Atomikos之类的分布式事务管理器。但这会进入XA两阶段提交的范畴性能和可用性都有代价不能当成默认选择。4.3 错误三Transactional 里发送MQ消息下面这段代码看起来“挺对”实际有坑Transactional public void createOrder(OrderDO order) { orderMapper.insert(order); // 事务还没提交消息就先发出去了 mqProducer.send(order-topic, order); }如果消息先发数据库事务后提交消费者可能在“订单还没真正落库”的时候就收到了消息去查订单却发现查不到。反过来如果事务提交成功但消息发送失败消息就丢了。这正是“本地事务和消息发送原子性”的问题需要专门方案解决要么本地消息表要么消息事务。4.4 错误四自调用导致事务失效如前所述这是很隐蔽的坑。面试官问“事务不生效的原因有哪些”至少应该能说出方法要public、异常要被Spring代理捕获且符合rollbackFor条件、同类内部调用不经过代理、数据库引擎要支持事务比如MySQL要InnoDBMyISAM就不支持。这些都是排查“事务不生效”的标准思路。5. 分布式事务方案盘点XA、TCC、SAGA与消息方案面试要答好分布式事务不是把方案名背一遍而是要能说清楚每个方案的取舍。下面用一个表把主流方案讲明白方案核心思路一致性优点缺点适用场景XA两阶段提交资源管理器统一协调prepare后commit强一致数据一致性最强阻塞、性能差、协调者单点维护成本高交易系统内部低并发强一致场景跨库跨服务使用较少TCCTry/Confirm/Cancel业务层分阶段操作预留资源确认提交失败补偿业务层可控的强/最终一致灵活性高对业务隔离好实现复杂每个业务都要写Try/Confirm/Cancel三段逻辑需要明确预留资源的场景比如账户冻结、扣款SAGA把一个长事务拆成多个本地事务每个事务配一个补偿操作最终一致适合长流程不需要长时间锁资源中间状态可见补偿逻辑复杂可能出现部分成功订单流程、旅游预订、多步骤业务流程本地消息表业务数据和消息数据在同一本地事务中写入异步发送最终一致实现简单不依赖特殊中间件需要额外开发定时任务和消息表存在重复发送绝大多数订单、库存场景消息事务MQ支持“先半消息后提交/回滚”本地事务完成后决定消息投递最终一致减少本地消息表开发MQ负责回查依赖支持事务消息的MQ如RocketMQ运维成本增加有RocketMQ的团队常见选择我的判断是真实业务中60%到70%的跨服务一致性场景其实可以用“本地消息表 幂等消费 定时对账”解决。TCC和SAGA不是不好而是复杂度高设计和使用成本都大通常只在强业务规则、高资金风险、需要资源预占的场景里才值得上。面试时不要一上来就抛TCC。先把“最终一致性 消息 幂等”讲清楚再补充TCC、SAGA在什么情况下才会考虑显得你是一个有业务判断力的候选人而不是只会背概念。6. 订单与库存场景的正确解法回到面试和实战都最高频的问题用户下单订单服务创建订单库存服务扣库存。怎么保证两个服务的数据最终一致6.1 方案一本地消息表本地消息表的核心思想是把“业务操作”和“待发送消息”放在同一个本地数据库事务里。事务提交之后由异步任务把消息发出去。这样只要订单落库成功消息记录就一定存在消息发送失败也没关系定时任务不断重试。流程如下订单服务本地事务插入订单记录 插入一条“待发送消息”记录。事务提交。定时任务扫描“待发送消息”把消息发送到MQ或直接调用库存服务接口。库存服务消费消息扣减库存。库存服务需要使用唯一ID做幂等防止重复扣库存。先看核心代码// 文件路径src/main/java/com/example/order/service/OrderService.java Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private LocalMessageMapper localMessageMapper; Transactional(rollbackFor Exception.class) public void createOrderWithMessage(OrderDO order) { // 第一步本地事务里写业务数据 orderMapper.insert(order); // 第二步同时写入本地消息表 LocalMessageDO message new LocalMessageDO(); message.setMessageId(UUID.randomUUID().toString()); message.setOrderId(order.getOrderId()); message.setContent(JsonUtils.toJson(order)); message.setStatus(0); // 0待发送1已发送 localMessageMapper.insert(message); } }订单和本地消息表在同一个库、同一个事务里。只要createOrderWithMessage方法正常返回消息表里一定有一条待发送记录如果订单插入失败消息也不会存在。接着是异步发送任务// 文件路径src/main/java/com/example/order/job/MessageSendJob.java Component public class MessageSendJob { Autowired private LocalMessageMapper localMessageMapper; Autowired private InventoryServiceClient inventoryServiceClient; Scheduled(fixedDelay 5000) public void sendPendingMessage() { ListLocalMessageDO pending localMessageMapper.selectByStatus(0); for (LocalMessageDO message : pending) { try { inventoryServiceClient.deductStock(message.getContent()); localMessageMapper.updateStatus(message.getId(), 1); } catch (Exception e) { // 记录日志消息保持待发送状态下次继续尝试 } } } }这里有一个关键点消息发送成功后要把消息状态改成“已发送”。如果发送成功但更新状态失败这个任务会重复发送同一条消息。所以库存服务的消费端必须幂等。库存服务侧的核心代码// 文件路径src/main/java/com/example/inventory/consumer/InventoryConsumer.java Service public class InventoryConsumer { Autowired private InventoryMapper inventoryMapper; Autowired private ConsumeLogMapper consumeLogMapper; Transactional(rollbackFor Exception.class) public void deductStock(String messageId, Long skuId, Integer count) { // 用消费日志表做幂等messageId有唯一约束 int inserted consumeLogMapper.tryInsert(messageId); if (inserted 0) { // 已处理过直接返回 return; } // 扣减库存SQL里用条件防止超卖 int affected inventoryMapper.decreaseWithCondition(skuId, count); if (affected 0) { throw new RuntimeException(库存不足); } } }幂等逻辑很关键。如果库存服务收到两次相同消息第一次处理成功第二次会被consumeLogMapper.tryInsert挡住不再执行扣减。如果去掉幂等定时任务重试一次库存就被多扣了一次。6.2 方案二RocketMQ事务消息如果团队已经使用支持事务消息的MQ典型是RocketMQ可以用事务消息替代本地消息表。核心区别在于本地消息表把“消息”放在业务库中事务消息则交给MQ来保证“业务事务”和“消息发送”的原子性。RocketMQ事务消息的流程是订单服务发送一条“半消息”Broker暂时不投递给消费者。订单服务执行本地事务创建订单。本地事务执行成功返回COMMIT_MESSAGE执行失败返回ROLLBACK_MESSAGE。Broker收到COMMIT后才把消息投递给库存服务。如果订单服务在步骤3宕机Broker会通过回查机制询问本地事务结果再决定提交或回滚。代码示例// 文件路径src/main/java/com/example/order/mq/OrderTransactionListener.java Component public class OrderTransactionListener implements TransactionListener { Autowired private OrderService orderService; Override public LocalTransactionState executeLocalTransaction(Message message, Object arg) { try { OrderDO order (OrderDO) arg; orderService.createOrder(order); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } Override public LocalTransactionState checkLocalTransaction(MessageExt message) { // 回查订单是否存在 String orderId message.getUserProperty(orderId); boolean exists orderService.isOrderExists(orderId); return exists ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } }发送半消息的入口大致如下// 文件路径src/main/java/com/example/order/service/OrderProducer.java Service public class OrderProducer { Autowired private TransactionMQProducer transactionMQProducer; public void sendOrderMessage(OrderDO order) { try { Message message new Message(); message.setTopic(order-topic); message.setBody(JsonUtils.toJson(order).getBytes(StandardCharsets.UTF_8)); message.putUserProperty(orderId, order.getOrderId()); // 发送半消息并传入本地事务参数 transactionMQProducer.sendMessageInTransaction(message, order); } catch (Exception e) { // 这里需要处理发送失败的情况 throw new RuntimeException(发送事务消息失败, e); } } }库存服务消费侧同样要遵循“先幂等再扣库存”的原则。事务消息能解决的只是生产者侧的原子性消费者侧仍然是“至少一次投递”重复消费只能靠应用层幂等兜住。6.3 两个方案怎么选这个问题的判断标准不复杂如果项目里已经有RocketMQ并且团队能接受RocketMQ的运维成本事务消息更优雅省掉了本地消息表和定时任务。如果团队只有Kafka或RabbitMQ且不想为事务消息引入新组件本地消息表是最稳妥的通用方案。如果项目并发不高、团队规模小本地消息表优先因为逻辑透明排查方便。无论选哪个都要记得分布式事务没有银弹核心是“消息可靠 消费幂等 定时对账”。这三件事做到位大多数订单库存问题都能兜住。7. 面试避坑答题框架与常见追问7.1 一个稳健的回答框架面试官问“订单和库存的分布式事务怎么解决”可以按下面步骤回答第一步明确事务边界。先说“订单和库存是两个服务、两个数据库无法用单个数据库的本地事务解决Transactional只能管订单库本地操作”。第二步做一致性取舍。再说“订单库存场景可以接受最终一致性先创建订单再异步扣库存”。第三步给具体方案。可以选择本地消息表或事务消息。要讲清楚业务表和消息表同库同事务异步发送下游幂等消费。第四步补充兜底机制。说明“消息发送失败由定时任务重试消费重复靠唯一ID防重还有对账任务兜底”。这套回答的关键是先边界再取舍再落地最后补兜底。面试官听到的是“这个候选人真的处理过问题”而不是“背了一堆方案”。7.2 常见追问有哪些“消息丢了怎么办”答如果使用本地消息表业务表和消息表在同一事务里消息不会凭空消失发送失败由定时任务重试。如果使用RocketMQ事务消息半消息是否提交取决于本地事务结果还有回查机制兜底。另外业务侧还要做定时对账比如每天扫描没有扣库存的订单主动补发。“消费者重复消费了怎么办”答消息中间件一般只保证至少一次投递所以消费端必须幂等。常见做法消费记录表加唯一约束或者用数据库唯一索引、Redis setnx做防重扣减库存使用条件更新避免超卖。“库存服务处理成功后回执丢了怎么办”答接受“最终一致”的设计前提。只要消费者端幂等消息重发也能保证不重复扣减库存。处理成功的回执丢失不改变数据结果重试一次还会读到消费记录。“你说用TCC那TCC的Confirm阶段失败了怎么办”答TCC也不能保证绝对不失败Confirm失败后通常需要重试、幂等再不行走人工或对账补偿。TCC的价值在于Try阶段预留资源把失败可能性降低但不可能消除。“你们生产上真有分布式事务吗”答这里务必诚实。如果没做过就说“我学习过也熟悉方案但目前生产场景主要用X方案”。不要编造经历。面试官更看重思路不靠编故事。7.3 面试中不要说的话“我们直接加个分布式事务中间件就行”——没有结合业务取舍。“Transactional也能解决跨库”——说明没理解事务边界。“强一致和最终一致没区别”——说明没理解分布式系统的本质。“Kafka能发事务消息就能解决所有问题”——事务消息只解决生产者侧消费幂等还是要自己处理。8. 常见问题与排查方法把线上常见的分布式事务问题整理成一张排查表建议收藏。问题现象可能原因排查方式解决方案订单已创建库存没扣本地消息表发送任务失败或消费失败重试用尽查本地消息表status字段、发送任务日志、MQ消费位点增强定时重发增加消费失败重试次数超限进入死信或告警库存扣了但订单不存在消息在本地事务提交前被消费者读取或订单事务回滚但消息已发看消息消费时间和订单落库时间检查发送是否在事务提交后使用afterCommit发送或直接使用本地消息表/事务消息Transactional内远程调用失败本地事务仍然提交远程调用异常被捕获代理无法感知在方法入口和异常捕获处打印日志确认是否有异常抛出不把远程调用放在本地事务中改用消息异步通知多数据源场景一个库回滚另一个库提交没有配置全局事务管理器Transactional只绑定一个数据源查看两个库的binlog或业务日志确认提交时间点引入JTA/XA或调整设计为最终一致性方案库存被重复扣减消费者重复消费没有幂等处理查消费记录表中是否存在相同messageId增加唯一约束使用幂等判断后再扣库存定时任务重复发送同一条消息消息表没有唯一约束多个任务实例并发扫描查消息表是否有重复记录检查任务是否加了分布式锁消息表加唯一索引任务调度加分布式锁订单状态长时间停留在“待扣库存”对账任务没有触发或补发机制缺失查对账任务日志、消息表、订单状态流转记录增加定时对账任务对超时订单主动补发或人工处理排查分布式事务问题的核心思路不是去猜哪一行代码写错了而是先看“数据流的每一步终态”。订单状态是什么消息表状态是什么消费记录是否存在库存流水是什么。沿着这四个点看问题范围很快能缩小。9. 最佳实践与工程建议9.1 明确事务边界别把外部IO放进来一个方法里如果同时有数据库操作和远程调用优先考虑拆分数据库操作放在本地事务中远程调用放在事务外需要保证两者一致时用消息或事件驱动而不是在事务里直接等待跨服务结果。如果确实要在事务提交后做点事可以注册afterCommit回调Transactional public void createOrder(OrderDO order) { orderMapper.insert(order); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { mqProducer.send(order-topic, order); } }); }这样可以保证“事务提交成功后再发消息”比直接在事务里发消息安全。但要清楚它仍然不能解决“消息发送失败后的重试”问题真正的可靠性还要靠本地消息表或事务消息。9.2 幂等是分布式事务的地基无论选哪种方案幂等都是底线。建议在消息表、消费记录表、流水表等关键数据表上加唯一约束。唯一约束比“先查再判”更可靠因为并发场景下“查不到然后插入”可能因为并发产生重复。扣减库存建议用条件更新避免并发超卖update inventory set stock stock - #{count} where sku_id #{skuId} and stock #{count}如果影响行数为0说明库存不足业务上再做回滚或提示。9.3 消息可靠性要分层“消息不会丢”不是单一环节能保证的而是整条链路共同保证发送端业务数据与消息数据同事务或者使用事务消息传输端MQ本身要做持久化消费端先幂等再处理业务兜底层定时对账任务发现不一致主动补齐。9.4 尽量从架构上减少分布式事务一个经常被忽略的最佳实践是能不跨服务就不跨服务。订单和库存如果必须强一致可以考虑把订单和库存的写入放到同一个服务、同一个数据库内用本地事务管理。只有当业务确实无法聚合时才引入分布式事务方案。很多公司为了微服务而微服务把一个强一致的写操作拆成两个服务然后花大力气解决分布式事务这是本末倒置。架构设计阶段先问一句这两个数据真的必须分开吗9.5 对账和人工补偿是最后防线再好的系统也会有极端异常。建议保留订单状态流转日志、库存流水、消息记录方便对账。对账任务可以用定时Job每天扫描“已创建但未扣库存”的订单超过一定时间就告警或自动补发。线上分布式事务出问题时第一原则是先止血保留现场再复盘。不要为了“尽快恢复”而乱改数据导致问题无法追溯。9.6 面试准备建议如果你正在准备面试不要只背TCC的定义。做一个练习题把你负责过的业务画出完整调用链标出哪些是本地事务哪些是跨服务失败时数据会发生什么状态然后设计一套补偿方案。这个功夫花下去比背十个方案都有用。面试官问“分布式事务”本质上是想确认你是不是一个能在真实复杂环境下保证数据不出问题的工程师。你能把自己的业务讲清楚比任何概念都更有说服力。