ARTICLE DETAIL

资讯详情

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

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

MySQL事务从原理到实战:ACID、隔离级别、锁与MVCC全解析 1. 事务到底是什么为什么绕不开它刚接触 MySQL 那会儿我其实挺不理解为啥非要有“事务”这种东西。不就是几条 SQL 吗一条一条执行不就完了直到有一次在线上改数据吃了大亏——一条 UPDATE 执行到一半系统报错结果钱扣了、订单没生成两边对不上账查了半天才发现是两条 SQL 只成功了一条。从那以后我才真正意识到事务不是一个“可选的高级功能”而是保障数据不出错的基本底线。事务Transaction说直白点就是“一组 SQL 语句打包成一个整体要么全部成功要么全部失败”。你可以把它想象成点外卖你把菜、餐具、发票放进同一个袋子里骑手一单送过去。如果中途发现有東西不能配送那就整单取消退回重来不会出现“菜送到了筷子没送到”的尴尬状态。MySQL 里 InnoDB 引擎对事务的支持是最完整的MyISAM 这类老引擎根本不支持事务这也是为什么现在建表几乎默认都用 InnoDB。这篇文章的价值在于帮你把事务从概念到落地彻底打通。不管你是刚学 SQL 的入门者还是已经写过几年业务代码但一直对隔离级别、锁机制模棱两可的开发读完后你都能清楚地知道事务的四大特性是什么、该怎么在自己的代码里正确使用事务、遇到并发问题怎么排查和解决。我尽量用实际场景来讲不整那些玄乎的术语。关于网上那些“mysql事务处理”“mysql事务面试题”的热搜其实核心就一个你不仅要记住 ACID 四个字母还要能说清楚每个特性的实现原理以及在真实业务中怎么用。这些恰恰是这篇文章覆盖的重点。2. 四大特性 ACID逐个拆开讲透ACID 是事务的四个核心特性很多面试题都会考但能把每个特性讲清楚、讲到能落地的说实话不多。2.1 原子性Atomicity要么全做要么全不做原子性强调的是“不可分割”。一个事务里的多个操作对数据库来说就是一个整体。要么全部执行成功并提交要么全部不执行回到事务开始前的状态。这里有个关键点回滚ROLLBACK怎么做到InnoDB 是通过 undo log回滚日志来实现的。每次事务里修改数据之前InnoDB 会先把修改前的数据记录到 undo log 里。如果事务执行到一半失败要回滚就用 undo log 里的旧数据把当前值覆盖回去。换句话说数据库不是靠“撤销”这个动作来实现原子性的而是靠“记录旧值、再恢复旧值”这个机制。实际操作中我见过不少人以为“原子性 事务里不能有报错”。其实不是。事务本身不阻止 SQL 报错它只是保证报错后整个事务能回到原点。你在事务里执行了 5 条 SQL第 3 条因为语法问题报错只要你没提交COMMIT并且用 ROLLBACK 回滚前面 2 条已经执行的修改也会被撤销。这就是原子性的意义。注意有些执行会自动提交事务比如 DDL 语句CREATE TABLE、ALTER TABLE 等。MySQL 里这类语句会隐式触发一次提交所以事务中混入 DDL 时务必要小心——它会把前面未提交的事务直接提交掉。2.2 一致性Consistency业务规则的最后防线一致性这个概念经常被讲得云里雾里我换个方式说事务执行前数据库处于一种“符合业务规则”的状态事务执行成功后数据库仍然处于“符合业务规则”的状态。这个“业务规则”包括约束、外键、唯一性、触发器也包括你在业务代码里定义的那些逻辑。举个例子。转钱场景A 账户扣 100 元B 账户加 100 元。如果 A 扣了钱B 没加上那总金额对不上这就破坏了“总金额守恒”的规则。事务做完后不管成功失败两边之和必须和开始时一致。这既是一致性的要求也是原子性带来的自然结果。但要注意光靠数据库自身的原子性并不足以保证一致性。业务逻辑本身的正确性同样关键。比如你写了一个事务把 A 的钱扣了 100却只在 B 上加了 50数据库层面事务执行得很顺利但业务规则已经被破坏了。所以一致性必须由“数据库机制 业务代码控制”共同完成。在实践里我经常把一致性作为开发自查清单的一项每次写完一个事务先问自己一句“如果这个事务执行成功业务状态是否依然合理”养成这个习惯能帮你拦下不少隐蔽的 bug。2.3 隔离性Isolation并发环境下的秩序维护者隔离性解决的是“多个事务同时执行时互不干扰”的问题。如果不做隔离并发事务之间就可能出现脏读、幻读、不可重复读等问题后面会细讲。InnoDB 实现隔离性主要靠两种手段锁Lock和 MVCC多版本并发控制Multi-Version Concurrency Control。MVCC 的思路很有意思——它不阻止读写冲突而是给每个事务一份“数据快照”。事务读到的数据是它开始瞬间的快照版本其他事务对数据的修改不会直接影响这个快照。通过这种方式读操作之间永远不会互相阻塞写操作之间用锁来协调。我经常拿“多人同时编辑同一个在线文档”来类比隔离性你打开文档时看到的是打开那一刻的内容版本别人之后怎么改你要么刷新才看得到要么在他保存并发冲突时选择保留哪一份。数据库的隔离性就是这套机制在数据层面上的落地。隔离性不是越高越好。更高的隔离级别带来更强的数据一致性但往往意味着更多的锁等待和性能损耗。怎么选取决于你的业务对数据准确性的容忍度。后面第 4 章我会带你逐个对比。2.4 持久性Durability数据落盘的承诺持久性指事务一旦提交它对数据库的改变就是永久性的。哪怕提交后数据库马上崩溃、服务器断电恢复后数据也必须还在。InnoDB 实现持久性依赖的是 redo log重做日志。每次事务提交时InnoDB 会先把事务的修改写入 redo log这个动作叫“刷日志”然后才去改真正的数据文件。如果中途断电MySQL 重启后会根据 redo log 把还没来得及写进数据文件的修改重放一遍保证你已经提交的事故不会丢。这里有个细节值得注意redo log 是“先写日志、再写数据”的。这就是常说的 WALWrite-Ahead Logging预写日志机制。它的好处是日志是顺序写入磁盘的比随机写数据文件快得多所以能兼顾持久性和性能。你可以把 redo log 理解为快递底单——包裹数据可能还在路上但底单日志已经记录了这个包裹一定会送达。持久性也分级别。MySQL 有个参数innodb_flush_log_at_trx_commit控制提交时如何刷 redo log 到磁盘设置为 1默认每次事务提交都要把 redo log 刷入磁盘最安全但最慢。设置为 2只写操作系统缓存不强制刷盘。MySQL 挂了不丢操作系统崩溃可能丢最近 1 秒数据。设置为 0交给后台线程定时刷盘性能最好但丢数据的窗口最大。实测中高并发支付类场景我不敢动这个参数老老实实用 1如果是日志类或非核心业务我会调到 2 换性能。3. 事务实操从入门到写顺手理论归理论事务到底怎么写在业务代码里很多人的姿势其实是错的。这一章我直接给能抄的作业。3.1 基础语法与一个完整示例事务操作的核心命令就三张牌BEGIN或START TRANSACTION开始事务、COMMIT提交事务、ROLLBACK回滚事务。完整用法长这样-- 开始事务 START TRANSACTION; -- 执行具体操作 UPDATE account SET balance balance - 100 WHERE user_id 1; UPDATE account SET balance balance 100 WHERE user_id 2; -- 一切顺利提交 COMMIT;如果中间出问题执行ROLLBACK回滚。我建议你在真实代码里这样控制START TRANSACTION; UPDATE account SET balance balance - 100 WHERE user_id 1; -- 模拟一个业务校验失败比如余额不足 IF (SELECT balance FROM account WHERE user_id 1) 0 THEN ROLLBACK; ELSE UPDATE account SET balance balance 100 WHERE user_id 2; COMMIT; END IF;上面这种写法是“先判断再决定提交还是回滚”。实际项目里我更推荐“捕获异常再回滚”的写法# Python PyMySQL 示例异常时回滚 cursor conn.cursor() try: conn.begin() cursor.execute(UPDATE account SET balance balance - 100 WHERE user_id 1) cursor.execute(UPDATE account SET balance balance 100 WHERE user_id 2) conn.commit() except Exception as e: conn.rollback() raise e finally: cursor.close() conn.close()你发现没有事务最容易出错的地方往往不在 SQL 本身而在你忘了 commit 或 rollback。事务开着一直不提交行锁就一直不释放别的会话只能干等这是线上最常见的“锁等待”事故源头。3.2 事务与自动提交的坑有一个算一个很多人踩过“事务怎么没生效”的坑最终发现罪魁祸首就是 MySQL 的自动提交autocommit配置。MySQL 默认每个单独的 SQL 语句都会被当作一个事务自动提交也就是说你写了START TRANSACTION之后执行几条 SQL如果中间没有显式 COMMITMySQL 并不会帮你把这些 SQL“绑”成一个整体。所以用事务时要注意几点确认autocommit的实际值SHOW VARIABLES LIKE autocommit;默认是ON。如果你希望两条 SQL 在同一个事务里就必须显式START TRANSACTION然后用 COMMIT / ROLLBACK 收尾。如果在编程框架Spring 等里用了事务注解或事务编程接口通常框架会帮你管理提交和回滚但你要搞清楚框架的配置是不是“真·事务”很多初学者在 MySQL 命令行里手动执行几条 SQL以为它们在一个事务里其实早就在 autocommit 下各自提交了。事务没生效最典型的症状是程序里看起来有事务管理但去数据库看其中某条 SQL 执行成功、后面报错时前面的修改还在。排查第一步永远是查 autocommit第二部是查有没有用对存储引擎InnoDB第三部才是查代码逻辑。我这里单独说一下引擎问题SHOW TABLE STATUS LIKE your_table;可以看Engine字段。如果是MyISAM那恭喜你事务命令照常能执行但根本不会回滚效果跟裸 SQL 一模一样。老项目中经常能看到历史遗留的 MyISAM 表这种坑必须逐个排查。3.3 保存点SAVEPOINT事务里的回滚“书签”如果你在事务里执行了 10 条 SQL只是某一条出了错并不希望把前面 9 条全部回滚掉保存点就派上用场了。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; -- 继续后面的操作 UPDATE account SET balance balance 50 WHERE user_id 3; COMMIT;执行ROLLBACK TO SAVEPOINT sp1之后事务会回到 sp1 这个位置user_id1 的扣款保留user_id2 的加款被撤销。SAVEPOINT适合的事务像“批量处理多条数据、允许部分失败但不想全量放弃”的场景。不过我得提醒你保存点不是让你随便浪的护身符。用得太多事务内部的逻辑会变得非常难理解和维护同事看了你的代码都想打人。能用好“整体回滚”的尽量别乱用保存点。它更适合那些“单行处理 单项失败可跳过”的批处理类事务。4. 隔离级别与并发问题事务的进阶核心隔离性不是一个“开或关”的开关它分级别。MySQL 里InnoDB支持四种隔离级别从宽松到严格依次是READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。默认是 REPEATABLE READ。这一层要是聊透了很多拦路虎都能解决。4.1 先说清楚三种典型的并发问题脏读事务 A 修改了一行数据还没提交事务 B 读到了这行“修改后但未提交”的数据。之后 A rollback 了B 拿到的就是一个“不存在的脏数据”。脏读是并发问题里最危险的因为数据凭空消失业务根本没法兜底。不可重复读事务 A 两次读同一行数据但第二次读到的内容和第一次不一样。原因是这两次读取之间事务 B 修改了这行并提交了。重点在于“同一行、两次读、结果不同”影响一致性判断。幻读事务 A 按某个条件查询一批数据第一次查出 5 条事务 B 插入了 1 条符合条件的新数据并提交事务 A 再次按相同条件查询查出 6 条。多出来的那条记录就是“幻影行”。幻读和不可重复读的区别是不可重复读是“行内容变了”幻读是“行的数量变了”。为了不让这几个名词变成“死记硬背”我整理了一张对照表面试要是问起来你把这个表在脑子里过一遍就够了。并发问题现象是哪条语句导致的最难受的后果脏读读到未提交数据UPDATE / INSERT 未提交读到账目不对的数据不可重复读同一行两次读结果不同其他事务 UPDATE 已提交统计/换算失真幻读同一条件两次查行数不同其他事务 INSERT 已提交业务逻辑漏处理数据4.2 四种隔离级别怎么选不后悔隔离级别的本质是“允许出现哪些并发问题”。看下面这个表就行隔离级别脏读不可重复读幻读性能损耗READ UNCOMMITTED可能可能可能最小READ COMMITTED不会可能可能较小REPEATABLE READ默认不会不会可能InnoDB 基本解决中等SERIALIZABLE不会不会不会最大这里有个很容易忽略的知识点MySQL 在 REPEATABLE READ 级别下幻读问题已经被 InnoDB 用 next-key lock间隙锁 记录锁和 MVCC 大幅度解决了。所以实际上 MySQL 的 REPEATABLE READ 比概念上的 REPEATABLE READ 更严格这也是为什么 MySQL 敢把它作为默认隔离级别。隔离级别设置语法-- 会话级当前连接生效 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 全局级新连接默认生效 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;改动只对后续事务生效这个要记得。而且如果线上有连接池旧连接的隔离级别不会跟着变改完要重启连接池里的连接才稳。选择原则我个人的经验是支付、订单、库存等资金/数据强一致场景默认 REPEATABLE READ 用着就很好一般不改。读多写少、追求吞吐量的报表类应用可以谨慎降到 READ COMMITTED避免间隙锁带来的额外阻塞。READ UNCOMMITTED 几乎不用除非你做一些“丢几条数据无所谓的统计预演”。SERIALIZABLE 线上能不用就不用并发能力断崖式下降。4.3 隔离级别的底层支持MVCC 和锁的配合理解了“是什么”还得知道“怎么做到的”这就得聊 MVCC 和行锁了。MVCC 的核心是 undo log 里链在一起的“数据版本链”。每行数据背后都有多个历史版本事务读取时根据隔离级别决定“读到哪个版本”。REPEATABLE READ 下事务第一次读时生成一个“读视图”Read View之后每次读都基于这个视图所以同一个事务里多次读同一行数据结果一致。这正是不可重复读被解决的原因。锁这边InnoDB 的行锁不止一种具体分三兄弟记录锁Record Lock锁住一行记录别的事务改不了。间隙锁Gap Lock锁住“两个索引记录之间的空隙”主要是为了防止幻读——锁住空隙后别的事务就不能在这个区间里插入新记录。临键锁Next-key Lock记录锁 间隙锁的组合锁定一个“左开右闭”的区间。为了让你少踩坑我用一个实际例子说明。表ordersid 是主键现有 id 为 1、5、10 三行。执行SELECT * FROM orders WHERE id BETWEEN 5 AND 10 FOR UPDATE;时InnoDB 会锁住(5,10]这个区间的记录还会锁住(5,10)这个空隙防止别的会话往里面插 id7 的记录。也就是说你去“锁读”了一个范围就会有范围性的锁这不是光锁你读到的 2 条记录就完事的。这也是为什么 REPEATABLE READ 下“更新一段范围内的行”很容易引起别的会话等待——间隙锁把“插入”也拦住了。如果业务里经常有“按范围批量更新”的操作并发又高那些被阻塞的 INSERT 最终会引发锁等待超时报Lock wait timeout exceeded。排查这种问题核心思路是看information_schema.innodb_trx表里有没有长时间运行未提交的事务。5. 常见问题与排查技巧实录事务用久了踩的坑真的比教程里写的多。我在下面按“问题现象 - 排查思路 - 解决方案”逐条列很多经验是常规文档里查不到的。5.1 事务没生效数据没回滚现象代码里写了回滚数据库里数据还是变了。排查步骤SHOW VARIABLES LIKE autocommit;确认是否为 ON。SHOW TABLE STATUS LIKE your_table;确认引擎是不是 InnoDB。查看框架日志确认事务管理是否真的生效比如 Spring 的事务注解有没有被代理拦截。常见原因有个很隐蔽的事务方法在同一个类内部通过this调用Spring 的 AOP 代理拦不到事务注解导致事务完全不起作用。解决办法是注入自身代理或者拆到不同类。这个坑真的非常经典我见过不止一个团队栽在这里。5.2 锁等待超时Lock wait timeout exceeded现象SQL 执行到一半报Lock wait timeout exceeded; try restarting transaction。排查步骤查当前有没有长事务SELECT * FROM information_schema.innodb_trx\G这里会列出所有正在运行的事务、它的开始时间、占用锁的情况。重点关注trx_started时间很老的事务。 2. 查阻塞关系SELECT * FROM sys.innodb_lock_waits\G看谁在等谁的锁。 3. 找到源头事务直接 kill 掉它但要在确认业务可中断的前提下KILL trx_mysql_thread_id;治理建议业务代码里事务一定要短、快、小。不要在事务里做远程调用、发消息、复杂文件处理等耗时操作。事务活着锁就活着“长事务”是锁等待头号元凶。5.3 死锁自己把自己锁死了现象报错Deadlock found when trying to get lock; try restarting transaction。本质两个事务互相持有对方要的锁谁也等不到谁InnoDB 检测到之后会主动回滚其中代价小的事务来打破死锁。所以死锁不一定越改越复杂数据库自动帮你解决了。预防技巧事务里操作多张表时按相同顺序加锁。比如约定先操作用户表再操作订单表全员遵守死锁概率大幅降低。合理使用索引。连接表如果索引不对可能锁多行甚至全表死锁范围就被放大了。小事务 快速提交让锁持有时间变短。排查执行SHOW ENGINE INNODB STATUS\G在LATEST DETECTED DEADLOCK块里能看到死锁事务双方的 SQL 栈定位完就改加锁顺序。5.4 数据不一致核对不上的排查思路如果你已经用了事务但数据还是不一致我会按下面的顺序找问题是不是多个事务并发写的单独测一条链路看看是否只有并发时才出问题。如果是重点看隔离级别和锁。是不是事务边界没控制住日志里看 COMMIT 和 ROLLBACK 实际落在哪儿。很多不一致都是“你以为提交了其实前面某个环节抛了异常自动回滚了”。是不是缓存层的锅事务只保证数据库内部一致性如果你在事务中改了数据库又写了 RedisRedis 写失败但数据库提交了两边就分叉了。这种“跨存储一致性”问题事务解决不了需要引入本地消息表、事务消息等方案。5.5 长事务的影响半径长事务不只是锁等待的问题。它还会导致undo log 无法清理MySQL 里 undo log 要保留到“没有其他事务需要用到它”为止长事务一直不提交undo log 越积越多ibdata1文件越涨越大。binlog 和学习日志变大。主从延迟被放大。我处理过长事务的通用手法是查information_schema.innodb_trx里的事务开始时间把超过max_binlog_cache_size或明显超时的事务找出来逐一会同业务确认是否可以提交或回滚。代码侧要加“事务执行时长监控”超过阈值直接告警别等锁超时被用户投诉了才知道。6. 事务监控与运维自查清单想做事务用得稳不只是写代码时注意更要在运维侧建立习惯。顺带提一句网上关于“分布式事务”的讨论很热但基本功没打牢就去追分布式事务大概率越调越乱。这里我先列清单分布式部分放下一章简单聊聊思路。每日自查三件事查长事务SELECT trx_id, trx_started, trx_state, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx WHERE trx_started NOW() - INTERVAL 10 SECOND;10 秒以上还在跑的事务就要留意了正常业务事务应该在毫秒级完成。查锁等待SELECT * FROM sys.innodb_lock_waits;看看有没有持续累积的等待链。查死锁日志SHOW ENGINE INNODB STATUS\G看LATEST DETECTED DEADLOCK部分统计哪些 SQL 经常参与死锁。参数层面的建议基于常见实践请按实际压测结果调整innodb_lock_wait_timeout默认 50 秒业务上一般建议调到 5~10 秒让异常事务快速失败而不是卡住用户。transaction-isolation除非能确认业务对数据一致性的容忍度否则不建议随便把 REPEATABLE READ 改成 READ COMMITTED。autocommit程序连接池里建议保持默认 ON因为框架会帮你管理事务边界如果你自己写 JDBC 裸代码就要格外小心。7. 延伸一步分布式事务要不要碰文章快收尾了但我必须提醒你单体数据库事务解决的是“一个库里多个表”的一致性可现在的业务基本都是微服务、多库、多 MQ跨库跨服务的一致性问题单靠本地事务是不可能解决的。比如“订单和库存分布式事务”这种搜索热词背后本质是订单在订单服务下单库存服务要扣减。两个服务各用一个独立的数据库本地事务各自管怎么保证“订单创建成功但库存没扣”这种事不会发生这就要引入分布式事务方案了。主流的思路就几条路两阶段提交2PC协调者发起准备各参与者准备完毕后再统一提交。强一致但性能和可用性较差现在用的少了。TCCTry-Confirm-Cancel业务层面补偿让每个参与方自己实现 try、confirm、cancel灵活但代码量大。本地消息表 消息队列把“写本地业务表 写消息表”放进同一个本地事务然后异步投递消息其他服务消费消息做后续操作。这个方案落地成本低一致性也能接受。事务消息如 RocketMQ 的事务消息把本地事务和消息发送变成“要么都成功要么都失败”是本地消息表的半托管版本。我给个建议除非你真的已经把本地事务用得非常熟练、能讲清隔离级别和锁机制否则不要贸然上分布式事务。分布式事务的复杂度是指数级上升的调试成本也很高。能做业务拆分简化、能接受最终一致性的优先选可靠消息方案别一上来就是 TCC 或 2PC。写在最后的经验我用了很多年 MySQL对事务的理解也是一步步在错误里堆积出来的。一个心得想分享给你事务不是越严格越好的。选隔离级别和锁策略本质上是在“数据准确性”和“系统性能”之间做权衡。你要懂原理才能做出合理的决断。还有个小技巧我每次写完涉及事务的代码都会用一个“脏数据检测”脚本随机挑几个线上时间窗口的数据做总和核对、关键字段连续性核对。事务保证的是数据库层面的正确性但最终业务是否真的是对的还得靠数据验证来兜底。如果你现在正在写一个和订单、库存、支付有关的系统花一周时间把本文涉及的实操全部过一遍你会少熬很多夜。记住MySQL 的事务没有黑魔法搞懂 ACID、隔离级别、锁和 MVCC 这四件事你就已经超过绝大多数只会在 CRUD 里打转的人了。
返回列表