MySQL进阶-事务与锁机制

一、事务基础:不止是BEGIN/COMMIT,读懂ACID才是关键

我们先从最基础的事务说起。很多人以为事务就是"执行一段SQL,要么全成,要么全不成",这话没错,但只说对了一半。MySQL中的事务,核心是靠ACID四大特性保障的,这也是事务的灵魂,必须逐个吃透。

1. ACID四大特性(逐点拆解+通俗示例)

ACID分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),我们结合实际业务场景(转账)来解读,更易理解。

(1)原子性(Atomicity):"要么全做,要么全不做"

核心定义:事务中的所有操作,要么全部执行成功,要么全部执行失败,不会出现"部分成功、部分失败"的中间状态。哪怕执行过程中出现断电、崩溃,MySQL也会回滚到事务开始前的状态,保证数据不混乱。

示例:用户A给用户B转账100元,整个事务包含两个操作:① A的余额减少100元;② B的余额增加100元。

  • 正常情况:两个操作都执行成功,事务提交,数据一致;

  • 异常情况:执行完①后,数据库突然崩溃,此时MySQL会回滚①的操作,A的余额恢复原样,不会出现"A少了100,B没多100"的尴尬局面。

底层支撑:undo log(后续会详细讲),用于事务回滚时撤销已执行的操作。

(2)一致性(Consistency):事务结束后,数据始终合法

核心定义:事务执行前后,数据库的完整性约束(主键、外键、唯一约束等)不会被破坏,数据始终处于合法状态。一致性是最终目标,ACID的其他三个特性,都是为了保证一致性。

示例:还是转账场景,假设A的余额只有50元,却要给B转100元。此时事务执行时,会触发"余额不能为负"的约束,事务直接回滚,不会出现A余额为-50元的非法数据------这就是一致性的体现。

补充:一致性不仅依赖数据库本身的约束,还依赖业务逻辑。比如"转账后两人总余额不变",这就是业务层面的一致性,需要开发者在SQL或代码中保障。

(3)隔离性(Isolation):并发事务互不干扰

核心定义:多个事务同时执行时,一个事务的执行不会被其他事务干扰,每个事务都感觉自己是"单独执行"的。隔离性解决的是"并发事务冲突"的问题,也是我们后续重点讲解的内容。

示例:两个事务同时操作同一条数据------事务1给A的余额加100,事务2给A的余额减50。隔离性会保证,要么事务1先执行完,事务2再执行;要么反之,不会出现"两个操作交叉执行,导致余额计算错误"的情况。

注意:隔离性不是"完全隔离",不同的隔离级别,隔离程度不同,对应的并发问题也不同,后续会详细拆解。

(4)持久性(Durability):事务提交后,数据永久保存

核心定义:事务一旦提交(COMMIT),无论后续数据库发生什么故障(断电、崩溃、重启),提交的数据都会永久保存在数据库中,不会丢失。

示例:A给B转账100元,事务提交后,哪怕数据库马上重启,重启后A的余额还是减少100,B的余额还是增加100,不会因为重启而恢复到转账前的状态。

底层支撑:redo log(后续详细讲),事务提交时,会将数据变更写入redo log,即使数据库崩溃,重启后也能通过redo log恢复已提交的数据。

2. 事务的边界:那些你可能忽略的细节

掌握了ACID,还要明确事务的"开始"和"结束",尤其是一些隐式提交的场景,很容易踩坑。

(1)显式事务(推荐使用)

通过明确的SQL指令控制事务边界,清晰易懂,适合业务开发中使用:

sql 复制代码
-- 开始事务
BEGIN;  -- 或 START TRANSACTION
-- 事务操作(增删改查)
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- A减100
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- B加100
-- 提交事务(数据永久生效)
COMMIT;
-- 若出现异常,回滚事务(恢复到BEGIN前的状态)
-- ROLLBACK;
(2)隐式事务(默认开启,需谨慎)

MySQL默认开启"自动提交"模式(可以通过 show variables like 'autocommit'; 查看,默认值为ON),此时每一条SQL语句都会被当作一个独立的事务,执行完自动提交,无需手动BEGIN/COMMIT。

示例:直接执行一条UPDATE语句,会自动提交事务:

sql 复制代码
UPDATE account SET balance = 1000 WHERE id = 1;  -- 执行完自动COMMIT,数据永久变更

注意:如果需要执行多条SQL作为一个事务,必须先关闭自动提交(SET autocommit = OFF;),执行完后手动COMMIT,否则会导致事务拆分,出现数据不一致。

(3)隐式提交场景(重点避坑)

有些操作会"隐式触发COMMIT",哪怕你没有手动执行COMMIT,当前事务也会被提交,常见场景:

  • 执行DDL语句:CREATE TABLE、ALTER TABLE、DROP TABLE等,执行这些语句会自动提交当前事务;

  • 执行LOCK TABLES、UNLOCK TABLES语句;

  • 退出MySQL客户端(正常退出);

  • 执行某些系统函数:如FLUSH TABLES WITH READ LOCK。

