本文主要讨论的是RC隔离级别,代码主要集中在5.7.22,为了描述方便本文中涉及的semi update就是官方说的semi-consistent read特性。水平有限,仅供参考。
一、问题说明
最近遇到一个问题,以下是模拟出来的现象(RC隔离级别,5.7.31版本),正常情况下,这个update语句的执行时间很快,但是到了高并发情况下就很慢了。
image.png
当然这个问题解决很简单,但是其背后还是有很多值得挖掘的地方,这里就从问题分析触发,顺带挖一下其涉及的部分。
二、分析方式
既然是update语句并发处理的情况变慢,我们先从常规触发看看是不是被堵塞了。首先我们能看到state为updating状态,那么就说明如下:
- MDL LOCK堵塞不可能,因为state状态不对MDL LOCK堵塞的现象
- 可能是row lock堵塞,因为在update语句的情况下row lock堵塞也是updating状态
进一步通过show engine 和 确认没有出现row lock堵塞,show engine截图如下:
image.png
我们可以看到这里事务都处于活跃状态,大部分是unlock_row阶段,也有fetching rows阶段的事务,那么说明事务是在运行的,那么接下来通过CPU耗用确认是否会话出现了内部堵塞,如果长时间的堵塞CPU肯定会下降,如果是在耗用CPU干活就可能CPU就比较高,如下:
image.png
我们看到CPU还是比较高的,那么CPU高也有两种可能就是遇到spin 和 正常的代码逻辑,对于spin来讲一般是内部mutex在正式放弃CPU前做的多次尝试,这个和我们的参数spin设置有关,并且show engine 可能会有输出,通过show engine进行确认如下:
image.png
这里我们确实可以看到一个mutex叫做LOCK_SYS,接着看看perf信息如下:
image.png
确实有大量的ut_delay耗用CPU,且函数指向了加行锁等待上,同时LOCK_SYS也正是row_lock的全局hash结构所在位置的mutex,这就说明了这个语句出现了大量的row_lock需要加锁和解锁,导致LOCK_SYS mutex出现了热点锁。
接着查看表结构,建表语句如下:
create table testsemi(a int auto_increment primary key,b int,c int,d int,key(b,c));
修改语句大概如下:
update testsemi set d=20 where c=20;
数据量大约百万左右。
当然这样由于c=20不是索引的前缀,在RR模式下会出现全纪录加锁,而在RC模式下会触发2个优化:
- Innodb层 semi update
- MySQL层unlock row
解决当然也很简单,起码c列上要有个索引能够用到。接下来我们就讨论这两个优化大概实现方式和一个存在的问题。
三、RC隔离级别下的semi update和unlock row优化
3.1 相关列子
为了更好的解释这两种特性我们先来看两个例子,建表语句和数据如下:
mysql> show variables like '%transaction_isolation%';
+-----------------------+----------------+
| Variable_name | Value |
+-----------------------+----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+----------------+
mysql> show create table testsemi30 \G;
*************************** 1. row ***************************
Table: testsemi30
Create Table: CREATE TABLE `testsemi30` (
`a` int(11) NOT NULL AUTO_INCREMENT,
`b` int(11) DEFAULT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) NOT NULL,
PRIMARY KEY (`a`),
KEY `b` (`b`,`c`)
) ENGINE=InnoDB AUTO_INCREMENT=29 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1 row in set (0.00 sec)
ERROR:
No query specified
mysql> select * from testsemi30;
+----+------+------+---+
| a | b | c | d |
+----+------+------+---+
| 2 | 2 | 2 | 0 |
| 4 | 4 | 4 | 0 |
| 6 | 6 | 6 | 0 |
| 8 | 8 | 8 | 0 |
| 12 | 12 | 12 | 0 |
+----+------+------+---+
5 rows in set (0.00 sec)
3.1.2 例子1:
session1:
mysql> begin;
Query OK, 0 rows affected (0.01 sec)
mysql> update testsemi30 set d=6 where c=6;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> desc update testsemi30 set d=6 where c=6;
+----+-------------+------------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
| 1 | UPDATE | testsemi30 | NULL | index | NULL | PRIMARY | 4 | NULL | 5 | 100.00 | Using where |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+------+----------+-------------+
1 row in set (0.01 sec)
显然这个语句是全表扫描的update,但是最终看到的加锁row lock只有一条如下:
---TRANSACTION 808623, ACTIVE 19 sec
2 lock struct(s), heap size 1160, 1 row lock(s), undo log entries 1
MySQL thread id 16, OS thread handle 140735862056704, query id 349 localhost root
TABLE LOCK table `test`.`testsemi30` trx id 808623 lock mode IX
RECORD LOCKS space id 9694 page no 3 n bits 72 index PRIMARY of table `test`.`testsemi30` trx id 808623 lock_mode X(LOCK_X) locks rec but not gap(LOCK_REC_NOT_GAP)
Record lock, heap no 4 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 4; hex 80000006; asc ;;
1: len 6; hex 0000000c56af; asc V ;;
2: len 7; hex 7b000001ea0fdc; asc { ;;
3: len 4; hex 80000006; asc ;;
4: len 4; hex 80000006; asc ;;
5: len 4; hex 80000006; asc ;;
这就是unlock row的核心作用,但是实际上每行都加过锁,只是不符合where条件的记录的被unlock 掉了,下文描述。继续做一个操作如下:
session2:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from testsemi30 where c=4 for update;
此处堵塞,row lock如下:
TABLE LOCK table `test`.`testsemi30` trx id 808624 lock mode IX
RECORD LOCKS space id 9694 page no 3 n bits 72 index PRIMARY of table `test`.`testsemi30` trx id 808624 lock_mode X(LOCK_X) locks rec but not gap(LOCK_REC_NOT_GAP)
Record lock, heap no 3 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 4; hex 80000004; asc ;;
1: len 6; hex 0000000c5687; asc V ;;
2: len 7; hex e200000089011d; asc ;;
3: len 4; hex 80000004; asc ;;
4: len 4; hex 80000004; asc ;;
5: len 4; hex 80000004; asc ;;
RECORD LOCKS space id 9694 page no 3 n bits 72 index PRIMARY of table `test`.`testsemi30` trx id 808624 lock_mode X(LOCK_X) locks rec but not gap(LOCK_REC_NOT_GAP) waiting(LOCK_WAIT)
Record lock, heap no 4 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 4; hex 80000006; asc ;;
1: len 6; hex 0000000c56af; asc V ;;
2: len 7; hex 7b000001ea0fdc; asc { ;;
3: len 4; hex 80000006; asc ;;
4: len 4; hex 80000006; asc ;;
5: len 4; hex 80000006; asc ;;
这是因为这个语句虽然会触发unlock row,但是当加锁在primary id a=6 这一行的时候被session 1堵塞掉了,因为session 1经过unlock row特性优化后还是持有primary id a=6的这行记录的锁,当然select语句不存在semi update一说。
3.1.2 例子2:
如果将上面session 2的select for update语句换为update语句就不同了如下:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update testsemi30 set d=4 where c=4;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
这个语句是可以完成。事务上锁如下:
---TRANSACTION 808627, ACTIVE 4 sec
2 lock struct(s), heap size 1160, 2 row lock(s), undo log entries 1
MySQL thread id 18, OS thread handle 140735862867712, query id 363 localhost root
TABLE LOCK table `test`.`testsemi30` trx id 808627 lock mode IX
RECORD LOCKS space id 9694 page no 3 n bits 72 index PRIMARY of table `test`.`testsemi30` trx id 808627 lock_mode X(LOCK_X) locks rec but not gap(LOCK_REC_NOT_GAP)
Record lock, heap no 3 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 4; hex 80000004; asc ;;
1: len 6; hex 0000000c56b3; asc V ;;
2: len 7; hex 7e000001da1d79; asc ~ y;;
3: len 4; hex 80000004; asc ;;
4: len 4; hex 80000004; asc ;;
5: len 4; hex 80000004; asc ;;
这实际上就是semi update的核心理念,它能够让本应该堵塞的update语句继续执行,即便session 1持有primary id a=6的这行记录的锁,也可以继续。
3.2 unlock row
就是例子1中的测试
1、Update访问一条数据,innodb层获取row lock。
2、MySQL层根据where条件,如果是不需要的行,则直接unlock掉,这个操作的核心函数就是ha_innobase::unlock_row
而在Update上,我们也很容看到这种比较和过滤,下面是MySQL 过滤where条件的行
mysql_update:
if ((!qep_tab.skip_record(thd, &skip_record) && !skip_record)) //跳过操作 是否符合查询条件
table->file->unlock_row(); //如果是where条件过滤的直接跳到解锁这步
对比比较我们可以直接debug整数的比较函数如下:
#0 Item_func_eq::val_int (this=0x7fff2800ad28) at /opt/percona-server-locks-detail-5.7.22/sql/item_cmpfunc.cc:2506
#1 0x0000000000f4a17b in QEP_TAB::skip_record (this=0x7fff9f1cdf78, thd=0x7fff28012cc0, skip_record_arg=0x7fff9f1ce0fe) at /opt/percona-server-locks-detail-5.7.22/sql/sql_executor.h:457
#2 0x0000000001626efa in mysql_update (thd=0x7fff28012cc0, fields=..., values=..., limit=18446744073709551615, handle_duplicates=DUP_ERROR, found_return=0x7fff9f1ce268,
updated_return=0x7fff9f1ce260) at /opt/percona-server-locks-detail-5.7.22/sql/sql_update.cc:816
这个地方可以看到两个比较的值
(gdb) p val1
$12 = 2
(gdb) p val2
$13 = 2
另外在ha_innobase::unlock_row函数中为了适配semi update,也做了相应的逻辑如下,
switch (m_prebuilt->row_read_type) {
case ROW_READ_WITH_LOCKS: //如果是加锁了
if (!srv_locks_unsafe_for_binlog //判定隔离级别为RC才做解锁
&& m_prebuilt->trx->isolation_level
> TRX_ISO_READ_COMMITTED) {
break;
}
/* fall through */
case ROW_READ_TRY_SEMI_CONSISTENT://如果semi update,TRY_SEMI才进行解锁
row_unlock_for_mysql(m_prebuilt, FALSE); mysql_update
break;
case ROW_READ_DID_SEMI_CONSISTENT://如果semi update,为DID_SEMI那么就不做了,因为没有锁可以解了,semi update 已经在引擎层解掉了
m_prebuilt->row_read_type = ROW_READ_TRY_SEMI_CONSISTENT;
break;
}
这是因为对于semi update遇到row lock堵塞的时候直接就在堵塞后直接解锁了,不需要回到MySQL层解锁(如下文所述)。那么这个特性两个重要影响就是如下:
- 每行row lock加锁是不可避免的,但是会在MySQL层判定后解锁,那么最终这个事务加锁的记录就会很少,这会提高业务的并发,这一点是非常重要的,这种情况下show engine 最终看到的row lock 锁信息就很少了。
- 但是频繁的lock/unlock rec导致LOCK_SYS这个mutex很容易成为热点mutex。
我们可以简单看一下unlock rec的函数lock_rec_unlock,这个函数一上来就可能看到加锁LOCK_SYS,然后通过hash算法,在lock_sys_t中找到对用cell的头节点,然后遍历找到相应的block对应的lock_t结构,然后调用lock_rec_reset_nth_bit函数,解锁相应的位图结构(row lock所在的位置)。
3.3 semi update
就是例子2中的测试,这个特性一定要在出现了row lock堵塞后才会进行判定,是innodb层直接就解除了堵塞,如下,
1、Update 修改一行数据之前设置标记ROW_READ_TRY_SEMI_CONSISTENT
2、访问一行数据,innodb层尝试获取row lock,如果被堵塞则触发semi update判定,判定的规则包含
- 不能为唯一性扫描(unique_search)
- 必须为主键(index != clust_index)
- 不能产生死锁(Check whether it was a deadlock or not)
- RC隔离级别或者innodb_locks_unsafe_for_binlog参数设置了(8.0移除了本参数)
- update语句才可以
主键的非唯一性扫描,最常见的就是全表扫描了。
3、访问本行修改前的old rec 记录(row_sel_build_committed_vers_for_mysql),并且解除堵塞(lock_cancel_waiting_and_release),解除的时候,会将事务wait_lock设置为NULL,同时从 trx_lock中移除,lock_sys_t中的hash结构也会清除掉。 实际上lock_cancel_waiting_and_release就是本特性的核心函数。及如下:
lock_cancel_waiting_and_release
->lock_rec_dequeue_from_page //lock_sys_t中的hash结构会清除,trx_lock中移除
->lock_reset_lock_and_trx_wait //wait_lock设置为NULL
4、返回old rec给mysql层,并且设置变量did_semi_consistent_read=true(导致设置标记ROW_READ_DID_SEMI_CONSISTENT)
5、判定是否满足where条件,如果不满足就扫描下一行了,如果满足再次进入innodb层进入堵塞状态,这个时候ROW_READ_DID_SEMI_CONSISTENT标记已经设置不会再做semi update的判定了,同时如上文如果ROW_READ_DID_SEMI_CONSISTENT标记设置了就不会真正触发unlock row操作。
和unlock row特性不同,unlock row 围绕的核心是让整个语句执行完成后加锁的行更少,而semi update 围绕的核心是出现了堵塞后update语句(触发了全表扫描)是否能够继续,这是非常重要的不同点。
四、额外的问题
分析到这里,我们知道了本案例中是由于没有使用到索引进行update语句出现了大量的lock rec和unlock rec 导致lock_sys_t 结构的mutex LOCK_SYS出现了热点锁,但是还有一个奇怪的问题如下:
image.png注意到这里的row lock和lock struct 都是比较多的,为什么会这样呢,经过unlock row和semi update过后锁定的行数应该是只有1行。
为了更方便的讨论这部分,我们将涉及到的数据结构的元素画个简单的图,同时讲上面提到的lock_sys_t涉及的hash结构也画一下,需要注意的是这些数据结构元素很多很多,这里只话了和问题相关的部分,涉及得很少。
image.png
这里需要注意几点:
- 对于这个rec_hash这个hash查找表的hash值来自于space_id和page_no。
- lock_t是所谓的lock struct,相关的属性比如LOCK_X|LOCK_S,还有LOCK_REC_NOT_GAP/LOCK_REC_GAP 等都是它的属性,而不是某行记录的属性。
- 一个lock_t的bit map最多能够容纳下一个page的所有行的加锁情况。
- bit map才是实际的加锁的体现,它附着在每一个lock_t结构上,innodb通过lock_t[1]快速的找到了他的位置,然后进行设置,在函数lock_rec_reset_nth_bit可以看到这种操作如下:
reinterpret_cast<byte*>(&lock[1])
好了回到上面的问题, row locks和lock struct这两个输出,实际上来自如下:
- row locks:trx->lock->n_rec_locks 这个值是trx_lock_t上的一个统计值而已,在每个调用函数lock_rec_reset_nth_bit和lock_rec_set_nth_bit的末尾减少和增加,对应是解锁和加锁某一行操作。
- lock struct: UT_LIST_GET_LEN(trx->lock.trx_locks) 这个值实际上就是上面我们看到的链表的长度,应该来说是比较准确的。
那么,虽然unlock row 释放了rec lock也就是设置了其标记的bit位,但是lock_t结构本身没有释放,所以lock struct多也可以理解,但是因为上锁和解锁通常要遍历整个page所在lock_sys_t的cell链表上的所有lock struct,如果lock struct多那上LOCK_SYS mutex持有的时间就更长,也符合我们本次问题由于没有用到索引,且并发执行大量的update导致的LOCK_SY mutex的spin。
但是row locks看起来就不那么准确了,随后我做了一个测试,只做了少量的行,触发了一次semi update,看到了结果也是2 row lock,如下:
表结构和数据:
mysql> show create table testsemi40 \G
*************************** 1. row ***************************
Table: testsemi40
Create Table: CREATE TABLE `testsemi40` (
`a` int(11) NOT NULL AUTO_INCREMENT,
`b` int(11) DEFAULT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) NOT NULL,
PRIMARY KEY (`a`),
KEY `b` (`b`,`c`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
mysql> select *from testsemi40;
+---+------+------+----+
| a | b | c | d |
+---+------+------+----+
| 2 | 2 | 2 | 0 |
| 4 | 4 | 4 | 0 |
| 6 | 6 | 6 | 0 |
+---+------+------+----+
3 rows in set (0.00 sec)
session 1:
mysql> begin;
Query OK, 0 rows affected (0.10 sec)
mysql> update testsemi40 set d=6 where c=6;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
session2:
mysql> begin;
Query OK, 0 rows affected (0.10 sec)
mysql> update testsemi40 set d=2 where c=2;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
show engine信息,session2上锁的信息如下:
---TRANSACTION 808633, ACTIVE 4 sec
2 lock struct(s), heap size 1160, 2 row lock(s), undo log entries 1 (这里有2 row locks)
MySQL thread id 18, OS thread handle 140735862867712, query id 381 localhost root
TABLE LOCK table `test`.`testsemi40` trx id 808633 lock mode IX
RECORD LOCKS space id 9695 page no 3 n bits 72 index PRIMARY of table `test`.`testsemi40` trx id 808633 lock_mode X(LOCK_X) locks rec but not gap(LOCK_REC_NOT_GAP)
Record lock, heap no 2 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
0: len 4; hex 80000002; asc ;;
1: len 6; hex 0000000c56b9; asc V ;;
2: len 7; hex 21000001ec2701; asc ! ' ;;
3: len 4; hex 80000002; asc ;;
4: len 4; hex 80000002; asc ;;
5: len 4; hex 80000002; asc ;;
但是我顺着show engine打印本事务的每个lock_t中的bit map加锁结构发下如下:
断点:lock_rec_print
大体输出流程如下:
lock_print_info_all_transactions
循环输出所有的事务的信息
->lock_trx_print_locks
循环输出当前事务的所有lock_t 行锁信息
->lock_rec_print
循环lock_t的位图信息,打印出详细的加锁行
我们只需要在lock_rec_print 函数中通过如下输出
(gdb) p (&lock[1])
$21 = (const ib_lock_t *) 0x2fd79c0
(gdb) x/8bx 0x2fd79c0
0x2fd79c0: 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00
打印所有的lock_t结构就可以了
实际上这里只有一个实际上就只有1个lock_t(当然是rec_lock,不讨论table_lock)结构,看到的加锁信息就是0x04,转二进制就是100,显然就是1行加锁了嘛,对应的heap no 2这一行, heap no 0和heap no 1是innodb的page里面的2个伪列。工具blockinfo输出可以确认如下:
(1) INFIMUM record offset:99 heapno:0 n_owned 1,delflag:N minflag:0 rectype:2
(2) normal record offset:126 heapno:2 n_owned 0,delflag:N minflag:0 rectype:0
(3) SUPREMUM record offset:112 heapno:1 n_owned 5,delflag:N minflag:0 rectype:3
这样我们就确认了在semi update的方式下,row locks的这个计数器统计应该是出现问题的,有什么情况下不会调用lock_rec_reset_nth_bit函数来减少这个计数器呢?
实际这个问题就出现在semi update的核心函数lock_cancel_waiting_and_release上,解除等待时候是将整体lock_t结构给抹掉了,而MySQL层又不会调用unlock row,因为lock_t结构都没有了,也就是核心减少计数器的函数lock_rec_reset_nth_bit并没有调用。因此这个trx->lock->n_rec_locks 计数器在semi update触发的情况下只增加了没减少。言外之意就是semi update在高并发下发生的次数越多,row locks的计数就越不准确。
那么稍微修改一下代码验证一下(仅为验证这种场景,这种修改可能并不可取),我使用在8.0.23上做了同样测试结果一致,同时在8.0.23代码上做的修改,增加2行如下:
void lock_reset_lock_and_trx_wait(lock_t *lock) /*!< in/out: record lock */
{
...
@see trx_lock_t::wait_lock_type for more detailed explanation. */
lock->type_mode &= ~LOCK_WAIT;
ut_ad(lock->trx->lock.n_rec_locks.load() > 0); //增加
lock->trx->lock.n_rec_locks.fetch_sub(1, std::memory_order_relaxed); //增加
然后我们使用前面的方式继续测试发现得到row lock值已经准确了如下:
---TRANSACTION 2740515, ACTIVE 6 sec
2 lock struct(s), heap size 1200, 1 row lock(s), undo log entries 1 (这里显示正确了)
MySQL thread id 9, OS thread handle 140736352634624, query id 36 localhost root starting
show engine innodb status
---TRANSACTION 2740513, ACTIVE 54 sec
2 lock struct(s), heap size 1200, 1 row lock(s), undo log entries 1
MySQL thread id 8, OS thread handle 140736353167104, query id 21 localhost root
当然这么改可能是不合适的,因为这个函数调用者还很多,这里只是修改后验证一下这个猜想。确实这种情况容易导致DBA误判,实际上row lock 并没有row locks统计出来的那么多,随后给官方提交下BUG看看。
最后
这个问题处理起来还是比较简单,但是背后还是有很多可以深挖的地方,本文主要使用的代码是5.7.22,对于semi update下row locks不准的情况在8.0.28 也测试了,依旧存在这个问题。另外在8.0中热点锁LOCK_SYS视乎做了拆分,也许情况会好一些,随后也可以学习下这部分内容,看看官方如何拆锁的。
网友评论