MySQL事务及原理详解
MySQL
事务
事务是一组不可分割的操作集合,这些操作要么同时成功提交,要么同时失败回滚。
acid事物的四大特性
原子性
最小工作单元,要么同时成功,要么同时失败。
例如A转账300给B,A账户-300与B账户+300必须满足操作原子性,避免出现A已转账但B未收到的一致性问题。
一致性
事务操作的起点和终点必须是从一个一致性状态到另一个一致性状态,也就是数据库的数据变化必须符合预定义期望变化。(不会出现一个数据库修改成功、一个失败的情况)
例如在转账案例中事务开始时的账户总额等于事务结束时的账户金额。(并不是一定相等,数据变化符合业务预定义期望即可)
隔离性
并发的事务是相互隔离的。
例如多个并发转账事务,每个转账操作的数据是相互独立的,不会出现数据混乱的情况。
持久性
一旦事务提交,其结果就是永久的,不会因系统崩溃丢失。
事务提交后会将数据持久化到硬盘,例如在装张案例中,变更后账户数据持久化在硬盘,数据库崩溃依然被保留。
并发事务问题
脏读
事务A读取到事务B未提交的修改。
不可重复读
同一事务内多次读取同一数据时因为其他事物在此期间提交了数据修改导致结果不同。
幻读
同一事务内对一张表的查询结果集不同,因为其他事务在此期间插入删除了数据。
select * 结果集行数不同。
select count()/sum() 等聚合函数,查询内容可能不同。
例如,事务A查询name=张三不存在,事务B插入张三,事务A按照张三不存在的业务逻辑插入张三但无法插入。
隔离级别
读未提交(RU)
允许事务读取其他事物未提交的修改(脏读)。
并发性能最高。
读已提交(RC)
不允许事务读取其他事物未提交的修改(脏读)。
无法避免不可重复读现象。
可重复读(RR)
不会出现脏读和不可重复读问题。
无法避免幻读问题。
MySQL默认隔离级别。
串行化(S)
完全避免所有并发问题。
并发性能最低。
如何选择隔离级别
隔离级别越高,并发性能越低。
-
读未提交(RU):仅适用对数据准确性要求极低,并发性能要求极高的场景,如监控数据,日志采集,瞬时数据不影响整体的场景,但实际实际生产环境下中还是极少使用,规避脏读风险。
-
读已提交(RC):适用大部分普通业务场景,也是大部分数据库的默认隔离级别。例如用户信息页,用户A修改提交后,用户B刷新就能看到用户A提交的修改内容,但不会看到用户A未提交的内容。
RC下不可重复读问题:
🛒 场景一:库存扣减(并发抢购)
-
业务逻辑: 用户下单时,需要检查并扣减商品库存(例如商品A,初始库存10件)。
-
事务A (用户1下单):
BEGIN;
(RC隔离级别)SELECT stock FROM products WHERE id = 'A';
// 返回 10 (库存充足)- (基于查询结果10,决定继续下单逻辑… 生成订单、计算价格等,耗时几毫秒/秒)
-
事务B (用户2下单): (几乎与事务A同时发生)
BEGIN;
(RC隔离级别)SELECT stock FROM products WHERE id = 'A';
// 也返回 10 (库存充足)UPDATE products SET stock = stock - 1 WHERE id = 'A';
// 扣减1件,库存变为9COMMIT;
// 用户2下单成功,库存更新为9并生效
-
事务A 继续执行:
- (执行完其他逻辑后)
UPDATE products SET stock = stock - 1 WHERE id = 'A';
// 此时基于 *当前已提交数据* (stock=9) 扣减,库存变为8COMMIT;
// 用户1下单成功
-
问题:
- 两个用户都成功下单购买了商品A。
- 最终库存变为 8,这符合物理扣减。
-
不可重复读在哪里?
- 事务A 在步骤2读取
stock=10
。 - 在它执行后续逻辑时,事务B 修改并提交了库存(变为9)。
- 当事务A 执行更新操作(步骤2.2)时,它没有基于自己最初读到的10去减1,而是基于最新已提交值9去减1。虽然最终库存正确(8),但事务A在逻辑判断(库存是否充足)后,执行更新操作时依赖的数据(库存值)已经发生了变化(10 -> 9)。这就是一次“不可重复读”(在同一个事务A内,如果它再次执行
SELECT stock...
,结果会是9,而不是最初的10)。
- 事务A 在步骤2读取
-
潜在风险:
-
超卖风险: 如果初始库存只有1件,多个事务都读到1(认为充足),然后都去扣减1(事务B扣成0并提交,事务A再基于0扣减就会变成-1)。这就是经典的并发超卖问题!虽然RC下避免了脏读(不会读到事务B未提交的扣减),但因为不可重复读,两个事务都基于“过时”的充足判断进行了扣减,导致库存为负。解决超卖通常需要额外的并发控制(如乐观锁、悲观锁、Redis分布式锁等),而不仅仅是依赖隔离级别。
-
🕒 场景二:预约系统(时间段占用检查)
- 业务逻辑: 用户预约某个资源(如会议室A在10:00-11:00时段)。
- 事务A (用户1预约):
BEGIN;
(RC隔离级别)SELECT COUNT(*) FROM bookings WHERE room = 'A' AND start_time < '11:00' AND end_time > '10:00';
// 返回 0 (表示10:00-11:00空闲)- (用户1填写预约信息,点击确认… 耗时几秒)
- 事务B (用户2预约): (几乎与事务A同时发生,且操作更快)
BEGIN;
(RC隔离级别)SELECT ...
// 同样返回0 (空闲)INSERT INTO bookings (room, start_time, end_time, user) VALUES ('A', '10:00', '11:00', 'user2');
// 插入预约记录COMMIT;
// 用户2预约成功
- 事务A 继续执行:
- (用户1点击确认)
INSERT INTO bookings (room, start_time, end_time, user) VALUES ('A', '10:00', '11:00', 'user1');
// 尝试插入- (可能成功也可能失败,取决于唯一性约束)
COMMIT;
- 问题:
- 事务A和事务B都检查了同一时间段,都认为它是空闲的(SELECT返回0)。
- 事务B更快地插入记录并提交。
- 事务A随后也尝试插入记录。
- 不可重复读在哪里?
- 事务A在步骤2执行SELECT查询,得知会议室A在10:00-11:00空闲。
- 在它执行插入操作之前,事务B已经插入并提交了占用该时间段的记录。
- 当事务A执行插入操作时,它所依赖的“空闲”状态(SELECT的结果)已经不再成立(因为事务B的插入已提交)。事务A在逻辑判断(是否空闲)后,执行插入操作时依赖的数据状态(时间段是否被占用)已经发生了变化。如果表上有
(room, start_time, end_time)
的唯一约束,事务A的插入会失败(主键/唯一键冲突)。如果没有唯一约束,则会产生双重预订!
- 潜在风险:
- 双重预订: 最严重的后果!同一个时间段被预约给了两个用户,导致冲突和用户投诉。解决双重预订通常需要更严格的并发控制,如对目标时间段加行锁(SELECT FOR UPDATE)或使用乐观锁(版本号)。
-
-
可重复读(RR):适用同一事务内涉及一个以上对同一数据的查询,业务要求不能使两次查询结果不一致。
幻读问题典型案例
假设存在一张
goods
表,存储商品库存信息,初始数据如下:id name stock 1 手机 10 2 电脑 5 现在有两个并发事务:事务 A 负责查询并修改库存小于 10 的商品,事务 B 负责插入一条新的库存小于 10 的商品记录。
步骤 1:事务 A 启动并首次查询
事务 A 开始,执行查询 “库存小于 10 的商品”:
-- 事务 A BEGIN; -- 第一次查询:查询库存 < 10 的商品 SELECT * FROM goods WHERE stock < 10;
此时结果为:
id name stock 2 电脑 5 步骤 2:事务 B 插入新数据并提交
事务 B 启动,插入一条新商品记录(库存 8,符合
stock < 10
),并提交事务:-- 事务 B BEGIN; -- 插入一条新商品,库存 8(符合 stock < 10) INSERT INTO goods (name, stock) VALUES ('平板', 8); COMMIT;
此时表中数据变为:
id name stock 1 手机 10 2 电脑 5 3 平板 8 步骤 3:事务 A 再次查询并尝试修改
事务 A 再次执行相同的查询:
-- 事务 A -- 第二次查询:再次查询库存 < 10 的商品 SELECT * FROM goods WHERE stock < 10;
在 RR 隔离级别下,由于 MVCC 的可重复读特性,事务 A 第二次查询的结果仍为:
id name stock 2 电脑 5 但此时如果事务 A 尝试修改 “所有库存 < 10 的商品”(例如批量增加库存):
-- 事务 A -- 尝试修改所有库存 < 10 的商品 UPDATE goods SET stock = stock + 2 WHERE stock < 10; COMMIT;
执行后,事务 A 查看最终数据时会发现:新插入的 “平板”(id=3)的库存也被修改为 10(8+2)。
这就是幻读:事务 A 两次查询都没看到 “平板”,但修改操作却影响了它,仿佛数据 “凭空出现” 并被修改。-
在RR级别下,不可重复读场景能被解决,但依然会出现更新操作前判断失效的情况,update是当前读会直接读取最新数据修改,依然会出现同时判断成功的超卖问题。
-
场景一:库存扣减
RR下的行为:
1.事务A开始并创建快照,执行
SELECT stock...
读取的始终是快照中的库存值(如10)2.事务B开始并执行扣减库存,此时数据库中stock值为9
3.事务A开始执行扣减库存操作
UPDATE stock = stock - 1
但会读取到被修改后的最新数据修改。结果:事务AB库存判断成功虽然解决了不可重复读问题但还是会导致超卖。
解决方法:乐观锁、悲观锁、分布式锁、库存判断加
For UPDATE
<select id="selectStockForUpdate" resultType="com.example.Goods">SELECT id, stock FROM goods WHERE id = #{id} FOR UPDATE <!-- 关键:对查询到的行加排他锁 --> </select>
-
-
串行化(S):事务串行化执行,适用RR下会出现幻读且业务不允许的场景及事务必须严格按照提交顺序执行的场景。
风险:事务A先开启但是在未提交的情况下,事务B开启并检测B的余额,业务逻辑上B用户账户余额一定满足>=50,但是在RC,RR情况下事务A未提交所以事务B可能产生误判。
串行化解决方案:
- 严格顺序执行:
- 事务A完全执行后,再执行事务B
- 或事务B完全执行后,再执行事务A
- 严格顺序执行:
存储引擎
InnoDB
- 事务:支持 ACID(原子性、一致性、隔离性、持久性)。
- 外键:强制维护数据之间的逻辑关联。
- 行级锁:对特定行数据加锁,同一表内其他行仍然可以并发读写。提高了系统吞吐量。
MyISAM
- 无事务
- 无外键
- 表级锁
- 读取性能更高(被MongoDB替代,MongoDB文档级锁(类似行级锁)比MyISAM的表级锁插入性能更高)
MEMORY
- 内存:所有数据存储在RAM中,读写速度极快。
相对于Redis有以下局限性:
- 易失性:服务器宕机、重启丢失所有数据。
- 功能单一:支持的数据结构简单,缺乏Redis丰富的数据结构(列表、集合、哈希、位图、流等)。
- 无分布式支持:难以构建分布式架构。
特性 | InnoDB (MySQL) | MyISAM (MySQL) | MEMORY (MySQL) | MongoDB | Redis |
---|---|---|---|---|---|
存储模型 | 关系型 (行存储) | 关系型 (行存储) | 关系型 (行存储, 内存中) | 文档型 (BSON) | 键值对 + 多种高级数据结构 |
核心优势 | ACID 事务, 行级锁, 外键, 数据一致性强 | 读取插入性能高(简单查询, COUNT(*)) | 内存速度读写 (临时数据) | 灵活模式, 水平扩展, 高并发写入 (文档锁) | 内存速度, 丰富数据结构, 持久化, 分布式 |
主要劣势 | 相对复杂, 写并发优化需技巧 | 表级锁(并发写入差), 无事务, 崩溃易损 | 易失性(重启丢数据), 功能单一 | 弱于复杂 JOIN/事务 (虽有提升), 学习曲线 | 内存成本高, 不适合存超大对象/全量持久化 |
锁机制 | 行级锁 (写) | 表级锁 (写) | 表级锁 | 文档级锁 (现代引擎) | 单线程处理命令 (避免锁竞争) |
持久性 | 强 (WAL 日志) | 弱 (崩溃易损) | 无 (纯内存) | 可配置 | 可配置 (RDB/AOF) |
事务 | 完整 ACID 支持 | 不支持 | 不支持 | 支持多文档 ACID 事务 (现代版本) | 支持简单事务/乐观锁, 非完整 ACID |
扩展性 | 主从复制, 分片(较复杂) | 主从复制 | 无 | 内置强大分片 (Sharding) | 内置集群 (Redis Cluster) |
典型场景 | OLTP, 需要强一致性事务的应用 (支付, 订单) | 读取密集型应用, 静态数据, 日志(历史) | 临时表, 缓存(简单) | 读取插入频繁、更新删除少” 场景 | 缓存, 会话存储, 排行榜, 消息队列, 实时系统 |
索引
索引是帮助MySQL高效获取数据的数据结构。数据库维护着指向存储数据的数据结构和查找算法,以快速定位到查询的数据。
核心作用
加速检索:大幅减少扫描量,避免全表扫描。
加速排序和分组:利用索引的有序性可以极大提高GROUP BY
和ORDER BY
的速度。
代价
占用存储空间:存储索引需要额外的存储空间。
降低写操作速度:要维持索引的有序性,UPDATE
INSERT
DELETE
操作会增加所需的时间开销。操作涉及的索引越多,时间消耗越大。
索引结构
二叉树做索引的问题
二叉搜素树
- 退化风险:用二叉树做索引的数据结构会导致二叉搜素树结构取决于插入顺序,可能使树的层级过深,极端情况下(顺序插入)会退化成链表,增加查询的扫描量。
- 无法磁盘预读:二叉搜索树分散节点存储,每个节点只存一个数据,查询过程中每个数据的访问都涉及到一次磁盘IO,而磁盘IO正是外存访问中最耗时的环节。
红黑树
- 避免退化:红黑树会维持树的平衡,可以避免二叉搜索树的退化问题。
- 无磁盘预读:存储与二叉搜索树一样分散存储,一个节点只存储一个数据,涉及大量磁盘IO。
B-树和B+树
B-树
原理:多路平衡搜索树
核心特性:
- 每个节点存储多个键值和子节点指针。
- 节点大小为磁盘页大小。
- 树高远低于二叉树。
优势:
- 减少磁盘IO:由于节点大小为磁盘页大小,所以在检索过程中可以完整读取一个节点的所有键值进行比较检索,显著降低了磁盘IO的次数。
- 局部范围查询:节点内键值有序,可直接用于范围查询。
B+树
B+树在B-树的基础上做了更加适配数据库索引的优化。
-
B+树的节点不存储数据,仅在叶子结点存储数据。
-
B+树的叶子结点使用双向链表相连。
特性 B-树 B+树 数据存储位置 所有节点均可存数据 仅叶子节点存数据 叶子节点结构 不链接 通过指针双向链表串联 非叶子节点功能 存键值 + 数据 仅存键值(索引导航作用) 键值冗余 无 键值在非叶节点重复出现(冗余)
B+树的关键优化:
- 层级更低:B+树中的非叶子节点不存储数据,固定为磁盘页大小的节点空间全部用于存储键值和子节点的指针,因此将层级进一步压缩。
- 范围查询更加高效:B+树将存储数据的叶子结点用双向链表相连,范围查询只需查到起始键后遍历链表至结束键,避免里B-树范围查询时上下层级的切换。
- 全表扫描更快:顺序遍历叶子结点即可获得全量数据,无需树遍历。
索引分类
按物理存储方式分
维度 | 聚集索引 | 二级索引 |
---|---|---|
存储内容 | 完整数据行 | 索引列 + 主键值 |
数量 | 1 个/表 | 多个/表 |
查询速度 | 主键查询极快 | 需回表,可能慢 |
叶子节点结构 | 数据行 | (索引列, 主键) + 指向主键的指针 |
依赖关系 | 数据物理存储依据 | 依赖聚集索引的主键值 |
聚集索引下挂的完整的行数据。
聚集索引选取规则:
如果存在主键,主键索引就是聚集索引。
如果不存在主键,将使用第一个唯一(UNIQUE)索引作为聚集索引。
如果表没有主键,或没有合适的唯一索引,则InnoDB会自动生成一个rowid作为隐藏的聚集索引。
二级索引的叶子结点下挂的是键值和对应的主键值。
通过二级索引检索数据时,先检索出二级索引下对应键值下的主键值,再回表查询出对应完整行数据。
按逻辑功能分
主键索引:每个表有且只有一个,值唯一且不为空,建表时自动创建,属聚集索引。
唯一索引:允许存在多个,值唯一但允许有一个NULL,属于二级索引。
常规索引:允许存在多个,只可重复且可为空,属于二级索引。
前缀索引
基本原理
-
传统索引:存储字段的完整值。
-
前缀索引:仅存储字段的前 N 个字符。
-- 为email字段的前5个字符创建前缀索引 CREATE INDEX idx_email_prefix ON users(email(5));
核心优势
(1)减少索引体积
- 示例:若
email
字段平均长度为 20 字节,前缀索引(5 字节)可节省 75% 的存储空间。 - 效果:更小体积的索引可使内存中留出更多空间缓存更多数据页,减少磁盘 IO。
(2)提升写入性能
- 因索引体积小,插入 / 更新时的索引维护开销降低,页分裂次数降低。
前缀长度选择:
- 过短:区分度不足,导致索引扫描行数和回表查询次数激增。
- 过长:失去空间优势,性能接近完整索引。
- 建议通过
COUNT(DISTINCT LEFT(field, N))
/COUNT(*)测试不同长度的区分度。
-- 计算email前5个字符的唯一值占比
SELECT COUNT(DISTINCT LEFT(email, 5)) / COUNT(DISTINCT email) AS prefix_coverage
FROM users; -- 结果接近1时,说明前5个字符区分度高
联合索引
创建语句:CREATE INDEX idx_abc ON table(a, b, c);
通过单索引包含多列的方式提高多条件查询的效率,是数据库性能优化的重要手段。
最左前缀法则:
联合索引的列必须从左到右连续使用,否则索引失效。
覆盖索引:
原理:
通过让联合索引覆盖查询所需的所有字段,避免回表查询以提升查询效率。
-
未覆盖联合索引
-
查询流程
-- 索引(a,b,c,d) SELECT * FROM table WHERE a=1 AND b=2 -- 需回表查询c,d字段
-
-
覆盖索引
-
查询流程
-- 索引(a,b,c,d) SELECT c,d FROM table WHERE a=1 AND b=2 -- 索引中包含查询的所有字段,无需回表查询就能得到结果
-
索引有效的查询:
WHERE a=1 -- 仅用a
WHERE a=1 AND b=2 -- 用a和b
WHERE a=1 AND b=2 AND c=3 -- 用a、b、c
WHERE b=2 AND a=1 -- 使用先后顺序不影响有效性
索引失效的各种场景
- 违反最左前缀法则
-- CREATE INDEX idx_abc ON table(a,b,c)
WHERE b=1 -- 跳过最左
WHERE a=1 AND c=2 -- 跳过中间
修复:新建索引。
- 对索引列进行运算或函数操作
-- 索引: (create_time)
SELECT * FROM logs WHERE YEAR(create_time)=2023; -- 索引列函数操作
修复:改为范围查询,BETWEEN ’2023-01-01‘ AND ’2023-12-31‘
- 隐式类型转换
-- 索引: (phone VARCHAR(20))
SELECT * FROM contacts WHERE phone = 13800138000; -- 隐式类型转换(数字转字符串)
修复:
WHERE phone = '13800138000' -- 显式类型匹配
- OR连接非索引列
-- 索引: (status)
SELECT * FROM orders
WHERE status = 'paid' OR amount > 1000; -- ✘ (amount 无索引)
修复:
-- 方案1: 为 amount 建索引
ALTER TABLE orders ADD INDEX idx_amount (amount);-- 方案2: 改写为 UNION
SELECT * FROM orders WHERE status = 'paid'
UNION ALL
SELECT * FROM orders WHERE amount > 1000;
- LIKE模糊匹配%通配符在前
-- 索引: (title)
SELECT * FROM articles WHERE title LIKE '%database%';
修复:
-- 使用全文索引
ALTER TABLE articles ADD FULLTEX INDEX(title)SELECT * FROM articles WHERE MATCH(title) AGAINST('database')
- 范围查询阻断后续列
-- 索引: (age, city)
SELECT * FROM users
WHERE age > 20 AND city = 'Beijing'; -- city 失效
修复:
-- 调整索引顺序 (范围查询置后)
ALTER TABLE users ADD INDEX idx_city_age (city, age);
索引设计原则
- 对数据量大且查询频繁的表建立索引。
- 对常用于条件查询(WHERE)、排序(ORDER BY)、分组(GROUP BY)操作的字段建立索引。
- 字符串类型字段如果字段长度过长,可以建立前缀索引,但要尽量保证区分度。
- 尽量使用联合索引,联合索引很多时候可以可以覆盖索引,避免回表查询。
- 尽量选择区分度高的列做索引,区分度越高,回表查询的次数就越少,检索效率就越高。
- 索引数量不是越多越好,增加索引数量会影响写操作的效率。
锁
锁是保证数据一致性的并发控制手段。
MySQL中的锁按照锁的颗粒度分为三类:
- 全局锁:锁定整个数据库。
- 表级锁:锁定整张表。
- 行级锁:锁定特定行。
全局锁
锁定整个数据库实例,禁止所有写操作(DDL,DML),允许读(DQL)。
锁定范围:所有数据库实例(包括系统数据库、用户创建的数据库)。
用途:数据库升级或迁移前做全库的逻辑备份,生成一致性快照。避免边备份边写导致数据库数据不一致。
flush tables with read lock -- 加全局锁mysqldump -uroot –p1234 itcast > itcast.sql -- 数据备份unlock tables -- 释放全局锁
数据库加全局锁存在以下问题:
主库上加锁备份,备份期间写操作不能执行,业务停摆。
从库备份,备份期间从库不能执行主库同步过来的二进制日志(binlog),主从延迟。
在InnoDB引擎中,备份时可已执行以下语句--single-transaction
,不使用全局锁而获得一致性快照。
mysqldump --single-transaction -uroot –p123456 itcast > itcast.sql
表级锁
表锁
- 读锁(共享锁/S锁):只能读表,不能写操作,可多个事务同时加读锁。
- 写锁(排它锁/X锁):加锁后其他事务无法再加任何锁,持锁事务内可进行读写操作。
- 读锁升级为写锁时该表被其他事务持有读锁会阻塞升级,写锁与读锁互斥。
应用场景:全表更新(UPDATE table SET col=1)。
元数据锁
-
自动加锁:访问表时自动加锁。
-
锁定范围:表结构(列、索引信息)。
-
作用:防止
DDL
与DML
、DQL
语句冲突。-
例如:
-
防止
SELECT
执行期间表结构被修改(ALTER TABLE
) -
防止添加索引(
ALTER table ADD INDEX idx_a_b (a,b)
)期间插入/删除/更新数据
-
-
意向锁
核心特性:
- 自动加锁:当添加行级锁时由InnoDB自动添加。
- 目的:快速检查行锁与表级锁的兼容性,避免全表扫描判断。
锁类型:
- 意向共享锁(IS):当事务要对表中行加S锁时自动添加,表示事务准备在表的某些行上加共享锁。
- 意向排它锁(IX):当事务要对表中行加X锁时自动添加,表示事务准备在表的某些行上加排他锁。
- IS和IX互相不冲突
锁兼容性:
当前锁 | 请求IS锁 | 请求IX锁 | 请求表S锁 | 请求表X锁 |
---|---|---|---|---|
无锁 | 兼容 | 兼容 | 兼容 | 兼容 |
IS锁 | 兼容 | 兼容 | 兼容 | 阻塞 |
IX锁 | 兼容 | 兼容 | 阻塞 | 阻塞 |
表S锁 | 兼容 | 阻塞 | 兼容 | 阻塞 |
表X锁 | 阻塞 | 阻塞 | 阻塞 | 阻塞 |
行级锁
行锁、间隙锁、临键锁是InnoDB实现事务隔离性的关键机制,其锁定粒度最小,发生锁冲突的概率最低,并发度最高。
行级锁只在当前读语句生效,快照读不触发。
当前读的触发语句:
SELECT ... FOR UPDATE; -- 排他锁(X锁)
SELECT ... LOCK IN SHARE MODE; -- 共享锁(S锁)
UPDATE ...;
DELETE ...;
INSERT ...;
行锁(记录锁)
-
锁基于索引实现
InnoDB行锁通过锁住索引项实现。若SQL未使用索引,则会锁住聚簇索引(即主键索引)。 -
锁定的最小化原则
仅锁定查询访问路径涉及的索引记录,而非所有关联索引。例如:- 使用主键查询 → 仅锁主键索引;
- 使用二级索引查询 → 锁二级索引+主键索引;
- 全表扫描 → 锁聚簇索引所有行。
-
自动触发:
-- 对user_id=1的行加排他锁(X锁) UPDATE users SET balance = 100 WHERE user_id = 1-- 对user_id=2的行加共享锁(S锁) SELECT * FROM users WHERE user_id = 2 LOCK IN SHARE MODE
-
锁兼容性
当前锁 请求 S 锁 请求 X 锁 S 锁 允许 阻塞 X 锁 阻塞 阻塞 -
作用与意义:行锁主要用于控制对单个数据行的并发访问,从而支持更高级别的事务隔离,并减少数据操作冲突,提高系统的并发能力,保证数据的一致性和完整性。例如在高并发的电商订单系统中,对订单表的某一行记录加行锁,可防止多个事务同时修改同一订单数据,避免数据混乱。
间隙锁
锁定索引记录之间的间隙。
-
目的:防止其他事务在该间隙插入数据,主要用于避免RR隔离级别下的幻读问题。
-
触发条件:
-- 当查询条件无精确匹配时,锁定间隙 SELECT * FROM users WHERE age > 20 AND age < 30 FOR UPDATE; -- 锁定(20, 30)的间隙 -- 表中只有10、30的记录 SELECT * FROM users WHERE age = 20 -- 索引记录20不存在,锁定(10,30)的间隙区间
示例场景
假设
users
表有以下数据:id name age 1 Alice 20 2 Bob 30 -- 会话1(事务隔离级别:可重复读) BEGIN; SELECT * FROM users WHERE age BETWEEN 20 AND 30; -- 返回2行-- 会话2 INSERT INTO users (name, age) VALUES ('Charlie', 25); -- 插入age=25的行-- 会话1再次查询 SELECT * FROM users WHERE age BETWEEN 20 AND 30; -- 仍返回2行(无幻读,因间隙锁)
当会话1执行范围查询语句后,会锁定间隙(20,30)阻塞范围内的写操作,直到会话1事务提交。
- 自动触发:在RR隔离级别下间隙锁自动触发,触发条件包括非唯一索引、范围查询、唯一索引等值查询未命中。
- RR仍然存在的幻读问题
- 在RR隔离级别下,MySQL提供了两种机制
- MVCC(快照读)
- 事务开始时生成一致性视图。
- 普通
SELECT
语句。 - 读取事务开始时的数据。
- 锁机制(当前读)
SELECT ... FOR UPDATE
- 读取最新的数据。
- MVCC(快照读)
- 当事务一开始使用快照读,快照读后其他事务进行了写操作
DELETE
INSERT
,然后当前事务又使用了当前读,就会出现幻读现象。
- 在RR隔离级别下,MySQL提供了两种机制
临键锁
是InnoDB默认的行锁算法,本质是行锁和间隙锁+间隙锁的组合。
-
锁定范围:锁定一个左开右闭的区间(a,b]。锁定的是索引记录b本身(行锁),以及该记录之前的间隙(a,b)(间隙锁)。
-
幻读问题场景:
- 事务 A 第一次查询
WHERE age > 20
,返回 3 条记录。 - 事务 B 插入一条新记录
age = 25
并提交。 - 事务 A 再次相同查询,多出一条记录(幻影行)。
- 事务 A 第一次查询
-
临键锁如何解决:
- 事务 A 执行
SELECT * FROM users WHERE age > 20 FOR UPDATE
时:- 会对
age > 20
的所有索引范围加临键锁。 - 例如:若存在
age=18, 22, 30
的记录,则锁定的区间包括:(18, 22]
(锁定age=22
的行 +(18,22)
的间隙)(22, 30]
(锁定age=30
的行 +(22,30)
的间隙)(30, +∞)
(最后一个记录之后的间隙锁)。
- 会对
- 效果:事务 B 无法在
age>20
的任何间隙(如(18,22)
、(22,30)
等)插入新数据(如age=25
),从而彻底杜绝幻读。
- 事务 A 执行
-
退化行为:
- RU、RC隔离级别:
- 加行锁
- 不会使用间隙锁
- 临键锁退化为行锁
- 唯一索引未命中时:
- 不加行锁
- 加间隙锁(查询值在索引中应在的索引间隙)
- 临键锁退化为间隙锁
- 唯一索引等值查询命中:
- 加行锁
- 不加间隙锁
- 临键锁退化为行锁
- 无索引查询
- 退化为表锁
- 锁定所有行和间隙
- RU、RC隔离级别:
FOR UPDATE
(排他锁)
作用:
对查询涉及的行(及间隙)加 排他锁(X 锁),其他事务无法修改或加锁这些数据。
行为详解:
特性 | 说明 |
---|---|
锁类型 | 排他锁(X Lock) |
其他事务的读操作 | 普通 SELECT (快照读)可读取,但加锁读(如 FOR SHARE )会被阻塞 |
其他事务的写操作 | 所有写操作(UPDATE/DELETE/INSERT)都会被阻塞 |
锁范围 | 在 REPEATABLE READ 下默认加 临键锁(Next-Key Lock),锁住行+间隙 |
使用场景 | 需要修改数据前确保数据不被其他事务修改(如库存扣减、订单状态更新) |
示例:
START TRANSACTION;
-- 对 id=100 的行加排他锁(其他事务无法修改或加锁)
SELECT * FROM orders WHERE id = 100 FOR UPDATE;
-- 执行更新操作(此时数据已被锁定,安全)
UPDATE orders SET status = 'paid' WHERE id = 100;
COMMIT;
FOR SHARE
(共享锁)
作用:
对查询涉及的行(及间隙)加 共享锁(S 锁),允许其他事务读,但禁止修改。
行为详解:
特性 | 说明 |
---|---|
锁类型 | 共享锁(S Lock) |
其他事务的读操作 | 允许其他事务加 FOR SHARE 锁或普通 SELECT (快照读) |
其他事务的写操作 | 禁止其他事务加 FOR UPDATE 或执行写操作(UPDATE/DELETE/INSERT) |
锁范围 | 在 REPEATABLE READ 下也可能加 间隙锁/临键锁(防止幻读) |
使用场景 | 需要读取数据并确保在事务结束前数据不被修改(如校验数据一致性) |
示例:
START TRANSACTION;
-- 对用户余额加共享锁(其他事务可读但不可修改)
SELECT balance FROM accounts WHERE user_id = 5 FOR SHARE;
-- 检查余额是否充足(此时余额不会被其他事务修改)
IF balance >= 100 THENUPDATE accounts SET balance = balance - 100 WHERE user_id = 5;
END IF;
COMMIT;
注意:
MySQL 8.0 之前使用LOCK IN SHARE MODE
,8.0+ 推荐使用标准语法FOR SHARE
。
For UPDATE
在不同隔离级别下的加锁行为
读未提交(RU)
加锁行为
- 行锁:对查询命中的索引记录加排他行锁,但不加间隙锁(Gap Lock) 和临键锁(Next-Key Lock)。
- 无间隙保护:允许其他事务在锁定记录的间隙中插入新行,无法防止幻读。
示例
假设users
表有age
索引,数据为(10, 20, 30)
:
-- 会话1(RU隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
-- 命中age=20的记录,加行锁
SELECT * FROM users WHERE age = 20 FOR UPDATE;-- 会话2
-- 允许插入间隙(20, 30)中的数据(无间隙锁)
INSERT INTO users (age) VALUES (25); -- 成功
-- 修改age=20的记录被阻塞(行锁生效)
UPDATE users SET name = 'test' WHERE age = 20; -- 阻塞
读已提交(RC)
加锁行为
- 行锁:对查询命中的索引记录加排他行锁。
- 禁用间隙锁:InnoDB 在 RC 级别会自动关闭间隙锁,临键锁退化为行锁(仅锁记录,不锁间隙)。
示例
-- 会话1(RC隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 范围查询,命中age=20,加行锁;但不锁间隙(10,20)和(20,30)
SELECT * FROM users WHERE age BETWEEN 10 AND 30 FOR UPDATE;-- 会话2
-- 允许插入间隙中的数据(无间隙锁)
INSERT INTO users (age) VALUES (25); -- 成功
-- 修改age=20的记录被阻塞(行锁生效)
UPDATE users SET name = 'test' WHERE age = 20; -- 阻塞
结果
会话 1 再次执行相同查询时,会读取到会话 2 插入的age=25
的新行(幻读发生)。
可重复读(RR)
加锁行为
- 行锁 + 临键锁 / 间隙锁:
- 若查询条件为唯一索引精确匹配(如
WHERE id = 10
,id
是主键):仅加行锁(无间隙锁)。 - 若查询条件为非唯一索引或范围查询(如
WHERE age > 10
、WHERE age = 20
且age
非唯一):加临键锁(行锁 + 间隙锁),锁定记录及前面的间隙。
- 若查询条件为唯一索引精确匹配(如
- 防止幻读:通过间隙锁阻止其他事务在锁定范围内插入新行。
唯一索引精确匹配
-- 表结构:id为主键(唯一索引),数据为(1, 3, 5)
-- 会话1(RR隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
-- 唯一索引精确匹配,仅加行锁(无间隙锁)
SELECT * FROM users WHERE id = 3 FOR UPDATE;-- 会话2
-- 允许插入间隙(3,5)中的数据(无间隙锁)
INSERT INTO users (id) VALUES (4); -- 成功
-- 修改id=3的记录被阻塞(行锁生效)
UPDATE users SET name = 'test' WHERE id = 3; -- 阻塞
非唯一索引范围查询
-- 表结构:age为非唯一索引,数据为(10, 20, 30)
-- 会话1(RR隔离级别)
BEGIN;
-- 范围查询,加临键锁,锁定范围(10, 20]和(20, 30]
SELECT * FROM users WHERE age BETWEEN 10 AND 30 FOR UPDATE;-- 会话2
-- 插入间隙(10,20)中的数据被阻塞(间隙锁生效)
INSERT INTO users (age) VALUES (15); -- 阻塞
-- 插入间隙(20,30)中的数据被阻塞(间隙锁生效)
INSERT INTO users (age) VALUES (25); -- 阻塞
-- 修改age=20的记录被阻塞(行锁生效)
UPDATE users SET name = 'test' WHERE age = 20; -- 阻塞
串行化(S)
加锁行为
- 最严格锁定:所有
SELECT ... FOR UPDATE
会锁定所有访问的行和间隙,等价于 “行锁 + 全表间隙锁”。 - 串行执行:所有事务按顺序执行(本质就是严格的加锁行为导致事务只能串行执行),完全禁止并发修改,自然防止脏读、不可重复读和幻读。
- 普通 SELECT 的特殊处理:即使不加
FOR UPDATE/SHARE
,SELECT
也会自动加共享锁(S 锁)(隐式转化为**SELECT ... FOR SHARE
**),阻塞其他事务的写操作。
示例
-- 会话1(串行化隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
-- 锁定age=20的记录及所有相关间隙
SELECT * FROM users WHERE age = 20 FOR UPDATE;-- 会话2
-- 所有操作均被阻塞(包括查询和插入)
SELECT * FROM users WHERE age = 20; -- 阻塞(等待共享锁)
INSERT INTO users (age) VALUES (25); -- 阻塞(间隙被锁定)
UPDATE users SET name = 'test' WHERE age = 20; -- 阻塞(行锁生效)
InnoDB存储引擎
InnoDB存储结构
表空间
则每张表都会有一个表空间(xxx.ibd),一个mysql实例可以对应多个表空间
- 系统表空间
- 存储数据字典(表结构定义、索引信息等)、Change Buffer、Doublewrite Buffer
- undo log,默认在此可更改到独立表空间
- 默认存储在
ibdata1
文件中
- 独立表空间
- 每个表单独对应一个
.ibd
文件(存储表数据和索引)
- 每个表单独对应一个
- 通用表空间
- 存储多个表的数据和索引
- 临时表空间
- 临时表数据
CREATE TEMPORARY TABLE
- 排序和聚合操作的临时数据
ORDER BY
、GROUP BY
等 - JOIN多表连接的临时数据
- 临时表数据
- Undo 表空间
- 存储 Undo Log(默认位于系统表空间,可分离)
段
- 数据段:B+树叶子节点
- 索引段:B+树非叶子结点
- 回滚段:管理undo log
区
- 连续分配的最小单元(1区 = 64个连续页 = 1MB也就是)
- 作用:减少随机 I/O(预分配连续空间),避免大量小页零散分布
页
- 磁盘IO最小单元(默认 16KB)
行
- InnoDB 存储引擎数据是按行进行存放的
InnoDB的内存架构
核心组件
缓冲池Buffer Pool
- 作用:缓存磁盘数据页,减少磁盘IO操作
- LRU算法(最近最少使用)
- 分区管理:
- 新子列表 (37%):频繁访问的热数据
- 旧子列表 (63%):新加载的冷数据
- 在专用服务器上,通常将多达**80%**的物理内存分配给缓冲池
日志缓冲区Log Buffer
- 用来保存要写入到磁盘中的log日志数据(redo log 、undo log), 默认大小为 16MB,日志缓冲区的日志会定期刷新到磁盘中。如果需要更新、插入或删除许多行的事 务,增加日志缓冲区的大小可以减少磁盘 I/O。
- 参数:
- innodb_log_buffer_size:缓冲区大小
- innodb_flush_log_at_trx_commit:日志刷新到磁盘时机,取值主要包含以下三个:
- 1: 日志在每次事务提交时写入并刷新到磁盘,默认值。
- 0: 每秒将日志写入并刷新到磁盘一次。
- 2: 日志在每次事务提交后写入,并每秒刷新到磁盘一次。
更改缓冲区Change Buffer
针对非唯一二级索引,在执行DML语句时,如果这些语句不在Buffer Pool中,不会直接操作磁盘进行修改,而是先将数据变更存在Change Buffer中,在未来数据读取时,将数据合并到Buffer Pool中,再将合并后的数据刷新到磁盘中。
自适应哈希索引
自适应hash索引,用于优化对Buffer Pool数据的查询。MySQL的innoDB引擎中虽然没有直接支持 hash索引,但是给我们提供了一个功能就是这个自适应hash索引。因为前面我们讲到过,hash索引在 进行等值匹配时,一般性能是要高于B+树的,因为hash索引一般只需要一次IO即可,而B+树,可能需 要几次匹配,所以hash索引的效率要高,但是hash索引又不适合做范围查询、模糊匹配等。 InnoDB存储引擎会监控对表上各索引页的查询,如果观察到在特定的条件下hash索引可以提升速度, 则建立hash索引,称之为自适应hash索引。
MVCC
多版本并发控制,是数据库实现高并发访问的核心技术,维护一个数据的多个版本,使得MySQL能在RR和RC级别不使用锁机制的情况下实现非阻塞读,同时保证事务的隔离性。
RU读取最新的数据版本,除事务回滚用到undo log不涉及MVCC快照读。
S将所有读操作隐式转换为当前读(FOR SHARE),同样不涉及快照读。
MVCC 核心组成
组件 | 作用 |
---|---|
Undo Log | 存储数据历史版本链 |
---------------- | |
Read View | 事务开启时生成的"数据可见性快照" |
----------------- | |
表中隐藏列 | 记录事务版本信息 |
DB_TRX_ID | 最近修改/插入该数据的事务ID,最后一次修改该记录的事务ID |
DB_ROLL_PTR | 指向 Undo Log 的指针(用于回溯历史版本),指向上一个版本 |
undo log
回滚日志,是一种逻辑日志但记录的数据修改前的物理行数据值。是InnoDB引擎中实现事务原子性、一致性和MVCC的重要机制。记录事务对数据的修改操作,用于事务回滚时提供撤销修改的数据依据,或在快照读时提供历史版本数据。
-
undo log类型
- Insert undo log(插入回滚日志):仅用于记录
INSERT
操作。- 记录内容:插入的完整行数据(包括所有字段值)。
- 原因:插入的记录在事务提交前,仅对当前事务可见,其他事务无法访问。若事务回滚,只需通过 undo log 定位到这些插入的行,直接删除即可(反向操作是 “删除插入的行”,而 undo log 记录行数据是为了精准定位要删除的记录)。
- Update undo log(更新回滚日志):用于记录
UPDATE
和DELETE
操作(注:InnoDB 中DELETE
本质是标记删除,也属于特殊的更新)。- 记录内容:被修改行的旧版本数据(包括所有字段值),而非抽象的 “反向操作逻辑”。
- 原因:更新 / 删除操作会改变行的已有数据,回滚时需要恢复到修改前的状态。例如,若将
age=20
改为age=30
,undo log 会记录age=20
(旧值)以及未修改的字段数据,回滚时直接用旧值覆盖新值即可;若删除一行,undo log 会记录该行删除前的完整数据,回滚时重新插入该数据(恢复删除)。
- Insert undo log(插入回滚日志):仅用于记录
-
存储方式
-
存储在 InnoDB 的undo 表空间。
-
按 “段”管理,每个事务会分配一个或多个 undo log 段。
-
核心作用
-
事务回滚
- 当事务回滚
ROLLBACK
或数据库崩溃,InnoDB
通过undo log
实现对数据修改的撤销,恢复到事务开始时的状态。 - 示例
BEGIN; UPDATE users SET balance = 100 WHERE id = 1; -- 记录undo log(旧值balance=50) DELETE FROM orders WHERE id = 10; -- 记录undo log(旧记录完整信息) ROLLBACK; -- 执行undo log:balance恢复为50,orders表恢复id=10的记录
- 当事务回滚
-
MVCC支持
- 快照读(普通SELECT)时,InnoDB通过undo log获取数据的历史版本,确保事务执行过程中看到的是事务开始时的一致性视图,不会受其他事务影响,避免了脏读、不可重复读、幻读。
事务回滚支持
- 事务开始:分配undo log空间。
- 修改操作:每次执行写操作,将旧的数据版本写入undo log。
- 例如:
UPDATE t SET a=2 WHERE id=1
(原 a=1),undo log 记录(id=1, a=1)
。
- 例如:
- 事务提交:
- INSERT undo log:直接标记为可删除。
- UPDATE/DELETE undo log:保留,供其他事务的快照读使用,由 purge 线程后续清理。
- 事务回滚:反向执行 undo log 中的操作(如将 a=2 恢复为 a=1), v 彻底撤销事务影响。
MVCC支持
- 配合每行数据隐藏列DB_TRX_ID(记录最后一次修改的事务ID)和DB_ROLL_PTR(指向undo log的指针)。
- 当事务需要获取对应的数据版本时,通过DB_ROLL_PTR遍历undo log获取符合当前事务可见性的版本。
版本链
版本链是快照读(普通SELECT
)实现一致性视图的核心。
版本链的每个节点对应事务对某行的一次修改
,而非一个事务的多次修改。版本链是通过行记录的 roll_ptr
指针和 undo log 记录的 prev
指针串联形成的,每一次修改都会生成一个新的 undo log 节点。
示例:
一张表的原始数据为:
id | age | name | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
30 | 30 | A30 | 1 | null |
四个并发事务同时访问这张表
事务2 | 事务3 | 事务4 | 事务5 |
---|---|---|---|
开始事务 | 开始事务 | 开始事务 | 开始事务 |
修改age=3(id=30) | 查询id=30的记录 | ||
提交事务 | |||
修改name=A3(id=30) | |||
查询id=30的记录 | |||
提交事务 | |||
修改age=10(id=30) | |||
查询id=30的记录 | |||
查询id=30的记录 | |||
提交事务 |
当事务2执行修改时,创建最新的版本(age=3),旧数据会记录在undo log日志,形成下图版本链:
当事务3执行修改操作时,创建新的版本(name=A3),旧数据(age=3,非整行数据)会记录在undo log,新版本DB_ROLL_PTR指向修改前旧版本:
当事务3执行修改操作时,创建新的数据版本,旧数据(age=3)记录在undo log,新数据版本DB_ROLL_PTR指向修改前的旧版本:
不同事务或相同事务对同一记录进行修改,会导致该记录的undo log形成一条不同版本的版本链表,链表头部是最新的数据版本,尾部是最早的数据版本。
Read View
一致性视图,在事务开始时创建,记录了事务启动时活跃事务状态。通过比对Read View中的参数和undo log中数据版本的事务ID,可以判断事务在某时间点能看到的数据版本范围,是事务内一致性读的关键。
Read View的组成
Read View包含四个核心字段
字段 | 含义 |
---|---|
m_ids | 当前活跃的事务ID集合 |
min_trx_id | 最小活跃事务ID |
max_trx_id | 即将分配的事务ID,即当前最大事务ID+1 |
creator_trx_id | 创建当前Read View的事务ID |
活跃事务:指Read View创建时还未提交的事务。
创建Read View的时机
不同的隔离级别下创建Read View的时机也不同:
- RC:在事务每次快照读时创建。
- RR:在事务第一次快照读时创建,后续快照读复用当前Read View。
判断可见性
当事务访问某一行数据时,会遍历其undo log版本链,找到该事务可见的数据版本,trx_id是undo log版本链中的DB_trx_id(创建该版本的事务ID)。
判断规则:
条件 | 是否可见 | 说明 |
---|---|---|
trx_id == creator_trx_id | 可见 | 该版本是当前事务自己修改的 |
trx_id < min_trx_id | 可见 | 该版本在Read View创建前就已提交 |
trx_id >= max_trx_id | 不可见 | 该版本在Read View创建后才创建 |
trx_id ∈ m_ids | 不可见 | 该版本由Read View创建时未提交的事务修改 |
trx_id ∉ m_ids 且 min_trx_id ≤ trx_id < max_trx_id | 可见 | 该版本在Read View创建时已提交 |
隔离级别的实现原理
事务隔离级别的实现是MVCC和锁机制配合的结果。
涉及到的核心机制:
机制 | 作用 | 适用的隔离级别 |
---|---|---|
MVCC(undo log+ReadView ) | 实现非阻塞读(快照读),通过版本链提供一致性视图 | RC,RR |
临键锁(间隙锁+记录锁) | 锁定索引间隙和记录,防止插入和修改,解决幻读、脏写 | RR,S |
间隙锁 | 锁定索引间隙,防止插入,避免幻读 | RR,S |
行锁(记录锁) | 锁定单行索引记录,避免写冲突(脏写) | 所有写操作 |
undolog | 事务回滚 |
读未提交RU
核心特性:直接读取最新的数据(包括未提交的数据变化)脏读,所以RU的实现不依赖ReadView
。
- undo log的表现:
- 读操作直接访问最新的数据版本(包括未提交的修改)。
- undo log仅用于事务回滚。
- 锁机制:
- 写操作加排它锁(X锁),持锁至事务结束,避免脏写。
- 不会阻止该行的读操作,读操作不会加锁,排它锁只阻塞尝试获取锁的操作。
- 读操作(包括当前读)不加锁,导致脏读。
- 写操作加排它锁(X锁),持锁至事务结束,避免脏写。
读已提交RC
核心特性:避免脏读、不可重复读问题、幻读问题、读已提交的最新数据版本。
- MVCC:
- 每次快照读创建新的
ReadView
,保证每次读取的都是最新的已提交版本,快照读在undo log版本链找到事务可见的数据版本(当前快照读时最新已提交的数据版本)。 - 因为使用
ReadView
,利用ReadView
中关于ReadView
创建时的参数(m_ids等)与undolog版本链的事务ID参数(DB_trx_ID)比对,能避免读到活跃事务修改的数据版本,以此避免脏读问题。 - 会因为每次快照读都创建新的
ReadView
,每个Readview
可见的数据版本可能不同,造成不可重复读的问题。
- 每次快照读创建新的
- 锁机制:
- 当前读加记录锁,持锁至事务结束,锁定当前行,避免其他事物修改该行数据,造成脏写。
- 不加间隙锁,会出现幻读。
可重复读RR
-
MVCC:
- 第一次快照读时创建
ReadView
,该事务内所有快照读会在共用该ReadView
在undo log版本链上找到事务可见的数据版本(事务开始时已提交的数据版本),避免脏读和不可重复读。 - 只使用MVCC快照读读取固定的一个数据版本,不会出现幻读问题。
- 第一次快照读时创建
-
锁机制:
- 当前读使用临键锁,防止幻读和脏写。
- 只使用当前读,或第一次读操作是当前读,会对查询的数据范围加临键锁,即便之后在锁范围内再使用快照读也不会出现幻读问题。但是如果之后的快照读不在锁定范围并且又使用当前读暴露了其他事务的修改,也会出现不可重复读和幻读。
-
仍存在的幻读问题:
-
快照读当前读混合读
- 由快照读读取事务开始时的数据版本变成读取最新版本的当前读,且中间有其他事务修改该数据。
-- 事务 A (RR) BEGIN; -- 快照读:基于 MVCC 首次 Read View SELECT * FROM users WHERE age > 20; -- 返回 2 行 (id=30,40)-- 事务 B 插入并提交:INSERT INTO users(age) VALUES(25); -- id=50-- 当前读:直接读取最新数据(绕过 MVCC) SELECT * FROM users WHERE age > 20 FOR UPDATE; -- 返回 3 行 (id=30,40,50) COMMIT;
-
快照读更新操作引发数据可见(隐式当前读与快照读混用)
- 更新使其他事务插入的行可见
-- 事务A (RR) BEGIN; SELECT * FROM users WHERE age>20; -- 快照读:返回id=30 (age=30)-- 事务B:INSERT INTO users(age) VALUES(25); COMMIT;UPDATE users SET status=1 WHERE age>20; -- 当前读:更新id=30和id=新行 SELECT * FROM users WHERE age>20; -- 看到id=30和id=新行
- 事务与其他事务更新不同列
-- 事务A (RR) BEGIN; SELECT * FROM users WHERE id=1; -- 看到(name='A', age=20)-- 事务B:UPDATE users SET name='B' WHERE id=1; COMMIT;-- 事务A更新不同列 UPDATE users SET age=21 WHERE id=1; -- 当前读:基于(name='B', age=20)更新 SELECT * FROM users WHERE id=1; -- 看到(name='B', age=21)
- 其他事务删除数据
-- 事务A (RR) BEGIN; SELECT * FROM users WHERE id=1; -- 看到数据-- 事务B:DELETE FROM users WHERE id=1; COMMIT;UPDATE users SET age=21 WHERE id=1; -- 0 rows affected(数据已不存在) SELECT * FROM users WHERE id=1; -- 无结果
- 本事务与其他事务修改不同行
-- 初始数据:id=1, col1=100, col2=200 -- 事务A (RR) | 事务B ----------------------|------------------- BEGIN; | SELECT col1 FROM t; | BEGIN; --> 100 || UPDATE t SET col2=300;| COMMIT; UPDATE t SET col1=150;| SELECT * FROM t; | --> col1=150, col2=300|
-
原因分析:
- RR 通过事务开始时固定的 ReadView 确保快照读避免不可重复读和幻读。但更新操作(隐式当前读)会绕过 ReadView 直接读取最新数据版本,继承其他事务的修改(包括插入/删除/更新),并将修改后的数据以本事务 ID 写入新版本。这导致:
- 若其他事务插入新行且匹配更新条件 → 幻读
- 若其他事务更新同一行 → 不可重复读
- 若其他事务删除行且尝试更新该行 → 行消失(不可重复读)
- RR 通过事务开始时固定的 ReadView 确保快照读避免不可重复读和幻读。但更新操作(隐式当前读)会绕过 ReadView 直接读取最新数据版本,继承其他事务的修改(包括插入/删除/更新),并将修改后的数据以本事务 ID 写入新版本。这导致:
-
解决办法:
- 读操作使用加锁读,也是串行化的解决方案吗,但业务中可考虑上述情况是否会出现。
-
串行化S
- MVCC:
- 禁止快照读,所有读装换为当前读。
- 锁机制:
- 将普通读操作隐式加**
SELECT ... FOR SHARE
(共享锁)**。 - 每次读操作都会对查询范围内的数据行和间隙加临键锁,彻底避免幻读和不可重复读。
- 将普通读操作隐式加**
事务原理
Undo Log回滚
前像版本
- 事务回滚要将数据恢复到前像版本,而前像版本指的是数据行隐藏字段DB_ROLL_PTR指向的undo log版本链的直接前驱版本,从最新的修改开始执行create_trx_id是当前事务id的版本链的反向逻辑就能恢复行数据版本。
- DB_ROLL_PTR指向的版本链中的版本一定是在该版本创建时已提交的事务修改的,mysql的写操作是隐式加锁读(当前读),对同一数据行的写操作事务一定是串行执行的。
- 除了可用于回滚的直接前驱版本,也就是更早版本,依然存在是MVCC给其他未提交且可见此版本的事务用于快照读的。
不同类型的 Undo Log 中旧版本的存储内容和回滚操作
操作类型 | 存储内容 | 回滚操作 |
---|---|---|
UPDATE | 被修改前行数据的完整版本(含所有字段旧值) | 用 undo log 中记录的旧值覆盖当前行数据,恢复 DB_TRX_ID 和 DB_ROLL_PTR 为修改前的状态,撤销字段更新。 |
DELETE | 整行数据的完整版本(含所有字段旧值,相当于特殊更新的旧状态) | 清除行的删除标记(DELETE_BIT ),用 undo log 中的旧值恢复行数据可见性,DB_TRX_ID 和 DB_ROLL_PTR 回退到删除前的版本。 |
INSERT | 新插入行的完整主键信息(主键值及元数据) | 根据主键定位到插入的行,执行物理删除(因插入行未提交,其他事务不可见,删除后无残留)。 |
回滚核心流程:逆向遍历 undo log 并执行反向操作
回滚过程会从事务的最后一个修改操作开始,逆向遍历事务的 undo log 链表,逐个对每个操作执行 “反向逻辑”,直到所有修改被撤销。具体步骤如下:
步骤 1:定位事务的 undo log 链表
InnoDB 通过事务 ID 找到该事务对应的 undo log 链表,链表的 “头节点” 是事务最后一次修改生成的 undo log 记录,“尾节点” 是事务第一次修改生成的 undo log 记录。
步骤 2:从最后一个修改开始逆向处理
回滚按 “逆序” 处理每个 undo log 记录(即先撤销最后执行的操作,再撤销倒数第二个,以此类推),确保数据恢复的正确性。以下按操作类型分述:
场景 1:撤销 INSERT 操作(基于 Insert undo log)
- undo log 内容:记录了插入行的完整数据(含主键)。
- 反向操作:根据 undo log 中的主键定位到插入的行,直接删除该行(因为插入的行在事务提交前仅对当前事务可见,删除后其他事务无法感知)。
- 示例:事务内执行
INSERT INTO user VALUES (1, '张三')
,回滚时通过 Insert undo log 找到id=1
的行,执行删除。
场景 2:撤销 UPDATE 操作(基于 Update undo log)
- undo log 内容:记录了被修改行的完整旧版本数据(修改前的所有字段值)。
- 反向操作:根据 undo log 中的主键定位到数据行,用旧版本数据覆盖当前版本(即恢复
DB_TRX_ID
为旧版本的事务 ID,DB_ROLL_PTR
指向旧版本的前驱 undo log)。 - 示例:事务内先执行
UPDATE user SET age=30 WHERE id=1
(原 age=20),回滚时通过 Update undo log 找到id=1
的行,将 age 恢复为 20,DB_ROLL_PTR
指向修改前的旧版本 undo log。
场景 3:撤销 DELETE 操作(基于 Update undo log)
- undo log 内容:记录了被删除行的完整旧版本数据(删除前的所有字段值)。
- 反向操作:根据 undo log 中的主键定位到被标记删除的行,恢复其数据为旧版本(清除删除标记
delete_flag
),并更新DB_TRX_ID
和DB_ROLL_PTR
为旧版本信息。 - 示例:事务内执行
DELETE FROM user WHERE id=1
,回滚时通过 Update undo log 找到id=1
的行,恢复其数据(取消删除标记),使其可见性恢复到删除前的状态。
初始状态
账户表 (accounts)
+----+-------+---------+
| id | name | balance |
+----+-------+---------+
| 1 | Alice | 1000.00 |
| 2 | Bob | 500.00 |
+----+-------+---------+
事务操作序列
BEGIN; -- 事务A开始
-- 操作1:Alice转出100
UPDATE accounts SET balance = 900.00 WHERE id = 1;
-- 操作2:Bob转入100
UPDATE accounts SET balance = 600.00 WHERE id = 2;
数据页未持久化
初始状态
回滚流程
结果:
- 内存数据恢复为每项修改数据的前像
- 磁盘数据保持 (无需操作)
- 无磁盘 I/O 发生
数据页全部持久化
初始状态
操作1持久化:Page1已刷盘 → id=1, balance=900
操作2持久化:Page2已刷盘 → id=2, balance=600Undo Log:记录1: id=1, old_balance=1000记录2: id=2, old_balance=500
回滚流程
关键步骤详解:
- 内存回滚
- 立即将内存中的数据恢复为前像值
- 缓冲池标记为脏页(因为与磁盘不一致)
- Redo Log 保护
- 保证回滚操作本身的持久性
- 数据页刷盘
- 后台线程将恢复后的数据刷到磁盘
- 刷盘过程依然通过 Doublewrite 防止页断裂
部分数据页持久化
初始状态
操作1持久化:Page1已刷盘 → id=1, balance=900
操作2未持久化:Page2在内存 → id=2, balance=600 (脏页)Undo Log:记录1: id=1, old_balance=1000记录2: id=2, old_balance=500
回滚流程
关键步骤详解:
- 内存回滚
- 不论是否持久化都将内存中的数据恢复为前像值
- 已经持久化的数据页将缓冲池标记为脏页(因为与磁盘不一致)
- 未持久化的数据页将脏页标识去除(与磁盘数据一致,无需刷盘)
- Redo Log 保护
- 保证回滚操作本身的持久性
- 数据页刷盘
- 后台线程将恢复后的数据刷到磁盘
- 刷盘过程依然通过 Doublewrite 防止页断裂
Redo Log
-
重做日志,记录的是事务提交时数据页的物理修改,在刷新脏页到磁盘中发生错误时或数据库崩溃时,用于数据恢复,以实现事务持久性。
-
组成:Redo Log Buffer(重做日志缓冲)和Redo Log File(重做日志文件),前者在内存中,后者在磁盘中
-
脏页:在执行事务的增删改操作时会先对内存中的
Buffer Pool
缓冲池进行修改,如果缓冲池中不存在则会由后台线程将数据从磁盘中读出存放在缓冲池中,并对数据进行修改,修改后的数据页就称为脏页(与磁盘中数据不一致)。 -
Redo Log解决的问题:后台线程会在一定时机将脏页刷新到磁盘中,但刷新不是实时的,如果事务已提交并返回成功,但是如果在未成功刷盘时出错或崩溃
- 导致已提交的事务丢失,事务的持久性就未能保证。
- 未提交的事务的部分数据页被刷新到磁盘中,导致数据不一致。
WAL日志先行
-
日志先行,所有的数据页修改前必须先将对应的修改记录写入到日志,并保证日志落盘以保证事务的持久性。
-
工作流程
-
事务执行阶段
事务在修改数据页时会同步生成redo log记录(包括表空间ID、页号、偏移量、修改值等物理信息)
- 物理逻辑日志:记录页级别的物理修改,而非 SQL 语句
- 实时生成:每条数据修改都立即产生日志
- 内存缓冲:日志暂存内存,未直接落盘
- 设计目的:利用内存缓冲避免每次修改都触发磁盘 I/O,大幅提升事务执行效率。
- 事务提交阶段
- 事务提交时,根据
innodb_flush_log_at_trx_commit
参数决定日志的刷盘策略。- 策略1(默认安全):立即将 Log Buffer 中的日志刷到磁盘文件
- 策略2(平衡):仅写入操作系统缓存
- 策略0(高性能):依赖后台线程异步刷盘
- 保证事务提交时,相关redo log至少进入操作系统持久化层,满足事务的持久化要求。
- 事务提交时,根据
- 后台处理阶段
- 当日志文件写满 75% 时,触发 Checkpoint(检查点)
- 将内存中最早的脏页刷入磁盘
- 更新系统表空间中的
checkpoint_lsn
- 回收已刷盘日志的存储空间
- 崩溃恢复阶段
- 定位系统表空间中的
checkpoint_lsn
(最近一次刷盘成功的点) - 从LSN开始扫描Redo Log文件
- Redo重做:重新应用Redo Log中的所有日志记录,恢复数据页状态。
- 注意:Redo重做操作并不是直接去修改磁盘上的数据页,而是将redolog记录的修改应用到缓冲池中对应的数据页上。如果缓冲池中没有对应的数据页,则从磁盘读取到缓冲池,然后在缓冲池中应用Redo Log的修改。
- Undo回滚:根据Undo Log回滚所有未提交事务的修改(这些事务无法继续完成,回滚保证一致性)。
- 未持久化修改:恢复内存数据前像,与磁盘数据一致,去除脏页标识。
- 已持久化修改:恢复内存数据前像,与磁盘数据不一致,标记脏页,添加回滚Redo,刷盘后将数据恢复值前像版本。
- 定位系统表空间中的
- 如果不应用redo log,那么想保证事务的持久性,就要在事务提交时,将所有被该事务修改的脏页同步到磁盘中,这些脏页可能在磁盘中分散的位置,所以同步操作会涉及到大量的随机磁盘IO。
- WAL日志先行的机制下,读数据页的修改会以日志形式记录在redo log buffer,在事务提交时再将日志持久化到redo log文件中,而写入redolog文件的操作是追加写,只是一种高效的顺序写IO。
- 在redolog持久化到磁盘后,事务的持久性就已经被保证,即使数据库崩溃也可以依靠redo重放来恢复修改,所以缓冲池中脏页的刷盘就可以是
- 延迟的:
- 降低提交延迟,用户能更快得到提交成功的响应,
- 增加合并机会,让后续可能对一个页的修改在缓冲池中合并,最终只刷一次盘。
- 批量的:
- 分摊磁盘IO开销,一次磁盘IO的时间成本被分摊到了多个数据页上,平均每个页的IO成本降低。
- 分摊系统调用开销,一次系统调用的成本被多个数据页分摊。
- 可优化的
- 操作系统IO调度器,会尝试对批量的请求进行排序(如类似电梯算法 - SCAN或C-SCAN),使磁头移动路径更短,减少随机磁盘IO的性能损耗。
- 延迟的:
WAL将随机数据修改转化为顺序日志写入,避免每次修改都触发磁盘 I/O,大幅提升事务执行效率,并且延迟刷盘可以增加脏页修改合并机会。
-
事务原理实现
原子性 (Atomicity): “要么全做,要么全不做”
- 核心机制: Undo Log
- 实现过程:
- 执行任何修改(
INSERT/UPDATE/DELETE
)前,先在 Undo Log 中记录修改前的数据状态(旧值或反向操作逻辑)。(注意:写入 Undo Log 本身也是一个修改,会被 Redo Log 记录以保证 Undo Log 的持久性)。 - 修改内存中的数据页(产生脏页)。
- 提交 (Commit):
- 生成包含
COMMIT
标记的 Redo Log 记录并 强制刷盘 (fsync
)。(此时持久性已保证) - 脏页异步刷盘。
- 生成包含
- 回滚 (Rollback) / 失败:
- 引擎根据 Undo Log 中的记录,执行逻辑逆操作(如
DELETE
的逆操作是INSERT
,UPDATE
是恢复旧值),将数据恢复到事务开始前的状态。
- 引擎根据 Undo Log 中的记录,执行逻辑逆操作(如
- 关键点: Undo Log 提供了将事务所有修改“撤销”回去的能力。无论提交还是回滚,事务内的操作被视为一个不可分割的整体。Redo Log 保证了 Undo Log 操作本身的可靠性。
- 执行任何修改(
一致性 (Consistency): “数据库总是从一个一致状态转换到另一个一致状态”
- 核心机制: ACID 共同目标 + 数据库约束 + 应用逻辑
- 实现过程:
- 原子性 确保事务边界内的转换是原子的,不会停留在中间不一致状态。
- 隔离性 防止并发事务看到彼此未完成的不一致修改。
- 持久性 确保提交的状态是永久的,不会因崩溃丢失导致状态回退。
- 数据库约束 (主键、外键、唯一、非空、CHECK):在事务执行过程中(通常在语句级或事务提交时)进行校验。违反约束的操作会被拒绝,触发回滚(依赖 Undo Log)。
- 应用逻辑:业务规则需要开发者在事务代码中正确实现。
- 关键点: A、I、D 是实现 C 的基础手段。Undo Log 在回滚违反约束的操作、MVCC 在提供一致性读视图上都对一致性有直接贡献。
隔离性 (Isolation): “并发执行的事务相互隔离,感觉像串行执行”
- 核心机制: 锁机制 + MVCC (基于 Undo Log)
- 实现过程:
- 写-写冲突 (核心:锁机制):
- 当一个事务要修改某数据项时,必须先获得相应的锁(如行锁、X锁)。
- 其他事务试图修改同一数据项时会被阻塞(或根据隔离级别报错),直到锁释放。这保证了同一时间只有一个事务能修改特定数据,防止数据被并发写破坏。
- 例如(Repeatable Read):事务A修改行R时加X锁,事务B尝试修改R会被阻塞直到A提交/回滚释放锁。
- 读-写冲突 (核心:MVCC + Undo Log):
- MVCC 基础: 每行数据包含隐藏字段
DB_TRX_ID
(最后修改它的事务ID)和DB_ROLL_PTR
(指向该行在 Undo Log 中旧版本记录的指针),形成数据行的版本链。 - 快照读 (非锁定读): 当读操作发生时(在 RC 或 RR 级别下):
- 系统根据事务启动时刻(或语句开始时刻,取决于隔离级别)生成一个 Read View。Read View 包含当时所有活跃(未提交)事务ID列表。
- 通过
DB_ROLL_PTR
遍历版本链。 - 找到满足以下条件的版本:
- 创建该版本的事务ID
<
Read View 中最小活跃事务ID (说明该版本在事务开始时已提交)。 - 或 创建该版本的事务ID 在 Read View 中但等于自身事务ID (说明是自己修改的)。
- 且 该版本的
DB_TRX_ID
是链中满足上述条件的最大值 (即该事务开始时能看到的最新已提交版本)。
- 创建该版本的事务ID
- 读取该版本的数据(存储在 Undo Log 中)。读操作不阻塞写操作,写操作也不阻塞读操作。
- 例如(Repeatable Read):事务A开始时生成Read View V1。事务B在A之后修改并提交了行R。事务A再次读R时,通过V1和Undo Log链,仍然会读到B修改前的版本(快照)。
- MVCC 基础: 每行数据包含隐藏字段
- 关键点: 锁机制 直接处理并发写,强制串行化写操作。MVCC 利用 Undo Log 提供的历史版本,为读操作提供一致性视图,解决了读写冲突,极大提高了并发读性能。不同的隔离级别(RC, RR)主要通过调整 Read View 的生成时机(语句级/事务级)和锁的范围(如 RR 的间隙锁)来实现。Redo Log 保证了 Undo Log 版本链的持久性,支撑 MVCC 在崩溃恢复后仍有效。
- 写-写冲突 (核心:锁机制):
持久性 (Durability): “一旦事务提交,修改永久保存”
- 核心机制: Redo Log + WAL 原则
- 实现过程:
- 事务提交时,其产生的所有修改操作(包括数据修改和 Undo Log 的写入)对应的 Redo Log 记录(物理日志),以及一个标识事务提交的
COMMIT
记录,必须被 强制刷盘 (fsync
) 到持久化存储(Redo Log File)中。这是 WAL 原则的核心要求。 - 此时,即使系统立即崩溃,这些修改操作已安全保存在磁盘上。
- 内存中被修改的数据页(脏页)不需要在提交时立即刷盘。数据库会在后台选择合适的时间(Checkpoint 机制),将脏页批量、异步地写回磁盘数据文件。这极大提高了性能(将随机写转化为顺序写 + 延迟批量刷脏页)。
- 崩溃恢复:
- 数据库重启时,首先定位到 Redo Log 中最近的 Checkpoint(记录了当时哪些脏页已刷盘)。
- 从 Checkpoint 开始扫描 Redo Log。
- 重做 (Redo): 重新执行所有 Checkpoint 之后、日志末尾之前的、且带有
COMMIT
标记的 Redo Log 记录对应的操作。这确保了所有已提交事务的修改都被重新应用到数据文件。 - 回滚 (Undo): 对于 Redo Log 中存在但没有
COMMIT
标记的事务(崩溃时未提交的事务),利用 Undo Log 进行回滚(原理同原子性中的回滚),撤销这些未完成事务的修改。
- 关键点: 强制刷盘 Redo Log (含Commit标记) 是持久性的绝对保证。异步刷脏页是性能优化。崩溃恢复中的 Redo 阶段确保了已提交修改不丢失,Undo 阶段(依赖 Undo Log)保证了未提交修改被清除,共同维护了数据库状态的一致性。Undo Log 本身的写入也受 Redo Log 保护。
- 事务提交时,其产生的所有修改操作(包括数据修改和 Undo Log 的写入)对应的 Redo Log 记录(物理日志),以及一个标识事务提交的
日志
事务日志
重做日志Redo Log
InnoDB特有
- 事务持久性保证:确保已提交的事务不会因为数据库崩溃丢失。
- 崩溃恢复:数据库崩溃重启通过Redo 重放在缓冲池中恢复已提交事务修改的数据页,将数据库恢复值崩溃前的状态,然后在一定时机将脏页持久化到磁盘。
- 性能提升:通过WAL日志先行的操作,修改操作保证日志落盘,不需要立即写入事务修改的数据页来保证事务的持久性,从随机磁盘IO变成了顺序磁盘IO。
- 物理特性:记录的是物理逻辑日志(描述修改操作在哪个表空间哪个页做了什么修改)
回滚日志Undo Log
InnoDB特有
- 事务原子性保证:撤销未提交事务的修改,将修改的数据行恢复为Undo Log版本链的直接前驱版本,保证事务要么全部成功,要么全部回滚。
- MVCC(多版本并发控制)支持:维护数据行的多个版本,形成undo log版本链,配合事务ReadView判断该事务在该数据行的可见版本,为事务读操作提供一致性快照,实现非阻塞读的同时避免了并发事务读写冲突。
- 逻辑特性:记录的是逻辑日志(修改前旧值或恢复修改前状态所需的信息)
二进制日志Binlog
- 服务层实现:MySQL Server层实现,不涉及具体某个存储引擎。
- 主从同步:主库记录所有更改数据库的DDL(CREATE、ALTER)和DML(INSERT、UPDATE、DELETE)语句,作为从库同步数据的来源。
- 数据恢复:可以基于某个时间点的全量备份,加上从该时间点开始的Binlog进行数据恢复,恢复到该时间点之后Binlog有记录的任意时间点。
错误日志Error Log
- 故障诊断:记录MySQL Server启动、执行过程中的错误信息,警告信息。
- 启动问题:排查MySQL无法启动或异常终止的原因。
- 运行错误:记录SQL语句执行错误,崩溃堆栈信息等。
慢查询日志Slow Query Log
- 性能优化:记录执行超过阈值的SQL语句,帮助开发者找出可能需要优化的SQL。