资讯详情

资讯详情

MySQL事务从原理到实践:ACID、隔离级别与常见坑一次讲透

做后端开发的人应该都有过这种经历一条UPDATE语句把整张表的金额字段都改错了或者订单创建到一半程序崩溃结果库存扣了、订单却没生成。这种数据“半吊子”状态正是事务要解决的问题。MySQL里的事务说白了就是一组SQL操作打包成一个整体要么全部成功要么全部失败不存在中间状态。这篇文章我就从实际操作出发把事务的开启、提交、回滚到ACID四个特性的底层逻辑一次性讲透。不管是刚入门的学生还是写了好几年业务代码的开发事务这块都值得花时间理清楚。很多线上事故本质上就是事务边界画错了、隔离级别选错了、回滚没处理好。下面这些内容都是我实际排查问题时的经验总结希望对你有用。1. 为什么需要事务先搞清楚它解决了什么事1.1 从一次转账说起假设你的账户系统里有这样两条SQLUPDATE account SET balance balance - 500 WHERE user_id 1; UPDATE account SET balance balance 500 WHERE user_id 2;第一条是扣款第二条是入账。如果第一步执行成功第二步执行前数据库突然断电、进程被杀、或者网络闪断那么钱扣了但对方没收到账就平不上。这就是典型的部分成功问题。事务的存在就是让这两条SQL变成一个不可分割的整体。用事务包裹后要么两步都执行完要么两步都不生效系统重启后数据依然是对得上的。1.2 并发场景下的数据一致性单线程场景可能还不明显一旦并发上来问题就多了。比如两个用户同时抢购最后一件商品用户A和B同时读到库存为1A执行扣减库存变0B不知道库存已变也执行扣减库存变-1。如果不做控制超卖就发生了。事务配合锁机制能保证同一时刻只有一个事务能修改这条库存记录另一个事务必须等它提交后才能继续。1.3 事务不是MySQL独有的概念其实所有主流关系型数据库都有事务MySQL只是实现方式有所不同。你明白了MySQL里的运作机制以后再接触其他数据库核心概念都是相通的。差别主要在语法细节、锁策略、日志实现上。注意MyISAM引擎是不支持事务的支持事务的是InnoDB引擎。MySQL 5.5之后InnoDB是默认引擎但建表时依然可以指定MyISAM所以排查事务不生效的问题时第一反应应该是看表引擎。2. 事务的完整操作手册2.1 开启一个事务MySQL默认是自动提交模式也就是每一条SQL执行完就立刻提交不需要你手动管。真正要操作事务时第一件事是关闭自动提交或者显式开启事务。常用写法-- 方式一标准SQL START TRANSACTION; -- 或者 BEGIN; -- 执行你的业务SQL... UPDATE account SET balance balance - 500 WHERE user_id 1; UPDATE account SET balance balance 500 WHERE user_id 2; -- 确认无误后提交 COMMIT;BEGIN和START TRANSACTION在大多数场景下效果一样但有一个细节值得注意START TRANSACTION是标准SQL语法写起来也更语义化我个人的习惯是用它。另外START TRANSACTION后面还可以跟修饰符比如START TRANSACTION WITH CONSISTENT SNAPSHOT这是针对一致性读的后面讲隔离级别时会再提到。2.2 提交与回滚事务结束只有两种正确方式COMMIT提交或者ROLLBACK回滚。-- 提交 COMMIT; -- 回滚 ROLLBACK;提交的含义是把事务期间的所有修改持久化到磁盘其他事务从此能读到这些改动。回滚则相反事务期间做的所有修改全部撤销就像从来没发生过。实际操作中业务代码里最常见的回滚逻辑是这样START TRANSACTION; UPDATE account SET balance balance - 500 WHERE user_id 1; -- 模拟异常比如这里通过程序抛出了一个错误 -- 如果发生异常执行 ROLLBACK ROLLBACK;生产环境里一般不靠人眼判断该提交还是回滚而是靠程序里的异常捕获机制。比如Java里就是try-catch块catch到异常就执行rollback正常执行完就走commit。2.3 中途存档点SAVEPOINT有些场景下事务内部操作很多但你不希望因为最后一步失败就把前面所有操作都回滚掉。这时候可以用SAVEPOINT也就是保存点。START TRANSACTION; UPDATE account SET balance balance - 500 WHERE user_id 1; SAVEPOINT sp1; UPDATE account SET balance balance 500 WHERE user_id 2; -- 发现入账金额错了回滚到sp1位置 ROLLBACK TO SAVEPOINT sp1; -- 继续执行其他操作 UPDATE account SET balance balance 600 WHERE user_id 2; -- 最终提交 COMMIT;这个机制在实际业务里用得不算多但一旦用上就很救命。比如批量导入数据时你按1000条一个批次处理希望跳过出错的那一条而保留前面正确的数据SAVEPOINT就是干这个的。注意ROLLBACK TO SAVEPOINT并不会释放所有锁也不会结束事务。它只是撤销保存点之后的变更。事务本身还活着直到你执行COMMIT或ROLLBACK。2.4 自动提交和隐式提交默认情况下MySQL每个单独的SQL语句都是一个自动提交的事务。也就是说你执行一条UPDATE成功就自动commit了失败就自动回滚不需要你干预。要查看当前是否开启自动提交SHOW VARIABLES LIKE autocommit;输出结果里autocommit的值是ON的话就是自动提交模式。临时关闭可以这样SET autocommit 0;注意这个设置只对当前会话有效不影响其他连接。而且从autocommit 0切换到事务操作时不需要额外写BEGIN因为关闭自动提交后你执行的第一条SQL就隐式开启了事务直到你手动COMMIT或ROLLBACK。另外还有一个隐式提交的坑。有些SQL语句执行时会强制提交当前事务哪怕你正在一个事务中间DDL语句比如CREATE TABLE、ALTER TABLE、DROP TABLE账户和权限管理语句比如CREATE USER、GRANT等锁相关操作比如LOCK TABLES管理类语句。我踩过的坑是在一个事务里先更新了业务数据然后执行了一个ALTER TABLE加字段结果前面的更新被自动提交了后面业务异常回滚时发现前面的操作根本没撤销掉。所以事务边界内尽量别混用DDL。2.5 事务操作速查表操作SQL说明开启事务START TRANSACTION / BEGIN显式开启事务提交事务COMMIT持久化所有修改回滚事务ROLLBACK撤销所有未提交修改设置保存点SAVEPOINT xxx在事务内标记位置回滚到保存点ROLLBACK TO SAVEPOINT xxx撤销保存点后的操作释放保存点RELEASE SAVEPOINT xxx删除保存点查看自动提交SHOW VARIABLES LIKE autocommit查看当前会话状态3. 四大特性拆解ACID背后的实现逻辑3.1 原子性Atomicity要么全做要么全不做原子性保证事务是一个不可分割的工作单元。事务内的所有操作要么全部执行成功要么全部不执行。这个特性靠的是undo log回滚日志。事务每次修改数据之前会先把修改前的旧值写入undo log。如果事务中途失败需要回滚InnoDB就根据undo log里的旧值把数据还原回去。这个机制很像写作文之前先复印一份原稿涂坏了就照着原稿重新抄一遍。以那条转账SQL为例START TRANSACTION; UPDATE account SET balance balance - 500 WHERE user_id 1; UPDATE account SET balance balance 500 WHERE user_id 2; ROLLBACK;执行完第一条UPDATE后旧值5000被写入undo log。执行第二条前发现有问题直接ROLLBACKInnoDB根据undo log把user_id1的余额从4500还原成5000数据回到初始状态。3.2 一致性Consistency数据永远符合约束一致性指的是事务执行前后数据库都处于合法状态不会破坏完整性约束、唯一索引、外键、触发器等规则。这里容易混淆的是事务机制本身只是手段一致性的保证需要应用层和数据库层配合。比如账户余额不能为负数这种业务规则数据库并不知道需要你在SQL或代码层面判断。从实现角度看一致性部分是靠原子性、隔离性共同支撑的。原子性保证了没有中间状态被持久化隔离性保证并发事务之间不会互相干扰两者都满足了最终呈现出来的数据自然是一致的。举一个反面例子-- user_id1 的余额只有 300 START TRANSACTION; UPDATE account SET balance balance - 500 WHERE user_id 1; COMMIT;这条SQL在事务层面没有任何问题MVCC机制、锁机制都会正常工作。但余额变成了-200业务上完全说不通。这就是为什么说一致性需要开发者主动参与数据库只能保证它“知道”的约束业务规则还得靠你自己把关。3.3 隔离性Isolation并发事务互不干扰隔离性保证多个事务并发执行时一个事务的中间状态对另一个事务是不可见的。这就要提到事务隔离级别。我先说结论具体操作下一节展开。InnoDB支持四种隔离级别READ UNCOMMITTED读未提交READ COMMITTED读已提交REPEATABLE READ可重复读SERIALIZABLE串行化MySQL默认是REPEATABLE READ。隔离级别越高并发性能越低但数据一致性越强。实际选型时不是越高越好而是根据业务需求匹配。隔离性的实现主力是锁和MVCC多版本并发控制。InnoDB用了行锁、间隙锁、next-key lock去控制并发写用MVCC读快照去控制一致性读。这两个机制配合才保证了不同隔离级别下的不同表现。3.4 持久性Durability提交了就别想丢持久性是指事务一旦提交修改就是永久性的即使数据库立刻宕机数据也不会丢。这个特性靠的是redo log重做日志。InnoDB有个经典的策略修改数据页时不会立即写磁盘因为随机IO太慢而是先写redo log再异步刷盘。redo log是追加写的顺序IO速度快得多。流程是事务提交时把修改记录写入redo log buffer把redo log buffer刷到磁盘上的redo log文件事务才算真正提交成功之后数据页什么时候刷到磁盘由后台线程慢慢处理。如果刷盘前数据库崩了重启时InnoDB读取redo log把已经提交的事务重新应用一遍数据就找回来了。这就是崩溃恢复的底层逻辑。注意redo log是物理日志记录的是对哪个页的哪个偏移做了什么修改undo log是逻辑日志记录的是怎么把数据撤销回去。两者作用完全不同别搞混。4. 隔离级别与并发实践实操中最容易踩坑的地方4.1 隔离级别的表现四种隔离级别对应的并发问题用表格列出来最直观隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不会可能可能REPEATABLE READ不会不会InnoDB下基本不会SERIALIZABLE不会不会不会脏读一个事务读到了另一个事务未提交的数据。如果那个事务回滚了你读到的就是坏数据。不可重复读同一个事务里两次读取同一行数据结果不一样。因为另一个事务在这期间提交了修改。幻读同一个事务里两次查询同一范围的数据结果行数不一样。另一个事务插入或删除了符合条件的行。4.2 InnoDB默认级别为什么是可重复读MySQL官方把默认隔离级别设为REPEATABLE READ很多人以为是为了和标准SQL对齐其实不是。根本原因是InnoDB在REPEATABLE READ级别下通过next-key lock和MVCC消除了幻读现象所以REPEATABLE READ在MySQL里实际达到了SQL标准的串行化读效果。这里有个具体表现在REPEATABLE READ下事务第一次执行SELECT时创建了一个一致性快照之后事务内所有普通SELECT都读这个快照不会看到其他事务后来提交的新数据。这就是为什么同一个事务里两次查询同一行结果永远一致。但要注意这个看不到新数据只针对普通一致性读。如果你用SELECT ... FOR UPDATE或者LOCK IN SHARE MODE那就走当前读会读到最新已提交数据会加锁。4.3 修改隔离级别的两种方式全局设置影响所有新连接的会话SET GLOBAL transaction_isolation READ-COMMITTED;当前会话设置只影响当前连接SET SESSION transaction_isolation REPEATABLE-READ;注意MySQL 8.0里控制隔离级别的变量名是transaction_isolation5.7及以前是tx_isolation。很多人从5.7迁移到8.0时脚本里写的还是老变量名会直接报错。查看当前隔离级别SELECT transaction_isolation;4.4 四种级别怎么选我的建议互联网高并发业务大部分场景我推荐用READ COMMITTED。理由是这种级别下锁的范围更小死锁概率低吞吐量更高。MySQL在READ COMMITTED下会把行锁退化掉一些间隙锁的持有并发度更好。金融类、订单类对强一致要求高的场景用REPEATABLE READ或者SERIALIZABLE更稳。SERIALIZABLE几乎就是所有并发操作排队执行性能代价很大我实际中很少见到生产环境用它。实操心得隔离级别不是越高越好。我自己经历过一个系统使用默认REPEATABLE READ一个批量更新操作因为间隙锁互相阻塞导致大量超时。改成READ COMMITTED后阻塞降了一个数量级。所以如果你发现系统有频繁的锁等待优先排查隔离级别是不是设高了。5. 事务实操中常见的翻车现场与定位方法5.1 事务没生效这是新手最容易遇到的问题。排查顺序如下第一看表的存储引擎。使用命令确认SHOW TABLE STATUS LIKE your_table\G;或者SELECT engine FROM information_schema.tables WHERE table_schema your_db AND table_name your_table;如果是MyISAM事务直接不生效。建议直接改成InnoDBALTER TABLE your_table ENGINE InnoDB;线上表改引擎前先评估锁表时间数据量大会阻塞读写。第二看是不是有框架层面的自动提交。很多ORM框架默认把每条SQL当作一个事务自动提交你还需要手动开启事务才能包住多条SQL。比如用某些框架的数据库访问层如果没有显式调用事务方法就相当于没开启事务。第三看事务的传播行为和边界。比如在一个方法里开了事务调了另一个方法那个方法内部又开了新事务两个事务相互独立一个回滚不影响另一个。这种问题用日志和SHOW ENGINE INNODB STATUS里的活跃事务数可以判断。5.2 锁等待超时出现Lock wait timeout exceeded是并发写同一行数据最常见的错误。默认锁等待时间是50秒可通过参数调整SHOW VARIABLES LIKE innodb_lock_wait_timeout;如果只是偶尔锅不在这调大也没用反而拖慢了故障感知。优先做的是定位持有锁的事务分析事务的SQL看是不是没用索引导致行锁升级成了表锁看是不是事务长时间不提交。定位持锁事务可以用SELECT * FROM information_schema.innodb_trx;主要关注trx_started字段。如果某个事务开启时间很长基本就是它一直占着锁不释放后面的只能排队。5.3 死锁怎么处理死锁的特征是互相等待对方持有的锁谁都没法前进。InnoDB检测到死锁后会回滚其中一个事务另一个继续执行并抛出一个报错Deadlock found when trying to get lock。死锁常见原因是多个事务以不同顺序加锁。比如事务A先更新表1再更新表2事务B先更新表2再更新表1两者就可能互相卡住。解法有几种强制所有事务按相同顺序访问表或行尽量缩小事务范围快速提交减少持有锁的时间在业务里加锁重试机制死锁发生后重试整个事务检查SQL的索引使用情况避免全表扫描导致锁范围扩大。死锁不是数据库故障而是并发冲突的必然现象设计上要接受它可能发生并做好重试。5.4 事务到底有没有提交成功有些时候程序日志显示事务执行完了但数据没变化。先检查是不是连的不是同一个MySQL实例读写分离下你查的从库落后事务是不是被框架级别静默回滚了有没有在COMMIT之前抛异常异常被吞了。排查事务状态最直接的是看information_schema.innodb_trxSELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx;trx_state为RUNNING表示事务还活着如果长时间没提交基本可以判定是有问题的事务。5.5 常见问题速查表现象可能原因排查方式事务里的SQL没生效表引擎不支持事务SHOW TABLE STATUS回滚没效果事务已被隐式提交检查事务中是否混入DDL频繁锁等待隔离级别过高/没走索引查看慢查询和锁监控事务开启过多连接池里的会话自动提交关闭后没重置检查连接池配置和回话状态数据不一致读写分离从库延迟检查主从延迟用时死锁报错加锁顺序不一致统一访问顺序、加锁重试6. 写在最后的实操心得事务这个东西概念上不难真正难的是边界划分和并发控制。我见过太多系统出事不是不懂ACID而是把一堆网络请求、远程调用、消息发送都塞进了一个大事务里事务长时间不提交最后锁住了半张表。所以有个非常实用的建议控制事务的粒度和时长事务里尽量只保留数据库操作别把外部调用放进来。另一个建议是给业务表设计时提前考虑好隔离级别和唯一约束。比如用唯一索引天然挡住重复提交很多时候比锁和事务更高效。事务能保证你对数据库的操作安全但保证不了业务逻辑本身是对的。我这几年的体感是把事务用好的核心不在于背下多少命令而在于你在写SQL之前先想清楚这句话会不会被并发影响、会不会产生中间状态、失败时能不能干净地退回去。带着这三个问题去写代码很多坑还没踩就绕过去了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →