ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MySQL 事务与并发控制:ACID、隔离级别与 MVCC 深度解析

MySQL 事务与并发控制:ACID、隔离级别与 MVCC 深度解析 MySQL 事务与并发控制ACID、隔离级别与 MVCC 深度解析摘要熊大给光头强转 100 块钱钱扣了但熊大“被杀”了这钱到底转没转多个用户同时读写数据库为什么你读到的数据时而对时而不对本文从 ACID 四大特性讲起用生活化的比喻带你理解事务的完整生命周期再深入剖析并发场景下的四大问题、四种隔离级别以及 MySQL 实现“非阻塞读”的核心武器——MVCC 与 ReadView。一、从熊大转账说起为什么需要事务假设熊大要给光头强转账 100 元数据库里有两条记录SELECT*FROMaccountWHEREname熊大;-- balance 200SELECT*FROMaccountWHEREname光头强;-- balance 100转账需要两步操作UPDATEaccountSETbalancebalance-100WHEREname熊大;-- 熊大变成 100UPDATEaccountSETbalancebalance100WHEREname光头强;-- 光头强变成 200如果第一步执行完熊大的余额已经扣了 100但就在这时——服务器挂了——第二步还没来得及执行。光头强没收到钱熊大的钱却没了。这就是没有事务保护的后果。事务Transaction要做的事情很简单把多个操作打包成一个“不可分割”的整体要么全部成功要么全部失败。转账事务 开始事务 | v 熊大余额 -100 ──┐ | │ 这两步是一个整体 v │ 任何一步失败全部回滚 光头强余额 100 ──┘ | v 提交事务Commit→ 数据永久生效二、ACID事务的四大铁律事务之所以可靠是因为它遵循 ACID 四大特性。我们可以用“银行转账”来类比理解。2.1 Atomicity原子性定义一个事务中的所有操作要么全部完成要么全部不完成不存在“做了一半”的状态。熊大视角熊大扣钱和光头强加钱这两件事必须一起发生。如果光头强没收到熊大的钱必须原封不动退回来。MySQL 如何实现利用undo log回滚日志。如果事务执行到一半失败了MySQL 会根据 undo log 把已经修改的数据恢复回去就像什么都没发生过。原子性的保障 —— undo log 事务执行 UPDATE balance100 | v 先写 undo log把 balance 改回 200 | v 再修改数据页balance 100 | v 如果事务失败/回滚 → 读 undo log → 恢复 balance2002.2 Consistency一致性定义事务执行前后数据库必须处于“合法状态”。所有的约束外键、唯一性、CHECK 等都必须满足。熊大视角转账前两人余额加起来是 300转账后加起来还是 300。钱不会凭空消失或凭空产生。注意一致性是原子性、隔离性、持久性共同作用的结果它更像是一个“目标”而不是独立实现的机制。2.3 Isolation隔离性定义多个事务并发执行时一个事务内部的操作不应该被其他事务干扰。熊大视角熊大转账的同时光头强也在查余额。隔离性保证光头强看到的结果要么是转账前的要么是转账后的不会看到“熊大已经扣了钱但光头强还没收到”这种中间状态。MySQL 如何实现主要通过锁和MVCC多版本并发控制来实现。MVCC 让读操作不阻塞写操作写操作也不阻塞读操作大大提高了并发性能。2.4 Durability持久性定义一旦事务提交它对数据库的修改就是永久性的即使系统随后崩溃数据也不会丢失。熊大视角转账成功后即使银行服务器下一秒全部断电光头强卡里的 200 元也不会变回 100。MySQL 如何实现利用redo log重做日志。事务提交时先把修改记录写到 redo log 并刷盘然后再慢慢把数据页写到磁盘。即使断电重启后也可以通过 redo log 恢复数据。持久性的保障 —— redo log 事务提交 | v 写 redo log顺序写磁盘极快 | v 返回提交成功给客户端 | v 后台线程慢慢把脏页刷到数据文件随机写较慢 | v 即使此时断电 → 重启后读取 redo log → 恢复数据三、事务的一生状态与语法3.1 事务的状态转换一个事务从诞生到结束会经历以下状态事务状态转换图 开始事务 | v --------- | active | ← 事务正在执行中 --------- | | 正常执行完毕 v ----------------- | partially | ← 部分提交已执行完最后一条语句 | committed | 但修改还没真正刷盘 ----------------- | | 成功写入 redo log v --------- |committed| ← 完全提交永久生效 --------- 开始事务 | v --------- | active | --------- | | 遇到错误 / 用户主动回滚 v --------- | failed | --------- | v --------- | aborted | ← 回滚完成事务结束所有修改被撤销 ---------3.2 基本语法-- 方式1显式开启事务BEGIN;-- 或STARTTRANSACTION;-- 执行一系列操作UPDATEaccountSETbalancebalance-100WHEREname熊大;UPDATEaccountSETbalancebalance100WHEREname光头强;-- 提交事务让修改永久生效COMMIT;-- 或者回滚事务撤销所有修改ROLLBACK;3.3 SAVEPOINT保存点如果一个大事务里只想回滚一部分可以用保存点BEGIN;UPDATEaccountSETbalancebalance-100WHEREname熊大;SAVEPOINTsp1;-- 设置保存点UPDATEaccountSETbalancebalance100WHEREname光头强;-- 突然发现光头强的账号填错了ROLLBACKTOsp1;-- 只回滚到保存点熊大的扣款保留COMMIT;-- 熊大扣了 100光头强没收到等重新操作SAVEPOINT 的作用 BEGIN | v 熊大 -100 ←────── 这个修改保留 | v SAVEPOINT sp1 ←── 在这里打个“书签” | v 光头强 100 ←────── 这个修改被撤销 | v ROLLBACK TO sp1 | v COMMIT3.4 autocommit自动提交MySQL 默认开启autocommit ON这意味着每一条单独的 SQL 语句都会被当作一个事务自动提交。-- 查看当前设置SHOWVARIABLESLIKEautocommit;-- 关闭自动提交SETautocommitOFF;建议在应用程序中通常使用显式的BEGIN ... COMMIT来控制事务而不是依赖autocommit。只有在执行单条查询时才让 autocommit 自动处理。3.5 隐式提交Implicit Commit某些语句会自动把前面未提交的事务悄悄提交掉这称为“隐式提交”。常见的触发场景包括DDL 语句CREATE TABLE、ALTER TABLE、DROP TABLE数据库管理CREATE USER、GRANT加载数据LOAD DATA锁表LOCK TABLESBEGIN;UPDATEaccountSETbalance100WHEREname熊大;CREATETABLEt(idINT);-- ⚠️ 隐式提交上面的 UPDATE 被自动提交了ROLLBACK;-- 对 CREATE TABLE 之后的新事务无效UPDATE 已经无法回滚重要经验不要在事务里混用 DML增删改和 DDL建表、改表否则你的事务边界会被悄悄打破。四、并发带来的四大麻烦数据库通常需要同时服务多个客户端。多个事务同时读写数据时如果没有适当的隔离机制就会出现各种问题。4.1 脏写Dirty Write场景两个事务同时修改同一条记录一个事务覆盖了另一个事务未提交的修改。脏写示意图 时间线 ────────────────────────────── 事务 A读取 balance 200 | v 事务 B读取 balance 200 | | 事务 A 修改 balance 100未提交 | | v v 事务 B 修改 balance 300覆盖了 A 的修改 | v 事务 A 回滚 → balance 应该恢复为 200 | v 但 B 已经覆盖了最终 balance 300A 的回滚把 B 的改丢了后果一个事务的回滚会“抹掉”另一个事务已经提交的修改。严重程度 最高。所有隔离级别都禁止脏写。4.2 脏读Dirty Read场景一个事务读到了另一个事务还未提交的修改。脏读示意图 事务 A 事务 B ────────────────────────────────────────── UPDATE balance100 未提交 | v SELECT balance → 读到 100脏读 | v ROLLBACK balance 恢复 200 | v 业务基于 balance100 做了错误决策后果事务 B 基于一个“不存在”的数据做了决策如果 A 最终回滚B 的决策就是错误的。严重程度 高。4.3 不可重复读Non-Repeatable Read场景在同一个事务内两次读取同一条记录结果不一样。不可重复读示意图 事务 A 事务 B ────────────────────────────────────────────── SELECT balance → 200 | v UPDATE balance 100 COMMIT | v SELECT balance → 100 ← 同一个事务里两次读取结果不同后果事务 A 内部的数据一致性被破坏。如果 A 在做统计或校验结果可能前后矛盾。严重程度 中。某些业务场景可以接受但做报表、对账时必须避免。4.4 幻读Phantom Read场景在同一个事务内两次执行相同的条件查询第二次读到了第一次没有的行或者原本有的行消失了。幻读示意图 事务 A 事务 B ────────────────────────────────────────────── SELECT * FROM account WHERE balance 50 → 结果2 条记录熊大 200光头强 100 | v INSERT INTO account VALUES (兔宝, 80); COMMIT | v SELECT * FROM account WHERE balance 50 → 结果3 条记录多了个兔宝 ↑ 同一个事务同样的查询条件结果集变多了 幻读注意幻读强调的是“结果集的行数/内容变了”而不可重复读强调的是“某一条具体记录的值变了”。严重程度 中。五、四种隔离级别在性能与正确性之间取舍SQL 标准定义了四种事务隔离级别每种级别解决不同的问题隔离级别脏写脏读不可重复读幻读READ UNCOMMITTED❌ 禁止⚠️ 允许⚠️ 允许⚠️ 允许READ COMMITTED❌ 禁止❌ 禁止⚠️ 允许⚠️ 允许REPEATABLE READ❌ 禁止❌ 禁止❌ 禁止⚠️ 允许*SERIALIZABLE❌ 禁止❌ 禁止❌ 禁止❌ 禁止* MySQL 的 InnoDB 在 REPEATABLE READ 下通过 MVCC 间隙锁基本解决了幻读问题这是 MySQL 的一大特色。隔离级别与并发问题的关系 READ UNCOMMITTED ── 什么都不防性能最好数据最不靠谱 | v READ COMMITTED ── 防脏读Oracle/SQL Server 默认 | v REPEATABLE READ ── 防不可重复读MySQL InnoDB 默认 | v SERIALIZABLE ── 全部防死性能最差数据最靠谱5.1 READ UNCOMMITTED读未提交事务可以读到其他事务尚未提交的数据。性能最好但数据一致性最差。生产环境几乎不用。5.2 READ COMMITTED读已提交事务只能读到其他事务已经提交的数据。解决了脏读但仍然存在不可重复读和幻读。这是 Oracle 和 SQL Server 的默认隔离级别。如果你的业务对“同一次事务内数据必须完全一致”要求不高可以考虑使用它来获得更好的并发性能。5.3 REPEATABLE READ可重复读MySQL InnoDB 的默认隔离级别。在同一个事务内多次读取同一条记录的结果是始终一致的。MySQL 的 REPEATABLE READ 几乎解决了幻读InnoDB 通过 MVCC 保证快照读看不到幻行通过间隙锁Gap Lock保证当前读也不产生幻读。5.4 SERIALIZABLE串行化最严格的隔离级别。所有事务串行执行完全避免所有并发问题但并发性能极差。除非对数据一致性有极端要求如金融核心的对账否则不建议使用。-- 查看当前隔离级别SELECTtransaction_isolation;-- 设置会话隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;六、MVCC没有锁也能实现隔离6.1 为什么需要 MVCC如果完全用锁来实现隔离最简单的办法是一个事务读数据时加读锁写数据时加写锁。但这样读和写就会互相阻塞并发性能很差。MVCCMulti-Version Concurrency Control多版本并发控制的核心思想是写操作不覆盖旧数据而是生成一个新版本读操作根据情况选择合适版本读取从而实现“读写不互斥”。有无 MVCC 的对比 无 MVCC纯锁机制 事务 A 读记录 R → 加读锁 事务 B 写记录 R → 必须等 A 释放读锁 ← 读写阻塞 有 MVCC 事务 A 读记录 R → 读某个历史版本不用加锁 事务 B 写记录 R → 生成新版本 R也不用等 A ← 读写互不阻塞6.2 版本链 undo log 串联起来的历史InnoDB 的每行记录都隐藏了两个系统列trx_id最后修改这行记录的事务 IDroll_pointer指向 undo log 的指针用来找到上一个版本当一个事务修改某行数据时MySQL 不会直接覆盖原数据而是把原数据拷贝到 undo log 中修改数据页中的记录把trx_id设为当前事务 ID把roll_pointer指向刚才写入的 undo log版本链的结构 数据页中的记录最新版本 ---------------------------------------------------- | trx_id 100 | roll_pointer ──┼── undo log 1 | name 熊大 | balance 100 | | ---------------------------------------------------- | ------------------------ v undo log 1上一个版本 ------------------------------------------ | trx_id 80 | roll_pointer ──┼── undo log 2 | balance 200 | | | ---------------------------------------------------- | ------------------------ v undo log 2更早版本 -------------------------------- | trx_id 50 | roll_pointer NULL | balance 0 | | -------------------------------- 通过 roll_pointer 串联起来就是一条版本链小知识undo log 不仅用于回滚事务还用于构建历史版本供其他事务读取。只有当没有任何事务需要访问这些旧版本时undo log 才会被清理purge。6.3 事务 ID 的生成每个事务在启动时都会分配一个唯一递增的事务 IDtransaction id。这个 ID 决定了“谁先谁后”——ID 小的事务先于 ID 大的事务发生。事务 ID 的分配 事务 1 启动 → trx_id 1 事务 2 启动 → trx_id 2 事务 3 启动 → trx_id 3 | v 修改某行时该行的 trx_id 就写上自己的事务 ID七、ReadView判断“我能看见哪个版本”版本链解决了“历史数据存放在哪”的问题但还有一个关键问题一个事务应该读哪个版本InnoDB 用ReadView读视图来解决这个问题。7.1 ReadView 的四个核心字段当一个事务执行 SELECT快照读时InnoDB 会生成一个 ReadView它记录了当前系统中的“事务快照”字段含义m_ids生成 ReadView 时所有活跃未提交事务的 ID 列表min_trx_idm_ids中的最小值max_trx_id生成 ReadView 时系统即将分配的下一个事务 IDcreator_trx_id生成这个 ReadView 的事务自己的 ID7.2 可见性判断规则拿到 ReadView 后InnoDB 沿着版本链从最新版本开始遍历用下面的规则判断“这个版本我能看到吗”版本可见性判断流程 对于版本链上的某个版本trx_id V 1. 如果 V creator_trx_id → 是我自己的修改当然可见 ✅ 2. 如果 V min_trx_id → 这个版本在 ReadView 生成前就已经提交了可见 ✅ 3. 如果 V max_trx_id → 这个版本在 ReadView 生成后才开始不可见 ❌ 4. 如果 min_trx_id V max_trx_id → 检查 V 是否在 m_ids 中 - 在 m_ids 中 → 事务还没提交不可见 ❌ - 不在 m_ids 中 → 事务已提交可见 ✅ReadView 判断示例 当前系统中事务 10、20、30 已提交事务 40、50 正在执行事务 60 尚未启动 事务 50 生成 ReadView m_ids {40, 50} ← 活跃事务 min_trx_id 40 max_trx_id 60 ← 下一个要分配的事务 ID creator_trx_id 50 版本链上的各个版本 trx_id65 → 65 60 → 不可见未来事务的修改 ↓ trx_id50 → 50 creator_trx_id → 可见自己的修改✅ ↓ trx_id40 → 40 在 m_ids 中 → 不可见未提交❌ ↓ trx_id30 → 30 40 → 可见已提交✅ ↓ trx_id20 → 20 40 → 可见已提交✅7.3 一句话总结 ReadViewReadView 就像你站在某个时间点拍了一张系统的“快照”。在这张快照里未提交的事务对你不可见未来发生的事对你也不可见你只能看到已经尘埃落定的事实。八、RC 与 RR 的本质区别ReadView 何时生成这是 MySQL 面试中最常考的问题之一也是理解隔离级别差异的关键。8.1 READ COMMITTEDRC—— 每次 SELECT 都生成新 ReadView在 RC 隔离级别下事务中的每一条 SELECT 语句都会生成一个新的 ReadView。RC 下的 ReadView 生成时机 事务 ARC 事务 B ───────────────────────────────────────── SELECT → 生成 ReadView1 | v 读到 balance 200 UPDATE balance 100 COMMIT | v SELECT → 生成 ReadView2 ← 新的 ReadView | v 读到 balance 100 ← 能读到 B 已提交的修改结果RC 解决了脏读因为未提交的事务在 m_ids 中不可见但无法解决不可重复读——因为第二次 SELECT 时事务 B 已经提交新生成的 ReadView 能看到它。8.2 REPEATABLE READRR—— 第一次 SELECT 时生成 ReadView之后复用在 RR 隔离级别下事务在第一次执行 SELECT 时生成 ReadView之后整个事务期间都复用这一个 ReadView。RR 下的 ReadView 生成时机 事务 ARR 事务 B ───────────────────────────────────────── 第一次 SELECT → 生成 ReadView1 | v 读到 balance 200 UPDATE balance 100 COMMIT | v 第二次 SELECT → 复用 ReadView1 ← 还是原来的快照 | v 读到 balance 200 ← 仍然是 200保证了可重复读结果即使事务 B 后来提交了因为 ReadView1 中的m_ids和max_trx_id没有变事务 A 仍然看不到 B 的修改。这就保证了在同一个事务内多次读取结果始终一致。8.3 RC vs RR 对比图RC vs RR 的核心差异 ┌─────────────────────────────────────────────────────────────┐ │ READ COMMITTED │ │ │ │ SELECT #1 → ReadView A → 看到已提交数据 │ │ | │ │ | 其他事务提交 │ │ v │ │ SELECT #2 → ReadView B → 看到新提交的修改不可重复读 │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ REPEATABLE READ │ │ │ │ SELECT #1 → ReadView A → 看到已提交数据 │ │ | │ │ | 其他事务提交 │ │ v │ │ SELECT #2 → ReadView A → 仍然看到原来的数据可重复读 │ │ │ │ 整个事务只用一个 ReadView │ └─────────────────────────────────────────────────────────────┘8.4 “当前读”与“快照读”的区别前面讲的 SELECT 生成 ReadView都属于快照读Snapshot Read读的是历史版本。但某些操作需要读最新的、已提交的数据这称为当前读Current Read。当前读不依赖 ReadView而是直接读取最新版本必要时还会加锁。触发当前读的语句包括SELECT...FORUPDATE;-- 加排他锁读最新版本SELECT...FORSHARE;-- 加共享锁读最新版本UPDATE...;-- 修改前必须读最新版本DELETE...;-- 删除前必须读最新版本INSERT...;-- 插入操作注意在 RR 级别下快照读可以避免幻读因为 ReadView 不变但当前读仍然可能遇到幻读。InnoDB 通过**间隙锁Gap Lock**来解决当前读的幻读问题这是另一个话题了。九、总结与实战速查9.1 核心概念速查表概念一句话解释事务把多个操作打包成“要么全成、要么全败”的整体ACID原子性、一致性、隔离性、持久性undo log回滚日志用于事务回滚和构建历史版本redo log重做日志用于崩溃恢复保证持久性脏写覆盖了别人未提交的修改脏读读到了别人未提交的修改不可重复读同一事务内两次读同一条记录值不同幻读同一事务内两次条件查询结果集行数不同MVCC多版本并发控制用版本链实现读写不阻塞版本链通过 roll_pointer 串联的 undo log 链条ReadView事务的快照决定能看到哪些版本快照读普通的 SELECT读历史版本当前读FOR UPDATE / UPDATE / DELETE读最新版本9.2 隔离级别选择建议隔离级别选择决策树 是否需要最强的数据一致性 | ├── 是 → 考虑 SERIALIZABLE但先确认性能是否可接受 | └── 否 → 是否有“同事务内多次读取必须一致”的要求 | ├── 是 → REPEATABLE READMySQL 默认推荐 | └── 否 → 是否允许读到其他事务未提交的数据 | ├── 否 → READ COMMITTEDOracle 默认 | └── 是 → READ UNCOMMITTED几乎不用9.3 实战 checklist事务使用 checklist □ 事务是否包含了最小必要的操作事务越大持有锁越久 □ 事务中是否混用了 DDLDDL 会触发隐式提交 □ 是否正确处理了异常并执行 ROLLBACK □ 是否需要 SAVEPOINT 来部分回滚 □ 隔离级别是否满足业务需求 □ 长事务是否会导致 undo log 膨胀大查询也可能导致 □ UPDATE/DELETE 是否带 WHERE 条件防止误改全表9.4 常见误区澄清误区 1REPEATABLE READ完全不会幻读。正解RR 通过 ReadView 避免了快照读的幻读但当前读SELECT ... FOR UPDATE在没有间隙锁保护的情况下仍可能幻读。InnoDB 的间隙锁机制补上了这个缺口。误区 2BEGIN之后立刻就分配了事务 ID。正解在 MySQL 中事务 ID 是在第一次执行修改操作INSERT/UPDATE/DELETE时才分配的纯读事务只执行 SELECT通常不分配事务 ID。误区 3ROLLBACK会把所有修改都撤销。正解ROLLBACK 只能撤销当前事务内的修改。如果事务中发生了隐式提交如执行了 CREATE TABLE隐式提交之前的修改已经无法回滚。延伸阅读MySQL 官方文档Transaction Isolation Levels本文配套博客EXPLAIN 完全指南一张图看懂 MySQL 执行计划本文配套博客MySQL 查询优化器的双重人格成本计算与查询重写
返回列表