拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL事务从原理到实践:ACID、隔离级别与锁机制全解析

MySQL事务从原理到实践:ACID、隔离级别与锁机制全解析

最近在技术社区经常看到有人把事务挂在嘴边,但真要落到实际操作和原理层面,能讲清楚的人不多。我一直在业务一线写SQL、调性能,和事务打了多年交道,今天就把MySQL里事务的操作和四大特性彻底拆开聊一聊。这篇文章既适合刚接触数据库的新手,也适合那些会用但说不清原理的开发者,我会从实际场景出发,把该踩的坑和该记的要点都列出来。

1. 事务到底是什么,为什么你离不开它

1.1 一个转账场景让你秒懂事务

假设你在网上给朋友转了1000块钱,这个动作在数据库层面其实由两条SQL组成:一条把你的账户余额减1000,另一条把他账户余额加1000。如果第一条执行成功、第二条由于某种原因失败了,会发生什么?你的钱平白无故消失,朋友却没收到。没有事务的情况下,这种事故会真实发生。

事务的本质就是:把一组操作打包成一个不可分割的执行单元,要么全部成功,要么全部失败。这个概念可以类比为你在淘宝下单的整个流程,从提交订单到扣减库存,如果库存扣减失败,订单提交也应该一并取消,而不是留下一个缺货的幽灵订单。

我刚入行时犯过一个印象深刻的错误:在一个批量导入功能里,循环执行了上百条UPDATE语句,没有用事务包裹。结果跑到第57条时,外部API调用抛了异常,代码捕获后继续往下跑,最终100多条数据里一半更新成功、一半没动。排查数据对账问题花了一下午,从那之后我养成了习惯——凡是涉及到多条数据变更的操作,第一反应就是包事务。

1.2 哪些场景必须用事务

不是所有SQL都需要事务,但下面这几类场景是事务的重灾区,也是面试官最爱问的地方:

  • 账户资金变动:转账、充值、提现、退款,每一笔都要保证资金流的绝对一致
  • 订单与库存:下单扣库存、取消订单恢复库存,这类跨表操作必须原子化
  • 多条关联数据写入:比如创建用户的同时初始化他的权限、配置、默认地址
  • 状态机流转:订单从待支付到已支付再到已发货,每一步状态变更都要确保不跳变

在实际开发中,判断要不要用事务有一个非常简单的标准:如果后一条SQL的结果依赖于前一条SQL的成功执行,那么它们就应该放在同一个事务里。

2. MySQL事务的四大特性,一条条讲透

2.1 原子性:要么全做,要么全不做

原子性对应的是Commit和Rollback机制。当一个事务开始时,InnoDB会通过undo log记录所有操作的原始数据,一旦事务执行了Rollback,就会利用undo log把数据恢复到操作前的状态。这就像你用文本编辑器改文章时保留了原始备份,不满意随时Ctrl+Z撤销。

原子性的关键在于:InnoDB不是等事务提交了才往磁盘刷数据,而是边执行边把修改同步到redo log和内存缓冲池。如果中途崩溃,重启时靠undo log回滚未提交事务,靠redo log重放已提交但尚未落盘的数据。这个恢复机制非常有意思,后续讲持久性时我再展开。

2.2 一致性:业务规则的红线

一致性这个概念最容易被人误解,很多人以为它只是数据库层面的一种约束。实际上,一致性是应用程序和数据库共同作用的结果,它的核心含义是:事务执行前后,数据库必须从一个合法状态转变到另一个合法状态。

什么叫合法状态?举个例子:数据库规定账户金额不能为负数。如果你从余额为50的账户转出100,事务结束时账户余额变为-50,这就是违反一致性。无论事务成功还是失败,数据最终都不能处于这种非法状态。

MySQL中支持一致性的底层机制包括:主键约束、唯一约束、外键约束、CHECK约束、NOT NULL约束,以及触发器等。但根本保证还是靠开发者自己写好业务逻辑,把事务边界划分正确。数据库只能保证操作的中途状态不可见,至于最终结果是否符合业务规则,这个责任在代码里。

注意:一致性是四大特性里最抽象也最关键的一个,它把原子性、隔离性、持久性串联起来——没有其他三个特性的支撑,一致性无从谈起。

2.3 隔离性:多事务并行不干扰