示例:踩坑场景------想执行两条UPDATE后提交,结果中间执行了ALTER TABLE,导致事务提前提交:

sql 复制代码
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
ALTER TABLE account ADD COLUMN phone VARCHAR(20);  -- 隐式提交,第一条UPDATE生效
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- 这是一个新事务
ROLLBACK;  -- 只能回滚第二条UPDATE,第一条已经被隐式提交,无法回滚

二、并发事务的"坑":三大并发问题详解

当多个事务同时操作同一份数据时,若没有隔离机制,就会出现各种并发问题。MySQL定义了三大经典并发问题:脏读、不可重复读、幻读。我们逐个拆解,结合示例理解,搞清楚"是什么、为什么会出现、怎么解决"。

1. 脏读(Dirty Read):读取到"未提交"的数据

定义

事务A读取了事务B已经修改但尚未提交的数据,之后事务B发生回滚,事务A读取到的数据就是"无效的脏数据",这就是脏读。

示例(清晰还原脏读场景)

假设存在表account,初始数据:id=1,balance=1000(用户A的余额)。

时间顺序 事务A(查询A的余额) 事务B(修改A的余额)
1 - BEGIN;
2 - UPDATE account SET balance = 800 WHERE id = 1;(未提交)
3 SELECT balance FROM account WHERE id = 1;(读取到800,脏数据) -
4 - ROLLBACK;(事务B回滚,A的余额恢复为1000)
5 SELECT balance FROM account WHERE id = 1;(读取到1000,发现之前读的是脏数据) -

危害:事务A基于脏数据做了后续操作(比如根据读取到的800余额,进行转账),会导致业务逻辑错误,数据不一致。

2. 不可重复读(Non-Repeatable Read):同一事务内,多次读取结果不一致

定义

事务A在同一个事务内,多次读取同一条数据,期间事务B修改并提交了这条数据,导致事务A多次读取的结果不一致,这就是不可重复读。

注意:不可重复读和脏读的区别------脏读读取的是"未提交"的数据,不可重复读读取的是"已提交"的数据,只是前后读取结果不一致。

示例(还原不可重复读场景)

表account初始数据:id=1,balance=1000。

时间顺序 事务A(多次查询A的余额) 事务B(修改A的余额)
1 BEGIN; -
2 SELECT balance FROM account WHERE id = 1;(读取到1000) -
3 - BEGIN;
4 - UPDATE account SET balance = 800 WHERE id = 1; COMMIT;(事务B提交)
5 SELECT balance FROM account WHERE id = 1;(读取到800,与第一次读取不一致) -
6 COMMIT; -

危害:事务A在同一个事务内,基于不同的读取结果做操作,会导致业务逻辑混乱(比如第一次读取1000,计划转账500;第二次读取800,转账500就会导致余额为负)。

3. 幻读(Phantom Read):同一事务内,多次查询结果行数不一致

定义

事务A在同一个事务内,多次执行同一个查询语句(范围查询),期间事务B插入/删除了符合该查询条件的记录,导致事务A多次查询的结果行数不一致,就像出现了"幻觉",这就是幻读。

注意:幻读和不可重复读的区别------不可重复读是"单条数据的内容变化",幻读是"符合条件的记录行数变化"。

示例(还原幻读场景)

表account初始数据:id=1(balance=1000)、id=2(balance=2000),查询条件:balance > 1500(此时只有id=2符合条件)。

时间顺序 事务A(多次查询balance > 1500的记录) 事务B(插入符合条件的记录)
1 BEGIN; -
2 SELECT * FROM account WHERE balance > 1500;(返回1条记录:id=2) -
3 - BEGIN; INSERT INTO account (id, balance) VALUES (3, 2500); COMMIT;(插入符合条件的记录)
4 SELECT * FROM account WHERE balance > 1500;(返回2条记录:id=2、id=3,行数不一致) -
5 COMMIT; -

危害:事务A基于第一次查询的行数做操作(比如统计符合条件的用户数、批量更新),后续行数变化会导致操作结果错误(比如统计人数少算1个,批量更新漏更1条)。

三、事务隔离级别:解决并发问题的"钥匙"

为了解决上述三大并发问题,MySQL定义了4种事务隔离级别,从低到高依次是:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。不同隔离级别,解决的并发问题不同,性能也不同(隔离级别越高,性能越差)。

先上一张核心总结表,清晰呈现4种隔离级别与并发问题的对应关系(√表示解决,×表示未解决):

隔离级别 脏读 不可重复读 幻读 性能 备注
读未提交(READ UNCOMMITTED) × × × 最高 几乎不用,会读取未提交数据
读已提交(READ COMMITTED) × × 较高 Oracle默认隔离级别
可重复读(REPEATABLE READ) √(MySQL特殊实现) 中等 MySQL默认隔离级别,解决了幻读
串行化(SERIALIZABLE) 最低 完全串行执行,无并发问题

