MySQL中的锁机制
约 4235 字大约 14 分钟
2025-12-05
MySQL中的锁机制
MySQL的锁机制是数据库管理系统为了保证并发操作的一致性和隔离性而设计的重要机制。它用于控制多个事务对共享资源的访问,防止数据不一致或冲突。
锁的分类
按照锁的粒度进行划分:表级锁、行级锁、页级锁;
按照访问模式划分:共享锁(S锁)、排他锁(X锁);
按照实现方式划分:乐观锁、悲观锁;
乐观锁和悲观锁
乐观锁和悲观锁从本质上它不是锁,而一种加锁逻辑的划分。
- 悲观锁:默认冲突一定会发生,所有访问数据的操作都需要先获取到锁;
- 乐观锁:默认冲突一般不会发生,所有访问数据的操作都可以执行,只不过需要借助版本号或时间戳等方式来保证数据一致性;
对于乐观锁和悲观锁是开发过程中的一个思想上的总结,其实不仅仅在数据库层面上有体现,甚至于在所有需要用到锁的场景都会有这两种思想。
对于悲观锁而言,它适用于写入操作很频繁的应用场景下;相反,乐观锁适合于读取操作更频繁的场景,因为读取操作不会对数据产生影响,采用乐观锁对于每次查询可以减少一次加锁的开销。
共享锁和排他锁
共享锁和排他锁是指在锁的实现过程中根据锁的互斥逻辑不同的一个分类:
- 共享锁(S锁):允许其它线程的读操作,但是阻塞其它线程的写操作;
- 排他锁(X锁):阻塞其它线程的读写操作;
所以在MySQL中,共享锁也被成为读锁,排他锁也被成为写锁。
当一个应用程序中读取操作与写入操作都很频繁的时候,对于悲观锁而言,他会对每次读取或写入都进行加锁,所以读取和写入之间都是互斥的操作。但是,随着应用程序的并发要求越来越高,就需要对传统的悲观锁进行改进,因此将对数据库的操作分为两种读操作和写操作,只需要保证读-写和写-写互斥就可以了。
所以对于排他锁和共享锁,是在单纯的悲观锁上对于锁的粒度进一步细分的思想,这一点其实可以从ReentrantLock和ReentranReadWriteLock这两个类的设计思想也是类似。
如果你也看到了这里,请着重理会的是:在不同的场景下锁机制的一种演进的思路
假如需要给某行数据添加共享锁:
select * from persons where id = 1 lock for share mode;假如需要给某行数据添加排他锁:
select * from persons where id = 1 for update;表级锁和行级锁
表级锁和行级锁,以及页级锁其实都是具体到锁实现的时候锁定资源的范围来进行划分的:
表级锁:锁定的一整表,开销小,但是并发性低;
行级锁:锁定的是某一行的记录,开销大,但是并发性高;
页级锁:锁定的就是对应的数据页,介于表级锁和行级锁之间;
其实对于锁粒度的划分更多要看适用的场景,对于MyISAM而言,它仅支持表级锁,而InnoDB支持表级锁和行级锁。
InnoDB中锁的实现
InnoDB虽然以行级锁位主,但是在特定的场景下也会使用表锁。
表级锁
InnoDB的表级锁主要分为两类:
意向锁:在行锁申请之前,用于快速判断表中是否存在行级锁;
显式表锁:通过SQL语句手动加锁;
意向锁
意向锁是表级锁,它的作用为了协调行级锁的冲突检测。
假如对应一张表中 id = 10 的记录添加行级锁,当有另外一个事务也需要操作这个 id = 10 的记录时,它就需要判断这条记录是否有被加锁。如果没有意向锁话,它必须遍历整张表来获得对应的锁范围(因为行级锁中还存在间隙锁和临键锁,所以不能仅仅通过获取 id = 1 这条记录的锁定情况)。
意向锁也被分为了两种:
意向共享锁(IS锁):事务打算对某些行加共享锁前,需要先申请该表的意向共享锁;
意向排他锁(IX锁):事务打算对某些行加排他锁前,需要先申请该表的意向排他锁;
锁之间的兼容规则:
| 当前锁 | IS | IX | S | X |
|---|---|---|---|---|
| IS | 兼容 | 兼容 | 兼容 | 兼容 |
| IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
| S | 兼容 | 不兼容 | 兼容 | 不兼容 |
| X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
也就是说,当事务A对某行加X锁之前,需要先申请该表得IX锁,那么此时对于该表而言它存在IX锁和X锁;
如果此时要添加的是表级别的S锁,那么它会与IX锁不兼容,而导致阻塞等待IX锁的释放;
如果此时要添加的是行级别的S锁,那么它会先申请IS锁,因为IX与IS锁是兼容的,所以IS锁是可以正常申请到的。
如果此时要添加的行级别的S锁,与事务A加锁的行一致,就会因为S锁与X锁不兼容而导致阻塞;
如果此时要添加的行级别的S锁,与事务A加锁的行不一直,就会直接在对应的行上添加S锁;
显式表级锁
在MySQL中,可以使用lock tables语句显式对表加上表级别共享锁(S锁)和排他锁(X锁)。
SQL -- 允许其它会话读取该表,但禁止写入(包括更新、插入和删除)或其它表的锁操作 lock tables tablename READ; -- 禁止其它会话对该表进行任何读写或加锁操作; lock tables tablename WRITE; -- 解锁语法 UNLOCK TABLES;
当使用lock tables或unlock tables的时候,都会隐式提交当前会话中未提交的事务。如果加锁涉及到多张表的时候,必须一次性显式对多张表进行加锁,一旦加锁成功后,当前会话只能访问已显式锁定的表,其它表无法访问(包括系统表)。
对于MyISAM和INNODB都是支持显式对表进行添加S锁和X锁的,但是在MyISAM中通常由用户主动来控制,而INNODB中依赖于行级锁和事务,显式添加表锁可能会破坏MVCC并发机制,需要谨慎使用。
行级锁
行级锁是InnoDB的重头戏,所以我们对于行级锁的行为要理解的更为透彻,才有助于我们在开发的过程中不会出现死锁的问题。
InnoDB的行锁是通过给索引上锁来实现的,所以只有通过索引条件检索数据,InnoDB才会使用行级锁,否则InnoDB将使用表锁。所以对于非唯一索引而言,仅管访问的是不同的行,也会因为索引键相同而被锁定。
即便在查询条件中使用了索引字段,但具体是否使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的,如果MySQL认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下InnoDB将使用表锁,而不是行锁。因此在分析锁冲突时,也是需要检查SQL的执行计划,以确认是否真正使用了索引。
添加行级锁的方式
隐式加锁
对于update、delete和insert语句,InnoDB会自动给设计数据集加排他锁(X锁);对于普通的Select语句,InnoDB不会加任何锁;
显式加锁
显式添加共享锁:select ... lock in share mode
显式添加排他锁:select ... for update
行级锁的表现形式
记录锁(Record LOCK)
记录锁就是为某行记录加锁,它封锁改行的索引记录;
select * from person where id = 1 for update;上述语句会锁定id=1的记录。需要注意的是id列必须为唯一索引列或主键列,否则上述语句加的锁就是变成临键锁;
同时查询语句必须为精准匹配(= 或 in),不能为>、<、like等,否则也会退化成临键锁。
通过主键索引与唯一索引对数据进行UPDATE操作时,也会对该行数据加记录锁:
-- id为主键列或唯一索引列
update set age = 50 where id = 1;为什么id如果是非唯一索引或主键索引,会退化成临键锁?
如果id列不是非唯一索引或主键索引,那么对于 id = 1 这个键,它可能有很多条记录。如果我们只是简单的锁定 id = 1 这个键下的几条记录,如果有其它事务在 id = 1和 id =3 之间插入一条数据 id = 2成功,就会容易出现幻读问题。
间隙锁(Gap Locks)
间隙锁是基于非唯一索引,它锁定一段范围内的索引记录。使用间隙锁锁定的是一个区间,而不仅仅是这个区间中的每一条数据。
select * from persons where id between 1 and 10 for update;即所有在(1,10)这个区间内的记录行都会被锁住,所有id为2、3、4、5、6、7、8、9的数据行的插入都会被阻塞,但是1和10两条记录行并不是被锁住。
临键锁(Next-Key Locks)
Next-Keys 可以理解为一种特殊的间隙锁,通过临键锁可以解决幻读的问题。每个数据行商的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。需要强调的一点是,InnoDB中的行级锁都是基于索引实现的。
临键锁锁定的范围从第一个不符合条件的键到上一个键的左闭右开区间。
锁信息的查询
InnoDB在执行加锁时,遵循的使Next-Key Locking(临键锁)算法。其核心是通过索引定位目标位置,并在必要时对间隙或记录加锁。

