ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MySQL事务实战:隔离级别、锁与分布式事务避坑指南

2026/10/5 7:41:54 拓冰建站 浏览量
MySQL事务实战:隔离级别、锁与分布式事务避坑指南 如果你真刀真枪跑过线上业务尤其是电商订单、库存扣减、资金账户这类场景大概率被MySQL事务折腾过。明明一条UPDATE执行成功了数据却不对明明加了事务并发一高还是超卖更别提死锁回滚后程序报错用户一脸懵来投诉。这篇不打算给你照搬文档而是从业务角度把MySQL事务的原理、用法、选型、踩坑串一遍看完能直接用起来也能自查自己的代码为什么出了幺蛾子。不管你是刚接触MySQL的初级开发还是已经写了几年SQL但没系统梳理过事务的博主/后端都可以参考这套思路。我按“为什么需要事务—隔离级别怎么选—代码里怎么写—锁和死锁怎么排查—分布式场景怎么扩展—最后放一批实战坑”的顺序讲全程没有废话都是实际项目里用得上的东西。1. 先从业务场景理解事务到底解决了什么问题1.1 没有事务时的订单与库存先看一个最经典的场景用户下单扣库存。表结构简化成订单表orders和库存表stock下单逻辑分两步向orders插入一条订单再把stock表的库存减1。这两条SQL不管谁先谁后只要中间有一句失败就会出现“订单记录了但库存没扣”或者“库存扣了但订单没记”的尴尬情况。没有事务时如果你先插入订单再更新库存更新语句因为库存不足或网络超时失败了前端已经提示下单成功后台却只有一条订单记录库存原封不动。商家发货时发现根本没货用户却已经付款这就是典型的业务数据不一致。有了事务这两步被包在同一个begin和commit之间要么全成功要么全失败。一旦第二步报错回滚之后第一步插入的订单也会自动消失业务回到初始状态。这个“要么全有要么全无”的核心能力就是事务存在的第一意义。1.2 ACID到底在保护什么教科书上把事务的四个特性叫ACID很多人背过就忘。我重新拆解一下每个特性对应一个实际的业务风险Atomicity原子性一组SQL作为一个整体不能只执行一半。实现上靠undo log事务回滚时把已做的修改撤销。你可以理解为“转账扣款成功但收款失败系统要把扣掉的钱还回去”。Consistency一致性事务完成后数据要符合业务规则比如库存不能为负数、账户余额不能透支、订单状态和金额必须匹配。这个约束不仅要靠数据库约束还要靠业务代码的正确性。事务只是保证在提交前后数据状态是合法的不是说只要有事务业务逻辑就一定对。Isolation隔离性并发执行的事务之间不能互相干扰。两个事务同时扣同一个库存不能互相覆盖。隔离性的强弱由隔离级别控制后面专门讲。Durability持久性提交后数据就永久生效即使数据库崩溃也不会丢。实现上靠redo log提交时把变更写入日志再异步刷到磁盘。四个特性不是平等的其中Isolation最容易成为性能瓶颈。你隔离得越严并发度越低隔离得越松数据就越容易出问题。所以事务设计的关键就是找到业务可接受的隔离级别而不是一律最强的串行化。1.3 哪些业务必须用事务哪些不用不是所有SQL操作都要套事务。我对事务的使用原则通常是这样判断的强一致场景必须用资金变动、订单状态流转、库存扣减、积分增减、价税计算。哪怕只是一行UPDATE如果后面还有补偿逻辑也要用。弱一致场景不用强事务日志写入、埋点上报、点赞数统计、浏览记录。这类数据允许丢失或最终一致强行用事务还会拖垮性能。只读查询不需要显式事务除非你要保证多次查询看到同一快照。MySQL在可重复读级别下单条SELECT已经是快照读天然一致所以不需要包事务。还有一个容易被忽略的事务不是越级越好。一个事务里塞了几百条SQL锁持有时间变长并发冲突概率变大回滚成本也高。我见过一个大事务把整个订单表的行锁都占了导致所有下单操作排队这个后面在实战坑里单独说。2. 事务的隔离级别四个级别不是越多越好2.1 四个隔离级别和三个“读”问题先记住最常考的四个隔离级别从宽松到严格依次是READ UNCOMMITTED读未提交、READ COMMITTED读已提交、REPEATABLE READ可重复读、SERIALIZABLE串行化。不同级别能解决或容忍的并发读问题不同常见的三个问题如下脏读读到另一个事务未提交的数据。如果对方回滚你读到的就是无效数据。不可重复读同一事务内两次相同的SELECT读到不同结果。原因是其他事务在两次读之间提交了修改。幻读同一事务内两次相同的SELECT查出来的记录集合不一样。不是行数据变了而是有新的行插入或删除。比如统计订单总数第一次是10条第二次是11条多出来的那一行就是“幻影”。不同隔离级别的表现可以用这张表概括隔离级别脏读可能不可重复读可能幻读可能说明READ UNCOMMITTED会会会基本不用性能优势也很小READ COMMITTED不会会会Oracle等数据库默认级别REPEATABLE READ不会不会可能InnoDB特殊处理MySQL默认级别SERIALIZABLE不会不会不会所有操作串行性能最差注意MySQL的REPEATABLE READ下InnoDB通过间隙锁和当前读机制其实能避免一部分幻读但并不是所有场景都百分百规避。比如你用了普通的快照读普通SELECT第一次查询生成快照后后面再查永远看同一份数据幻读自然不存在但如果你用SELECT ... FOR UPDATE这类当前读情况就要看锁范围了。2.2 MySQL默认的REPEATABLE READ为什么够用很多人奇怪MySQL为什么默认是REPEATABLE READ而Oracle默认是READ COMMITTED。因为MySQL的Replication主从复制在早期基于binlog的statement格式时需要REPEATABLE READ保证从库和主库的一致性。如果主库在READ COMMITTED下并发执行事务statement binlog里记录的可能不是确定性的结果备库重放会出错。另外InnoDB的REPEATABLE READ通过MVCC实现快照读锁开销比SERIALIZABLE小得多读取不需要加锁写入才需要行锁。所以它既保证了稳定性又保留了不错的并发性能。实际项目中绝大多数业务用默认级别就够了没必要为了“看起来更安全”去改SERIALIZABLE那个代价太高。2.3 怎么查和改隔离级别改了以后有什么影响查询当前隔离级别的SQLSELECT transaction_isolation; -- MySQL 8.0及以后 SELECT tx_isolation; -- MySQL 5.7及更老版本临时修改当前会话SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;全局修改需要改配置文件my.cnf或my.ini里的transaction-isolation READ-COMMITTED然后重启MySQL生效。但我不建议你随意全局改除非业务确实需要并且你已经评估过binlog格式和复制影响。如果改成READ COMMITTED配合binlog_formatROW通常更安全不过这个属于运维层面的大改动需要充分测试。我实际经验是大部分业务保持REPEATABLE READ就行。真正想优化并发的时候重点不应该放在隔离级别上而是缩短事务执行时间、减少锁竞争、优化慢SQL。把隔离级别降到READ COMMITTED能减少间隙锁但可能引入不可重复读问题对业务来说不一定值得。3. 事务的落地写法从命令行到Java注解3.1 手动事务START TRANSACTION/COMMIT/ROLLBACK在MySQL命令行或客户端工具里手动开事务核心就是这样START TRANSACTION; UPDATE stock SET count count - 1 WHERE sku_id 1001 AND count 0; INSERT INTO orders (order_no, sku_id, buyer_id) VALUES (202501010001, 1001, 8888); COMMIT;如果第二步或INSERT报错你执行ROLLBACK;所有改动全部撤销。这里有几个容易踩的细节START TRANSACTION会隐式提交之前的语句。如果你前面已经执行了UPDATE或DELETE再执行START TRANSACTION前面的操作会被自动提交掉可能不是你想的“从头开始”。执行了DDL语句CREATE TABLE、ALTER TABLE等会隐式提交当前事务因为DDL无法回滚。显式加锁的语句比如LOCK TABLES、UNLOCK TABLES也会隐式提交事务。自动提交模式MySQL默认autocommit1每条语句单独提交。如果你在JDBC连接串里没有设置关闭自动提交直接用connection.executeUpdate两次并不构成一个事务中间崩了前一条也不会回滚。所以要么用START TRANSACTION要么在应用层关闭自动提交。我建议在笔试题和实际代码里都尽量用明确的START TRANSACTION / COMMIT / ROLLBACK而不是依赖隐式行为。3.2 SAVEPOINT部分回滚的技巧有时候一个事务里有多个操作你只想回滚到某个中间点不全部撤销。用SAVEPOINTSTART TRANSACTION; UPDATE account SET balance balance - 100 WHERE user_id 1; SAVEPOINT sp1; UPDATE account SET balance balance 100 WHERE user_id 2; -- 如果这里报错 ROLLBACK TO SAVEPOINT sp1; -- 之后再COMMIT第一条扣款会被保留第二条不会生效这种写法适合批量处理数据时把单条失败的数据标记出来继续处理剩余数据而不是整个批次回滚。注意ROLLBACK TO SAVEPOINT之后事务并没有结束还需要手动COMMIT或继续执行。3.3 Java里Transactional的正确打开方式Java后端最常见的是Spring的Transactional注解。用法很简单Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { // 1. 插入订单 // 2. 扣减库存 // 3. 清空购物车 } }注意几个细节必须指定rollbackFor。默认情况下Spring事务只对RuntimeException和Error回滚对受检异常Exception的直接子类不回滚。那就是说如果你抛了个业务异常但它是受检异常事务照样提交数据就错了。所以统一用Transactional(rollbackFor Exception.class)最稳妥。事务不生效的三大经典场景方法不是public、方法内部this调用、类没有被Spring管理。尤其this调用比如Service内部一个方法调用另一个带事务注解的方法事务配置会失效因为代理对象没有介入。事务要加载方法上而不是接口上虽然JDK动态代理可以读接口注解但CGLIB代理不一定能正确处理干脆直接写实现类方法上兼容性最好。我见过太多因为没加rollbackFor引发的问题。其实代码逻辑没错SQL也没错就是异常被吞了或者事务没回滚造成脏数据。3.4 事务失效的常见原因除了上面说的三个场景还有几个很容易忽视数据库引擎是MyISAM。MyISAM不支持事务START TRANSACTION也没有回滚效果。MySQL 8.0已经默认InnoDB但如果有人改了表引擎就等着踩坑吧。同一个类内部方法调用包括通过this调用或者通过其他方法间接调用代理不经过事务失效。多线程环境下事务内部新开的线程里的数据库操作不在同一事务中。因为事务和线程绑定子线程没有继承父线程的事务上下文。事务方法里catch了异常吞掉不重新抛出Spring感知不到异常自然不会回滚。数据库连接被切换。比如事务内使用了多个数据源或者强制切换了连接都会打破事务边界。建议在开发阶段给出所有关键写的操作都强制打印日志一旦发现数据不对先看异常有没有被吞再查隔离级别别一上来就怀疑MySQL事务实现有问题。4. 锁和死锁事务背后的隐形推手4.1 MySQL锁分类从表锁到行锁再到间隙锁事务隔离和性能背后真正的执行者是InnoDB的锁。面试和实操都会遇到先理清分类。按锁粒度分表级锁锁定整张表例如LOCK TABLES table WRITE。开销小、加锁快但并发冲突大基本用于MyISAM场景InnoDB下用得不多除非做DDL或元数据锁。行级锁InnoDB最大特色。锁住单条记录并发度高但加锁开销大。间隙锁Gap Lock锁住一个区间的“缝隙”不允许其他事务在这个缝隙里插入新数据用于避免幻读。Next-Key Lock行锁和间隙锁的组合既锁住记录又锁住记录之间的区间是InnoDB在REPEATABLE READ级别下默认的锁策略。按锁模式分共享锁S锁多个事务可以同时读同一行但谁都不能写。排他锁X锁一旦某事务持有X锁其他事务既不能读也不能写普通读快照除外因为快照读不加锁。加锁的SQL示例-- 排他锁当前读 SELECT * FROM stock WHERE sku_id 1001 FOR UPDATE; -- 共享锁 SELECT * FROM stock WHERE sku_id 1001 LOCK IN SHARE MODE;还有意向锁Intention Lock表级锁用来快速判断表里是否已有行锁占用分为意向共享锁IS、意向排他锁IX加行锁之前会自动加意向锁。有时候看到Waiting for table metadata lock就和表级元数据锁有关通常是因为有个长事务在跑ALTER TABLE迟迟拿不到锁。4.2 死锁是怎么产生的怎么定位和解决死锁的本质是多个事务以不同顺序抢占资源造成循环等待。最简单的例子事务AUPDATE t SET v1 WHERE id1; 然后想更新id2。 事务BUPDATE t SET v2 WHERE id2; 然后想更新id1。两个事务各自先拿了一个锁又等对方手里的锁就死锁了。InnoDB检测到死锁后会自动回滚代价较小的事务然后抛出Deadlock found when trying to get lock; try restarting transaction错误。解决办法的核心原则是固定加锁顺序。所有事务先更新id较小的行再更新id较大的行就不会循环等待。另外缩小事务范围、尽快提交也可以降低死锁概率。如果业务上无法避免死锁那么应用层要捕获死锁异常并重试通常重试1-3次即可。查看最近一次死锁信息SHOW ENGINE INNODB STATUS;在输出里找LATEST DETECTED DEADLOCK部分里面有事务的SQL语句、持锁和等待锁的详细信息。这是排查死锁最直接的入口。4.3 如何监控锁等待和长事务运维或排查问题的时候最怕的就是界面卡住、请求超时但数据库还正常。这时候先看有没有锁等待SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM sys.innodb_lock_waits;查当前正在执行的事务和持有的锁SELECT * FROM information_schema.innodb_trx; SELECT * FROM performance_schema.data_locks;查是否有长事务SELECT trx_id, trx_state, trx_started, trx_waiting, trx_mysql_thread_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) 60;发现长事务后可以根据trx_mysql_thread_id找到对应连接该COMMIT的COMMIT该KILL的KILLKILL 12345;这条命令只能通过具备PROCESS权限的用户执行生产环境谨慎操作但要了解关键时刻能救业务。5. 分布式事务跨库跨服务时怎么办5.1 单库事务的边界本地事务再可靠也只能覆盖一个数据库实例。一旦业务拆成微服务订单服务在A库库存服务在B库甚至订单和库存分属不同数据库InnoDB事务就无法保证跨库统一回滚了。你不可能在同一个START TRANSACTION里UPDATE两个库里不同连接的表。这时候分布式事务方案就有用武之地。但别一提分布式事务就上Seata或二阶段提交很多场景可以用更轻量的方式解决。5.2 本地消息表和最终一致性最常见的做法是本地消息表 消息队列。以订单为例在订单库本地开一个事务写订单记录 插入一条“扣库存消息”到本地message表一次commit。后台任务或消息中间件把message中的消息投递到MQ消费端去扣库存。如果扣库存失败重试重试多次仍失败可以做人工干预或标记失败订单。这个方案的优点是不需要引入分布式事务框架利用本地事务保证业务操作和消息记录的一致性靠消息重试实现最终一致。缺点是有一定开发量且需要自己对幂等和重试负责。5.3 二阶段提交和三阶段提交的取舍如果需要强同步可以用二阶段提交2PC比如MySQL XA事务。但2PC的缺点是阻塞协调者挂了参与者一直锁资源。三阶段提交3PC通过引入超时机制改善阻塞但实现复杂实际场景用得少。还有一种成熟方案是TCCTry-Confirm-Cancel把每个操作拆成预留、确认、取消三阶段。比如扣库存Try阶段预占库存Confirm阶段实际扣减Cancel阶段释放预留。优点是可以满足强一致要求实现可控缺点是业务侵入强每个操作都要写三段逻辑对开发要求高。我的建议是优先考虑最终一致能用普通幂等重试解决的就不要上重型分布式事务。分布式事务的故障排查成本远高于单库事务一旦协调者、消息、网络链路任何一环出问题业务都要卡住。6. 实操经验事务使用中的常见坑与排查技巧6.1 大事务引发的binlog和主从延迟我在生产环境遇到过最隐蔽的问题就是大事务导致主从复制延迟。一个事务内UPDATE了100万行虽然SQL在同一事务里秒级执行但commit时要写大量redo并生成完整binlog主库刚提交完成从库要等这些日志全部重放就滞后了。对用户来说数据可能已经更新但读到从库还是旧数据。这种问题的排查方式是先看主从延迟SHOW SLAVE STATUS\G;找到Seconds_Behind_Master字段。如果数值很大就去查innodb_trx看看是不是有大事务还没提交。解决策略是拆事务把一次UPDATE 100万行拆成每批1000行循环分批commit。这样每批的锁时间短日志量小从库追赶也容易。注意拆批之后如果中途失败会出现部分数据更新的情况所以需要业务上有重试或补偿机制。6.2 事务里不要做远程调用和耗时操作这也是个大坑。事务本质是锁资源的保护期你一个事务里调用第三方HTTP接口等对方10秒响应相当于这10秒一直持有一批行锁。其他事务要操作这些行全部排队阻塞。经验法则事务只放纯数据库操作而且尽量少。外部调用放在事务提交之后或者事务开始之前。如果必须依赖外部调用结果才能决定是否提交先调用再开事务去更新数据库而不是把调用包在事务里。有一次跟同事排查“请求全部超时”发现代码里把发短信、调用风控接口、记录日志都塞进一个事务直接把数据库连接池耗尽。这种问题不是MySQL能靠参数解决的必须重构代码。6.3 连接池与事务超时设置Spring的Transactional默认超时时间依赖底层连接和数据库配置。MySQL本身没有事务超时的严格概念但在InnoDB中锁等待有超时时间默认50秒由innodb_lock_wait_timeout控制。如果你不设置事务方法超时一旦发生锁等待最多等50秒才报错对在线业务来说体验极差。建议在事务注解上显式指定超时Transactional(timeout 5, rollbackFor Exception.class)超时会通过JDBC驱动发送到MySQL事务内执行超过时间就会抛异常并回滚。注意timeout的单位是秒我习惯设5-15秒具体看业务可接受范围。连接池方面要关注spring.datasource.hikari.maximum-pool-size和connection-timeout。事务数激增时如果连接池数量不够会大量等待获取连接拖垮整体响应。6.4 实用SQL查看事务、锁、连接状态最后附上我排查事务问题最常用的几条SQL建议收藏。-- 查看当前所有事务 SELECT * FROM information_schema.innodb_trx\G; -- 查看事务等待锁的情况 SELECT * FROM sys.innodb_lock_waits; -- 查看InnoDB状态包括死锁和锁等待详细 SHOW ENGINE INNODB STATUS\G; -- 查看当前连接 SHOW PROCESSLIST; -- 查看锁等待超时参数 SHOW VARIABLES LIKE innodb_lock_wait_timeout;有两次线上事故我用这几条命令很快找到了元凶一条是因为应用启动时执行了ALTER TABLE被一个长事务占了元数据锁所有查询全卡在metadata lock上一条是因为事务内出现死锁但没有正确处理导致连接重试风暴。都是靠innodb_trx和SHOW PROCESSLIST定位的。再提醒一句performance_schema和sys库提供了大量方便视图MySQL 5.7以上直接使用即可。低版本如果information_schema查不到详细数据可以考虑升级。写到这里我觉得MySQL事务最大的教训不是原理有多难而是实际使用中太容易“想当然”。事务是数据库的底牌也是应用层最容易踩雷的地方。你只要把隔离级别选对、事务范围控制好、锁冲突处理好大部分问题都能避免。遇到死锁或者长事务先别慌从连接、锁、SQL执行计划三个层面去查一般都能找到答案。另外多说一句事务相关的问题在面试里问得很多但千万别光背概念能用实际项目例子说明怎么排查、怎么解决才能体现真正的理解深度。