隔离性解决的是多个事务同时操作同一批数据时产生的冲突问题。MySQL的InnoDB引擎提供了四种隔离级别,从低到高依次是:

  • 读未提交(Read Uncommitted):能看到其他事务尚未提交的数据,可能出现脏读
  • 读已提交(Read Committed):只能看到其他事务已提交的数据,解决了脏读,但可能出现不可重复读
  • 可重复读(Repeatable Read):同一个事务内多次查询结果一致,解决了不可重复读,这是MySQL默认隔离级别
  • 串行化(Serializable):最高的隔离级别,事务排队执行,解决了幻读,但性能影响极大

要理解隔离级别,得先弄清几个经典问题:脏读、不可重复读、幻读。这三个问题在面试中出现的频率极高。用一个例子快速说明:两个事务A和B同时操作id=1的记录。事务B修改了某字段值但尚未提交,此时事务A进行查询。如果隔离级别为读未提交,A会直接看到B改完但还不稳定的数据,这就是脏读。如果B已经提交,而A在同一个事务里先后读取两次,两次结果不同,这就是不可重复读。幻读则更隐蔽——A查询某个范围内的记录,B往这个范围里插入了新记录并提交,A再次用相同条件查询时,发现多出了原本不存在的行,仿佛产生了幻觉。

MySQL默认的可重复读级别通过MVCC机制(多版本并发控制)在很大程度上避免了幻读问题,这也是为什么网上很多文章说MySQL的可重复读已经能防止幻读了。但在某些边界场景下,幻读依然可能发生。如果你需要绝对严格的隔离,请使用串行化级别,但代价是并发能力的断崖式下降。

2.4 持久性:数据一旦提交就丢不了

持久性的实现主要依赖redo log。每一步数据修改,都会先向redo log里写入日志记录,然后才更新内存中的数据页。Redo log采用先写日志、再写数据的策略(Write-Ahead Logging),即WAL。如果数据库在数据页落盘前崩溃了,重启时会重放redo log中的记录,把崩溃前已经提交的事务重新执行一遍。

打个比方:你是个记账员,每笔进出账都先记在随身携带的草稿本上(redo log),等到晚上再把草稿整理进正式账本(磁盘数据文件)。即使白天出了意外草稿本丢了,只要还有备份记录就能把账理清。

InnoDB默认开启了双写缓冲区(doublewrite),还额外保证页写入的完整性。需要提醒的是,持久性不是绝对意义上的零丢失,在某些极端硬件故障下依然可能出现问题。所以生产环境的数据库备份策略永远是不可省略的最后一道保险。

3. 实操:MySQL中事务的完整操作方法

3.1 基础操作:开启、提交、回滚

在MySQL命令行或者客户端工具中,事务操作的核心命令非常简单:

-- 开启事务 START TRANSACTION; -- 执行业务SQL UPDATE accounts SET balance = balance - 1000 WHERE user_id = 1; UPDATE accounts SET balance = balance + 1000 WHERE user_id = 2; -- 一切正常,提交事务 COMMIT;

如果中间的SQL执行出了问题,比如账户余额不足导致更新失败,可以这样做:

START TRANSACTION; UPDATE accounts SET balance = balance - 1000 WHERE user_id = 1; -- 发现第二条SQL报错,余额不足 ROLLBACK;

执行Rollback后,第一条UPDATE的影响会完全撤销,数据恢复原状。

这里有个容易踩的坑:很多人习惯用BEGIN来开启事务,其实BEGIN和START TRANSACTION在绝大多数场景下等价,但START TRANSACTION后面可以额外加上修饰符,比如START TRANSACTION WITH CONSISTENT SNAPSHOT,用来开启一致性快照。需要做一致性读取的场景,用后一种更严谨。

3.2 保存点:事务里给自己留条退路

有时候你不希望整个事务全部回滚,只想撤销某一段操作,这时候就需要保存点。打个比方:你在做一顿复杂的菜,每完成一个步骤就往回退一步看效果,而不是从零开始。

START TRANSACTION; UPDATE accounts SET balance = balance - 200 WHERE user_id = 1; SAVEPOINT sp1; UPDATE accounts SET balance = balance + 200 WHERE user_id = 2; -- 发现给user_id=2加钱加多了,想只撤销这一步 ROLLBACK TO SAVEPOINT sp1; COMMIT;

执行ROLLBACK TO SAVEPOINT sp1后,事务会回滚到保存点sp1的位置,第一条更新保留,第二条更新撤销。要注意的是,回滚到保存点并不会释放事务持有的所有锁,这点在高并发环境要格外留意,否则容易造成死锁。

