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把数据恢复到操作前的状态。这就像你用文本编辑器改文章时保留了原始备份不满意随时CtrlZ撤销。原子性的关键在于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同时操作id1的记录。事务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_id2加钱加多了想只撤销这一步 ROLLBACK TO SAVEPOINT sp1; COMMIT;执行ROLLBACK TO SAVEPOINT sp1后事务会回滚到保存点sp1的位置第一条更新保留第二条更新撤销。要注意的是回滚到保存点并不会释放事务持有的所有锁这点在高并发环境要格外留意否则容易造成死锁。3.3 自动提交一个需要谨慎对待的默认行为MySQL默认开启了自动提交autocommit1意味着每一条单独的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) ) ENGINEInnoDB; 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持有id1的锁想去锁id2事务B持有id2的锁想去锁id1。两边互相等待谁也不放手最终InnoDB检测到死锁后会主动回滚其中一个事务释放锁让另一个继续执行。遇到死锁时先看最近一次死锁日志SHOW ENGINE INNODB STATUS\G;日志里有LATEST DETECTED DEADLOCK部分会详细列出两个事务分别持有和等待的锁。规避死锁的方法有几种最重要的就是保证所有事务按照同一个顺序来访问资源。比如多个事务都要更新id1和id2两行数据就约定死规则先更新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事务从概念到实操的全部内容。事务这个东西表面上只是三个命令的事实际用起来却涉及锁、日志、隔离级别、异常处理等大量细节。你踩过的坑多了才会真正理解为什么别人总说“事务边界就是性能边界”把事务当成一件严肃的事来做线上系统才能睡得安稳。
返回列表