重点说明:MySQL的默认隔离级别是可重复读(REPEATABLE READ),并且通过"临键锁(Next-Key Lock)"的机制,解决了幻读问题(这是MySQL与其他数据库的区别,比如Oracle的可重复读不会解决幻读)。

1. 各隔离级别详细解读(结合示例+底层实现)

(1)读未提交(READ UNCOMMITTED):最低隔离级别

核心逻辑:允许事务读取其他事务"未提交"的修改,几乎没有隔离性。

特点:性能最高,但并发问题最多(脏读、不可重复读、幻读都存在),实际开发中几乎不用,除非是对数据一致性要求极低的场景(比如临时统计数据,不用于业务决策)。

如何设置:

sql 复制代码
-- 临时设置当前会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 查看当前隔离级别
SELECT @@session.tx_isolation;
(2)读已提交(READ COMMITTED):解决脏读

核心逻辑:事务只能读取其他事务"已提交"的修改,禁止读取未提交的数据,从而解决脏读问题。但无法解决不可重复读和幻读。

底层实现:每次读取数据时,都会生成一个新的read view(后续MVCC部分详细讲),只读取read view中"已提交"的事务数据。

示例:沿用之前的脏读场景,当隔离级别设为读已提交时,事务A在步骤3读取不到事务B未提交的800,只能读取到1000,避免了脏读。但如果事务B提交后,事务A再次读取,还是会出现不可重复读。

适用场景:对数据一致性有一定要求,但对并发性能要求较高的场景(比如电商订单查询、用户信息查询),很多互联网项目会用这个隔离级别。

(3)可重复读(REPEATABLE READ):MySQL默认,解决不可重复读和幻读

核心逻辑:事务在第一次读取数据后,会生成一个固定的read view,后续所有读取操作都基于这个read view,即使其他事务修改并提交了数据,事务内读取的结果也不会变化,从而解决不可重复读;同时通过临键锁,解决幻读。

底层实现:read view(固定不变)+ 临键锁(Next-Key Lock)。

示例:沿用不可重复读场景,隔离级别设为可重复读:

  • 事务A第一次读取到1000,生成read view;

  • 事务B修改并提交,将余额改为800;

  • 事务A再次读取,还是会读取到1000(因为read view固定,看不到事务B提交的修改),避免了不可重复读;

  • 对于幻读,通过临键锁,禁止其他事务插入/删除符合查询条件的记录,从而避免幻读。

注意:可重复读的"可重复",是指"同一事务内,多次读取同一条数据/同一范围数据,结果一致",但并不是说数据真的没有变化,而是事务看不到其他事务提交的变化。

(4)串行化(SERIALIZABLE):最高隔离级别

核心逻辑:将所有事务"串行执行",即一个事务执行完,另一个事务才能执行,完全禁止并发操作,因此能解决所有并发问题。

底层实现:通过表锁实现,事务执行时,会对查询的表或数据加锁,其他事务只能等待锁释放后才能执行。

示例:两个事务同时操作同一张表,事务A先执行,会对表加锁,事务B只能等待事务A提交/回滚、释放锁后,才能执行,不会出现任何并发问题。

适用场景:对数据一致性要求极高,几乎不允许并发的场景(比如金融行业的资金对账、账务核算),因为性能极差,一般不用于高并发业务。

2. 隔离级别相关操作(实战必备)

查看和设置隔离级别,是日常开发和排查问题的常用操作,记住以下SQL:

sql 复制代码
-- 1. 查看当前会话的隔离级别
SELECT @@session.tx_isolation;
-- 2. 查看全局的隔离级别
SELECT @@global.tx_isolation;
-- 3. 设置当前会话的隔离级别(临时生效,退出会话失效)
SET SESSION TRANSACTION ISOLATION LEVEL 隔离级别;
-- 示例:设置为可重复读
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 4. 设置全局的隔离级别(永久生效,需重启MySQL)
SET GLOBAL TRANSACTION ISOLATION LEVEL 隔离级别;

四、InnoDB锁机制:解决并发冲突的核心(重中之重)

事务隔离级别是"策略",锁机制是"实现手段"。InnoDB作为MySQL最常用的存储引擎(支持事务和锁),其锁机制直接决定了并发性能和数据一致性。这部分内容较多,我们按"锁类型→锁作用对象→锁等待与死锁"的顺序,逐一拆解,结合示例和模型图,让你彻底理解。

先明确:InnoDB vs MyISAM 锁的区别

很多新手会混淆两个存储引擎的锁机制,这里先做个对比,重点记住:InnoDB支持行锁和表锁,MyISAM只支持表锁,这也是InnoDB适合高并发场景的核心原因。

存储引擎 锁类型 并发性能 是否支持事务
InnoDB 行锁、表锁、意向锁 高(只锁一行,不影响其他行)
MyISAM 只有表锁 低(锁整张表,其他事务无法操作)

1. InnoDB的锁类型(按功能分类)

InnoDB的锁类型主要分为5种:共享锁(S锁)、排他锁(X锁)、意向锁(IS/IX锁)、记录锁(Record Lock)、间隙锁(Gap Lock)、临键锁(Next-Key Lock)。其中,记录锁、间隙锁、临键锁属于"行级锁",共享锁和排他锁是"锁的粒度",意向锁是"表级锁",用于协调行锁和表锁。

