1 问题现象
自发布了 INSERT 并发死锁问题的文章,收到了多次死锁问题的交流。一个具体案例如下:
研发反馈应用发生死锁,收集如下诊断内容:
------------------------ LATESTDETECTEDDEADLOCK ------------------------ 2023-07-0406400x7fc07dd0e700 ***(1)TRANSACTION: TRANSACTION182396268,ACTIVE0secfetchingrows mysqltablesinuse1,locked1 LOCKWAIT21lockstruct(s),heapsize3520,2rowlock(s),undologentries1 MySQLthreadid59269692,OSthreadhandle140471135803136,queryid3738514953192.168.0.215user1updating deletefromltb2wherec='CCRSFD07E'andj='Y15'andb>='20230717'andd!='1'ande!='1' ***(1)WAITINGFORTHISLOCKTOBEGRANTED: RECORDLOCKSspaceid603pageno86nbits248indexPRIMARYoftable`testdb`.`ltb2`trxid182396268lock_modeXlocksrecbutnotgapwaiting ***(2)TRANSACTION: TRANSACTION182396266,ACTIVE0secfetchingrows,threaddeclaredinsideInnoDB1729 mysqltablesinuse1,locked1 28lockstruct(s),heapsize3520,2rowlock(s),undologentries1 MySQLthreadid59261188,OSthreadhandle140464721291008,queryid3738514964192.168.0.214user1updating updateltb2setf='0',g='0',is_value_date='0',h='0',i='0'wherec='22115001B'andj='Y4'andb>='20230717' ***(2)HOLDSTHELOCK(S): RECORDLOCKSspaceid603pageno86nbits248indexPRIMARYoftable`testdb`.`ltb2`trxid182396266lock_modeXlocksrecbutnotgap ***(2)WAITINGFORTHISLOCKTOBEGRANTED: RECORDLOCKSspaceid603pageno86nbits248indexPRIMARYoftable`testdb`.`ltb2`trxid182396266lock_modeXlocksrecbutnotgapwaiting ***WEROLLBACKTRANSACTION(1) ------------
以上 space id 603 page no 86 n bits 248,其中 space id 表示表空间 ID,page no 表示记录锁在表空间内的哪一页,n bits 是锁位图中的位数,而不是页面偏移量。记录的页偏移量一般以 heap no 的形式输出,但此例并未输出该信息。
基本环境信息
确认如下问题相关信息:
数据库版本:Percona MySQL 5.7
事务隔离级别:Read-Commited
表结构和索引:
CREATETABLE`ltb2`( `ID`bigint(20)unsignedNOTNULLAUTO_INCREMENTCOMMENT'ID', `j`varchar(16)DEFAULTNULLCOMMENT'', `c`varchar(32)NOTNULLDEFAULT''COMMENT'', `b`dateNOTNULLDEFAULT'2019-01-01'COMMENT'', `f`varchar(1)NOTNULLDEFAULT''COMMENT'', `g`varchar(1)NOTNULLDEFAULT''COMMENT'', `d`varchar(1)NOTNULLDEFAULT''COMMENT'', `e`varchar(1)NOTNULLDEFAULT''COMMENT'', `h`varchar(1)NOTNULLDEFAULT''COMMENT'', `i`varchar(1)DEFAULTNULLCOMMENT'', `LAST_UPDATE_TIME`timestampNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'修改时间', PRIMARYKEY(`ID`), UNIQUEKEY`uidx_1`(`b`,`c`) )ENGINE=InnoDBAUTO_INCREMENT=270983DEFAULTCHARSET=utf8mb4COMMENT='';
关键信息梳理
事务 T1 | |
---|---|
语句 | delete from ltb2 where c = 'code001' and j = 'Y15' and b >= '20230717' and d != '1' and e != '1' |
关联对象及记录 | space id 603 page no 86 n bits 248 index PRIMARY of table testdb.ltb2 |
持有的锁 | 未知 |
等待的锁 | lock_mode X locks rec but not gap waiting |
事务 T2 | |
---|---|
语句 | update ltb2 set f = '0', g = '0', is_value_date = '0', h = '0', i = '0' where c = '22115001B' and j = 'Y4' and b >= '20230717' |
关联对象及记录 | space id 603 page no 86 n bits 248 index PRIMARY of table testdb.ltb2 |
持有的锁 | lock_mode X locks rec but not gap |
等待的锁 | lock_mode X locks rec but not gap waiting |
可以看到在主键索引上发生了死锁,但是在查询的条件中,并未使用主键列。
那为什么会在主键列出现死锁?
在分析死锁根因问题前,需要先清楚 SQL 的执行情况。
2 SQL 执行情况
执行计划
以上两个 SQL 发现都有列 b、c 作为条件,且该列构成了索引唯一索引 uidx_1。简化 SQL 改为查询语句,并确认执行计划:
mysql>descselect*fromltb2whereb>='20230717'andc='code001'; #部分结果 +------+-------------------+------+---------+ |type|possible_keys|key|Extra| +------+-------------------+------+---------+ |ALL|uidx_1|NULL|Usingwhere| +------+-------------------+------+---------+
注意:自 MySQL 5.6 开始可以直接查看 UPDATE/DELETE/INSERT 等语句的执行计划。因个人习惯、避免误操作等原因,还是习惯改为 SELECT 查看执行计划。
执行计划中可能的索引有uidx_1(b,c),但实际并未使用该索引,而是采用全表扫描方式执行。
根据经验,由于列 b 为索引的最左列。但查询的条件为b>= '20230717',即该条件不是等值查询。因此数据库可能只能“使用”到 b 列。为进一步确认不使用 b 列索引的原因,查询数据分布:
mysql>selectcount(1)fromltb2; +------------+ |count(1)| +------------+ |4509| +------------+ mysql>selectcount(1)fromltb2whereb>='20230717'; +------------+ |count(1)| +------------+ |1275| +------------+
计算满足 b 列条件的数据占比为 1275/4509 = 28%,占比差不多达到了 1/3。此时也的确不应使用该使用索引。
难道已经是作为 MySQL 5.7 的数据库,优化器还是这么简单?
ICP 特性
带着问题,将条件设置一个更大的值(但小于该列的最大值),再次执行验证查询语句:
mysql>descselect*fromltb2whereb>='20990717'; #部分结果 +----------+---------+---------+ |key_len|rows|Extra| +----------+---------+---------+ |3|64|UsingIndexcondition| +----------+---------+---------+
优化器预估返回 64 行,数据占比 64/4509 = 1.4%,因此可以使用索引。但通过执行计划,从Extra列看到Using index condition提示。该提示则说明使用了索引条件下推(Index Condition Pushdown, ICP)。针对该特性,参考官方简要说明如下:
使用 Index Condition Pushdown,扫描将像这样进行:
获取下一行的索引元组(但不是完整的表行)。
测试 WHERE 条件中应用于此表的部分,并且只能使用索引列的进行检查。如果不满足条件,则继续到下一行的索引元组。
如果满足条件,则使用索引元组定位并读取整个表行。
测试适用于此表的 WHERE 条件的其余部分。根据测试结果接受或拒绝该行。
既然可以使用到 ICP 特性,进一步执行如下验证语句:
mysql>descselect*fromltb2whereb>='20990717'andc='code001'; #部分结果 +----------+---------+---------+ |key_len|rows|Extra| +----------+---------+---------+ |133|64|UsingIndexcondition| +----------+---------+---------+
发现当新增 c 列作为条件后,并且根据 key_len(索引里使用的字节数)可以判断,的确使用到了 uidx_1 索引中的 c 列。但 rows 的结果与实际返回结果差异较大(实际执行仅返回 0 行)。
更重要的是,既然具有 ICP 特性,针对原始的 SQL 为什么不能助于 ICP 特性使用到索引呢?
mysql>select*fromltb2whereb>='20230717'andc='code001'
执行计划跟踪
继续带着问题,通过 MySQL 提供的 OPTIMIZER TRACE,跟踪执行计划生成过程。命令如下:
SETOPTIMIZER_TRACE="enabled=on",END_MARKERS_IN_JSON=on; SETOPTIMIZER_TRACE_MAX_MEM_SIZE=1000000; --sql-1: select*fromltb2whereb>='20990717'andc='code001'; --sql-2: select*fromltb2whereb>='20990717'; --sql-3 select*fromltb2whereb>='20230717'andc='code001'; SELECT*FROMINFORMATION_SCHEMA.OPTIMIZER_TRACEG SEToptimizer_trace="enabled=off";
由于分析结果较长,截取 SQL-1 和 SQL-2 的部分结果 (rows_estimation 和 considered_execution_plans)。具体内容如下:
SQL-1
select*fromltb2whereb>='20990717'andc='code001' #分析结果 "analyzing_range_alternatives":{ "range_scan_alternatives":[ { "index":"uidx_1", "ranges":[ "0xe76610<= b" ] /* ranges */, "index_dives_for_eq_ranges": true, "rowid_ordered": false, "using_mrr": false, "index_only": false, "rows":64, "cost": 77.81, "chosen": true } ] /* range_scan alternatives */ } "best_access_path":{ "considered access_paths":[ "rows_to_scan": 64, "access_type":"range", "range_details":{ "used index";"uidx 1" } /* range_details */, "resulting_rows": 64, "cost": 90.61, "chosen": true } ] /* considered access_paths */ } /* best access_path */,
SQL-2
select*fromltb2whereb>='20990717' #分析结果 "analyzing_range_alternatives":{ "range_scan_alternatives":[ { "index":"uidx_1", "ranges":[ "0xe76610<= b" ] /* ranges */, "index_dives_for_eq_ranges": true, "rowid_ordered": false, "using_mrr": false, "index_only": false, "rows":64, "cost": 77.81, "chosen": true } ] /* range_scan alternatives */ } "considered access_paths":[ { "rows_to_scan": 64, "access_type":"range", "range_details":{ "used index":"uidx_1" } /* range_details */, "resulting_rows": 64, "cost": 90.61, "chosen": true } ] /* considered access_paths */,
根据以上信息:两个 SQL 的 cost 部分是完全相同的,且在优化器分析阶段只能识别到 b 的条件。分析阶段,只能根据优化器认为可用的列来计算 cost。ICP 特性,应该是在执行阶段采用用到的特性。
同时,根据 SQL-3 的执行跟踪结果,对比全表扫描和索引扫描的 cost,截取部分结果如下:
SQL-3
select*fromltb2whereb>='20230717'andc='code001'; #全表扫描结果 "range_analysis":{ "table_scan":{ "rows":4669, "cost":1018.9 }/*table_scan*/, #索引扫描评估结果 "analyzing_range_alternatives":{ "range_scan_alternatives":[ { "index":"uidx_1", "ranges":[ "@xe7ce0f]<= b" ] /* ranges */, "index dives_for_eq_ranges": true, "rowid_ordered": false, "using_mrr": false, "index_only": false, " rows": 1273, "cost": 1528.6, "chosen": false, "cause":"cost" } ] /* range scan_alternatives */, # 最优执行计划 "best_access_path": { "considered access_paths":[ { "rows_to_scan": 4669, "access_type":"scan", "resulting_rows": 4669, "cost": 1016.8, "chosen": true } ] /* considered access_paths *//* best access_path */ }
由于优化器阶段使用使用列 b,使用索引的成本高于全表扫描。那最终数据库就会选择使用全表扫描。除非应用使用 hint 强制索引:
mysql>descselect*fromltb2FORCEINDEX(uidx_1)whereb>='20230717'andc='code001'; #部分结果 +----------+---------+---------+ |key_len|rows|Extra| +----------+---------+---------+ |133|1273|UsingIndexcondition| +----------+---------+---------+
同时,根据执行计划的输出结果,rows 列应该是优化器阶段的输出,key_len/Extra 则包括了执行阶段的输出。
小结
综上所述,对于问题 SQL 和索引结构,由于列 b 为索引的最左列,且查询时的条件为 b>= '20230717'(非等值条件),数据库优化器只能“使用”到 b 列。并给予“使用”的列,评估扫码的行数和 cost。
如果优化器评估后,使用索引的成本更低,则可以使用该索引,并利用 ICP 特性进一步提高查询性能;
如果优化器评估后,使用全表扫描或的成本更低,那数据库就会选择使用全表扫描。
3 SQL 优化方案
根据第 2 部分明确了问题的原因后,通过调整索引,解决最左列尾范围查询的问题即可解决该问题。具体如下:
altertableltb2dropindexuidx_1; altertableltb2addindexuidx_1(c,b); altertableltb2addindexidx_(b);
死锁为何发生
自此,完成了 SQL 执行计划问题的分析和解决。但直接的问题是死锁,因查询语句无法使用索引,正常就应该使用全表扫描。但是全表扫描为什么会出现死锁呢?
在此,对死锁过程进行大胆猜想:
T1 时刻
trx-2 执行了 UPDATE,在处理行时,在 row_search_mvcc 函数中,查询到数据。获取了对应行的 LOCK_X,LOCK_REC_NOT_GAP 锁;
T2 时刻
trx-1 执行了 DELETE,在处理行时,在 row_search_mvcc 函数中,查询到数据,尝试获取行的 LOCK_X,LOCK_REC_NOT_GAP。但由于 trx-1 已经持有了该锁,因此被堵塞。并会创建一个锁(以指示锁等待);
T3 时刻
trx-2 继续执行 UPDATE 操作。由于是该操作除了在 T1 时刻的操作外,在其它位置,还需要获取锁(lock_mode X locks rec but not gap)。但由于 T2 时刻,trx-1 尝试获取该锁而被堵塞,并且也增加了一个锁。
假如此时,此处的实现机制和 INSERT 死锁案例一样,也没有先进行冲突检查。而只是看记录上是否存在锁的话,那么此时也会看到该记录上有 trx-1 事务的锁。从而导致 trx-2 第二次获取锁时,被堵塞。
死锁发生!