在之前的博客中,我写了一系列的文章,比拟零碎的学习了 MySQL 的事务、隔离级别、加锁流程以及死锁,我自认为对常见 SQL 语句的加锁原理曾经把握的足够了,但看到热心网友在评论中提出的一个问题,我还是彻底被问蒙了。
他的问题是这样的:
加了插入意向锁后,插入数据之前,此时执行了 select…lock in share mode 语句(没有取到待插入的值),而后插入了数据,下一次再执行 select…lock in share mode(不会跟插入意向锁抵触),发现多了一条数据,于是又产生了幻读。会呈现这种状况吗?
这个问题初看上去很简略,在 RR 隔离级别下,假如要插入的记录不存在,如果先执行 select...lock in share mode
语句,很显然会在记录间隙之间加上 GAP 锁,而 insert
语句首先会对记录加插入意向锁,插入意向锁和 GAP 锁抵触,所以不存在幻读;如果先执行 insert
语句后执行 select...lock in share mode
语句,因为 insert
语句在插入记录之后,会对记录加 X 锁,它会阻止 select...lock in share mode
对记录加 S 锁,所以也不存在幻读。两种状况如下所示:
先执行 INSERT 后执行 SELECT:
先执行 SELECT 后执行 INSERT:
然而咱们认真想一想就会发现哪里有点不对劲,咱们晓得 insert
语句会先在插入间隙上加上插入意向锁,而后开始写数据,写完数据之后再对记录加上 X 记录锁。
那么问题就来了,如果在 insert
语句加插入意向锁之后,写数据之前,执行了 select...lock in share mode
语句,这个时候 GAP 锁和插入意向锁是不抵触的,查问进去的记录数为 0,而后 insert
语句写数据,加 X 记录锁,因为记录锁和 GAP 锁也是不抵触的,所以 insert
胜利插入了一条数据,这个时候如果事务提交,select...lock in share mode
语句再次执行查问进去的记录数就是 1,岂不是就呈现了幻读?
整个流程如下所示(咱们把 insert
语句的执行分成两个阶段,INSERT 1 加插入意向锁,还没写数据,INSERT 2 写数据,加记录锁):
一、INSERT 加锁的困惑
在得出下面的论断时,我也感到很诧异。按理是不可能呈现这种状况的,只可能是我对这两个语句的加锁过程还没有想明确。
于是我又去温习了一遍 MySQL 官网文档,Locks Set by Different SQL Statements in InnoDB 这篇文档对各个语句的加锁有具体的形容,其中对 insert
的加锁过程是这样说的(这应该是网络上介绍 MySQL 加锁机制被援用最多的文档,预计也是被误会最多的文档):
INSERT sets an exclusive lock on the inserted row. This lock is an index-record lock, not a next-key lock (that is, there is no gap lock) and does not prevent other sessions from inserting into the gap before the inserted row.
Prior to inserting the row, a type of gap lock called an insert intention gap lock is set. This lock signals the intent to insert in such a way that multiple transactions inserting into the same index gap need not wait for each other if they are not inserting at the same position within the gap. Suppose that there are index records with values of 4 and 7. Separate transactions that attempt to insert values of 5 and 6 each lock the gap between 4 and 7 with insert intention locks prior to obtaining the exclusive lock on the inserted row, but do not block each other because the rows are nonconflicting.
If a duplicate-key error occurs, a shared lock on the duplicate index record is set. This use of a shared lock can result in deadlock should there be multiple sessions trying to insert the same row if another session already has an exclusive lock. This can occur if another session deletes the row.
这里讲到了 insert
会对插入的这条记录加排他记录锁,在加记录锁之前还会加一种 GAP 锁,叫做插入意向锁,如果呈现惟一键抵触,还会加一个共享记录锁。这和我之前的了解是齐全一样的,那么到底是怎么回事呢?难道 MySQL 的 RR 真的会呈现幻读景象?
在 Google 上搜寻了很久,并没有找到 MySQL 幻读的问题,百思不得其解之际,遂决定从 MySQL 的源码中一探到底。
二、编译 MySQL 源码
编译 MySQL 的源码非常简单,然而两头也有几个坑,如果能绕过这几个坑,在本地调试 MySQL 是一件很容易的事(当然能调试源码是一回事,能看懂源码又是另一回事了)。
我的环境是 Windows 10 x64,零碎上装置了 Visual Studio 2012,如果你的开发环境和我不一样,编译步骤可能也会不同。
在开始之前,首先要从官网下载 MySQL 源码:
这里我抉择的是 5.6.40 版本,操作系统下拉列表里选 Source Code,OS Version 抉择 Windows(Architecture Independent),而后就能够下载打包好的 zip 源码了。
将源码解压缩到 D:\mysql-5.6.40
目录,在编译之前,还须要再装置几个必要软件:
- CMake:CMake 自身并不是编译工具,它是通过编写一种平台无关的 CMakeList.txt 文件来定制编译流程的,而后再依据指标用户的平台进一步生成所需的本地化 Makefile 和工程文件,如 Unix 的 Makefile 或 Windows 的 Visual Studio 工程;
- Bison:MySQL 在执行 SQL 语句时,必然要对 SQL 语句进行解析,一般来说语法解析器会蕴含两个模块:词法剖析和语法规定。词法剖析和语法规定模块有两个较成熟的开源工具 Flex 和 Bison 别离用来解决这两个问题。MySQL 出于性能和灵便思考,抉择了本人实现词法解析局部,语法规定局部应用了 Bison,所以这里咱们还要先装置 Bison。Bison 的默认装置门路为
C:\Program Files\GnuWin32
, 然而千万不要这样,肯定要记得抉择一个不带空格的目录 ,譬如C:\GnuWin32
要不然在前面应用 Visual Studio 编译 MySQL 时会卡死; - Visual Studio:没什么好说的,Windows 环境下预计没有比它更好的开发工具了吧。
装置好 CMake 和 Bison 之后,记得要把它们都加到 PATH 环境变量中。做好筹备工作,咱们就能够开始编译了,首先用 CMake 生成 Visual Studio 的工程文件:
D:\mysql-5.6.40> mkdir project
D:\mysql-5.6.40> cd project
D:\mysql-5.6.40\project> cmake -G "Visual Studio 11 2012 Win64" ..
cmake 的 -G
参数用于指定生成哪种类型的工程文件,这里是 Visual Studio 2012,能够间接输出 cmake -G
查看反对的工程类型。如果没问题,会在 project 目录下生成一堆文件,其中 MySQL.sln 就是咱们要用的工程文件,应用 Visual Studio 关上它。
关上 MySQL.sln 文件,会在 Solution Explorer 看到 130 个我的项目,其中有一个叫 ALL_BUILD,这个时候如果间接编译,编译会失败,在这之前,咱们还要对代码做点批改:
- 首先是
sql\sql_locale.cc
文件,看名字就晓得这个文件用于国际化与本土化,这个文件里有各个国家的语言字符,然而这个文件却是 ANSI 编码,所以要将其改成 Unicode 编码; - 关上
sql\mysqld.cc
文件的第 5239 行,将DBUG_ASSERT(0)
改成DBUG_ASSERT(1)
,要不然调试时会触发断言;
当初咱们能够编译整个工程了,选中 ALL_BUILD 我的项目,Build,而后静静的期待 5 到 10 分钟,如果呈现了 Build: 130 succeeded, 0 failed 这样的提醒,那么祝贺,你当初能够纵情的调试 MySQL 了。
咱们将 mysqld 设置为 Startup Project,而后加个命令行参数 --console
,这样能够在控制台里查看打印的调试信息:
另外 client\Debug\mysql.exe
这个文件是对应的 MySQL 的客户端,能够间接双击运行,默认应用的用户为 ODBC@localhost,如果要以 root 用户登录,能够执行 mysql.exe -u root
,不须要明码。
三、调试 INSERT 加锁流程
首先咱们创立一个数据库 test,而后创立一个测试表 t,主键为 id,并插入测试数据:
> use test;
> create table t(id int NOT NULL AUTO_INCREMENT , PRIMARY KEY (id));
> insert into t(id) values(1),(10),(20),(50);
而后咱们开两个客户端会话,一个会话执行 insert into t(id) value(30)
,另一个会话执行 select * from t where id = 30 lock in share mode
。很显然,如果咱们能在 insert
语句加插入意向锁之后写数据之前下个断点,再在另一个会话中执行 select
就能够模拟出这种场景了。
那么咱们来找下 insert
语句是在哪加插入意向锁的。第一次看 MySQL 源码可能会有些手足无措,调着调着就会迷失在深深的调用层级中,咱们看 insert
语句的调用堆栈,一开始时还比拟容易了解,从 mysql_parse -> mysql_execute_command -> mysql_insert -> write_record -> handler::ha_write_row -> innobase::write_row -> row_insert_for_mysql,这里就进入 InnoDb 引擎了。
而后持续往下跟:row_ins_step -> row_ins -> row_ins_index_entry_step -> row_ins_index_entry -> row_ins_clust_index_entry -> row_ins_clust_index_entry_low -> btr_cur_optimistic_insert -> btr_cur_ins_lock_and_undo -> lock_rec_insert_check_and_lock。
一路跟下来,都没有发现插入意向锁的形迹,直到 lock_rec_insert_check_and_lock
这里:
if (lock_rec_other_has_conflicting(
static_cast<enum lock_mode>(LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION),
block, next_rec_heap_no, trx)) {
/* Note that we may get DB_SUCCESS also here! */
trx_mutex_enter(trx);
err = lock_rec_enqueue_waiting(
LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION,
block, next_rec_heap_no, index, thr);
trx_mutex_exit(trx);
} else {err = DB_SUCCESS;}
这里是查看是否有和插入意向锁抵触的其余锁,如果有抵触,就将插入意向锁加到锁期待队列中。这很显然是先执行 select … lock in share mode 语句再执行 insert 语句时的情景,插入意向锁和 GAP 抵触。但这不是咱们要找的点,于是持续摸索,然而惋惜的是,直到 insert 执行完结,我都没有找到加插入意向锁的中央。
跟代码十分辛苦,我放心是因为我跟丢了某块的逻辑导致没看到加锁,于是我看了看加其余锁的中央,发现在 InnoDb 里行锁都是通过调 lock_rec_add_to_queue
(没有锁抵触)或者 lock_rec_enqueue_waiting
(有锁抵触,须要期待其余事务开释锁)来实现的,于是在这两个函数高低断点,执行一条 insert
语句,仍然没有断下来,阐明 insert
语句没有加任何锁!
到这里我忽然想起之前做过的 insert
加锁的试验,执行 insert
之后,如果没有任何抵触,在 show engine innodb status
命令中是看不到任何锁的, 这是因为 insert
加的是隐式锁。什么是隐式锁?隐式锁的意思就是没有锁!
所以,基本就不存在之前说的先加插入意向锁,再加排他记录锁的说法,在执行 insert
语句时,什么锁都不会加。这就有点意思了,如果 insert
什么锁都不加,那么如果其余事务执行 select ... lock in share mode
,它是如何阻止其余事务加锁的呢?
答案就在于隐式锁的转换。
InnoDb 在插入记录时,是不加锁的。如果事务 A 插入记录且未提交,这时事务 B 尝试对这条记录加锁,事务 B 会先去判断记录上保留的事务 id 是否沉闷,如果沉闷的话,那么就帮忙事务 A 去建设一个锁对象,而后本身进入期待事务 A 状态,这就是所谓的隐式锁转换为显式锁。
咱们跟一下执行 select
时的流程,如果 select
须要加锁,则会走:sel_set_rec_lock -> lock_clust_rec_read_check_and_lock -> lock_rec_convert_impl_to_expl,lock_rec_convert_impl_to_expl
函数的外围代码如下:
impl_trx = trx_rw_is_active(trx_id, NULL);
if (impl_trx != NULL
&& !lock_rec_has_expl(LOCK_X | LOCK_REC_NOT_GAP, block,
heap_no, impl_trx)) {
ulint type_mode = (LOCK_REC | LOCK_X
| LOCK_REC_NOT_GAP);
lock_rec_add_to_queue(
type_mode, block, heap_no, index,
impl_trx, FALSE);
}
首先判断事务是否沉闷,而后查看是否已存在排他记录锁,如果事务沉闷且不存在锁,则为该事务加上排他记录锁。而本事务的锁是通过 lock_rec_convert_impl_to_expl
之后的 lock_rec_lock
函数来加的。
到这里,这个问题的脉络曾经很清晰了:
- 执行
insert
语句,判断是否有和插入意向锁抵触的锁,如果有,加插入意向锁,进入锁期待;如果没有,间接写数据,不加任何锁; - 执行
select ... lock in share mode
语句,判断记录上是否存在沉闷的事务,如果存在,则为insert
事务创立一个排他记录锁,并将本人退出到锁期待队列;
所以不存在网友所说的幻读问题。那么事件到此结束了么?并没有。
仔细的你会发现,执行 insert
语句时,从判断是否有锁抵触,到写数据,这两个操作之间还是有时间差的,如果在这之间执行 select ... lock in share mode
语句,因为此时记录还不存在,所以也不存在沉闷事务,不会触发隐式锁转换,这条语句会返回 0 条记录,并加上 GAP 锁;而 insert
语句持续写数据,不加任何锁,在 insert
事务提交之后,select ... lock in share mode
就能查到 1 条记录,这岂不是还有幻读问题吗?
为了彻底搞清楚这两头的细节,咱们在 lock_rec_insert_check_and_lock
查看完锁抵触之后下个断点,而后在另一个事务中执行 select ... lock in share mode
,如果它能胜利返回 0 条记录,加上 GAP 锁,阐明就存在幻读。不过事实上,这条 SQL 语句执行的时候卡住了,并不会返回 0 条记录。从 show engine innodb status
的 TRANSACTIONS
里咱们看不到任何行锁抵触的信息,然而咱们从 RW-LATCH INFO
中却能够看出一些端倪:
-------------
RW-LATCH INFO
-------------
RW-LOCK: 000002C97F62FC70
Locked: thread 10304 file D:\mysql-5.6.40\storage\innobase\btr\btr0cur.cc line 879 S-LOCK
RW-LOCK: 000002C976A3B998
Locked: thread 10304 file D:\mysql-5.6.40\storage\innobase\btr\btr0cur.cc line 256 S-LOCK
Locked: thread 10304 file d:\mysql-5.6.40\storage\innobase\include\btr0pcur.ic line 518 S-LOCK
Locked: thread 2820 file D:\mysql-5.6.40\storage\innobase\btr\btr0cur.cc line 256 S-LOCK
Locked: thread 2820 file D:\mysql-5.6.40\storage\innobase\row\row0ins.cc line 2339 S-LOCK
RW-LOCK: 000002C976A3B8A8 Waiters for the lock exist
Locked: thread 2820 file D:\mysql-5.6.40\storage\innobase\btr\btr0cur.cc line 256 X-LOCK
Total number of rw-locks 16434
OS WAIT ARRAY INFO: reservation count 10
--Thread 10304 has waited at btr0cur.cc line 256 for 26.00 seconds the semaphore:
S-lock on RW-latch at 000002C976A3B8A8 created in file buf0buf.cc line 1069
a writer (thread id 2820) has reserved it in mode exclusive
number of readers 0, waiters flag 1, lock_word: 0
Last time read locked in file btr0cur.cc line 256
Last time write locked in file D:\mysql-5.6.40\storage\innobase\btr\btr0cur.cc line 256
OS WAIT ARRAY INFO: signal count 8
Mutex spin waits 44, rounds 336, OS waits 7
RW-shared spins 3, rounds 90, OS waits 3
RW-excl spins 0, rounds 0, OS waits 0
Spin rounds per wait: 7.64 mutex, 30.00 RW-shared, 0.00 RW-excl
这里列出了 3 个 RW-LOCK:000002C97F62FC70、000002C976A3B998、000002C976A3B8A8。其中能够看到最初一个 RW-LOCK 有其余线程在期待其开释(Waiters for the lock exist)。上面列出了所有期待该锁的线程,Thread 10304 has waited at btr0cur.cc line 256 for 26.00 seconds the semaphore
,这里的 Thread 10304 就是咱们正在执行 select
语句的线程,它卡在了 btr0cur.cc
的 256 行,咱们查看 Thread 10304 的堆栈:
btr0cur.cc
的 256 行位于 btr_cur_latch_leaves
函数,如下所示,通过 btr_block_get
来加锁,看起来像是在拜访 InnoDb B+ 树的叶子节点时卡住了:
case BTR_MODIFY_LEAF:
mode = latch_mode == BTR_SEARCH_LEAF ? RW_S_LATCH : RW_X_LATCH;
get_block = btr_block_get(space, zip_size, page_no, mode, cursor->index, mtr);
这里的 latch_mode == BTR_SEARCH_LEAF,所以加锁的 mode 为 RW_S_LATCH。
这里要介绍一个新的概念,叫做 Latch,个别也把它翻译成“锁”,但它和咱们之前接触的行锁表锁(Lock)是有区别的。这是一种轻量级的锁,锁定工夫个别十分短,它是用来保障并发线程能够平安的操作临界资源,通常没有死锁检测机制。Latch 能够分为两种:MUTEX(互斥量)和 RW-LOCK(读写锁),很显然,这里咱们看到的是 RW-LOCK。
咱们回溯一下 select
语句的调用堆栈:ha_innobase::index_read -> row_search_for_mysql -> btr_pcur_open_at_index_side -> btr_cur_latch_leaves,从调用堆栈能够看出 select ... lock in share mode
语句在拜访索引,那么为什么拜访索引会被卡住呢?
接下来咱们看看这个 RW-LOCK 是在哪里加上的?从日志里能够看到 Locked: thread 2820 file D:\mysql-5.6.40\storage\innobase\btr\btr0cur.cc line 256 X-LOCK
,所以这个锁是线程 2820 加上的,加锁的地位也在 btr0cur.cc
的 256 行,查看函数援用,很快咱们就查到这个锁是在执行 insert
时加上的,函数堆栈为:row_ins_clust_index_entry_low -> btr_cur_search_to_nth_level -> btr_cur_latch_leaves。
咱们看这里的 row_ins_clust_index_entry_low
函数(无关代码已省略):
UNIV_INTERN
dberr_t
row_ins_clust_index_entry_low(
/*==========================*/
ulint flags, /*!< in: undo logging and locking flags */
ulint mode, /*!< in: BTR_MODIFY_LEAF or BTR_MODIFY_TREE,
depending on whether we wish optimistic or
pessimistic descent down the index tree */
dict_index_t* index, /*!< in: clustered index */
ulint n_uniq, /*!< in: 0 or index->n_uniq */
dtuple_t* entry, /*!< in/out: index entry to insert */
ulint n_ext, /*!< in: number of externally stored columns */
que_thr_t* thr) /*!< in: query thread */
{
/* 开启一个 mini-transaction */
mtr_start(&mtr);
/* 调用 btr_cur_latch_leaves -> btr_block_get 加 RW_X_LATCH */
btr_cur_search_to_nth_level(index, 0, entry, PAGE_CUR_LE, mode,
&cursor, 0, __FILE__, __LINE__, &mtr);
if (mode != BTR_MODIFY_TREE) {
/* 不须要批改 BTR_TREE,乐观插入 */
err = btr_cur_optimistic_insert(
flags, &cursor, &offsets, &offsets_heap,
entry, &insert_rec, &big_rec,
n_ext, thr, &mtr);
} else {
/* 须要批改 BTR_TREE,先乐观插入,乐观插入失败则进行乐观插入 */
err = btr_cur_optimistic_insert(
flags, &cursor,
&offsets, &offsets_heap,
entry, &insert_rec, &big_rec,
n_ext, thr, &mtr);
if (err == DB_FAIL) {
err = btr_cur_pessimistic_insert(
flags, &cursor,
&offsets, &offsets_heap,
entry, &insert_rec, &big_rec,
n_ext, thr, &mtr);
}
}
/* 提交 mini-transaction */
mtr_commit(&mtr);
}
这里是执行 insert
语句的要害,能够发现执行插入操作的前后别离有一行代码:mtr_start()
和 mtr_commit()
。这被称为 迷你事务(mini-transaction),既然叫做事务,那这个函数的操作必定是原子性的,事实上的确如此,insert
会在查看锁抵触和写数据之前,会对记录所在的页加一个 RW-X-LATCH 锁,执行完写数据之后再开释该锁(实际上写数据的操作就是写 redo log(重做日志),将脏页退出 flush list,这个前面有工夫再深入分析了)。这个锁的开释十分快,然而这个锁足以保障在插入数据的过程中其余事务无法访问记录所在的页。mini-transaction 也能够蕴含子事务,实际上在 insert
的执行过程中就会加多个 mini-transaction。
每个 mini-transaction 会恪守上面的几个规定:
- 批改一个页须要取得该页的 X-LATCH;
- 拜访一个页须要取得该页的 S-LATCH 或 X-LATCH;
- 持有该页的 LATCH 直到批改或者拜访该页的操作实现。
所以,最初的最初,假相只有一个:insert
和 select ... lock in share mode
不会产生幻读。整个流程如下:
- 执行
insert
语句,对要操作的页加 RW-X-LATCH,而后判断是否有和插入意向锁抵触的锁,如果有,加插入意向锁,进入锁期待;如果没有,间接写数据,不加任何锁,完结后开释 RW-X-LATCH; - 执行
select ... lock in share mode
语句,对要操作的页加 RW-S-LATCH,如果页面上存在 RW-X-LATCH 会被阻塞,没有的话则判断记录上是否存在沉闷的事务,如果存在,则为insert
事务创立一个排他记录锁,并将本人退出到锁期待队列,最初也会开释 RW-S-LATCH;
参考:
- Locks Set by Different SQL Statements in InnoDB
- Installing MySQL from Source
- CMake 入门实战
- MySQL 源代码:从 SQL 语句到 MySQL 外部对象
- MySQL · 源码剖析 · 一条 insert 语句的执行过程
- [MySQL 源码] 一条简略 insert 语句的调用栈
- MySQL5.7 : 对隐式锁转换的优化
- [MySQL 学习] Innodb 锁零碎 (4) Insert/Delete 锁解决及死锁示例剖析
- InnoDB 事务锁之行锁 -insert 加锁 - 隐式锁加锁原理
- InnoDB 事务锁之行锁 - 判断是否有隐式锁原理图
- InnoDB 事务锁之行锁 - 隐式锁转换显示锁举例了解原理
- MySQL 系列:innodb 源码剖析之 mini transaction
- MySQL – InnoDB mini transation
- MySQL · 引擎个性 · InnoDB redo log 漫游
近期热文举荐:
1.1,000+ 道 Java 面试题及答案整顿 (2021 最新版)
2. 别在再满屏的 if/ else 了,试试策略模式,真香!!
3. 卧槽!Java 中的 xx ≠ null 是什么新语法?
4.Spring Boot 2.5 重磅公布,光明模式太炸了!
5.《Java 开发手册(嵩山版)》最新公布,速速下载!
感觉不错,别忘了顺手点赞 + 转发哦!