(1)共享锁(S锁):读锁,可共享

核心定义:共享锁是"读锁",加了S锁的记录,允许其他事务加S锁(共享读取),但禁止其他事务加X锁(禁止修改)。

作用:用于读取数据,保证多个事务可以同时读取同一条数据,不会互相干扰,但禁止修改,避免读取过程中数据被修改。

手动加锁方式(查询时加S锁):

sql 复制代码
SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE;

示例:

  • 事务A给id=1的记录加S锁,执行上述查询;

  • 事务B可以给id=1的记录加S锁(正常查询);

  • 事务C想给id=1的记录加X锁(执行UPDATE),会被阻塞,直到事务A释放S锁。

(2)排他锁(X锁):写锁,不可共享

核心定义:排他锁是"写锁",加了X锁的记录,禁止其他事务加任何锁(无论是S锁还是X锁),只能由当前事务修改或读取。

作用:用于修改数据(UPDATE、DELETE、INSERT),保证同一时间只有一个事务能修改同一条数据,避免并发修改冲突。

手动加锁方式(查询时加X锁):

sql 复制代码
SELECT * FROM account WHERE id = 1 FOR UPDATE;

注意:InnoDB在执行UPDATE、DELETE、INSERT语句时,会自动给对应的记录加X锁,无需手动加锁;SELECT语句默认不加锁(快照读),只有手动加锁才会触发S锁或X锁。

示例:

  • 事务A给id=1的记录加X锁(执行UPDATE);

  • 事务B想给id=1的记录加S锁(LOCK IN SHARE MODE),会被阻塞;

  • 事务C想给id=1的记录加X锁(UPDATE),也会被阻塞;

  • 只有事务A提交/回滚,释放X锁后,事务B和C才能执行。

(3)意向锁(IS锁、IX锁):表级锁,协调行锁和表锁

核心定义:意向锁是"表级锁",用于告知数据库"某个事务即将对表中的某一行加S锁或X锁",避免表锁和行锁之间的冲突。意向锁分为两种:

  • 意向共享锁(IS锁):事务即将对表中的某一行加S锁,先给表加IS锁;

  • 意向排他锁(IX锁):事务即将对表中的某一行加X锁,先给表加IX锁。

作用:举个例子,假设事务A给表中的某一行加了X锁(行锁),此时事务B想给整张表加表锁(比如ALTER TABLE),如果没有意向锁,数据库需要逐行检查是否有行锁,效率极低;有了意向锁,事务A加X锁时,会先给表加IX锁,事务B加表锁时,发现表有IX锁,就知道表中有行锁,直接阻塞,无需逐行检查,提高效率。

注意:意向锁是InnoDB自动加的,无需手动操作,我们只需要了解其作用即可。

(4)记录锁(Record Lock):行级锁,锁具体的行

核心定义:记录锁是"行级锁",直接锁定表中某一条具体的记录(基于索引),只有加锁的记录会被阻塞,其他记录不受影响。

关键前提:记录锁必须基于索引,如果查询语句没有使用索引,InnoDB会将行锁升级为表锁(这是新手最容易踩的坑!)。

示例1(有索引,加记录锁):

sql 复制代码
-- id是主键索引(有索引)
BEGIN;
UPDATE account SET balance = 800 WHERE id = 1;  -- 自动给id=1的记录加X锁(记录锁)
-- 此时,其他事务可以修改id=2、id=3的记录,不会被阻塞

示例2(无索引,行锁升级为表锁):

sql 复制代码
-- name字段没有索引
BEGIN;
UPDATE account SET balance = 800 WHERE name = '张三';  -- 无索引,行锁升级为表锁
-- 此时,其他事务修改表中任何一条记录,都会被阻塞
(5)间隙锁(Gap Lock):行级锁,锁"间隙",解决幻读

核心定义:间隙锁是"行级锁",不锁定具体的记录,而是锁定两个记录之间的"间隙"(包括不存在的记录),防止其他事务在这个间隙中插入新的记录,从而解决幻读。

适用场景:只有在"可重复读(REPEATABLE READ)"隔离级别下,InnoDB才会使用间隙锁;其他隔离级别不会使用。

示例(清晰理解间隙锁):

表account中id(主键索引)的记录为:1、3、5,间隙包括:(0,1)、(1,3)、(3,5)、(5, +∞)。

sql 复制代码
-- 事务A,隔离级别为可重复读
BEGIN;
SELECT * FROM account WHERE id BETWEEN 1 AND 5 FOR UPDATE;  -- 加锁
-- 此时,InnoDB会给id=1、3、5加记录锁,同时给间隙(0,1)、(1,3)、(3,5)、(5, +∞)加间隙锁

