口语八股:MySQL 核心原理系列(二):事务与锁篇

事务与锁篇

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通过两种方式基本解决了幻读:

  1. 普通的SELECT用MVCC,读取的是事务开始时的快照,所以看不到后来新插入的数据
  2. 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字节):记录最后一次修改这行数据的事务ID
  • DB_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:活跃事务中最小的事务ID
  • max_trx_id:下一个要分配的事务ID
  • creator_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级别下:

  1. 事务A开始,创建Read View,此时m_ids=[100, 101]
  2. 事务B修改age=30并提交
  3. 事务A再次查询,还是用之前的Read View,发现DB_TRX_ID=101在m_ids里(当时还未提交),所以不可见,会读undo log里的旧版本,age还是25

在RC级别下:

  1. 事务A开始,创建Read View1
  2. 事务B修改age=30并提交
  3. 事务A再次查询,创建新的Read View2,此时m_ids=[100],不包含101了
  4. 发现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(共享锁) 冲突 兼容

💡 避免死锁的建议:

  1. 尽量按相同顺序访问数据
  2. 尽量使用索引访问数据,减少锁范围
  3. 尽量缩短事务时间
  4. 设置锁等待超时时间:innodb_lock_wait_timeout
相关推荐
Leon-Ning Liu1 小时前
Oracle云平台基础设施文档-控制台仪表板篇1
数据库·oracle
程序员敲代码吗1 小时前
MySQL崩溃问题:根源与解决方案
数据库·mysql
·云扬·2 小时前
MySQL Undo Log 深度解析:事务回滚与 MVCC 的底层支柱
android·数据库·mysql
海山数据库2 小时前
移动云大云海山数据库(He3DB)存算分离架构下Page页存储正确性校验框架介绍
数据库·架构·he3db·大云海山数据库·移动云数据库
java1234_小锋2 小时前
Java高频面试题:Zookeeper的通知机制是什么?
java·zookeeper·java-zookeeper
SQL必知必会2 小时前
SQL 数据分析终极指南
数据库·sql·数据分析
计算机学姐2 小时前
基于SpringBoot的药房管理系统【个性化推荐+数据可视化】
java·spring boot·后端·mysql·spring·信息可视化·java-ee
草根大哥2 小时前
AI编程实践-homex物业管理平台(Go + Vue3 + MySQL 多租户落地)
mysql·golang·vue·ai编程·gin·物业管理系统·多租户
SQL必知必会2 小时前
SQL 优化技术精要:让查询飞起来
数据库·sql