3.3 自动提交:一个需要谨慎对待的默认行为

MySQL默认开启了自动提交(autocommit=1),意味着每一条单独的SQL都会自动封装成一个事务并立即提交。这就是为什么你单独执行一条UPDATE,发现立即生效、无法回滚的原因。

-- 查看当前设置 SELECT @@autocommit; -- 临时关闭自动提交(只对当前会话有效) SET autocommit = 0;

关闭自动提交后,所有SQL都会在一个隐式事务里执行,直到你显式COMMIT或ROLLBACK。很多生产环境的最佳实践是:在应用层显式控制事务边界,而不是依赖自动提交。尤其是MyBatis、JPA这类ORM框架,往往默认把多条的增删改包在同一个事务里,如果底层数据库把自动提交开着,事务就形同虚设了。

3.4 事务套事务:MySQL不支持你真的了解吗

很多开发者在写存储过程或嵌套调用时,会习惯性地认为:外层开了一个事务,内层再开一个,内层失败不会影响外层。实际上,MySQL不支持真正意义上的事务嵌套。

START TRANSACTION; -- 执行若干SQL START TRANSACTION; -- 这里MySQL不会开启真正的新事务 UPDATE t SET x = 1 WHERE id = 1; ROLLBACK; -- 这个ROLLBACK会回滚整个外层事务

在MySQL中,事务的START TRANSACTION一旦遇到事务已经存在的情况,会自动提交当前事务再开启新事务,或者直接忽略。社区里有人用“SAVEPOINT模拟嵌套”来实现类似效果,但其实这只是噱头,并不能提供真嵌套的语义。

关键经验:不要在存储过程里写事务嵌套,你的内层事务异常回滚很可能把外层已执行的操作也一起回滚掉,造成难以排查的诡异问题。需要复杂事务逻辑时,把控制权放在应用代码里,用编程语言的事务模板管理。

4. 隔离级别实操与MySQL锁机制的那些事

4.1 一个个级别亲手试一下

用一个具体例子演示不同隔离级别下的事务行为。先准备一张表和数据:

CREATE TABLE t_account ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50), balance DECIMAL(10,2) ) ENGINE=InnoDB; INSERT INTO t_account(name, balance) VALUES ('张三', 1000), ('李四', 1000);

打开两个MySQL会话,分别模拟事务A和事务B。

第一步,验证读未提交:

-- 会话A SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; UPDATE t_account SET balance = 500 WHERE name = '张三'; -- 会话B(未提交) SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM t_account WHERE name = '张三'; -- 此时能看到500,也就是未提交的数据

第二步,把会话A回滚,验证读已提交:

-- 会话A ROLLBACK; -- 数据恢复为1000 -- 会话B再次查询,看到1000。说明读已提交下看不到未提交数据

第三步,验证可重复读:

-- 会话A SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT balance FROM t_account WHERE name = '张三'; -- 这里是1000 -- 会话B START TRANSACTION; UPDATE t_account SET balance = 2000 WHERE name = '张三'; COMMIT; -- 会话A再次查询 SELECT balance FROM t_account WHERE name = '张三'; -- 结果依然是1000,满足可重复读

有兴趣的朋友可以再试试会话A事务内第一次查询后,会话B插入一条新记录,会话A再次用同样条件查询范围数据,观察是否出现幻读。虽然MySQL在可重复读下通过间隙锁+MVCC规避了大部分幻读,但这个测试会让你更直观地理解边界在哪里。

4.2 如何设置隔离级别

设置隔离级别有两种方式——全局和会话级:

-- 设置当前会话隔离级别 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 设置全局隔离级别 SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- 查看当前会话隔离级别 SELECT @@transaction_isolation; -- 查看全局隔离级别 SELECT @@global.transaction_isolation;

在配置文件my.cnf中,也可以直接指定:

[mysqld] transaction-isolation = READ-COMMITTED

至于选哪种隔离级别,如果你没有特殊要求,直接沿用MySQL默认的可重复读就好。如果项目要做读写分离、系统并发很高,且很少在事务里做重复查询,可以考虑把隔离级别降为读已提交,性能和锁竞争会改善一些。但改动之前必须测试到位,不能拍脑袋。

4.3 InnoDB锁的分类和事务的关系

讲到隔离级别就不可能绕开锁。隔离级别本质上是锁策略的一种封装,不同的隔离级别背后对应着不同的加锁逻辑。