此时,其他事务无法在这些间隙中插入新记录:

  • 无法插入id=2(属于(1,3)间隙);

  • 无法插入id=4(属于(3,5)间隙);

  • 无法插入id=6(属于(5, +∞)间隙);

  • 但可以修改id=1、3、5的记录吗?不行,因为这些记录加了记录锁,只能等事务A释放锁。

作用:间隙锁的核心作用是"防止插入新记录",从而解决幻读问题------因为幻读的本质是"其他事务插入了符合查询条件的新记录",间隙锁锁定了所有可能插入的间隙,就避免了幻读。

(6)临键锁(Next-Key Lock):记录锁+间隙锁,InnoDB默认行锁

核心定义:临键锁是"记录锁+间隙锁"的组合,锁定的是"当前记录+当前记录和下一条记录之间的间隙",是InnoDB在可重复读隔离级别下的默认行锁方式。

模型图(简化理解):

假设表中id为1、3、5,临键锁的锁定范围如下:

  • 锁定id=1:临键锁范围是(0,1](间隙(0,1) + 记录1);

  • 锁定id=3:临键锁范围是(1,3](间隙(1,3) + 记录3);

  • 锁定id=5:临键锁范围是(3,5](间隙(3,5) + 记录5);

  • 锁定id>5:临键锁范围是(5, +∞)(间隙锁,无记录锁)。

示例:

sql 复制代码
-- 事务A,隔离级别可重复读
BEGIN;
SELECT * FROM account WHERE id = 3 FOR UPDATE;  -- 加临键锁
-- 锁定范围是(1,3],即间隙(1,3) + 记录3
-- 其他事务无法插入id=2(间隙(1,3)),也无法修改id=3(记录锁)

总结:临键锁 = 记录锁 + 间隙锁,既保证了行级锁定的并发性能,又解决了幻读问题,是InnoDB锁机制的核心。

2. 锁的作用对象:行锁 vs 表锁

结合上面的锁类型,我们再明确锁的作用对象,避免混淆:

(1)行锁(Record Lock、Gap Lock、Next-Key Lock)
  • 作用对象:表中的某一条或多条具体记录,以及记录之间的间隙;

  • 触发条件:基于索引的查询/修改(UPDATE、DELETE、INSERT,或手动加锁的SELECT);

  • 特点:并发性能高,只影响加锁的记录,不影响其他记录;

  • 注意:无索引时,行锁会升级为表锁。

(2)表锁(意向锁、手动表锁)
  • 作用对象:整张表;

  • 触发条件:① 无索引的查询/修改(行锁升级);② 手动加表锁(LOCK TABLES);③ DDL语句(ALTER TABLE、DROP TABLE等);

  • 特点:并发性能低,加锁后整张表无法被其他事务操作;

  • 手动加表锁示例:

sql 复制代码
-- 给account表加表锁,禁止其他事务修改
LOCK TABLES account WRITE;
-- 释放表锁
UNLOCK TABLES;

3. 锁等待与死锁:实战中最常遇到的问题

有锁就会有"锁等待"和"死锁",这是并发场景下最常遇到的问题,也是面试高频考点。我们需要搞清楚"是什么、为什么会出现、怎么排查、怎么解决"。

(1)锁等待(Lock Wait):一个事务等待另一个事务释放锁

定义:事务A持有某条记录的锁,事务B想给这条记录加锁,但此时锁被事务A持有,事务B会进入"等待状态",直到事务A释放锁,这个过程就是锁等待。

锁等待超时:如果事务A一直不释放锁,事务B会一直等待,直到超过MySQL的"锁等待超时时间",会报出错误:Lock wait timeout exceeded; try restarting transaction

相关参数(可配置):

sql 复制代码
-- 查看锁等待超时时间(默认50秒)
show variables like 'innodb_lock_wait_timeout';
-- 修改锁等待超时时间(临时生效)
set session innodb_lock_wait_timeout = 10;  -- 单位:秒

排查锁等待:

sql 复制代码
-- 查看当前正在执行的事务,找到持有锁的事务
show processlist;
-- 查看InnoDB锁状态,详细了解锁等待情况
show engine innodb status;

解决锁等待的方法:

  • 优化事务,减少事务执行时间,避免长事务(长事务会一直持有锁);

  • 合理设置锁等待超时时间,避免无限等待;

  • 排查持有锁的事务,若事务异常,手动终止(kill 事务ID)。

(2)死锁(Deadlock):两个事务互相等待对方释放锁

定义:两个或多个事务,互相持有对方需要的锁,并且都在等待对方释放锁,导致所有事务都无法继续执行,陷入"死循环",这就是死锁。

死锁示例(经典场景):

时间顺序 事务A 事务B
1 BEGIN; BEGIN;
2 UPDATE account SET balance = 800 WHERE id = 1;(持有id=1的X锁) UPDATE account SET balance = 800 WHERE id = 2;(持有id=2的X锁)
3 UPDATE account SET balance = 800 WHERE id = 2;(等待id=2的X锁,被事务B持有) UPDATE account SET balance = 800 WHERE id = 1;(等待id=1的X锁,被事务A持有)
4 死锁,MySQL自动终止其中一个事务,释放锁 死锁,MySQL自动终止其中一个事务,释放锁

