☰
MySQL事务实战:隔离级别、锁与分布式事务避坑指南
2026/10/5 7:41:50 网站建设 项目流程

如果你真刀真枪跑过线上业务,尤其是电商订单、库存扣减、资金账户这类场景,大概率被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 怎么查和改隔离级别,改了以后有什么影响

查询当前隔离级别的SQL:

SELECT @@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_format=ROW通常更安全,不过这个属于运维层面的大改动,需要充分测试。

我实际经验是:大部分业务保持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默认autocommit=1,每条语句单独提交。如果你在JDBC连接串里没有设置关闭自动提交,直接用connection.executeUpdate两次,并不构成一个事务,中间崩了前一条也不会回滚。所以要么用START TRANSACTION,要么在应用层关闭自动提交。

我建议在笔试题和实际代码里,都尽量用明确的START TRANSACTION / COMMIT / ROLLBACK,而不是依赖隐式行为。

3.2 SAVEPOINT:部分回滚的技巧

有时候一个事务里有多个操作,你只想回滚到某个中间点,不全部撤销。用SAVEPOINT:

START 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 死锁是怎么产生的,怎么定位和解决

死锁的本质是多个事务以不同顺序抢占资源,造成循环等待。最简单的例子:

事务A:UPDATE t SET v=1 WHERE id=1; 然后想更新id=2。 事务B:UPDATE t SET v=2 WHERE id=2; 然后想更新id=1。

两个事务各自先拿了一个锁,又等对方手里的锁,就死锁了。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的KILL:

KILL 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)通过引入超时机制改善阻塞,但实现复杂,实际场景用得少。

还有一种成熟方案是TCC(Try-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执行计划三个层面去查,一般都能找到答案。另外多说一句,事务相关的问题在面试里问得很多,但千万别光背概念,能用实际项目例子说明怎么排查、怎么解决,才能体现真正的理解深度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询