InnoDB锁大致分为三类:

  • 记录锁(Record Lock):锁住具体某一行记录
  • 间隙锁(Gap Lock):锁住某个区间范围,阻止其他事务在这个范围内插入新记录
  • 临键锁(Next-Key Lock):记录锁+间隙锁的组合,锁定当前行及其前面的区间

在可重复读级别,InnoDB默认使用临键锁来防止幻读;在读已提交级别,主要使用记录锁,间隙锁会自动禁用。

还有一个高频面试点:共享锁和排他锁。共享锁(S锁)允许多个事务同时读取同一行数据,排他锁(X锁)则阻止其他事务读取或修改这一行。普通的SELECT是不加锁的读取(MVCC),只有SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这些操作才会显式或隐式加锁。在业务代码里,SELECT ... FOR UPDATE是最常用的一种悲观锁写法,但它会阻塞其他写操作,一定要控制好事务持有锁的时间,能短则短。

5. 事务使用中常见的坑,每一个我都踩过

5.1 事务里不能执行的语句清单

不是所有操作都能放进事务里,下面这些语句在事务内执行会导致隐式提交,也就是事务被强制中断并提交:

  • DDL语句:CREATE TABLE、ALTER TABLE、DROP TABLE、TRUNCATE TABLE等
  • 管理语句:GRANT、REVOKE、SET PASSWORD等
  • 锁相关:LOCK TABLES、UNLOCK TABLES等

我曾经在一个数据迁移脚本里,事务执行到一半动态给一张表加索引,结果索引添加成功的同时事务被隐式提交,之前执行的数据更新全部直接生效。后来这只脚本造成了一部分数据处于半迁移状态,花了很长时间去对账修复。

经验教训:凡是涉及DDL的操作,尽量独立执行,不要和其他DML混在同一个事务里。

5.2 长事务的危害比想象中更严重

如果一个事务长时间不提交,它会一直持有某些行的锁,阻塞其他事务的读写操作。更糟的是,InnoDB的MVCC版本链会不断膨胀,undo log无法及时清理,导致数据库整体性能缓慢下降。

如何排查长事务?用下面这条SQL一眼看穿:

SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started ASC;

一旦发现某个事务的duration_seconds特别大,就要去应用代码里找找是不是事务边界放大了,或者是否存在网络延迟导致事务迟迟无法提交的情况。在应用编码时,永远记住:事务里只保留必要的业务操作,远程调用、外部IO、耗时计算尽量不要放进事务里。

5.3 死锁的排查与解决思路

死锁是并发事务绕不开的话题。经典死锁场景:事务A持有id=1的锁,想去锁id=2;事务B持有id=2的锁,想去锁id=1。两边互相等待,谁也不放手,最终InnoDB检测到死锁后,会主动回滚其中一个事务,释放锁让另一个继续执行。

遇到死锁时,先看最近一次死锁日志:

SHOW ENGINE INNODB STATUS\G;

日志里有LATEST DETECTED DEADLOCK部分,会详细列出两个事务分别持有和等待的锁。规避死锁的方法有几种,最重要的就是保证所有事务按照同一个顺序来访问资源。比如多个事务都要更新id=1和id=2两行数据,就约定死规则:先更新id小的,再更新id大的。这样永远不会形成循环等待。

另外,合理选择索引也很关键。如果更新语句的WHERE条件没有走索引,InnoDB可能需要锁住全表的大量记录,死锁概率暴增。对更新操作的WHERE条件字段建立合适的索引,是降低锁冲突的有效手段。

5.4 事务里出异常了到底该怎么处理

很多开发者在事务中遇到异常后的处理逻辑是“捕获异常然后继续跑”,这是极其危险的做法。正确姿势是:在事务内部遇到任何不可恢复的错误时,先回滚到安全点或直接回滚整个事务,然后记录日志,最后决定是否重试。

// 一个典型的事务处理模板 try { connection.setAutoCommit(false); // 执行多条SQL connection.commit(); } catch (SQLException e) { connection.rollback(); // 必须回滚 // 记录日志并抛出业务异常 } finally { connection.setAutoCommit(true); // 恢复自动提交 }

注意,事务的边界要尽量短。我在做一个订单服务时,最开始把发送短信通知、推送消息、甚至写日志都放在事务里,后来一个短信服务超时导致整个事务回滚,用户反复下单失败,业务投诉一片。后来把所有非核心操作全部移出事务,只保留订单和库存两条核心数据的变更,问题立刻消失。

