背景

由于业务变迁,合规要求,我们需要删除大量非本公司的数据,涉及到上百张表,几个T的数据清洗。我们的做法是先从基础数据出发,将要删除的数据id收集到一张表,然后再由上往下删除子表,多线程并发处理。

我们使用的是阿里的polardb,完全兼容mysql协议,5.7版本,RC隔离级别。删除过程一直很顺利,突然有一天报了大量:“Lock wait timeout exceeded; try restarting transaction”。从日志上看是获取锁失败了,马上想到出现死锁了,但我们使用RC,这个隔离级别下会出现不可重复读和幻读,但没有间隙锁等,并发效率比较高,在我们实际应用过程中,也很少遇到加锁失败的问题。

单从日志看我们确实先入为主了,以为是死锁问题,但sql比较简单,表数据量在千万级别,其中task_id和uid均有索引,如下:

delete from t_table_1 where task_id in (select id from t_table_2 where uid = #{uid})

拿到报错的参数,查询要删除的数据也不多,联系dba同学确认没有死锁日志,但出现大量慢sql,那为什么这条sql会是慢sql呢?

问题复现

表结构简化如下:

CREATE TABLE `t_table_1` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`task_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_task_id` (`task_id`)
) ENGINE=InnoDB; CREATE TABLE `t_table_2` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`uid` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB;

开始我们拿sql到数据库查询平台查库执行计划,无奈这个平台有bug,delete语句无法查看,所以我们改成select,“应该”是一样。这个“应该”加了双引号,导致我们走了一点弯路。

EXPLAIN SELECT * from t_table_1 where task_id in (select id from t_table_2 where uid = 1)

explain后可以看到是走了索引的

到这里可以总结:

1.没有死锁,这点比较肯定,因为没有日志,也符合我们的理解。

2.有慢sql,这点比较奇怪,通过explain select语句是走索引的,但数据库慢日志记录到,全表扫描,不会错。

那是select和delete的执行计划不同吗?正常来说应该是一样的,delete无非就是先查,加锁,再删。

拿到本地环境执行再次查看执行计划,发现确实不同,select的是一样的,但delete的变成全表扫描了。

首先这就符合问题现象了,虽然没有死锁,但每个delete语句都全表扫描,相当于全表加锁,后面的请求就只能等待释放锁,等到超时就出现“Lock wait timeout exceeded”。

那为什么delete会不走索引呢,接下来我们分析一下。

分析

