
1. 那些年我放进事务的危险操作清单1.1 先从最常见的网络调用说起我最早意识到事务里不该放操作这个问题是在一次订单系统重构的线上事故复盘会上。当时订单支付成功后需要同步调用仓储系统扣减库存、调用积分系统增加积分、还要给客户发送短信通知。按照最初的设计这些操作全部包在一个大事务里注解长这样子Transactional(rollbackFor Exception.class) public void processOrder(OrderDTO order) { // 1. 更新订单状态为已支付 orderMapper.updateStatus(order.getId(), PAID); // 2. 远程调用仓储服务扣库存 boolean stockResult stockClient.deductStock(order.getSkuId(), order.getQuantity()); if (!stockResult) { throw new BusinessException(库存不足); } // 3. 远程调用积分服务加积分 boolean pointsResult pointsClient.addPoints(order.getUserId(), order.getAmount()); if (!pointsResult) { throw new BusinessException(积分添加失败); } // 4. 发送短信通知 smsClient.send(order.getUserPhone(), 订单支付成功); // 5. 记录操作日志 logMapper.insert(OrderLog.of(order)); }代码逻辑看起来没毛病所有操作都在事务里任何一个环节失败数据库回滚保证数据一致。但现实很快抽了我一巴掌。某个下午仓储服务接口响应时间突然从50ms涨到3秒短信服务恰好又在重启结果订单模块瞬间堆积了几千个等待数据库连接的线程连接池打满整个订单服务不可用。当时我盯着监控面板上那条红色告警曲线心里凉了半截。问题根源很简单事务里每一次远程调用都意味着数据库连接要一直被攥在手里直到调用结束。如果外部服务慢、超时、或者连接被对方拒绝数据库连接就一直在那干等。而更致命的是所有本该短平快的事务因为外层一个慢接口集体拉长了持有数据库连接的时间。连接池像一锅粥一样被搅稠后面的正常请求进不来。我把远程调用列为最不该放进事务的操作第一位因为它用数据库的锁和连接为不可控的外部系统买单。如果你在事务里调了别人的接口那你的数据库事务就完全取决于那个接口的响应时间对方一抖你的整个系统跟着抖。1.2 文件与IO操作看似无辜的元凶第二个常见的坑是文件操作和本地IO。很多业务里会有这样的需求订单生成后需要导出PDF合同、或者把用户上传的图片做压缩保存、或者把数据批量写入到一个报表文件里。这类操作因为是本地的不像远程调用那么明显但如果把它们放进事务同样会出问题。我见过一个对账系统的代码每天晚上批量处理当天的交易流水处理逻辑里需要把每一笔流水的明细写入到本地CSV文件中代码逻辑大致是Transactional public void processDailyReconcile(ListTrans transList) { FileWriter writer new FileWriter(/data/reconcile/daily_ date .csv); for (Trans trans : transList) { // 写文件 writer.write(trans.toCSV()); // 同时写数据库状态 transMapper.markProcessed(trans.getId()); } writer.close(); }问题发生在磁盘空间满的时候。事务正在逐条写文件写到一半磁盘满了文件写入抛IOException。理论上异常会让事务回滚但文件已经写了一部分数据库回滚后文件并不会自己擦除那半截内容。第二天对账时发现数据库里流水状态是未处理但文件里记录却是有重复的。更尴尬的是整个事务因为频繁IO耗时从1秒涨到了10秒数据库锁被长时间持有半夜的批次任务直接和在线交易抢资源把高峰期拖慢了。IO操作和数据库事务放在一起最大的问题就是回滚不彻底。数据库事务只能保证数据库里的操作原子性它管不了你写了一半的文件、发送出去的消息、更新了的缓存、打出来的日志。你把文件写入放进事务等于告诉数据库请帮我保证文件和数据的一致性但数据库根本没这个能力最终提供的是虚假的安全感。2. 为什么这些操作不该进事务聊聊事务的本质与代价2.1 事务的ACID与锁、连接生命周期要理解哪些操作不该放进事务首先得明白事务到底在保护什么。数据库事务的核心是ACID原子性、一致性、隔离性、持久性。ACID里的A原子性指的是要么全部成功要么全部失败这个全部指的是数据库操作而不是业务系统里的所有行为。为了做到原子性和隔离性数据库在执行事务时会持有资源行锁、表锁、事务日志空间、数据库连接。InnoDB默认的隔离级别是REPEATABLE READ事务里执行UPDATE、INSERT、DELETE时会加排他锁直到事务提交或回滚才释放。这就意味着一个事务开启越久其他事务在这些行上的读写就越容易被阻塞。数据库连接是更稀缺的资源。绝大多数系统连接池配置的maxActive是50或100每一个并发事务都需要占用一个连接。如果你的事务平均执行时间从20ms涨到500ms那么同一时刻能同时处理的事务数就从50变成了10系统吞吐直接打五折。我见过最夸张的一次开发者把Redis缓存更新也塞进了事务每次事务里多了一次网络往返耗时增加1ms当时觉得没啥但高峰期一压测整个接口的TP99从80ms变成了300ms。所以判断一个操作能不能放进事务先要问三个问题这个操作执行时间是否可控这个操作是否只操作数据库资源这个操作失败后数据库是否能连带回滚只要有一个答案是否定的这个操作就不该放进事务。2.2 长事务的连锁反应连接池耗尽、主从延迟、死锁把不该放的操作放进事务直接后果就是长事务。长事务的破坏力是层层递进的。第一层连接池耗尽。数据库连接是有限的事务持有连接不动其他线程只能排队等连接。连接池等待超时一多服务直接报错。第二层锁膨胀和死锁。持有锁的时间越长与其他事务的锁冲突概率越高。尤其当多个事务都持有了各自的锁又都在等待对方释放锁时就会死锁。死锁之后数据库会选一个牺牲者回滚那个回滚反过来又掉到事务逻辑里触发补偿操作雪上加霜。第三层主从复制延迟。MySQL主从复制是基于binlog的异步复制主库提交事务后从库需要读取binlog去应用变更。长事务意味着binlog中的事件在事务提交前不会写入如果主库上一个大事务跑了很久从库场景下从库的SQL线程会卡住导致从库延迟读库的数据跟主库不一致。这个效应在报表明细、订单查询这类需要读从库的场景特别明显。第四层undo log 膨胀。长事务期间数据库需要保留大量的undo日志来支持MVCC快照读。事务不提交这些undo就不能清理磁盘占用飙升。如果恰好碰到一个大事务在写入大量数据undo log能把磁盘撑爆。网上那些数据库事务日志已满的报错很多时候就是长事务惹的祸。我自己经历过一次最经典的连锁反应一个事务里调了第三方支付回调接口对方超时设了60秒结果事务里数据库行锁被持有60秒。这60秒内所有其他请求只要需要更新同一行订单数据全部阻塞请求阻塞导致线程池线程耗尽线程池耗尽导致健康检查失败健康检查失败导致注册中心把服务下线服务下线导致流量被路由到其他节点其他节点同样因为这个事务逻辑被拖垮。一个不该放的远程调用最后拖死了集群。3. 分布式事务场景下的放大版事故3.1 从本地事务到分布式事务的坑如果说本地事务里放个远程调用算是埋雷那么在分布式事务里放远程调用基本等于拿着炸药包跳进游泳池。很多团队在微服务化之后依然习惯性地用本地事务去包多个服务的操作。比如前面说的订单库存积分如果都在一个Spring服务里一个Transactional就能把三个服务调用包起来看起来很爽但一旦服务拆分到独立部署你在A服务里开了一个本地事务然后去远程调B服务这里有个致命问题A的事务锁只在A的数据库上B服务如果操作了自己的数据库B的事务提交与否A根本控制不了。假设A里这样写Transactional public void createOrderAndInventory() { orderMapper.insert(order); inventoryService.deduct(); // 远程调B服务B里也有自己的事务 // 如果这里抛异常本地事务回滚但B的inventory已经扣减了 }B服务的事务已经提交了A本地事务回滚只能回滚A自己的数据库。结果就是订单没创建成功但库存扣了。一开始可能觉得下次让B也回滚于是写代码B失败了就抛异常但一旦A在调用B成功之后、在调用C之前宕机了B的扣减已经生效A数据库回滚这就产生了无法追平的差额。这时候你会想到分布式事务框架比如Seata、TCC、Saga、本地消息表。但这些方案的代价是什么以Seata AT模式为例它会在全局事务期间给涉及的每行数据加全局锁并通过undo_log记录修改前后镜像。如果全局事务里再混入大量远程调用、IO操作那么全局锁时间更长、undo_log占用更大、回滚概率也更高。换句话讲分布式事务并不会因为你用了框架就免疫原来的坑反而会把事务里该轻装上阵的原则放大到全局。3.2 消息发送与事务消息的取舍很多人会把发消息放进事务因为潜意识里觉得只要事务提交消息就必须发出去。Spring里常见的写法是Transactional public void updateOrderStatus(String orderId) { orderMapper.updateStatus(orderId); mqSender.sendOrderEvent(orderId); }如果发消息操作在事务内成功但是事务提交失败消息已经发出去消费端会基于一个不存在的新状态去干活然后和数据库数据打架。如果事务先提交但还没执行到发消息时服务宕机消息没发出去下游不知道状态变更。这俩都是典型的分布式一致性问题。有人会说用事务消息啊RocketMQ的事务消息方案确实能解决这个场景先发半消息然后执行本地事务本地事务成功则提交半消息失败则回滚。但事务消息的使用有前提你必须有可靠的消息中间件同时你的业务能给半消息提供对应的回查机制。很多中小团队根本没有能力把消息中间件运维得那么到位与其用一个复杂机制不如从设计上避免跨事务发消息。我后来会优先尝试本地消息表的思路先把要发送的消息事件作为一条记录和业务数据在同一个数据库事务里写入。然后有一个异步任务扫描这张消息表将未发送的消息发出去并在成功后标记发送状态。这样业务的事务里只剩两项操作更新业务表 插入消息表都是本地数据库操作没有远程调用。消息发送从业务事务中完全剥离出去变成了一个补偿流程。虽然会引入消息表、需要处理消息的幂等性但相比分布式事务和长事务带来的连锁反应这简直是小清新的方案。4. 实战复盘我如何一步步把不该做的操作移出事务4.1 核心思路事务边界最小化经历若干次事故之后我整理了一套处理事务边界的思路。现在做任何设计我都先画一张时间轴把业务操作按数据库内操作和数据库外操作分类。数据库内操作包括增删改查、存储过程如果能避免就用不用、基于SQL的计算。数据库外操作包括HTTP调用、RPC调用、消息发送、文件读写、缓存更新、线程睡眠、外部逻辑等待。我给自己定了一条铁律事务里只允许有数据库内操作其他一律移出去。如果业务上需要保证数据库外操作和数据变更的一致性那么优先选择异步化幂等补偿实在需要强一致再考虑分布式事务框架而且分布式事务框架里的全局事务分支也只放数据库操作。具体到落地我会把代码分成三个层次业务编排层负责调用各类服务组合流程。事务服务层只执行数据库内操作一个事务对应一个独立的数据库用例。补偿层当事务服务层执行失败通过消息或重试进行补偿。比如订单支付我会这样重构public void processOrder(OrderDTO order) { // 1. 在事务服务层里只做订单状态更新和消息事件插入 orderTxService.paySuccess(order); // Transactional内部只操作本地库更新订单状态 插入消息表 // 2. 事务提交后异步发送消息事件 eventPublisher.publish(order.getOrderId()); }消息的消费端负责调用库存扣减和积分赠送。如果库存扣减失败可以靠本地消息表的重试逻辑进行N次重试如果依然失败则进入人工补偿或依赖对账系统解决。这种方式下数据库事务从毫秒级提升到微秒级只做了两次本地DB操作而外部操作的失败不会长时间锁住数据库资源。4.2 重构方案与代码示例假设原始代码是前文那个大事务。重构后可以这样拆Service public class OrderTxService { Resource private OrderMapper orderMapper; Resource private OrderEventMapper eventMapper; Transactional(rollbackFor Exception.class) public ListLong markPaidAndCreateEvent(String orderId) { // 1. 更新订单状态 orderMapper.updateStatus(orderId, PAID); // 2. 插入事件记录本地库操作 Long eventId eventMapper.insert(OrderStatusEvent.of(orderId)); // 3. 返回事件ID供事务提交后发送消息 return List.of(eventId); } }业务编排层无事务Service public class OrderFlowService { Resource private OrderTxService orderTxService; Resource private EventPublisher eventPublisher; public void paySuccess(String orderId) { Long eventId orderTxService.markPaidAndCreateEvent(orderId); // 事务已提交 eventPublisher.publishEvent(eventId); } }需要注意一个细节事务提交时机。Spring的Transactional是通过代理级别生效的默认在方法返回前提交。所以必须保证事务方法先完整执行完毕才能调用eventPublisher。有人可能会把eventPublisher调到事务方法内部尤其是如果事务方法里直接调用同一类的另一个方法那么事务失效或时机错乱。我把两者拆到两个Service让事务已经在markPaidAndCreateEvent返回时提交。这样做的好处是如果eventPublisher发送失败我还能通过消息表状态去重试因为数据库里已经持久化了待发送事件。有读者可能会问如果我硬要同步获取结果比如发短信后立刻告诉用户成功怎么办我的建议是不要用业务主流程的同步请求去做这种强耦合的事情。短信通知完全可以异步返回已受理给前端后台异步发送如果发送失败用消息重试如果巨大的风险需要人工介入监控报警比试图同步执行更靠谱。4.3 排查与验证如何发现事务中有脏操作很多时候你不一定能马上发现自己代码里的事务问题。有几个排查手段特别有效。第一开启数据库慢查询日志和事务等待诊断。在MySQL里你可以通过SHOW ENGINE INNODB STATUS查看当前事务持有的锁和等待关系也可以查information_schema.innodb_trx看看有没有长事务存在。我习惯写一行SQL去捞超过N秒的事务SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_duration FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) 5;只要发现等待超过5秒还在跑的事务我会立刻去查对应的事务方法代码看里面有没有远程调用、IO、Sleep等操作。第二在应用层做事务耗时监控。可以用Spring的TransactionSynchronizationManager生成自定义的切面统计每个事务方法的执行时间和是否包含外部调用。最简单的方案是把Transactional方法进出的耗时埋点处理例如在入口记录开始时间出口计算时长如果超过100ms就输出告警日志。注意阈值要给足正常DB操作余量。我见过一个团队的标准是超过50ms就会去问是不是塞了别的业务然后他们真的抓出了好几个雷。第三检查代码里的Transactional标注位置。我后来总结了一条经验如果一个方法里有一条调用远程Service的语句同时该方法上有Transactional那么大概率是有问题的。我甚至会在团队代码评审里把这种写法直接当作必须重构级别的问题。5. 常见问题速查表与避坑清单5.1 高频问题与解决办法下面这份表是我在历次事故中整理出来的高频问题速查表方便你做代码评审或者说服同行的时候直接引用。事务里出现过的操作典型现象根因建议方案HTTP/RPC远程调用连接池耗尽、接口超时、锁等待外部系统耗时不可控移出事务异步化重试/补偿文件写操作本地/OSS事务回滚但文件已写、磁盘IO拉长事务数据库无法管理外部文件状态先写库再异步写文件失败靠重试或对账消息发送MQ/Kafka事务提交后消息未发或消息发了但事务回滚事务和消息分属不同系统本地消息表/事务消息/异步发送缓存更新Redis等数据库回滚但缓存已变、缓存与DB不一致两套存储无原子性事务外更新结合缓存失效或双删策略Sleep/等待/轮询锁持有时间被人为拉长吞吐骤降人为让线程占用事务资源移除等待改异步或定轮询移到事务外大范围查询/批量更新行锁升级成表锁主从延迟锁粒度和数据量过大分批提交控制事务范围动态拼接SQL循环执行事务中有大量单条SQL耗时叠加SQL执行次数过多批量操作或拆成多个高频事务5.2 我的三条独门经验如果说踩坑踩到今天有一点私人心得我想浓缩成三句话。第一句事务很短短到只做DB操作。我现在的标准是一个事务方法里哪怕多做一次网络请求都必须先问自己能不能把它拆出去。数据一致性的问题用领域事件去驱动后续流程而不是靠一个巨型事务去绑住所有系统。如果不解决外部系统的一致性记住没有银弹方案但异步化幂等对账永远值得优先考虑。第二句补偿代码宁可多写也不要指望事务回滚。每次看到别人在事务里调用外部API我会打个比方你手里攥着一根数据库连接的绳子另一端却绑在别人家的机器上别人家机器动不动都会让你手里的绳子晃。与其把toString包在大事务里不如把外呼操作放到事务后做好失败补偿。第三句参数里的超时时间比事务重要。每次涉及远程调用我要求对方必须提供超时时间并且把超时控制在1秒以内。真正好的架构不是把所有东西都包进一个安全壳而是让大家各自负责自己那段的确定性。最后再分享一个小技巧有一次排查性能瓶颈时我发现在一个事务里调用了并发度为10的线程池去批量调远端接口事务总耗时从50ms涨到2秒。我的处理很简单把远端调用全放到事务外改用CompletableFuture并行调用事务只负责在数据落地后更新状态。改造后事务耗时回到50ms以内而总流程耗时不降反升因为有个耗时的外部依赖本来就是非关键路径。很多时候我们不是不想把操作移出事务而是怕移出后出问题没人负责。所以我在团队里养成了一个习惯所有外部操作都必须拥有自己的状态表或消息表把是否完成这件事持久化。这样即使移出事务后续也能靠定时扫描和手动兜底不会出现无凭无据的损失。如果你也被事务里的远程调用、IO操作坑过建议你先从最简单的清单开始把消息发送挪出事务、把文件写入挪出事务、把Sleep挪出事务隔几天看看监控数据你会回来感谢自己。