5.5 常见报错与排查速查表

报错信息可能原因解决办法
Deadlock found when trying to get lock两事务循环等待资源统一资源访问顺序,用SHOW ENGINE INNODB STATUS查看详情
Lock wait timeout exceeded; try restarting transaction等待获取锁超时检查是否存在长事务,适当调大innodb_lock_wait_timeout或优化事务
Duplicate entry for key唯一索引冲突检查业务逻辑,做幂等处理或预校验
Cannot execute statement in a READ ONLY transaction事务被设置为只读检查事务配置,确认是否需要开启写权限
Transaction rollback: transaction is rolled back事务中出错被自动回滚查看具体错误SQL,修正后重试

6. 面试高频追问:事务相关的问题怎么答

平时带新人或者跟同行交流时,我总结出事务方向上几个经常被追问的深度问题,这里一起梳理下思路。

第一个是“MySQL的可重复读为什么能解决幻读”。听上去像绕口令,但核心机制是MVCC快照读和当前读的结合。普通SELECT走快照读,利用undo log版本链生成一致性的快照,查询到的始终是事务开始时的数据版本;而SELECT ... FOR UPDATE、UPDATE、DELETE走当前读,读取最新已提交版本并加锁,配合临键锁锁住范围,防止新记录插入。这两个机制配合,大部分幻读场景就都被堵住了。

第二个容易被问的是“为什么InnoDB用可重复读,而Oracle和PostgreSQL默认用读已提交”。这个问题没有标准答案,但MySQL官方的说法之一是可重复读可以更好地利用binlog在STATEMENT格式下的复制一致性。实际上更普遍的理解是:MySQL早年主从复制要求事务内所有操作在一个逻辑时间点执行,可重复读保证从库应用binlog时不会因为事务内不同SQL看到的数据版本不同而产生不一致。

第三个高频点是“既然事务能保证一致性,还需要分布式事务吗”。单体数据库里事务能解决一致性问题,但在微服务架构下,一次操作跨多个数据库、多个服务,每个服务都有自己独立的事务,全局一致性就要靠分布式事务来协调。常见的方案有基于消息的最终一致性、两阶段提交(2PC)、TCC补偿等。如果面试官问了这个问题,重点是把分布式事务的代价说清楚——很多方案其实是拿一定的可用性和性能损耗换更强的一致性,没有银弹。

7. 实操经验谈:一个事务优化的完整案例

最后分享一个我从实际项目里抽取出来的简化案例,完整呈现一次事务优化的思考过程。

业务场景:用户下单,系统需要扣减库存、创建订单、加积分。初始实现把这三个操作放在一个事务里,线上频繁出现库存超卖和订单创建延迟。排查后发现:创建订单时需要调用外部风控接口判定订单合法性,这个接口的平均耗时达到了800ms,仓储服务扣减库存也在同一个事务里等待,导致整个事务持有大量锁的时间超过了1秒。高并发下,后续请求要么排队超时,要么锁等待失败。

优化方案分了三个步骤:

第一步,把外部风控接口调用移出事务,改成在下单事务提交成功后,异步调用风控,如果风控不通过再走人工审核或自动取消流程。这样事务里的核心时间一下降到了100ms以内。

第二步,加积分从同步操作改成MQ消息异步处理。积分的最终一致性完全可以通过事务消息或者本地消息表实现,不必和下单事务强绑定。

第三步,把扣减库存的SQL做了优化,原来是对库存表整行做UPDATE,改成带条件的原子更新:

UPDATE inventory SET stock = stock - 1 WHERE product_id = 123 AND stock >= 1;

通过stock >= 1条件,让数据库在行锁层面完成超卖校验,避免在应用层SELECT后再UPDATE造成的竞态窗口。同时配合日志和返回值判断是否扣减成功,这样事务内不再需要先SELECT再UPDATE。

这个优化上线后,下单接口的TP99从900ms降到了150ms,库存超卖的问题也彻底消失。核心思想无非两点:事务内绝对不做慢操作,能用一行SQL解决的数据竞态不要用多行SQL解决。

以上就是MySQL事务从概念到实操的全部内容。事务这个东西,表面上只是三个命令的事,实际用起来却涉及锁、日志、隔离级别、异常处理等大量细节。你踩过的坑多了,才会真正理解为什么别人总说“事务边界就是性能边界”,把事务当成一件严肃的事来做,线上系统才能睡得安稳。

返回列表