select * from t_table_1 where task_id in (select id from t_table_2 where uid = #{uid})

回到这条简单sql,包含子查询,按照我们的理解,mysql应该是先执行子查询:select id from t_table_2 where uid = #{uid},然后再执行外部查询:select * from t_table_1 where task_id in(),但这不一定,例如我关了这个参数:

set optimizer_switch='semijoin=off';

这里我们先不用管这个参数的作用,下面会说到。

关闭后上面的sql就变成先扫描外部的t_table_1,然后再逐行去匹配子查询了,假设t_table_1的数据量非常大,那全表扫描时间就会很长,我们可以通过optimizer_trace证明一下。

optimizer_trace是mysql一个跟踪功能,可以跟踪优化器做的各种决策,包括sql改写,成本计算,索引选择详细过程,并将跟踪结果记录到INFORMATION_SCHEMA.OPTIMIZER_TRACE表中。

set session optimizer_trace="enabled=on";
set OPTIMIZER_TRACE_MAX_MEM_SIZE=10000000; -- 防止内容过多被截断
SELECT * from t_table_1 where task_id in (select id from t_table_2 where uid = 1)
SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE;

输出结果比较长,这里我只挑选主要信息

"steps": [
{
"expanded_query": "/* select#2 */ select `t_table_2`.`id` from `t_table_2` where (`t_table_2`.`uid` = 1)"
},
{
"transformation": {
"select#": 2,
"from": "IN (SELECT)",
"to": "semijoin",
"chosen": false
}
},
{
"transformation": {
"select#": 2,
"from": "IN (SELECT)",
"to": "EXISTS (CORRELATED SELECT)",
"chosen": true,
"evaluating_constant_where_conditions": [
]
}
}
] "expanded_query": "/* select#1 */ select `t_table_1`.`id` AS `id`,`t_table_1`.`task_id` AS `task_id` from `t_table_1` where <in_optimizer>(`t_table_1`.`task_id`,<exists>(/* select#2 */ select `t_table_2`.`id` from `t_table_2` where ((`t_table_2`.`uid` = 1) and (<cache>(`t_table_1`.`task_id`) = `t_table_2`.`id`)))) limit 0,1000"

sql简写一下就是

select * from t_table_1 t1 where exists (select t2.id from t_table_2 t2 where t2.uid = 1 and t1.task_id = t2.id)

可以看到in可以改成semijoin或exists,最终优化器选择了exists,因为我们关闭了semijoin开关。

按照这条sql逻辑查询,将会遍历t_table_1表的每一行,然后代入子查询看是否匹配,当t_table_1表的行数很多时,耗时将会很长。

通过explain观察执行计划可以看到t_table_1进行了全表扫描。

备注:想查看优化器改下后生成的sql,也可以通过show extended + show warnings:

explain extended SELECT * from t_table_1 where task_id in (select id from t_table_2 where uid = 1);
show warnings;

接着我们打开上面的参数开关,再次optimizer_trace跟踪一下

set optimizer_switch='semijoin=on';

得到如下:

"steps": [
{
"expanded_query": "/* select#2 */ select `t_table_2`.`id` from `t_table_2` where (`t_table_2`.`uid` = 1)"
},
{
"transformation": {
"select#": 2,
"from": "IN (SELECT)",
"to": "semijoin",
"chosen": true
}
}
] "expanded_query": "/* select#1 */ select `t_table_1`.`id` AS `id`,`t_table_1`.`task_id` AS `task_id` from `t_table_1` semi join (`t_table_2`) where (1 and (`t_table_2`.`uid` = 1) and (`t_table_1`.`task_id` = `t_table_2`.`id`)) limit 0,1000"

sql简写一下就是

select * from t_table_1 semi join t_table_2 where (`t_table_2`.`uid` = 1 and `t_table_1`.`task_id` = `t_table_2`.`id`)"

可以看到优化器这次选择将in转换成semijoin了,观察执行计划可以看到走了索引。

那如果换成delete呢?同样保持开关打开,跟踪如下:

"steps": [
{
"expanded_query": "/* select#2 */ select `t_table_2`.`id` from `t_table_2` where (`t_table_2`.`uid` = 1)"
},
{
"transformation": {
"select#": 2,
"from": "IN (SELECT)",
"to": "semijoin",
"chosen": false
}
},
{
"transformation": {
"select#": 2,
"from": "IN (SELECT)",
"to": "EXISTS (CORRELATED SELECT)",
"chosen": true,
"evaluating_constant_where_conditions": [
]
}
}
]

可以看到和关闭semijoin一样,对于delete优化器也是选择了exists,我们表是千万级别,全表扫描加锁,其它操作语句自然都会超时获取不到锁而失败。

semijoin

semijoin翻译过来是半连接,是mysql针对in/exists子查询进行优化的一种技术,参见文档

可以使用SHOW VARIABLES LIKE 'optimizer_switch';查看semijoin是否开启。

上面使用IN-TO-EXISTS改写后,外层表变成驱动表,效率很差,那如果使用inner join呢,使用条件过滤后,用小表驱动大表,但join查询结果是会重复的,和子查询语义不一定相同。如:

SELECT class.class_num, class.class_name
FROM class
INNER JOIN roster
WHERE class.class_num = roster.class_num;

这样会查询出多条相同class_num的记录,如果子查询,那么查询出来的class_num是不一样的,也就是去重。当然也可以加上distinct,但这样效率比较低。

SELECT class_num, class_name
FROM class
WHERE class_num IN
(SELECT class_num FROM roster);

semijoin有以下几种策略,以下是官方的解释:

Duplicate Weedout: Run the semijoin as if it was a join and remove duplicate records using a temporary table.

FirstMatch: When scanning the inner tables for row combinations and there are multiple instances of a given value group, choose one rather than returning them all. This "shortcuts" scanning and eliminates production of unnecessary rows.

LooseScan: Scan a subquery table using an index that enables a single value to be chosen from each subquery's value group.

Materialize the subquery into an indexed temporary table that is used to perform a join, where the index is used to remove duplicates. The index might also be used later for lookups when joining the temporary table with the outer tables; if not, the table is scanned. For more information about materialization, see Section 8.2.2.2, “Optimizing Subqueries with Materialization”.

以Duplicate Weedout为例,mysql会先将roster的记录以class_num为主键添加到一张临时表,达到去重的目的。接着扫描临时表,每行去匹配外层表,满足条件则放到结果集,最终返回。

具体使用哪种策略是优化器根据具体情况分析得出的,可以从explain的extra字段看到。

那么为什么delete没有使用semijoin优化呢?

这其实是mysql的一个bug,bug地址,描述场景和我们的一样。

文中还提到这个问题在mysql 8.0.21被修复,地址

大致就是解释了一下之前版本没有支持的原因,提到主要是因为单表没有可以JOIN的对象,没法进行一系列的优化,所以单表的UPDATE/DELETE是无法用semijoin优化的。

这个优化还有一些限制,例如不能使用order by和limit,我们还是应该尽量避免使用子查询。

在我们的场景通过将子查询改写为join即可走索引,现在也明白为什么老司机们都说尽量用join代替了子查询了吧。

这样delete居然不走索引的更多相关文章

  1. MySql Delete不走索引问题

    如果delete语句带有查询,写法不对会导致不走索引. 简单粗暴的办法:拆两条sql,一条查询,一条delete ======================= [不走索引的写法] DELETE FR ...

  2. not in 和 <> 不走索引

    首先我们要知道的一点就是CBO的代码oracle是不会对我们公开的,起码现在是.所以本文中的结论不一定适用所有的版本.在应用本文的结论之前最好先试一下. ok 下面就是本文的结论,当你在where语句 ...

  3. 诊断一句SQL不走索引的原因

    from http://www.itpub.net/thread-1852897-1-1.html 有论坛朋友在上面的帖子里问SQL为什么不走索引,正好这两天我也刚刚在看SQL优化,于是试着回答了一下 ...

  4. Select * 一定不走索引是否正确?

    Select * 一定不走索引是否正确? 走索引指的是:SQL语句的执行计划用到了1.聚集索引查找  2.索引查找  ,并且查询语句中需要有where子句 根据where子句的过滤条件,去聚集索引或非 ...

  5. Update关联查询不走索引,效率低下

    优化一个sql,就是有A,B两个表,要利用b表的字段更新a表对应的字段.形如 Sql代码 update A set A.a=(select B.b from B where A.id=B.id); 原 ...

  6. 【摘】Oracle执行计划不走索引的原因总结

    感谢原博主 http://soft.chinabyte.com/database/364/12471864.shtml 在Oracle数据库操作中,为什么有时一个表的某个字段明明有索引,当观察一些语的 ...

  7. oracle查询不走索引的一些情况(索引失效)

    Oracle建立索引的目的是为了避免全表扫描,提高查询的效率. 但是有些情况下发现即使建立了索引,但是写出来的查询还是很慢,然后会发现是索引失效导致的,所以需要了解一下那些情况会导致索引失效,即查询不 ...

  8. mysql 索引 大于等于 走不走索引 最左前缀

    你可以认为联合索引是闯关游戏的设计 例如你这个联合索引是state/city/zipCode 那么state就是第一关 city是第二关, zipCode就是第三关 你必须匹配了第一关,才能匹配第二关 ...

  9. oracle order by 字段不能为空 为空速度慢 不走索引

    oracle order by 字段不能为空 为空速度慢 不走索引

  10. mysql 索引优化,索引建立原则和不走索引的原因

    第一:选择唯一性索引 唯一性索引的值是唯一的,可以更快捷的通过该索引来确定某条记录. 2.索引的列为where 后面经常作为条件的字段建立索引 如果某个字段经常作为查询条件,而且又有较少的重复列或者是 ...

随机推荐

  1. Elasticsearch之常用术语

    一. 数据库和ES简单类比 关系型数据库 表(Table) 行(Row) 列(Cloumn) Schema SQL Elasticsearch 索引(Index) 文档(Document) 字段(Fi ...

  2. 15种实时uv实现方案系列(附源码)之一:Flink基于set实时uv统计

    UVStatMultiPlans(GitHub)项目持续收集各种高性能实时uv实现方案并对各种实现方案的优缺点进行对比分析! 需求描述 统计每分钟用户每个页面的uv访问量. Kafka数据格式 {&q ...

  3. nginx Ingress Controller Packaged by Bitnami

    环境介绍 节点 master01 work01 work02 主机/ip calico-master01/192.168.195.135 calico-master01/192.168.195.135 ...

  4. E-GraphSAGE: A Graph Neural Network based Intrusion Detection System 笔记

    E-GraphSAGE: A Graph Neural Network based Intrusion Detection System 目录 E-GraphSAGE: A Graph Neural ...

  5. oracle的根容器下新建pdb容器及本地用户

    在Oracle12C根容器下,新建pdb,要求根据种子pdb建目的pdb:db_test,配置监听:在目的pdb下建本地用户 首先根据种子pdb新建目的pdb 1.管理员身份登录 C:\WINDOWS ...

  6. Go语言系列——11-数组和切片、12-可变参数函数、13-Maps、14-字符串、15-指针、16-结构体、17-方法、18-接口(一)、19-接口(二)、19-自定义集合类型、20-并发入门

    文章目录 11-数组和切片 数组 数组的声明 数组是值类型 数组的长度 使用 range 迭代数组 多维数组 切片 创建一个切片 切片的修改 切片的长度和容量 使用 make 创建一个切片 追加切片元 ...

  7. Vocabulary

    词汇(Vocabulary) blackmail ( n.) :the obtaining of money or advancement by threatening to make known u ...

  8. vcpkg manifest 的使用

    最近项目上要使用 CMakeLists 管理,由于 Windows 版本有依赖到 vcpkg 提供的库,所以需要使用 vcpkg manifest 来统一设置库的版本,方便后续维护 推荐一个文章,介绍 ...

  9. 秋招过半零Offer怎么办?

    参加今年秋招的同学都知道,尤其是双非本科更是体验深刻.9 月份至今,面试寥寥无几.笔试也不是很多,大中小公司 Offer 没拿下一个.作为应届生的我们,该怎么办呢? 1.调整好心态 这个世界上有两种事 ...

  10. 用go封装和实现扫码登录

    用go封装和实现扫码登录 本篇为用go设计开发一个自己的轻量级登录库/框架吧 - 秋玻 - 博客园 (cnblogs.com)的扫码登录业务篇,会讲讲扫码登录的实现,给库/框架增加新的功能,最后说明使 ...