淘天电商订单表索引优化实战:慢SQL分析、索引结构、优化工具、变更SOP与常见问题解决。
原文标题:从一条慢SQL说起:交易订单表如何做索引优化
原文作者:阿里云开发者
冷月清谈:
怜星夜思:
2、文章中提到了索引下推(ICP)可以有效减少回表次数,提升查询效率。那么在什么情况下,即使查询条件能够利用到索引,MySQL仍然可能不使用ICP?
3、文章详细介绍了索引变更的SOP,但在实际操作中,仍然可能遇到各种突发情况。如果你负责一个核心交易表的索引变更,你会如何制定应急预案,以应对可能出现的各种风险?
原文内容
二、一个非典型的慢SQL
select order_id from tcorder where is_main = 1
and buyer_id=2********5
***************
order by create_time desc,order_id asc limit 0,10
|
字段
|
结果
|
说明
|
|
sql_id
|
71d14de3
|
SQL Parttern ID,基于原始SQL文本通过BKDR的HASH算法生成
|
|
execute_count
|
3106
|
慢SQL总次数
|
|
cost
|
2017
|
平均执行耗时,单位ms
|
|
send_row_count
|
10
|
平均返回行数
|
|
examined_row_count
|
8705
|
平均扫描行数
|
|
logical_read
|
40023
|
平均逻辑读,即SQL处理过程中读取的数据页和索引页的数量,包含从Innodb Buffer Pool和磁盘读取两部分的,数据页和索引页的大小都是16kb
|
|
physical_sync_read
|
5174
|
平均同步物理读,即SQL处理过程中从磁盘读取的数据页和索引页的数量
|
三、去掉order_id排序
四、索引知识回顾
B+Tree数据结构
|
order_id
|
create_time
|
is_main
|
status
|
type
|
|
1
|
1752595200
|
0
|
0
|
10000
|
|
2
|
1752595200
|
0
|
0
|
200
|
|
3
|
1752595200
|
1
|
0
|
200
|
|
4
|
1752595201
|
1
|
0
|
5000
|
|
5
|
1752595202
|
1
|
1
|
200
|
|
6
|
1752595203
|
0
|
0
|
10000
|
|
7
|
1752595203
|
1
|
0
|
200
|
|
8
|
1752595203
|
1
|
0
|
261
|
B+Tree的高度测算
|
高度
|
非叶子节点数量
|
叶子节点数量
|
最大记录数
|
|
1
|
0
|
1
|
2
|
|
2
|
1
|
952
|
952*2=1904
|
|
3
|
1+952=953
|
952*952=906304
|
952*952*2=1812608
|
|
4
|
1+952+952*952=907257
|
952*952*952=862801408
|
952*952*952*2=1725602816
|
|
高度
|
非叶子节点数量
|
叶子节点数量
|
最大记录数
|
|
1
|
0
|
1
|
170
|
|
2
|
1
|
241
|
241*170=1904
|
|
3
|
1+241=242
|
241*241=58081
|
241*241*170=9873770
|
|
4
|
1+241+241*241=58323
|
241*241*241=13997521
|
241*241*241*170=2379578570
|
B-Tree的数据结构
|
|
m阶B-Tree
|
m阶B+Tree
|
|
子节点数量
|
m+1
|
m
|
|
非叶子节点是否包含非索引字段数据
|
是,任何索引记录只存在于一个节点中
|
否,非叶子节点只存储索引字段,只用于二分查找,因此同一个索引记录可能出现在多个非叶子节点中,只有叶子节点保存了完整的索引数据
|
|
同一层的节点是否有双向指针
|
否
|
是
|
|
优势
|
没有数据冗余,数据规模较小时,因为命中了某个非叶子节点即可返回,点查场景下查询效率更高
|
在大规模数据下因为非叶子节点包含的索引记录更多,从而降低整体B+Tree的高度,提升查找效率。
每次查询必须走到叶子节点,从而保证查询RT的稳定。
同一层的节点有双向指针,提高列表查询的效率。
|
|
缺点
|
每次查询命中的节点的树高度可能不一致导致查询RT不稳定
同一层的节点没有双向指针,不利于列表查询。
|
有数据冗余
|
explain说明
select
********
from
(
select
order_id
from
`tcorder`
where
is_main = 1
and buyer_id = 1*******1
**************
order by
create_time desc,`order_id` asc
limit
0, 10
) t2,
`tcorder` t1
where
t2.order_id = t1.order_id
{
"query_block": {--最外层的SQL的执行成本
"select_id": 1,
"cost_info": {
"query_cost": "17.25"--预估的成本值,不是预估耗时,只能用作横向比较
},
"nested_loop": [--按照执行顺序列出来多个内层SQL预估的执行成本
{
"table": {
"table_name": "t2",
"access_type": "ALL",
"rows_examined_per_scan": 5,--预估的扫描行数
"rows_produced_per_join": 5,--预估的返回上层的行数
"filtered": "100.00",
"cost_info": {
"read_cost": "10.25",--预估的单次读取数据的成本
"eval_cost": "1.00",--预估的将单次读取的数据按照where条件过滤一遍的成本
"prefix_cost": "11.25",--整体的查询成本
"data_read_per_join": "80"--预估读取的数据量,单位字节
},
"used_columns": [
"order_id"
],
"materialized_from_subquery": {--表示t2是物化子查询产生的
"using_temporary_table": true,--使用了临时表保存结果
"dependent": false,--表示子查询的where条件跟外层SQL无关,在外层执行前或一次性计算并缓存
"cacheable": true,--表示子查询的结果可以缓存
"query_block": {
"select_id": 2,
"cost_info": {
"query_cost": "20928.00"
},
"ordering_operation": {--表示需要排序
"using_filesort": true,--表示走文件堆排排序
"table": {
"table_name": "tcorder",
"access_type": "ref",
"possible_keys": [
"ind_***_buyerid",
"idx_***_gmtcreate"
],
"key": "idx_***_gmtcreate",
"used_key_parts": [--表示实际生效的索引列
"buyer_id"
],
"key_length": "8",
"ref": [
"const"
],
"rows_examined_per_scan": 17440,
"rows_produced_per_join": 5,
"filtered": "0.03",
--index_condition表示通过索引下推过滤的查询条件
"index_condition": "
****************
((`tcorder`.`buyer_id` <=> 117075031) and (``tcorder`.`is_main` = 1) and
***************
",
"cost_info": {
"read_cost": "17440.00",
"eval_cost": "1.18",
"prefix_cost": "20928.00",
"data_read_per_join": "236K"
},
"used_columns": [
"order_id",
"buyer_id",
*************
],
--attached_condition表示通过mysql服务器层过滤的where条件
"attached_condition": "((ifnull(`tcorder`.`attribute4`,0) <> 1) and *************"
}
}
}
}
}
},
{
"table": {
"table_name": "t1",
"access_type": "eq_ref",
"possible_keys": [
"PRIMARY"
],
"key": "PRIMARY",
"used_key_parts": [
"order_id"
],
"key_length": "8",
"ref": [
"t2.order_id"
],
"rows_examined_per_scan": 1,
"rows_produced_per_join": 5,
"filtered": "100.00",
"cost_info": {
"read_cost": "5.00",
"eval_cost": "1.00",
"prefix_cost": "17.25",
"data_read_per_join": "200K"
},
"used_columns": [
"order_id",
****************
]
}
}
]
}
}
Query Profiler
|
命令
|
说明
|
示例
|
|
show variables like 'profiling'
|
查看是否开启profile功能,默认关闭
|
|
|
set profiling = 1
|
开启Query Profiler功能,开启后执行任何SQL都会被采集Profiler信息
|
|
|
show profiles
|
-查看当前保留的采集了profile信息的SQL列表,默认是最多保存15个,通过参数profiling_history_size控制
|
|
|
SHOW PROFILE [type] [FOR QUERY n] [LIMIT row_count [OFFSET offset]]
|
查看具体的profile信息,type取值可以是 ALL\BLOCK IO等,多个type之间用逗号间隔。FOR QUERY n中的n是SHOW PROFILES中的编号,可以查看指定缓存的Profiling信息。LIMIT OFFSET控制的是一条查询的Profiling信息中输出哪部分行(阶段)的信息。直接执行SHOW PROFILE展示的是最新一条被缓存的Profiling信息
|
|
|
取值
|
说明
|
|
ALL
|
显示所有的开销信息
|
|
BLOCK IO
|
显示块IO相关开销
|
|
CONTEXT SWITCHES
|
显示上下文切换相关开销
|
|
CPU
|
显示CPU相关开销信息
|
|
IPC
|
显示发送和接收相关开销信息
|
|
MEMORY
|
显示内存相关开销信息,5.7版本未实现
|
|
PAGE FAULTS
|
显示页面错误相关开销信息
|
|
SOURCE
|
显示和Source_function,Source_file,Source_line相关的开销信息
|
|
SWAPS
|
显示交换次数相关开销的信息
|
|
取值
|
说明
|
|
QUERY_ID
|
查询语句的ID
|
|
STATE
|
具体的状态,对应输出结果的status列
|
|
Duration
|
状态总持续时间,单位秒
|
|
CPU_user
|
用户CPU时间,单位秒
|
|
CPU_system
|
系统CPU时间,单位秒
|
|
Context_voluntary
|
上下文主动切换次数
|
|
Context_involuntary
|
上下文被动切换次数
|
|
Block_ops_in
|
磁盘阻塞写入次数
|
|
Block_ops_out
|
磁盘阻塞读取次数
|
|
Messages_sent
|
发送的消息量
|
|
Messages_received
|
接收的消息量
|
|
Page_faults_major
|
主要页面错误的数量
|
|
Page_faults_minor
|
次要页面错误的数量
|
|
Swaps
|
交换次数
|
|
Source_function
|
原函数(调用源码中的函数)
|
|
Source_file
|
源文件(MySQL源码中对应的.cc文件)
|
|
Source_line
|
源代码行(对应源码文件中的代码行数)
|
|
取值
|
说明
|
|
starting
|
MySQL 为执行该语句做初始准备(启动执行流程)。
|
|
checking permissions(或 checking permissions on table)
|
检查用户权限,确认是否有访问表/列的权限。
|
|
Waiting for query cache lock
|
等待查询缓存的锁(如果启用了 query_cache),常见于并发环境
|
|
Opening tables
|
打开表(打开文件/表句柄、表定义等),这是执行前的准备。
|
|
After opening tables / After opening tables
|
表示已完成打开表的步骤(有时重复出现)。
|
|
System lock
|
获取全局/系统级锁(各种内部同步),旧存储引擎或元数据操作时可见。
|
|
Waiting for table metadata lock/Waiting for metadata lock / Waiting for table lock
|
等待元数据锁(MDL),例如其他会话正在做 ALTER/LOCK TABLE 等。
|
|
init
|
初始化执行环境(分配内存、初始化变量等)。
|
|
optimizing / optimizing (or optimise)
|
优化器进行重写/选择索引并生成访问计划。
|
|
statistics
|
收集统计信息(表、索引基数、列分布等)以估算成本(优化器用这些信息)。
|
|
preparing
|
为执行做最后准备(例如为执行计划的某些部分生成临时结构)。
|
|
Sending data
|
通常表示把结果行从存储引擎/服务器发送到客户端、或是执行过程中从存储引擎读取并向上层传递行。
|
|
executing
|
表示运行语句的服务器端逻辑(包括存储过程/函数/触发器的代码、表达式求值、调用存储引擎的执行逻辑等),这是一个比较通用的执行阶段标识。
|
|
Copying to tmp table
|
将结果复制到临时表(当需要 GROUP BY、DISTINCT、某些 ORDER BY、子查询物化时)。这是将数据写入临时表的阶段。
|
|
Creating tmp table
|
创建临时表结构(内存或磁盘)以便后续使用。
|
|
Sorting for group / Sorting for order / Sorting result
|
对数据做排序(filesort),可能用于 GROUP BY、ORDER BY。filesort 多时开销大。
|
|
Creating sort index / Repair by sorting
|
与索引重建或修复相关(不常见于普通查询,常见于 REPAIR 操作)。
|
|
Waiting for table level lock / Waiting for table flush
|
等待表级锁或表刷新相关
|
|
Sorting for group
|
为 GROUP BY 排序或准备聚合(可能和 loose index scan 相关)
|
|
end / query end
|
查询逻辑结束,进入收尾阶段。
|
|
closing tables
|
关闭已打开的表/文件句柄。
|
|
freeing items
|
释放表达式/缓存所占资源(清除内存)。
|
|
cleaning up
|
最终清理,回收分配、释放临时表等。
|
索引下推
explain select
*
from
`tcorder`
where
`parent_id` = 37*******0834
and `from_group` = 3;
explain select
*
from
`tcorder`
where
`parent_id` = 37*******0834
and `from_group` > 3;
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.42"
},
"table": {
"table_name": "tcorder",
"access_type": "range",
"possible_keys": [
"ind_***_parentid"
],
"key": "ind_***_parentid",
"used_key_parts": [
"parent_id",
"from_group"
],
"key_length": "12",
"rows_examined_per_scan": 1,
"rows_produced_per_join": 0,
"filtered": "100.00",
"index_condition": "((`tcorder`.`parent_id` = 37*******0834) and (`tcorder`.`from_group` > 3))",
"cost_info": {
"read_cost": "1.18",
"eval_cost": "0.08",
"prefix_cost": "1.42",
"data_read_per_join": "15K"
},
"used_columns": [
"order_id",
***************
]
}
}
}
select
order_id
from
`tcorder`
where
is_main = 1
and buyer_id = 117075031
***************
-- and (
-- ifnull(attribute4, 0) <> 1
-- and ifnull(attribute4, 0) <> 2
-- )
***************
order by
create_time desc
limit
0, 10;
排序优化
select * from tcorder order by parent_id;
select * from tcorder order by parent_id,from_group;
select * from tcorder where parent_id=1234 order by from_group;
select * from tcorder where parent_id>1234 order by parent_id;
select * from tcorder where parent_id=1234 and from_group>1 order by from_group;
select * from tcorder order by parent_id desc,from_group desc;
select * from tcorder order by from_group;
select * from tcorder order by abs(parent_id);
select * from tcorder where parent_id>1234 order by from_group;
select * from tcorder where from_group=1 order by parent_id;
select * from tcorder order by parent_id desc,from_group asc;
{
"filesort_information": [--排序的字段和顺序
{
"direction": "desc",
"table": "`tcorder`",
"field": "create_time"
},
{
"direction": "asc",
"table": "`tcorder`",
"field": "order_id"
}
],
"filesort_priority_queue_optimization": {
"limit": 10,--查询SQL中带了limit 10
"rows_estimate": 285372816,--预估的参与排序的总行数
"row_size": 67,--预估的排序key的大小
"memory_available": 2097152,--sort buffer的大小
"chosen": true--说明使用了优先级队列排序
},
"filesort_execution": [
],
"filesort_summary": {
"rows": 11,--最终返回给上层的行数
"examined_rows": 10597,--实际处理的行数
"number_of_tmp_files": 0,--使用的临时文件的数量,为0表示未生成临时文件
"sort_buffer_size": 832,--实际使用的sort buffer的大小
"sort_mode": "<sort_key, additional_fields>"--排序key的构成是排序字段+额外返回的字段,即全量字段排序;如果sort_mode为<sort_key, rowid> 则表示执行rowid排序
}
}
五、索引变更SOP
分析慢SQL类型和根因
分析全量查询SQL构成
确认索引字段顺序
提交结构设计工单
线上灰度
压测验证
全量验证
六、常见的慢SQL问题
|
问题原因
|
说明
|
治理措施
|
|
区分度大的字段上没有索引,此时需要回表查,遍历大量记录来找到满足指定条件的记录,产生大量随机IO,性能甚至比顺序IO的全表扫描要慢。
|
区分度差的字段(比如状态类字段)没有索引时也会回表查,但是因为过滤性不强,只需遍历少量记录即可找到满足条件的记录,不会导致慢SQL
|
将过滤性强的字段
|
|
二级索引下,范围查询的字段排在前面,等值查询的字段排在后面
|
根据上述二级索引的数据结构可知,等值查询的字段排在后面无法利用索引过滤,排在前面时可以走索引过滤,进一步缩小范围查询遍历的索引记录数,即降低逻辑读
|
将等值查询的条件排在范围查询的前面
|
|
like查询通配符%在前面,此时相当于是判断某个字段是否以指定字符串结尾,无法利用索引来做过滤
|
字符串类的字段在索引中的顺序是基于字符串本身的字符,从左到右依次比较的,所以判断某个字段是否以指定字符串开头,即like查询通配符%在后面,可以利用索引的有序性来缩小索引遍历范围
|
更改查询方式
|
|
索引字段的查询条件带有函数表达式,此时无法在SQL优化阶段确认函数的值,依赖运行时索引字段的实际值来做函数计算,所以无法利用索引
|
and ifnull(create_time, 0) = SUBDATE(now(), INTERVAL 51 DAY) ifnull的结果无法提前确认,无法利用索引
and create_time > SUBDATE(now(), INTERVAL 51 DAY) SUBDATE的值可以提前计算,可以利用索引
|
更改查询方式
|
|
优化器索引选择错误
|
MySQL优化器依赖的统计信息来源于Innodb的周期性索引页采样机制,没有动态采样或者执行反馈能力的,即优化器无法感知实际的数据分布,如果存在多个可用索引时可能导致索引选择错误
|
合理设计索引,较少可用的索引个数
|
|
fillsort排序
|
filesort排序需要把满足条件的记录都查询出来才能执行排序动作,导致执行过程中产生的逻辑读远远大于预期;另外filesort排序的记录数过多会产生磁盘临时文件,进一步导致RT变长
|
调整为基于索引排序
|
|
查询的数据在索引B+Tree的左侧但是按照倒序排序
|
利用索引来排序时会根据排序的方向来调整索引叶子节点的读取方向,比如倒序排,则从右往左遍历,正序排则从左往由遍历。如果查询的数据在索引B+Tree的左侧,但是按照倒序排序则意味着需要从右到左遍历大部分的索引记录,增加没有意义的逻辑读。
|
根据实际的业务场景,调整排序顺序。比如订单列表场景,通常是查询最近1周内的数据,此时应该使用create_time desc,不能使用create_time asc。
|
|
count查询命中的记录数过大
|
count查询执行时会把满足条件的所有记录都捞出来,导致大量的逻辑读,容易产生慢SQL。mysql下无法直接优化,只能结合具体的业务场景考虑使用count limit子查询,mysql执行时判断count的结果达到了limit的限制后会中断扫描直接返回结果,从而减少逻辑读。
|
结合上游的业务场景分析去掉非必要的count查询或者使用count limit子查询,大多数场景下不需要一个精确的count数字的,比如某个用户最近1个月内退款超过100笔时就不允许参加指定优惠活动,此时不需要count所有的退款记录,只需要count limit 101笔即可。
|
|
应用GC
|
GC时会STW,导致应用侧感知到的RT较长,此时通常是1s内产生多个慢SQL,可以根据dbTrace的慢SQL日志快速确认
|
应用GC治理
|
|
活跃链接数高,cpu不高
|
通常是磁盘读写达到瓶颈,IO处理变慢导致活跃链接数变高
|
磁盘升配
|
|
CPU高,通常此时活跃链接也会高
|
慢SQL较多时会产生大量的逻辑读,CPU处理不过来,导致其他正常的查询请求的CPU等待时间变长,活跃链接数升高
|
增加限流保护,推动慢SQL治理
|
|
主备切换
|
备库变主库时,buffer pool缓存的数据较少,产生大量的磁盘读,容易产生慢SQL
|
无
|
|
网络丢包
|
DB对外的网络带宽出现尖刺时,受网络丢包影响可能导致应用侧的RT边长,产生慢SQL
|
排查产生带宽尖刺的原因,通常是同一类列表查询请求被定时触发,并发过高导致,调低并发值即可
|
-
13.7.5.30 SHOW PROFILE Statement:https://dev.mysql.com/doc/refman/5.7/en/show-profile.html
-
8.2.1.14 ORDER BY Optimization:https://dev.mysql.com/doc/refman/5.7/en/order-by-optimization.html
-
8.2.1.17 LIMIT Query Optimization:https://dev.mysql.com/doc/refman/5.7/en/limit-optimization.html
-
MySQL Order By排序的底层原理以及filesort排序:https://juejin.cn/post/7132125495633641508
-
MySQL · 引擎特性 · 排序实现:http://mysql.taobao.org/monthly/2020/03/09/
团队介绍
-
代码极客:编程能力强悍,痴迷代码与设计之美。
-
Al 先锋:关注AIGC、大模型等技术趋势,用技术思维撕裂业务天花板,敢想敢造;
-
创新行动派:好奇心强,乐于接受挑战,用技术思维突破业务边界。
主动式智能导购 AI 助手构建
为助力商家全天候自动化满足顾客的购物需求,可通过百炼构建一个 Multi-Agent 架构的大模型应用实现智能导购助手。该系统能够主动询问顾客所需商品的具体参数,一旦收集齐备,便会自动从商品数据库中检索匹配的商品,并精准推荐给顾客。
点击阅读原文查看详情。





















