MySQL 中的 INSERT 是怎么加锁的?
阅读本文大概需要 12 分钟。
来自:https://www.aneasystone.com/archives/2018/06/insert-locks-via-mysql-source-code.html
加了插入意向锁后,插入数据之前,此时执行了 select...lock in share mode 语句(没有取到待插入的值),然后插入了数据,下一次再执行 select...lock in share mode(不会跟插入意向锁冲突),发现多了一条数据,于是又产生了幻读。会出现这种情况吗?
select...lock in share mode
语句,很显然会在记录间隙之间加上 GAP 锁,而 insert
语句首先会对记录加插入意向锁,插入意向锁和 GAP 锁冲突,所以不存在幻读;如果先执行 insert
语句后执行 select...lock in share mode
语句,由于 insert
语句在插入记录之后,会对记录加 X 锁,它会阻止 select...lock in share mode
对记录加 S 锁,所以也不存在幻读。两种情况如下所示: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 加锁的困惑
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 真的会出现幻读现象?二、编译 MySQL 源码
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 环境下估计没有比它更好的开发工具了吧。
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" ..
-G
参数用于指定生成哪种类型的工程文件,这里是 Visual Studio 2012,可以直接输入 cmake -G
查看支持的工程类型。如果没问题,会在 project 目录下生成一堆文件,其中 MySQL.sln 就是我们要用的工程文件,使用 Visual Studio 打开它。首先是 sql\sql_locale.cc
文件,看名字就知道这个文件用于国际化与本土化,这个文件里有各个国家的语言字符,但是这个文件却是 ANSI 编码,所以要将其改成 Unicode 编码;打开 sql\mysqld.cc
文件的第 5239 行,将DBUG_ASSERT(0)
改成DBUG_ASSERT(1)
,要不然调试时会触发断言;
--console
,这样可以在控制台里查看打印的调试信息:client\Debug\mysql.exe
这个文件是对应的 MySQL 的客户端,可以直接双击运行,默认使用的用户为 ODBC@localhost,如果要以 root 用户登录,可以执行 mysql.exe -u root
,不需要密码。三、调试 INSERT 加锁流程
> 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 引擎了。lock_rec_insert_check_and_lock
这里:if (lock_rec_other_has_conflicting(
static_cast(
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;
}
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
,它是如何阻止其他事务加锁的呢?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
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);
select
语句的调用堆栈:ha_innobase::index_read -> row_search_for_mysql ->btr_pcur_open_at_index_side -> btr_cur_latch_leaves,从调用堆栈可以看出 select ... lock in share mode
语句在访问索引,那么为什么访问索引会被卡住呢?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,这个后面有时间再深入分析了)。insert
的执行过程中就会加多个 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;
推荐阅读:
内容包含Java基础、JavaWeb、MySQL性能优化、JVM、锁、百万并发、消息队列、高性能缓存、反射、Spring全家桶原理、微服务、Zookeeper、数据结构、限流熔断降级......等技术栈!
⬇戳阅读原文领取! 朕已阅