如上图所示,存在一张persons表,id列为主键,name列为普通索引,phone为非空唯一索引。
在出现锁相关的问题时,我们需要知道锁的一些具体信息,在MySQL中有一张表performance_schema.data_locks记录了当前所有的锁信息:
select object*name,index*name,lock*type,lock*mode,lock*status,LOCK*DATA from performance*schema.data*locks where OBJECT_SCHEMA = 'database-name';上述SQL语句查询的结果为:
object_name|index_name|lock_type|lock_mode|lock_status|LOCK_DATA|
-----------+----------+---------+---------+-----------+---------+
persons | |TABLE |IX |GRANTED | |
persons |PRIMARY |RECORD |X |GRANTED |1 |
persons |PRIMARY |RECORD |X |GRANTED |3 |其中:
- object_name:这个锁在哪个表,记录的是表的名称;
- index_name:这个锁锁住的使哪个索引,记录的是索引的名称;
- lock_type:锁的类型,RECORD标识行级锁,TABLE标识表级锁;
- lock_mode:锁的模式,这个是关键字段。IX表示意向排他锁,X表示排他锁;
- lock_status:记录锁的状态,GRANTED表示加锁成功,WAITING表示等待加锁;
- lock_data:表示锁定的键;
上述的结果表示的就是锁定的是persons表中的primary索引,锁定范围是:(-∞,1]和(1,3]。
记录锁(record lock)的锁信息展示:
select * from persons where id = 3 for update;对这个锁记录得到的内容就是:
object_name|index_name|lock_type|lock_mode |lock_status|LOCK_DATA|
-----------+----------+---------+-------------+-----------+---------+
persons | |TABLE |IX |GRANTED | |
persons |PRIMARY |RECORD |X,REC_NOT_GAP|GRANTED |3 |这里面的REC_NOT_GAP表示的就是临键锁退化成记录锁,锁定的数据为主键为3的记录;
间隙锁(gap lock)锁信息展示:
select * from persons where id between 1 and 10 for update;对应这个锁信息打印的结果就是:
object_name|index_name|lock_type|lock_mode|lock_status|LOCK_DATA|
-----------+----------+---------+---------+-----------+---------+
persons | |TABLE |IX |GRANTED | |
persons |PRIMARY |RECORD |X |GRANTED |3 |
persons |PRIMARY |RECORD |X |GRANTED |7 |
persons |PRIMARY |RECORD |X,GAP |GRANTED |9 |对于上面的SQL语句,它加锁的范围是:(1,3] 、(3,7]和(7,9)。这里的X,GAP表示的就是临键锁退化成间隙锁的标志。
行级锁的加锁逻辑
非空唯一索引的加锁逻辑
非空唯一索引精准匹配,且记录存在
来看下面的的事务,其中主要是t3时刻,事务B是否会阻塞:

加锁的步骤和逻辑如下所示:
- 明确id列的索引类型 —— 非空唯一索引;
- 对id=1的键添加临键锁,锁定的范围为:
(1,3] - 因为id为非空唯一索引,锁会升级成record lock(记录锁),锁定范围为:
1 - 在t3时刻,事务B要插入 id=2 的记录,它需要锁定的范围是:
2 - 事务A与事务B锁定的范围是没有发生冲突的,所以在t3时刻,事务B是可以正常插入的;
非空唯一索引精确匹配,其额记录不存在
来看下面的事务,主要是看t3时刻,事务B是否被阻塞:

加锁逻辑和步骤如下所示:
- 明确id列的索引类型 —— 非空唯一索引;
- 对id=4的键添加临键锁,锁定的范围为:
(3,7] - 因为id=4的记录不存在,所以锁定的范围会变为gap lock(间隙锁):
(3,7) - 事务B在t3时刻要插入的数据的主键为id=4,它属于事务A的锁定范围,所以会被阻塞;
非空唯一索引范围查询
来看下面的事务,主要是看t3时刻,事务B是否被阻塞:

加锁逻辑和步骤如下所示:
- 明确id列的索引类型 —— 非空唯一索引;
- 对于 id<=4 的条件,它会添加临键锁,锁定范围内:
(-∞,1]、(1,3]和(3,7] - 事务B要插入的数据id=2,所以它在临键锁的锁定范围内,因此它会被阻塞;
因此对于非空唯一索引的查询,在精确查询的时候,如果记录存在锁定的范围会从临键锁退化成记录锁,如果记录不存在,则会临键锁退化到间隙锁。在范围查询的时候,锁定的范围主要是临键锁,如果边界数据不存在就会退化为间隙锁;
普通索引的加锁逻辑
假如需要对name = 'lisi'的记录进行加锁:
start transaction;
select * from persons where name = 'lisi' for update;
commit;此时通过查阅锁的状态信息,得到的结果是:
object_name|index_name|lock_type|lock_mode |lock_status|LOCK_DATA |
-----------+----------+---------+-------------+-----------+-------------+
persons | |TABLE |IX |GRANTED | |
persons |idx_name |RECORD |X |GRANTED |'lisi', 3 |
persons |idx_name |RECORD |X |GRANTED |'lisi', 7 |
persons |PRIMARY |RECORD |X,REC_NOT_GAP|GRANTED |3 |
persons |PRIMARY |RECORD |X,REC_NOT_GAP|GRANTED |7 |
persons |idx_name |RECORD |X,GAP |GRANTED |'zhangsan', 1|可以看到:对普通索引的数据进行加锁时,主键索引对应的记录也同样会被加锁。
所以上述的SQL语句中锁定的范围就是:
对于主键索引而言,它是记录锁,锁定的范围是
3和7对于name索引而言,它是间隙锁加临键锁,锁定的范围是:
(lisi,3) >= x || (lisi,3) < x <= (lisi,7) ||(lisi,7) <x < (zhangsan,1)
根据上面的范围分析得到的结果,下面的SQL都是会被阻塞的:
insert into persons values(2,'lisi',222,222);
insert into persons values(2,'aa',11,11);
update persons set age = 666 where name = 'lisi';
insert into persons values(100,'wangwu',111,1111);但是下面的这个SQL语句是可以执行的:
insert into persons values(2,'zhc',444,4444);这里要理解的是name索引的顺序: lisi --> zhangsan --> zhouqi,因为存在间隙锁:(lisi,zhangsan),而zhc是大于zhangsan的,所以它是可以被插入进来的。
其实从这一点也可以看出来,对于非唯一索引而言,它锁定的范围会比较的大,不仅对主键索引上的键添加记录锁,还会存在多个临键锁或间隙锁,所以在加锁的时候,不推荐使用非唯一索引来进行加锁。这里说的加锁不光是显式加锁,还有隐式加锁,也就是在update或delete的时候尽量使用主键来删除,这样可以尽可能减少锁定的范围,有效提高事务的并发性。