尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MySQL事务实战:ACID、隔离级别与锁机制全解析

MySQL事务实战:ACID、隔离级别与锁机制全解析 做后端开发这些年“MySQL事务”这四个字算是简历里写得最多、但真正吃透得最晚的一个概念。前几天线上出了一起订单状态错乱的事故排查到最后问题恰恰出在事务边界没处理好——有人在事务里做了远程调用锁持有时间过长直接拖垮了库存表。这让我下定决心把事务的操作方式和四大特性从头到尾重新梳理了一遍。这篇文章不打算讲那种教科书式的定义堆砌。我试着把事务到底怎么操作、ACID分别靠什么机制撑起来、隔离级别在实际中怎么选、以及线上最容易踩的坑全部串起来讲清楚。如果你是刚接触MySQL的开发者这篇文章可以帮你建立起一个完整的事务知识框架如果是已经写过不少业务代码的老手那重点看第三、第四、第五节很多“面试背过但没细想”的点都在里面。1. 事务的标准操作真不是Begin加Commit那么简单1.1 四个核心命令的使用姿势我在面试候选人的时候十个里有八个能把“开启事务、提交、回滚”说出来但问到保存点SAVEPOINT怎么用就卡住了。实际上事务操作一共有四个标准命令START TRANSACTION或BEGIN、COMMIT、ROLLBACK、SAVEPOINT。前三个好理解多了个保存点很多人没实际用过。保存点的应用场景很有意思。当你在一个事务里执行了多条SQL前面几条都成功了最后一条出错你并不想把前面全部撤销只想回滚到出错前的那一步。这时候保存点就派上用场了START TRANSACTION; INSERT INTO user (name, age) VALUES (张三, 25); -- 设置保存点 sp1 SAVEPOINT sp1; INSERT INTO user (name, age) VALUES (李四, 30); -- 假设这条插入触发了某个约束错误我们需要回滚到 sp1 ROLLBACK TO SAVEPOINT sp1; -- 再次提交此时只有张三这条记录被插入 COMMIT;手动跑一遍之后你会明显感觉到ROLLBACK TO SAVEPOINT不是把整个事务回滚而是只撤销保存点之后的操作保存点之前的改动仍然保留在事务里最终由COMMIT统一提交。这在处理复杂的批量导入、循环写入这类场景时非常有用不用为了一个坏数据把前面几百条好数据全丢了。1.2 autocommit一个关于隐式提交的大坑MySQL默认是开启自动提交的。什么意思就是每执行一条UPDATE或INSERT它自己就悄无声息地COMMIT了。你去查一下SELECT autocommit;正常情况下返回1。这个默认值和很多人脑子里的“事务需要手动控制”直觉是冲突的。最典型的问题就是——你写了一段代码先执行了BEGIN然后执行了一条UPDATE又执行了一条UPDATE中途发生了异常你调用了ROLLBACK结果数据居然没回滚。排查半天发现是框架或者连接池在获取连接的时候自动设置了autocommit 1你的BEGIN白开了每条语句都独立提交了。还有一个非常隐蔽的坑MySQL存在隐式提交的语句列表。就算你开着事务一旦你执行了DDL语句CREATE TABLE、ALTER TABLE、DROP TABLE等或者执行了SET autocommit 1、GRANT这些管理语句MySQL会强制把当前事务提交掉。这个行为是服务器层面的设计很多老手都会踩雷——事务里先更新了业务数据然后顺手改了一下表结构结果业务数据被提前提交了再想回滚已经来不及。1.3 客户端工具下的事务行为差异用Navicat或者MySQL Workbench操作时很多人会困惑为什么我在查询窗口里写了START TRANSACTION执行了更新没写COMMIT然后当前窗口能查到数据别的窗口查不到这不奇怪因为事务是会话级别的每个连接看到的数据快照不同。但如果你在Navicat里直接双击修改一条记录工具往往默认走的是单条语句自动提交跟你在命令行里手动开启事务是两套逻辑。我个人的建议是凡是需要真正验证事务行为的实验一律用命令行客户端或者代码里用显式事务去测不要在图形化工具里“手改数据”你根本分不清哪些操作被工具自动提交了这会严重干扰你对事务边界的判断。操作方式是否默认开启事务是否自动提交适用场景命令行手动START TRANSACTION是否需手动COMMIT/ROLLBACK代码排查、事务原理验证单条INSERT/UPDATEautocommit1否是日常简单写入图形工具直接修改单元格不一定取决于工具配置多数默认自动提交不太建议用于验证事务JDBC/框架层注解事务由框架管理由框架控制生产业务代码2. 四大特性的真正含义和你理解的未必一样面试时背ACID不难但每一个特性到底在数据库内部是怎么实现的是很多人的知识盲区。这里我把每个特性的工程含义拆开讲。2.1 原子性要么全有要么全无靠的是undo log原子性说的是“事务是一个不可分割的工作单位”。经典的转账例子A账户扣100B账户加100中间任何一步失败整个操作全部回滚。这个好理解但真正值得琢磨的是MySQL靠什么机制让一堆已经执行的SQL可以“撤销”。核心是undo log。在InnoDB引擎中每个事务对数据做修改之前会把旧值写入undo log。当执行ROLLBACK时InnoDB不是去“反向执行”你的SQL而是从undo log里把旧值恢复回来。这个设计思路非常巧妙它不是记录“你要做什么”而是记录“如果回滚应该怎么恢复到之前的样子”。我举一个实际场景。一张订单表order你在事务里执行了DELETE FROM order WHERE id 100;在没有提交之前这条数据其实没有真正物理删除。undo log里保存了整行数据的内容如果你ROLLBACKInnoDB就把这行数据重新插回去对业务来说就像什么都没发生过一样。如果没有undo log数据一旦物理删掉再想恢复代价就完全不一样了。2.2 一致性最被高估也最被误读的特性一致性是四大特性里最抽象的一个。很多文章把它定义为“事务执行前后数据库的完整性约束没有被破坏”这个定义本身没错但太弱了。因为数据库层面的约束外键、唯一键、非空只是最基础的防线真正的一致性往往定义在业务规则里。举个例子转账场景A和B的余额总和必须保持不变。数据库本身不会约束“总和不变”这是业务逻辑。如果你在事务里只写了“A扣100”忘了写“B加100”事务提交后数据库依然满足所有约束但业务上总金额少了100。这说明什么说明事务只是提供了实现一致性的工具真正的一致性好坏取决于开发人员是否把业务规则完整地放进同一个事务里。所以我的理解是一致性不是某个具体机制直接保证的它是原子性、隔离性、持久性三者配合再加上应用层的正确逻辑最终达成的一种状态。看一个事务设计得怎么样不要只看它用了多少事务命令要看它的业务规则是否完整覆盖。2.3 隔离性多个事务并发时如何做到互不干扰隔离性解决的是并发问题。多个事务同时操作同一批数据如果没有隔离措施就会出现互相干扰。MySQL通过锁和MVCC多版本并发控制来实现隔离性这一块我会在第四节详细展开这里先记住一个关键结论隔离性不是“完全互不干扰”而是“在指定隔离级别下互相不干扰”。不同隔离级别允许不同程度的干扰。这就像合租按理说应该互不打扰但如果你选了“读未提交”这个合租规则室友半夜洗衣服的动静你全能听到。2.4 持久性数据能活下来的唯一保障持久性是指事务一旦提交即使系统崩溃数据也不会丢失。有人觉得“数据不是已经写进磁盘了吗怎么会丢”这就是真相所在——InnoDB在事务提交时数据页不一定已经刷到磁盘上。这里必须提redo log。InnoDB采用WALWrite-Ahead Logging机制事务提交时先把变更记录写入redo log并强制刷盘数据页本身可以留在内存缓冲池里稍后再刷。一旦崩溃MySQL重启时会用redo log把数据页重放恢复到崩溃前的状态。有一个参数直接影响持久性的强度SHOW VARIABLES LIKE innodb_flush_log_at_trx_commit;值为1时每次事务提交都会刷盘最安全值为0或2时性能更好但存在丢数据的风险窗口。生产环境如果不是对性能极度敏感我强烈建议保持在1。我也见过为了性能把这个参数调成2的结果服务器断电后丢了最近1秒的已提交事务业务方直接炸锅。这就是用持久性换性能的代价。3. 隔离级别与并发问题让事务真正可靠的边界条件隔离性和隔离级别经常被混着说。隔离性是一个特性隔离级别是它的量化标准。MySQL的InnoDB提供了四种隔离级别分别对应着要解决或者容忍哪些并发问题。3.1 三类经典的并发问题在讨论隔离级别之前先明确并发环境下的三个问题这是理解后面所有内容的地基。脏读一个事务读到另一个事务未提交的数据。假设事务A改了余额但还没提交事务B读到修改后的值然后A回滚了B就拿到一个“不存在”的数据。这是最恶劣的并发问题因为它读到的数据可能是假的。不可重复读同一个事务内两次读取同一行数据结果不一致。事务A第一次读到余额100事务B此时把余额改成200并提交事务A第二次再读就变成200了。同一个事务两次读同一行结果不同这就是不可重复读。幻读同一个事务内两次执行同一个范围查询结果集的数量不一样。第一次查出来10条记录事务B插入了一条新记录并提交第二次再查变成11条。“多出来的那条”就像幻觉一样。用生活场景类比你在一个房间里数人第一次数了10个出去转一圈回来看了一眼还是这10个人在——这是可重复读但你回来后发现多了一个陌生人而你知道刚才没人进来过——这就是幻读。3.2 四种隔离级别分别能解决什么隔离级别脏读不可重复读幻读实现方式读未提交READ UNCOMMITTED可能可能可能直接读最新数据不加锁读已提交READ COMMITTED不会可能可能每次读都生成新的Read View可重复读REPEATABLE READ不会不会基本不会事务开始生成Read View配合间隙锁串行化SERIALIZABLE不会不会不会所有读都加锁并发降到最低在MySQL里默认隔离级别是可重复读。注意我写的是“基本不会”出现幻读这是因为InnoDB的可重复读结合了Next-Key Lock临键锁在绝大多数场景下已经把幻读堵死了。这一点和标准SQL的定义不完全一样——标准SQL说可重复读下幻读是可能发生的但MySQL通过特殊的锁机制做到了规避。查看和修改隔离级别-- 查看当前会话隔离级别 SELECT transaction_isolation; -- 修改当前会话隔离级别 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;注意SESSION只影响当前会话GLOBAL影响之后新建的会话。很多人在代码里设置了全局隔离级别但连接池里的旧连接没刷新导致“改了没生效”的错觉这个问题我在排查线上问题时遇到过好几次。3.3 MySQL为什么默认选择可重复读这是个很经典的面试深挖题。除了“兼容历史行为”这种解释之外有一个技术原因是必须知道的——MySQL主从复制基于binlog在旧版本binlog格式为STATEMENT记录SQL语句时只有可重复读才能保证从库重放SQL得到与主库一致的结果。举个例子主库上有两个事务并发执行事务A删除了一条记录但还没提交事务B插入了一条id相同的记录并先提交。如果隔离级别是读已提交binlog里记录的SQL执行顺序可能和主库实际提交顺序不同从库重放时就会出现数据不一致。而可重复读配合间隙锁能限制并发的交错程度让基于语句的复制也安全。虽然现在生产环境大多已经把binlog_format改成了ROW不再依赖这个特性但MySQL官方把可重复读作为默认值的习惯一直保持到了现在。我记得去年帮一个客户排查主从数据不一致最后查出来就是因为业务方把隔离级别改成了读已提交加上binlog是STATEMENT格式数据就对不上了。如果业务上必须用读已提交请务必确认binlog格式是ROW。4. 底层靠什么撑起ACID锁、undo log、redo log与MVCC的分工4.1 undo log和redo log一个管撤销一个管重放很多初学者会把这两个日志搞混我用一句话区分undo log是为了回滚而存在它记录的是“怎么撤销这次修改”redo log是为了重放而存在它记录的是“这次修改改了什么”。它们的工作时机也完全不同事务执行过程中每修改一行数据InnoDB先写undo log再修改内存中的数据页。这样一旦崩溃或者回滚能用undo log恢复旧值。事务提交前InnoDB把修改写入redo log buffer提交时刷盘到redo log文件。这样崩溃后能从redo log恢复已经提交的修改。一个管“回到过去”一个管“恢复未来”两者配合原子性和持久性就都有了根基。4.2 MVCC版本链读操作不加锁的关键InnoDB的多版本并发控制MVCC是支撑读已提交和可重复读的核心机制。原理概括起来是每一行数据在更新时并不是覆盖旧值而是通过undo log维护了一个版本链每个版本都记录了生成它的事务ID。当一个SELECT查询进来InnoDB会生成一个Read View里面记录了两个关键信息产生这个视图时正在活跃的事务列表以及最大事务ID。查询去版本链上读取时只会读取“在Read View生成之前已经提交”或者“由当前事务自己生成”的版本其他版本一律不可见。这就是为什么可重复读下同一个事务多次SELECT都能看到一致的快照——因为它的Read View在第一次查询时就固定了。而读已提交每次查询都会重新生成Read View所以能看到其他事务新提交的数据也就会产生不可重复读。MVCC最大的价值是普通的快照读不加FOR UPDATE的SELECT不会阻塞写操作写操作也不会阻塞快照读读写并发能力大幅提升。我们在事务里大量使用SELECT而不担心锁冲突靠的就是它。4.3 锁的类型从记录锁到间隙锁MVCC解决的是快照读的问题但如果你在事务里执行的是SELECT ... FOR UPDATE、UPDATE、DELETE这些当前读就必须加锁了。InnoDB的锁可以分为三类记录锁Record Lock锁住索引上的某一条记录。注意是索引记录不是“行”如果没有索引那锁的范围就会失控。间隙锁Gap Lock锁住两条索引记录之间的区间防止其他事务在这个区间插入新记录这是解决幻读的关键。临键锁Next-Key Lock记录锁和间隙锁的组合锁住“当前记录前面的间隙”是InnoDB在可重复读级别下默认的锁算法。间隙锁这里需要展开讲一下因为它的副作用非常隐蔽。你执行SELECT * FROM order WHERE amount BETWEEN 100 AND 200 FOR UPDATE;假设amount上有索引表中没有符合条件的数据InnoDB也会在100到200这个区间加上间隙锁意味着其他事务无法在这个区间插入任何数据。好处是防止了幻读坏处是并发插入会被莫名其妙地阻塞。我见过一个商品表格运营在区间查询时加了FOR UPDATE结果整个新品种的插入全部卡住最后就是被间隙锁堵死的。所以一个非常实用的建议SELECT ... FOR UPDATE要克制能用普通查询就用普通查询真要锁也要确保查询条件能精准命中索引缩小锁的范围。5. 事务实操中容易踩的坑与排查方法5.1 大事务与长事务的危害我专门把“大事务”和“长事务”分开说因为这两个词虽然经常一起出现但引发的故障不同。大事务指一次操作的数据量很大比如一个UPDATE语句更新了几十万行。这种事务在执行期间会持有大量行锁redo log的产生量也很大提交时还可能让主从复制延迟激增。更糟的是如果执行到一半被ROLLBACKundo log需要回放巨额数据回滚本身就可能把数据库拖垮。长事务则是指事务从开启到提交之间的时间很长哪怕每步操作都很小。最常见的原因是开发人员把事务边界划得太大比如在事务里调用了外部HTTP接口或者做了复杂的计算和关联查询。一个接口响应3秒事务就开了3秒这期间所有涉及到的行都被锁着并发一高整个系统的请求就开始排队堆积。排查大事务和长事务最直接的办法是查information_schema.innodb_trxSELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_running_seconds, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;看到trx_running_seconds特别大的记录就要赶紧定位对应的连接确认业务代码里事务是否异常未提交。按我的经验大部分长事务都是异常分支没走ROLLBACK导致的比如Java代码里catch了异常但没让事务管理器回滚连接一直在池子里挂着事务。5.2 锁等待超时与死锁的排查链路锁等待超时是事务并发场景下最让人头疼的问题之一。抛出的错误大概长这样ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction默认超时时间是50秒由参数innodb_lock_wait_timeout控制。遇到这个问题我一般按下面的顺序排查第一步看当前哪些事务在跑SELECT * FROM information_schema.innodb_trx\G第二步看哪些事务在等锁哪些持有了锁SELECT r.trx_id AS wait_trx_id, r.trx_mysql_thread_id AS wait_thread, b.trx_id AS block_trx_id, b.trx_mysql_thread_id AS block_thread FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx r ON w.requesting_trx_id r.trx_id JOIN information_schema.innodb_trx b ON w.blocking_trx_id b.trx_id;第三步拿到阻塞线程ID后去SHOW PROCESSLIST查这个连接正在执行的SQL基本就能定位到源头了。死锁则是另一个常见问题MySQL会自动检测死锁并回滚其中代价最小的事务。查看最近一次死锁的完整信息SHOW ENGINE INNODB STATUS;重点看LATEST DETECTED DEADLOCK对应的段落。死锁的经典场景是两个事务按不同顺序更新同一批数据。比如事务A先更新user表再更新order表事务B先更新order表再更新user表两个事务互相等对方释放锁就形成循环等待。解决办法很朴素所有事务都按固定顺序访问资源比如先更新user再更新order这样永远只有一个事务能先拿到第一把锁另一个事务只需要等待不会互锁。5.3 事务里查询没走索引锁的范围会失控这一点在上文锁的类型那里埋了个伏笔这里单独拿出来强调因为它几乎是我在排查线上事故时遇到频率最高的问题。InnoDB的行锁是加在索引记录上的。如果你的UPDATE语句的WHERE条件没有用到任何索引InnoDB只能全表扫描才能找到要更新的记录那它实际对扫描过的每一行都加了锁。锁的范围从几行扩大到整个表或者大部分表其他事务的更新和插入全部被堵死。曾经有个客户说“数据库死锁频繁”我让他们把慢查询日志打开抓到一条UPDATE order SET status 1 WHERE order_no xxx;看执行计划EXPLAIN SELECT * FROM order WHERE order_no xxx;type是ALLrows几十万。order_no字段连索引都没有每个更新都要扫全表并发一高自然各种锁问题。解决办法就是给order_no加上索引一个CREATE INDEX就把死锁发生率降到了接近零。所以在设计事务相关的表时我有一条铁律所有高频出现在WHERE条件里的字段必须检查是否建了索引尤其是常作为更新条件的字段。没有索引的事务操作就像在拥挤的菜市场开货车不光自己动不了还把路全堵死了。5.4 事务与分布式事务的边界单个MySQL实例里事务可以很好地保证ACID。但一旦业务拆成微服务订单库、库存库、账户库各自独立本地事务就无能为力了。这时候要考虑分布式事务方案。各种分布式事务的主流姿势里Seata的AT模式和TCC模式在Java技术栈中用得比较多。不过我想提醒的是不要因为某个场景听起来“很分布式”一上来就引入分布式事务框架先问问业务能否接受最终一致性。很多场景通过本地消息表、事务消息、定时对账就能解决成本低得多也更容易排查问题。分布式事务框架能解决强一致性问题但它带来的运维复杂度、性能损耗和长事务风险都是一根根隐藏的刺。如果确实需要强一致订单和库存这类场景可以优先考虑Seata的AT模式它依赖全局锁和数据快照对代码侵入小。但要注意AT模式对线上数据库的并发压力有影响上线前必须做压测。6. 我在实际使用中的几点体会聊了这么多原理和排查手法最后分享几个我自己的使用习惯算是多年踩坑换来的积累。第一个习惯事务边界越小越好越清晰越好。提交之前的所有操作都应该是对同一个业务目标的服务绝不能把外部接口调用、文件读写、消息发送这类“不确定耗时”的操作放在事务里。真有这种需求可以在事务提交之后通过Spring的TransactionalEventListener或者在代码里事务方法返回之后再发消息。如果一定要在事务里发至少用事务同步器把发送动作注册成afterCommit。第二个习惯统一锁的顺序。不管团队多少人一起开发事务代码访问多张表的顺序要写进开发规范。谁先查user谁先查order全局统一这能从根上避免死锁。我见过太多死锁就是因为两个开发各自写各自的代码更新表的顺序相反导致的。第三个习惯隔离级别尽量别随意改。默认的可重复读已经非常优秀绝大多数业务不需要主动降级到读已提交。如果你觉得可重复读在某场景下有问题先确认是间隙锁阻塞还是快照读不符合预期再决定是否调整。随意降级隔离级别等于亲手把并发问题的口子撕开。第四个习惯多观察information_schema.innodb_trx和SHOW ENGINE INNODB STATUS。很多事务相关的问题其实在发生之前都有征兆比如长事务数量变多、锁等待时间变长。如果能把这两个视图做成监控项每天定期看一眼很多事故都能在还没炸之前发现。我目前只要发现某个库的活跃事务平均运行时间超过2秒就会去查业务代码基本一查一个准。MySQL事务这块知识看一遍文档很容易但真正要做到“遇到问题能快速定位、设计方案能避开坑”还得靠线上场景反复打磨。希望这篇文章能帮你在事务的理解深度上往前走一步下次再遇到锁超时、死锁、数据不一致这类问题心里能多几条清晰的排查路径。
返回列表