事务与锁篇
1.1 MySQL的四大事务隔离级别分别是什么?MySQL默认是哪个?
✅ 正确回答思路:
好的,我从低到高说一下这四个隔离级别:
1. READ UNCOMMITTED(读未提交)
- 最低的隔离级别
- 一个事务可以读到另一个事务还没提交的数据
- 会出现脏读问题。比如事务A修改了一条数据但没提交,事务B就能读到这个修改。如果事务A回滚了,事务B读到的就是脏数据
- 实际工作中基本不用这个级别,性能虽然高但数据不可靠
2. READ COMMITTED(读已提交)
- 一个事务只能读到其他事务已提交的数据
- 解决了脏读问题
- 但还是会有不可重复读问题。比如事务A先查询一条记录,这时事务B修改了这条记录并提交,事务A再查询,发现数据变了,两次读的结果不一样
- Oracle数据库的默认隔离级别就是这个
3. REPEATABLE READ(可重复读) ⭐
- 这是MySQL(InnoDB引擎)的默认隔离级别
- 在同一个事务内,多次读取同样的数据,结果都是一样的,不管其他事务怎么改
- 解决了脏读和不可重复读问题
- 理论上还存在幻读问题,但InnoDB通过MVCC(多版本并发控制)和间隙锁(Gap Lock)基本解决了幻读
4. SERIALIZABLE(串行化)
- 最高的隔离级别
- 事务完全串行执行,就跟排队一样
- 解决了脏读、不可重复读、幻读所有问题
- 但性能最差,实际很少用,除非对数据一致性要求极高的场景
总结成表格:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✘ | ✘ | ✘ | 高 |
| READ COMMITTED | ✔ | ✘ | ✘ | 较高 |
| REPEATABLE READ(MySQL默认) | ✔ | ✔ | InnoDB基本解决 | 较低 |
| SERIALIZABLE | ✔ | ✔ | ✔ | 低 |
实际项目经验: 大部分互联网项目用MySQL默认的RR级别就够了。我之前有个金融项目,对数据一致性要求特别高,才考虑用SERIALIZABLE,但也只是在关键的转账等操作上用,其他查询还是用RR。
💡 如何查看和设置隔离级别:
sql
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 设置会话级别的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
1.2 什么是脏读、不可重复读、幻读?能举例说明吗?
✅ 正确回答思路:
这三个概念我用实际例子来说明,会更清楚:
1. 脏读(Dirty Read)
假设你在ATM上取钱,账户余额1000元:
| 时间 | 你的事务 | 银行系统的事务 |
|---|---|---|
| T1 | 开始扣款,余额改成700(但未提交) | |
| T2 | 查询余额,看到700 | |
| T3 | 发现扣款失败,回滚,余额还是1000 | |
| T4 | 以为只有700了,不敢花钱 |
你读到了银行事务还没提交的数据(700),这就是脏读。银行后来回滚了,你读到的700就是"脏数据"。
2. 不可重复读(Non-Repeatable Read)
你在查商品库存:
| 时间 | 你的事务 | 其他事务 |
|---|---|---|
| T1 | 开始事务,查询iPhone库存:100 | |
| T2 | 卖出10台iPhone,库存改成90,提交 | |
| T3 | 再次查询iPhone库存:90 | |
| T4 | 疑惑:刚才明明是100,怎么变90了? |
在同一个事务里,两次读同一条数据,结果不一样,这就是不可重复读 。重点是已有数据被修改了。
3. 幻读(Phantom Read)
你在统计订单数据:
| 时间 | 你的事务 | 其他事务 |
|---|---|---|
| T1 | 开始事务,查询今天的订单:100条 | |
| T2 | 新增了5条订单,提交 | |
| T3 | 再次查询今天的订单:105条 | |
| T4 | 奇怪:明明刚才是100条,怎么冒出5条? |
在同一个事务里,两次查询,结果集的行数变了 ,多了或少了几行,这就是幻读 。重点是新增或删除了数据。
三者的区别总结:
- 脏读: 读到了未提交的数据(最严重)
- 不可重复读 : 前后两次读,数据内容变了(一行数据的值变了)
- 幻读 : 前后两次读,数据行数变了(多了或少了几行)
记忆技巧:
- 脏读:读到"脏东西"(未提交的数据)
- 不可重复读:侧重UPDATE,前后读不一致
- 幻读:侧重INSERT/DELETE,像出现了"幻影"
💡 InnoDB如何解决幻读: "在RR隔离级别下,InnoDB通过两种方式基本解决了幻读:
- 普通的SELECT用MVCC,读取的是事务开始时的快照,所以看不到后来新插入的数据
- SELECT ... FOR UPDATE这种当前读,用Next-Key Lock(行锁+间隙锁),锁住记录和记录之间的间隙,防止其他事务插入数据"
1.3 MVCC是什么?怎么实现的?
✅ 正确回答思路:
MVCC这个问题比较有深度,我分几个层次来说:
首先,MVCC是什么:
- MVCC全称Multi-Version Concurrency Control,多版本并发控制
- 它是一种并发控制机制,让读操作不加锁,大大提高数据库并发性能
- InnoDB在READ COMMITTED和REPEATABLE READ两个隔离级别下都用了MVCC
然后,为什么需要MVCC:
传统的方式是:读操作加共享锁,写操作加排他锁,读和写会互相阻塞。比如事务A在读一条记录,事务B要修改这条记录就得等。用户量一大,大家都在等锁,性能就很差。
MVCC的思路是:给每行数据维护多个版本,读的时候读旧版本,写的时候写新版本,读写不冲突,就像你在看一本书的第1版,我同时在写第2版,互不影响。
具体实现机制:
1. 隐藏字段
InnoDB在每行数据后面加了三个隐藏字段:
DB_TRX_ID(6字节):记录最后一次修改这行数据的事务IDDB_ROLL_PTR(7字节):回滚指针,指向这行数据的上一个版本,存在undo log里DB_ROW_ID(6字节):如果表没有主键,InnoDB会自动生成一个聚簇索引,就用这个ROW_ID
2. Undo Log版本链
每次修改数据,都会把老版本数据保存到undo log里,并用DB_ROLL_PTR指向它。这样就形成了一个版本链。
比如一条用户记录:
当前版本: id=1, name='李四', age=25, DB_TRX_ID=100
↓ (DB_ROLL_PTR)
undo log: id=1, name='张三', age=25, DB_TRX_ID=80
↓
undo log: id=1, name='张三', age=20, DB_TRX_ID=60
3. Read View(读视图)
这是MVCC的核心!事务在执行查询时,会生成一个Read View,记录当前系统中有哪些活跃事务。
Read View主要包含:
m_ids:当前所有活跃(未提交)事务的ID列表min_trx_id:活跃事务中最小的事务IDmax_trx_id:下一个要分配的事务IDcreator_trx_id:创建这个Read View的事务ID
4. 可见性判断规则
事务查询数据时,拿着Read View,沿着版本链找到对自己可见的版本:
if (DB_TRX_ID < min_trx_id) {
// 这个版本在所有活跃事务之前,肯定已提交,可见
} else if (DB_TRX_ID >= max_trx_id) {
// 这个版本在Read View之后才创建,不可见
} else if (DB_TRX_ID in m_ids) {
// 这个事务还在活跃中,未提交,不可见
} else {
// 在Read View之前已提交,可见
}
如果当前版本不可见,就沿着DB_ROLL_PTR找上一个版本,直到找到可见的版本。
RR和RC的区别:
- RR(可重复读):事务开始时创建Read View,整个事务期间都用这一个Read View,所以多次查询结果一致
- RC(读已提交):每次查询都创建新的Read View,所以能读到其他事务新提交的数据
实际例子说明:
假设当前有两个事务:
- 事务A(id=100):执行
SELECT * FROM user WHERE id = 1 - 事务B(id=101):执行
UPDATE user SET age = 30 WHERE id = 1
在RR级别下:
- 事务A开始,创建Read View,此时m_ids=[100, 101]
- 事务B修改age=30并提交
- 事务A再次查询,还是用之前的Read View,发现DB_TRX_ID=101在m_ids里(当时还未提交),所以不可见,会读undo log里的旧版本,age还是25
在RC级别下:
- 事务A开始,创建Read View1
- 事务B修改age=30并提交
- 事务A再次查询,创建新的Read View2,此时m_ids=[100],不包含101了
- 发现DB_TRX_ID=101不在m_ids里且小于max_trx_id,说明已提交,可见,所以读到age=30
💡 总结: MVCC通过隐藏字段、undo log版本链、Read View这三个机制,实现了读操作不加锁,让读写不阻塞,大大提高了数据库的并发性能。这也是InnoDB能在高并发场景下表现优异的重要原因之一。
1.4 MySQL有哪些锁?分别在什么场景下使用?
✅ 正确回答思路:
MySQL的锁机制比较复杂,我按不同的分类维度来说明:
一、按锁的粒度分:
1. 全局锁
sql
-- 加锁
FLUSH TABLES WITH READ LOCK;
-- 解锁
UNLOCK TABLES;
- 锁住整个数据库,所有表都只读,不能写
- 使用场景:全库逻辑备份,保证数据一致性
- 缺点:业务基本停摆,一般不用,而是用mysqldump的--single-transaction参数
2. 表级锁
- 表锁:
sql
LOCK TABLES user READ; -- 读锁
LOCK TABLES user WRITE; -- 写锁
使用场景很少,因为粒度太大,并发性差
- 元数据锁(MDL) :
- 自动加的,不需要手动
- 当对表做增删改查时,自动加MDL读锁
- 当对表结构做变更时,加MDL写锁
- 目的:防止DML和DDL并发冲突
- 意向锁 :
- InnoDB自动加的
- 目的:快速判断表里是否有行级锁
- 分为意向共享锁(IS)和意向排他锁(IX)
3. 行级锁(InnoDB支持,MyISAM不支持)
这是最重要的,也是实际工作中最常用的:
① 记录锁(Record Lock)
- 锁住单行记录
- 例如:
SELECT * FROM user WHERE id = 1 FOR UPDATE就会给id=1这行加记录锁
② 间隙锁(Gap Lock)
- 锁住两个索引记录之间的间隙
- 目的:防止其他事务在这个间隙内插入数据,解决幻读
- 只在RR隔离级别下有效
举例:表里有id为1、5、10的记录
sql
SELECT * FROM user WHERE id > 5 AND id < 10 FOR UPDATE;
会锁住(5, 10)这个间隙,其他事务无法插入id=6、7、8、9的记录
③ 临键锁(Next-Key Lock)
- 临键锁 = 记录锁 + 间隙锁
- 是InnoDB的默认行锁算法
- 锁住记录本身,以及记录之前的间隙
- 区间是左开右闭的 (a, b]
例如:表里有id为1、5、10、15的记录
sql
SELECT * FROM user WHERE id <= 10 FOR UPDATE;
会加的临键锁:
- (-∞, 1]
- (1, 5]
- (5, 10]
二、按锁的类型分:
1. 共享锁(S锁,读锁)
sql
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;
-- 或者新语法
SELECT * FROM user WHERE id = 1 FOR SHARE;
- 多个事务可以同时持有同一行的共享锁
- 持有共享锁时,其他事务不能加排他锁(写锁)
2. 排他锁(X锁,写锁)
sql
SELECT * FROM user WHERE id = 1 FOR UPDATE;
-- 或者 UPDATE、DELETE、INSERT 语句自动加排他锁
- 一次只有一个事务能持有排他锁
- 持有排他锁时,其他事务不能加任何锁
实际工作中的场景:
场景1:秒杀扣库存
sql
-- 先查库存
SELECT stock FROM product WHERE id = 100 FOR UPDATE;
-- 检查库存够不够
if (stock > 0) {
-- 扣库存
UPDATE product SET stock = stock - 1 WHERE id = 100;
}
用FOR UPDATE加排他锁,防止并发扣库存导致超卖
场景2:转账操作
sql
START TRANSACTION;
-- 给两个账户加锁,防止并发修改
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
SELECT balance FROM account WHERE id = 2 FOR UPDATE;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
场景3:唯一性检查后插入
sql
-- 先检查用户名是否存在
SELECT * FROM user WHERE username = 'zhangsan' FOR UPDATE;
-- 如果不存在,才插入
if (not exists) {
INSERT INTO user (username, ...) VALUES ('zhangsan', ...);
}
💡 锁冲突关系总结:
| 当前锁\请求锁 | X(排他锁) | S(共享锁) |
|---|---|---|
| X(排他锁) | 冲突 | 冲突 |
| S(共享锁) | 冲突 | 兼容 |
💡 避免死锁的建议:
- 尽量按相同顺序访问数据
- 尽量使用索引访问数据,减少锁范围
- 尽量缩短事务时间
- 设置锁等待超时时间:
innodb_lock_wait_timeout