目录
1.如果走的是主键/唯一索引的等值匹配,临键锁退化成记录锁或者间隙锁。
2.如果走的是普通索引的等值匹配,那么会对查到的数据行加锁,并且会向后推导出间隙锁
我们的业务通常放在一个事务中,开启事务start transaction/ begin ,提交事务commit ,回滚事务rollback。
以及查看加锁情况:
sql
SELECT
ENGINE,
ENGINE_TRANSACTION_ID,
OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_DATA
FROM performance_schema.data_locks

之后的演示中,我们重点观察的是输出Lock_Type,Lock_Mode和Lock_Data.
一、悲观锁
当形如SELECT ... FORM ... WHERE ... FOR UPDATE. 的SQL被执行时,数据库做了两件事情:1.select 2.给查询到的数据行(整行数据)上锁 ,在本事务结束之前这些数据行不能被其他事务写入。
只有等到当前事务commit 或者 rolback后,锁才会释放,其他事务才能加锁。
但是需要注意的是,查询是否走索引会影响锁定多少数据行,还与我们的数据库事务级别有关。
先查看数据库事务级别,MySQL默认为REPEATABLE-READ 可重复读。
sql
select @@transaction_isolation;

我们当前事务默认级别为可重复读,其他级别得事务可以总结如下
| 隔离级别 | 中文名称 | 脏读 | 不可重复读 | 幻读 (InnoDB) | 核心特点 |
|---|---|---|---|---|---|
READ UNCOMMITTED |
读未提交 | 存在 | 存在 | 存在 | 几乎不用,能读到别的事务没提交的数据,问题最多 |
READ COMMITTED |
读已提交 (RC) | 无 | 存在 | 存在 | 读到其他事务已经提交的数据;没有间隙锁,只有行锁;线上很多业务会改成这个级别 |
REPEATABLE READ |
可重复读 (RR,MySQL 默认) | 无 | 无 | 无 (临键锁解决) | 同一个事务多次读取,结果保持一致;存在行锁、间隙锁、临键锁 Next‑Key Lock |
SERIALIZABLE |
串行化 | 无 | 无 | 无 | 最高级别,所有普通 select 隐式转为select ... lock in share mode,全部加共享锁;完全串行执行,并发性能很差 |
for update会生成一个临键锁 = 间隙锁 + 记录锁
临键锁是一个左开右闭的区间锁,例如(a,b] 表示,a和b之间的空隙内无法插入新数据,b无法被修改。a可以被被修改。
什么是间隙锁?
例如表中有如下排序后的索引:
1,2,4,5,7,10
那么就有如下间隙:
(-∞,1)(1,2)(2,4)(4,5)(5,7)(7,10)(10,+∞)这7个开区间。
如果加间隙锁,就是把这些区间给锁住,禁止在此区间内插入数据。
只有该事务提交或者回滚,才会释放锁。
1、临键锁获取策略
索引 B + 树定位(PAGE_CUR_GE,找第一条 ≥目标值 的索引记录),这个值就是右边界。
在B+树索引中,这条索引天然存着上一条索引的索引值。这个值就作为左边界。
例如,有如下主键排序为:1,2,3,4,5,7
where条件中id = 3,那么拿到临键锁(2,3】
where条件中id = 6,那么拿到临键锁(5,7】
2、临键锁退化策略
为了演示,先建立如下表:
sql
create table test (
id int primary key AUTO_INCREMENT,
b varchar(10) not null unique,
c varchar(10) not null,
d varchar(10) not null,
index(c)
);
id是主键,b是唯一索引,c是普通索引,d是普通字段。
插入原始数据:

1.如果走的是主键/唯一索引的等值匹配,临键锁退化成记录锁或者间隙锁。
sql
select * from test where id = 3 for update; --id为主键/唯一索引
id = 3,先拿到临键锁(1,3】,查到有记录 ,则临键锁退化成记录锁,id = 3数据行被加锁

加锁情况:

当我们执行for update时会有一个IX,IX 锁(Intention Exclusive Lock,意向排他锁) 是 InnoDB 中的一种表级意向锁 。它的核心作用是:**表明一个事务"有意向"在表中的某些行上加排他锁(X锁)。**所以我们不用关心。
第二行,我们的Lock_Type是RECORD行锁,同时锁模式为X排他锁和非间隙锁。Not_Gap,这就意味着临建锁退化成记录锁,与我们分析一致。
sql
select * from test where id = 6 for update; --id为主键/唯一索引
id = 6,先拿到临键锁(5,7】,没有匹配到id = 6的记录 ,那么丢弃右端点,退化为间隙锁(5,7)


数据行被锁住,锁模式为间隙锁,右端点为7,左端点未知。这里需要自己推到出左端点,我们预期是5,那么就是(5,7)。为了验证,我们插入一条id = 6的数据,看是否可以成功。

可以发现,这个事务进入锁等待状态,几秒钟过后,事务锁等待超时而回滚。

与我们推导一致。
sql
select * from test where id in (1,2,5,6) for update; -- 同样的如果,id = 5 的记录不存在,则加间隙锁。
再例如这句SQL,本质是多次等值查询,每次查询都用不同的锁对象。
查id = 1,5时临键锁退化成两个记录锁(因为可以查到),查id = 2,6时匹配不到,那么临键锁退化成两个间隙锁(1,3)、(5,7).
因为不是范围查询,所以区间不可以合并。
2.如果走的是普通索引的等值匹配,那么会对查到的数据行加锁,并且会向后推导出间隙锁
c列是普通索引,我们适当增添数据如下:

c列排序后如下:
A、B1、B2、C、D、E
因为普通索引它没有进行唯一校验,需要一直向后方去找间隙,好的一点是索引已经排序好了,只需要找出第一个不同的索引值即可作为新间隙锁的右区间。
sql
select * from test where c = 'B' for update; -- c字段为普通索引
首先还是会拿到临键锁(A,B1】,命中数据,因此临键锁退化成记录锁,B1数据行加锁。
但是还没有结束,需要继续向后找。
B2命中,加入记录锁中。一直到C,未命中停止查找。将(B2,C)最为新的间隙锁。
整个过程,临键锁只退化了一次,生成了一个新的间隙锁和两个新的记录锁。


首先B1的id为3,B2的id为6的二级索引条目加上锁。
其次,c列值为'C',id = 5的间隙锁也生成了,与我们推断一致。
但是多了最后两条记录,为什么?因为要回表,B+树中普通索引除了存自己的索引键值等信息外,还会存主键值,需要拿到主键值给整个数据行加锁,这就需要回表操作。
因此多了两条记录,最后这两条记录才是数据行记录锁。
我们适当增加数据:

c列索引排序后:A,B1,B2,C,D,E,G
sql
select * from test where c = 'F' for update; -- c为普通索引

先拿到临键锁(E,G】,匹配不到F,因此右端点丢弃,临键锁退化为(E,G)间隙锁。

这与们推导一致。
3.如果走的是非索引,那么整个表都会被加锁(严重)
sql
select * from test where d = 'data2' for update; -- d字段为非索引字段


所有数据都被加锁了,而且还有一个虚拟上限+∞出现,更加说明整个表都被锁住。
此时无法往表中写入任何数据,造成很严重的后果。
sql
select * from test where d = 'data8' for update; --d为非索引字段


可以发现,无论是否命中数据,都会给整个表加锁。
4、范围查询时临键锁不退化,锁区间合并
sql
select * from test where id > 3 for update; -- id为主键

先拿到临键锁(3,5】,不退化,然后拿到(5,6】,(6,7】(7,10】,(10,11】,(11,=∞】
这个过程临键锁不退化,整个过程用的都是同意把锁,因此可以合并区间。
整体锁区间合并为(3,+∞】

这个时候从id = 5开始全部加的都是排他锁,(3,+∞】内不允许插入数据。
例如:

最后只能超时失败:

如果条件改成id>=3,那么锁区间为(1,+∞】,也需要能快速知道为什么。
类似的范围查询:>= ,<= ,> ,< ,between ... and ...
要注意between...and...拆分成>= and <= ,是等价的。都会进行一次扫描,得到的锁区间一致。

此时临键锁不退化,最终得到合并的锁区间:(1,7】

如果拆开的话,


仍然得到一样的锁区间(1,7】
二、总结
以上就是 MySQL 悲观锁机制的详细内容,使用悲观锁会阻塞住其他事务,在并发情况下,可能会导致数据库锁冲突增多、事务排队等待,严重时引发死锁,整体并发性能下降。
悲观锁基于当前读 实现,依靠行锁、间隙锁、临键锁来防止幻读;for update / lock in share mode、update、delete 都会触发当前读。
如果索引失效,会退化为表锁行为,给全表已有行加排他记录锁,大量事务被阻塞;
事务持有锁期间其他事务需要锁等待,等待超时会抛出lock wait timeout;
多个事务互相持有对方需要的锁,就会产生死锁,MySQL 会主动回滚代价较小的事务来解除死锁;
锁的粒度越大(范围查询、全表扫描),锁住的记录与间隙越多,并发能力越差。
高并发场景优先考虑 MVCC 快照读,或者改用乐观锁(版本号机制),减少锁竞争。禁止悲观锁走非索引字段,避免全表扫描。