死锁的产生条件(四大必要条件,缺一不可):

  • 互斥条件:锁是排他的,同一时间只能被一个事务持有;

  • 请求与保持条件:事务持有一个锁后,又请求另一个锁,且不释放已持有的锁;

  • 不可剥夺条件:事务持有的锁,不能被其他事务强制剥夺,只能由事务自己释放;

  • 循环等待条件:多个事务形成循环等待,每个事务都在等待下一个事务释放锁。

死锁的排查方法(实战必备)

MySQL会自动检测死锁,一旦发现死锁,会终止其中一个事务(选择"代价较小"的事务),并报出错误:Deadlock found when trying to get lock; try restarting transaction。但我们需要知道如何排查死锁原因,避免后续再次出现。

常用排查方法:

  1. 查看死锁日志(最核心):

  2. 开启慢查询日志,记录长事务(长事务容易引发死锁);

  3. 使用工具监控:如Percona Monitoring and Management(PMM),实时监控锁状态和死锁情况。

死锁的避免方案(重点)

死锁无法彻底根除,但可以通过以下方法减少死锁的发生,甚至避免:

  1. 统一事务操作顺序:所有事务操作同一张表中的多条记录时,按相同的顺序操作(比如都按id升序操作),避免循环等待。示例:事务A和事务B都先操作id=1,再操作id=2,就不会出现死锁。

  2. 减少事务粒度:拆分长事务,将一个大事务拆分为多个小事务,缩短事务执行时间,减少持有锁的时间。

  3. 避免手动加锁:尽量避免手动加S锁、X锁,依赖InnoDB自动加锁机制,减少锁冲突。

  4. 合理设置隔离级别:如果业务允许,可降低隔离级别(比如从可重复读改为读已提交),减少间隙锁的使用,降低死锁概率。

  5. 及时释放锁:事务执行完后,立即提交或回滚,不要长时间持有锁(比如避免在事务中调用外部接口、等待用户输入)。

五、MVCC:多版本并发控制(事务隔离级别的底层实现)

1. MVCC核心目标:解决"读写冲突",提升并发性能

在没有MVCC的情况下,读写操作会互相阻塞:

  • 事务A读取数据时,会给数据加S锁,此时事务B想修改数据,会被阻塞;

  • 事务B修改数据时,会给数据加X锁,此时事务A想读取数据,会被阻塞。

这种"读写互斥"的方式,在高并发场景下会严重影响性能。而MVCC通过"多版本存储",让读取操作读取历史版本,写入操作修改当前版本,二者互不干扰,实现了"读写分离",极大提升了并发能力。

补充:MVCC只适用于InnoDB的"读已提交(READ COMMITTED)"和"可重复读(REPEATABLE READ)"两个隔离级别,读未提交和串行化不适用(读未提交直接读最新数据,串行化直接加锁阻塞)。

2. MVCC底层实现:三大核心组件

MVCC的实现依赖三个核心组件,缺一不可,我们逐个拆解,结合示例理解,搞清楚"数据是如何保存多版本、如何被读取"的。

(1)隐藏字段:数据的"版本身份证"

InnoDB的每一张表,无论我们是否手动定义,都会自动添加三个隐藏字段(也叫系统字段),用于记录数据的版本信息,这是MVCC实现的基础:

  • DB_TRX_ID(事务ID):4字节,记录最后一次修改该数据的事务ID。每开启一个新事务,MySQL都会分配一个唯一的自增事务ID,事务执行修改操作(INSERT、UPDATE、DELETE)时,会将当前事务ID写入该字段。

  • DB_ROLL_PTR(回滚指针):8字节,指向该数据的"上一个版本",形成一条"版本链"。当数据被多次修改时,通过回滚指针,就能串联起所有历史版本,用于事务回滚和MVCC读取。

  • DB_ROW_ID(行ID):6字节,自增主键(如果表没有手动定义主键,InnoDB会自动生成该字段作为主键),用于唯一标识一行数据。

示例:直观理解隐藏字段和版本链

假设存在表account,初始数据:id=1,balance=1000,我们来看两次修改操作后,数据的版本变化:

  1. 事务1(ID=100)执行UPDATE,将balance改为800:

    1. 当前数据的DB_TRX_ID=100,DB_ROLL_PTR指向null(没有上一个版本);

    2. 同时,InnoDB会将修改前的旧版本(balance=1000)保存到undo log中,作为历史版本。

  2. 事务2(ID=101)执行UPDATE,将balance改为900:

    1. 当前数据的DB_TRX_ID=101,DB_ROLL_PTR指向undo log中事务1修改后的版本(balance=800);

    2. InnoDB将修改前的版本(balance=800)也保存到undo log中。

此时,数据的版本链结构:当前版本(balance=900,TRX_ID=101)→ 历史版本1(balance=800,TRX_ID=100)→ 历史版本2(balance=1000,TRX_ID=null),通过回滚指针串联,undo log就是这些历史版本的"存储容器"。

(2)undo log:历史版本的"存储容器"

undo log(回滚日志),我们在讲解事务原子性时提到过,它的核心作用是"事务回滚"------当事务执行失败或手动ROLLBACK时,通过undo log中的历史版本,恢复数据到事务开始前的状态。

而在MVCC中,undo log还有另一个核心作用:存储数据的历史版本,供事务读取。当事务需要读取历史版本时,InnoDB会通过数据的回滚指针,从undo log中找到对应的版本并返回。

undo log的分类(了解即可,重点掌握作用):

  • INSERT undo log:记录INSERT操作的历史版本,事务提交后可以直接删除(因为INSERT的记录之前不存在,回滚只需删除该记录即可,无需保留历史版本);

  • UPDATE/DELETE undo log:记录UPDATE、DELETE操作的历史版本,需要保留一段时间(直到没有事务再需要读取这些历史版本),之后由MySQL的"垃圾回收机制"自动清理。

注意:undo log是"逻辑日志",它记录的是"数据修改前的状态",而不是物理存储的变化,这样可以节省存储空间,同时便于回滚和版本读取。

(3)Read View:事务的"版本读取规则"

有了隐藏字段和undo log的版本链,事务如何确定"该读取哪个版本"?这就需要Read View(读视图)------它是事务开启时生成的一个"快照",定义了当前事务能看到的"有效数据版本范围",本质是一套"版本筛选规则"。

Read View包含4个核心参数,用于筛选符合条件的历史版本:

  • m_ids:当前正在执行的所有事务ID的集合(活跃事务ID);

  • min_trx_id:m_ids中最小的事务ID(当前活跃事务中,最早开启的事务ID);

  • max_trx_id:当前MySQL即将分配的下一个事务ID(比当前所有事务ID都大);

  • creator_trx_id:当前生成Read View的事务ID。

Read View的筛选规则(核心重点,务必吃透):

对于版本链中的某一个历史版本(DB_TRX_ID = trx_id),判断该版本是否能被当前事务读取,遵循以下规则:

  1. 如果 trx_id = creator_trx_id:当前版本是当前事务自己修改的,能读取;

  2. 如果 trx_id < min_trx_id:该版本是在当前所有活跃事务开启前就已经提交的,能读取;

  3. 如果 trx_id > max_trx_id:该版本是在当前事务开启后才修改的,不能读取;

  4. 如果 min_trx_id ≤ trx_id ≤ max_trx_id:判断trx_id是否在m_ids中(是否是活跃事务):

    1. 如果在:该版本对应的事务还在执行(未提交),不能读取;

    2. 如果不在:该版本对应的事务已经提交,能读取。

简单总结:Read View的核心是"只允许读取已经提交的、且符合当前事务可见范围的版本",这也是"读已提交"和"可重复读"隔离级别的核心实现逻辑。

3. MVCC工作流程(结合示例,彻底搞懂)

我们结合具体场景,演示MVCC的完整工作流程,以"可重复读"隔离级别为例(MySQL默认),直观感受"读不阻塞写、写不阻塞读"的效果。

场景:表account初始数据id=1,balance=1000,隐藏字段DB_TRX_ID=null,DB_ROLL_PTR=null。

|------|----------------------------------------------------|---------------------------------------------------------------------|
| 时间顺序 | 事务A(ID=100,隔离级别:可重复读) | 事务B(ID=101,隔离级别:可重复读) |
| 1 | BEGIN;(开启事务,生成Read View1) | - |
| 2 | SELECT balance FROM account WHERE id=1;(读取到1000) | - |
| 3 | - | BEGIN;(开启事务,生成Read View2) |
| 4 | - | UPDATE account SET balance=800 WHERE id=1;(DB_TRX_ID=101,回滚指针指向旧版本) |
| 5 | SELECT balance FROM account WHERE id=1;(仍读取到1000) | - |
| 6 | - | COMMIT;(事务B提交,修改生效) |
| 7 | SELECT balance FROM account WHERE id=1;(还是读取到1000) | - |
| 8 | COMMIT;(事务A提交) | - |
| 9 | SELECT balance FROM account WHERE id=1;(读取到800) | - |

流程解析(结合MVCC三大组件):

  1. 事务A开启时,生成Read View1,此时m_ids为空(无活跃事务),min_trx_id=100,max_trx_id=101,creator_trx_id=100;

  2. 事务A第一次读取时,数据版本的trx_id=null < min_trx_id=100,符合规则,读取到1000;

  3. 事务B开启,生成Read View2,此时m_ids={100},min_trx_id=100,max_trx_id=102,creator_trx_id=101;

  4. 事务B修改数据,将DB_TRX_ID设为101,回滚指针指向旧版本(1000),并将旧版本存入undo log;

  5. 事务A第二次读取时,还是用Read View1筛选:当前数据版本trx_id=101 > max_trx_id=101(不满足),于是通过回滚指针找到undo log中的旧版本(trx_id=null),读取到1000;

  6. 事务B提交后,事务A第三次读取,依然用Read View1(可重复读隔离级别,Read View只在事务开启时生成一次),还是读取到旧版本1000;

  7. 事务A提交后,Read View失效,再次读取时,直接读取当前最新版本(800)。

4. 关键区别:读已提交 vs 可重复读(MVCC实现差异)

很多人疑惑:为什么"读已提交"会出现不可重复读,而"可重复读"不会?核心差异就在于Read View的生成时机不同,这也是MVCC在两个隔离级别下的核心区别。

(1)读已提交(READ COMMITTED):每次读取都生成新的Read View

特点:事务内每次执行SELECT语句,都会重新生成一个Read View,因此能看到"其他事务刚提交的修改",但会导致"不可重复读"。

示例:沿用上面的场景,若事务A隔离级别改为"读已提交":

  • 步骤2:事务A第一次读取,生成Read View1,读取到1000;

  • 步骤6:事务B提交后,事务A步骤7读取时,会重新生成Read View2(此时m_ids为空,trx_id=101 < max_trx_id),能读取到事务B提交的800;

  • 结果:事务A两次读取结果不一致(1000→800),出现不可重复读。

(2)可重复读(REPEATABLE READ):只在事务开启时生成一次Read View

特点:事务开启时生成一次Read View,后续所有读取操作都使用同一个Read View,因此看不到"事务开启后其他事务提交的修改",避免了不可重复读。

这也是MySQL默认隔离级别"可重复读"能解决不可重复读的核心原因------通过固定Read View,保证事务内多次读取结果一致。

5. MVCC实战避坑:这些细节要注意

  • MVCC只适用于"快照读"(普通SELECT语句),不适用于"当前读"(加锁的SELECT,如LOCK IN SHARE MODE、FOR UPDATE,以及UPDATE、DELETE、INSERT)。当前读会直接读取最新数据,并加锁阻塞其他事务;

  • undo log的历史版本会占用存储空间,若存在大量长事务,会导致undo log膨胀,影响数据库性能,因此要避免长事务;

  • "可重复读"隔离级别下,事务读取的是"事务开启时的快照",并不是数据的最新状态,若业务需要读取最新数据,需使用"当前读";

  • MVCC和锁机制并不冲突,而是相辅相成:MVCC解决"读写不阻塞",锁机制解决"写写冲突",共同保证并发场景下的数据一致性。

六、事务与锁机制实战总结

1. 核心逻辑串联

事务(ACID)→ 并发问题(脏读、不可重复读、幻读)→ 隔离级别(4种,解决并发问题)→ 锁机制(实现隔离级别的手段)→ MVCC(实现非锁定一致性读,提升并发)。

2. 面试高频考点

  • ACID四大特性的定义及底层支撑(原子性→undo log,持久性→redo log);

  • 三大并发问题的区别(脏读:未提交;不可重复读:已提交,单条数据变化;幻读:已提交,行数变化);

  • 4种隔离级别的区别,MySQL默认隔离级别及特点(可重复读,解决幻读);

  • InnoDB锁类型(共享锁、排他锁、意向锁、记录锁、间隙锁、临键锁),临键锁的作用;

  • 死锁的产生条件、排查方法、避免方案;

  • MVCC的三大组件、工作流程,以及读已提交和可重复读的实现差异。

3. 工作实战避坑指南

  • 避免长事务:拆分大事务,减少事务执行时间,避免长时间持有锁、导致undo log膨胀;

  • 索引优化:InnoDB行锁基于索引,无索引会升级为表锁,导致并发性能下降;

  • 隔离级别选择:互联网项目优先选"读已提交"(兼顾并发和一致性),金融类项目选"串行化"(保证绝对一致性);

  • 避免死锁:统一事务操作顺序、减少事务粒度、及时提交/回滚事务;

  • 区分快照读和当前读:需要最新数据用当前读,需要读写不阻塞用快照读。

相关推荐
xifangge20256 小时前
【2026终极解法】彻底解决“由于找不到 msvcp140.dll,无法继续执行代码”报错(附微软运行库一键修复包)
windows·mysql·microsoft·报错·开发环境
treacle田6 小时前
达梦数据库-达梦数据库中link链接访问远程Sql Sever-记录总结
数据库·达梦-sqlserver
ClouGence6 小时前
不用搭复杂系统,也能做跨地域数据迁移?
大数据·数据库·saas
xcjbqd06 小时前
SQL中视图能否嵌套存储过程_实现复杂自动化报表逻辑
jvm·数据库·python
听*雨声6 小时前
软件设计师上午题5:数据库
数据库
hong78176 小时前
阿里coding plan qwen3.6-plus 不支持图片上下文长度只有200K,问题出在哪?
linux·运维·数据库
Paxon Zhang7 小时前
MySQL 大师之路**数据库约束,表设计,CRUD**
android·数据库·mysql
HealthScience7 小时前
clinvar数据集说明
数据库·oracle
王的宝库7 小时前
【MySQL】主从复制原理详解:从 Binlog 到数据一致性
数据库·mysql