💎写在前边
两年前的今天(大年初三),我发布了第一篇 MySQL 相关的文档,开始有了第一个粉丝,得到了在当时看来沉甸甸的流量 MySQL索引原理,设计原则
一年后,很巧的是在我的实习工作期间,我将教科书的内容,真正落地到企业当中,启动了 MySQL 慢查询治理,冥冥之中一切似有安排......
如今,我打算对这两年的MySQL做个了断,围绕 MySQL 慢查询治理工作展开,从几方面展开:
- 战略设计:三阶段防控治理,事前召回,事中止损,事后治理
- 典型案例:模糊搜索,延迟关联,减少 IO & 回表次数
- 增益 buff:chatgpt 优化建议工具
战略设计:三阶段优化治理
首先是战略上,我们分成了三阶段来推进 MySQL 治理,渗透到各个环节,各个角色当中,包括但不限于 RD、QA、SRE、DBA...
可以明显的看到,每个环节的关键词是不尽相同的,准入->治理->止损,我们是希望前置能发现问题的,而不是陷入「先污染后治理」的怪圈
事前召回:巡检智能检测,召回暴露风险
分成了两部分来做,增量检测+存量巡检,抓大放小,先做足覆盖,后续调优巡检阈值 & 支持白名单
- 增量检测:通过启发式规则 & 成本分析引擎,智能检测新增 SQL 性能,并从真实案例库抽调测试「大户人家」
- 存量巡检:扫描历史大表,识别潜在的数据量大 + 缺少索引的隐患,提前暴露风险,防止数据表扩张后没有第一时间建立好索引来应对
事中止损:实时会话定位,及时限流止损
此部分由于不同公司组件,基架不同,便不过多赘述,大差不大的原则:兜底>降级>熔断>限流,一般可以采用异构 方式,形成互备容灾
事后治理:成本代价模型调优,索引合并成本误判
问题背景:MySQL explain 有误,通过成本代价模型调优,解决「索引合并成本误判」的问题
具体的索引合并 SQL 这里我只贴出关键的部分,其他 where 条件没有贴出来,耗时达到了 2.5s,时常触发超时 kill
相关 SQL:
sql
SELECT
count(*)
FROM
`xxx`
WHERE
uid = ? -- 取值特别多,选择性很高
AND
status = 0; -- 只有2个取值,选择性很低
初步分析:索引合并的执行过程
MySQL 选择的 explain 策略是:uid+status 的索引合并策略
于是我们顺着索引合并的思路和执行过程,挖掘相应的瓶颈所在
那问题其实挺明朗了,短期的解决方法,直接在 SQL 层面忽略 status 索引,让 MySQL 走 uid 单列索引即可,耗时降低到了 200-400 ms
但从中长期来看,不免这个数据库还有其他 SQL 也有类似问题,考虑到根治,我们还需要在 MySQL 层面,分析为什么成本优化器会计算出错?
恶补知识:成本代价模型的计算方式
一句话总结:通过采样统计分析,得到统计信息/索引基数,进而推断出大致需要扫描的行数,从而计算成本
粗略的成本计算方式是怎样的
总代价 = I/O 代价 + CPU 代价 + 内存代价 + 远程代价
-
I/O
成本-
当我们想查询表中的记录时,需要先把数据或者索引加载到内存 中然后再操作。这个从磁盘到内存 加载的过程损耗的时间称之为
I/O
成本。MySQL
默认规定读取一个页面花费的成本 默认是1.0
-
-
CPU
成本- 读取以及检测记录 是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称之为
CPU
成本。MySQL
默认规定读取以及检测一条记录是否符合搜索条件 的成本默认是0.2
- 读取以及检测记录 是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称之为
如何得到统计信息 & 索引基数
有两种类型的扫描:
- 全表扫描:查看表数据行数
sql
SHOW TABLE STATUS LIKE 'xxx';
- 索引扫描:查询索引基数
ini
SELECT * FROM INFORMATION_SCHEMA.STATISTICS WHERE table_schema = '数据库名' AND table_name = '表名';
我们重点关注下 Cardinality 这一列:索引基数
-
含义:表示该索引列,有多少种不同的取值,比如这里粗略统计出 status 有 1 个取值(实际上2个)
-
计算规则:结合 SEQ_IN_INDEX 来看,两种类型规则不同
- 单列索引:本列有多少个不同的取值
- 联合索引:结合上一个列,组合起来有多少个不同的取值
如何通过基数,推断出 SQL 扫描行数
-
假设这里 uid 的 基数是 2100,表示 uid 有 2100 种取值,也就是说每一种的取值占比 = 1/2100
-
而我们本次查询,只查询 uid = 某个值(只有一个单点区间)
- 所以行数估算 = 1/2100 * 全表总行数
再次剖析:索引基数的统计方式 & 出错原因
统计方式 :最终基数 = 采样部分页面的基数平均值 × 总页面数
出错原因:
-
当索引重复值过多时,部分页面的基数平均值 ×总页面数,使得最终基数偏高(由于额外乘以总页面数放大了结果)
-
基数高,最终导致 MySQL 误以为索引的选择性很高,倾向于选择「 索引合并」策略,认为能够带来更高的过滤节省回表,从而产生了误判
扩展阅读
由于时间关系,大家感兴趣的再自行阅读,这里不过多展开
关于重复值过多的采样统计,MySQL 8.0 引入了 直方图解决,相关的计算:PolarDB 数据库内核月报
采样统计的方式,默认情况下 InnoDB 会对 20 个 叶子节点的信息进行统计,过程如下:(略)
-
取得 B+Tree 所有 叶子节点的数量,记为 N
-
随机取得 B+Tree 索引的 20个 叶子节点。统计每个叶子节点的不同记录的条数,即为P1,P2,...,P8
-
根据采样计算出 Cardinality 的预估值:Cardinality =( P1+P2+...+P8 )/20 * N
还有哪些情况会导致成本低,耗时却更高?
一篇抨击大多数情况下,统计信息出错导致成本误判的论文:Is Query Optimization a "Solved" Problem?
-
统计信息不准确,导致成本计算有误,比如这里索引的重复值过多,导致采样统计出来的值并不精准
-
计算成本时,只知道要读取 多少个页面 ,没有考虑 页面的连续性, 可能从 随机 IO 优化为 顺序IO
- 牺牲了额外扫描的页面数,但换来了顺序IO的高效,使得读取更多页面,成本看似更高但执行速度更快
-
计算成本时,无法得知要读取的页面,是否已经在 Buffer Pool 中 ,可能出现成本更高的策略已经缓存了,耗时会更低
短期解决:调优成本代价模型
通过查看索引统计信息,调研成本代价模型
ini
SELECT * FROM INFORMATION_SCHEMA.STATISTICS WHERE table_schema = '数据库' AND table_name = '数据表';
发现其中 status 的索引基数计算有误,偏差巨大,从 2->797(偏差了700多),导致 MySQL 误以为 status 索引的选择性很高,最终评估后使用了「索引合并策略」
于是分三步解决:
-
排查:是否统计信息已过时,很久未更新
-
止损:重新 analyze table 计算最新的 统计信息,但计算出来的结果仍然有较大偏差
-
调优:调大采样的页面数量,防止因为重复值过多导致计算有误
小结:
- 随着表行数增多,索引数量变多,索引统计信息可能统计出错,进而导致 MySQL 对索引的选择性判断有误,认为成本更低,但实际耗时会更高
但是呢,这终究没有从根本上解决问题,而且额外的采样页面,也会吃 CPU 内存,没有绝对的银弹
长期根治:单列索引合并治理 + 分库分表 + 冷热分离
研发侧构建索引 - SQL 映射关系
- 提高对于建立索引的敬畏心 ,新增索引时,统一收敛记录 索引解决的是哪几条 SQL
- 这样有利于后续治理时,更好拆分、合并索引,不至于畏手畏脚,不敢轻易碰索引,只能通过 ignore 来避开 ta
user_id 分库分表
- 我们的业务场景下,大多数情况下是用户侧查询,按 user_id 分表,可以极大减小查询的开销,且分区不容易倾斜
冷热分离
- 用户侧并不需要感知那么久远的站内信消息,可以考虑冷存在介质较差的数据库,具体的实现有很多种方式,这里就不过多赘述了,后边感兴趣可以再聊聊
扩展:其他数据库的解决方法: 位图索引
其实对于这种基数很小的索引列,其他数据库比如 Oracle 提供了位图索引,专门用于加速这类查询
并且当涉及到索引合并时,它也可以进行统计分析以确定索引是否对过滤条件具有足够的选择性, 最终保证使用它的成本。
这里就简单介绍下位图索引,具体细节不过多深入,毕竟是题外话
举个小 🌰, 表结构如下:
姓名(Name) | 性别(Gender) | 婚姻状况(Marital) |
---|---|---|
Melo | 男 | 已婚 |
Anthony | 女 | 已婚 |
Kobe | 男 | 未婚 |
James | 女 | 离婚 |
Curry | 女 | 未婚 |
索引的建立过程
比如性别这种区分度很低的列,针对每一个取值,位图索引形成一个向量
向量的每一位表示该行是否是男 ,如果是则为1,否为0。 同理,女向量位01011。
RowId | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
男 | 1 | 0 | 1 | 0 | 0 |
女 | 0 | 1 | 0 | 1 | 1 |
对于婚姻状况这一列,位图索引生成三个向量,已婚为11000...,未婚为00100...,离婚为00010...。
RowId | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
已婚 | 1 | 1 | 0 | 0 | 0 |
未婚 | 0 | 0 | 1 | 0 | 1 |
离婚 | 0 | 0 | 0 | 1 | 0 |
利用索引查询的过程
- 当我们使用查询语句 select * from table where Gender='男' and Marital="未婚";
- 首先取出男向量10100...,然后取出未婚向量00100...,将两个向量做 and 操作,这时生成新向量 00100...
- 可以发现第三位为1,表示该表的第三行数据就是我们需要查询的结果。
索引的存储结构
Oracle 数据库使用 B 树索引结构来存储每个索引键的 位图。
- eg:
status
是位图索引的键列,则用一棵 B 树存储 status 索引数据,叶块存储单独的位图。
特点和约束
- 位图索引适合枚举类型的取值,连续变化的需要分段离散化才能使用位图
- 不适合并发更新环境 ,可能造成事务阻塞:例如我们将列值从 蓝色 修改为 绿色 ,就需要同时锁定 蓝色 和 绿色的位图片段,其他针对这两个取值的修改就会被阻塞
典型案例:模糊搜索,延迟关联
sql
-- 耗时550-650ms
select * from `table` where (name like '%Melo%')
很常见的索引失效:以 % 开头的模糊查询,索引会失效,故走了全表扫描
优化方式:
sql
-- 耗时120-150ms
SELECT * FROM `table`
inner join
(
SELECT id FROM `table` WHERE name LIKE '%Melo%'
) tmp
on a.id = tmp.id;
优化前后对比:
-
全表扫描 -> 二级索引扫描 + 回表,筛选 name 的时候,降低了扫描的数据项大小
- 节省单次扫描的数据项大小:一次 磁盘 IO 可以加载更多的索引项
- 节省回表的次数:我们的场景下,符合条件的 id 一般是少数,在回表之前就大幅过滤了数据
具体过程:
优化前 | 优化后 | |
---|---|---|
执行过程 | 全表扫描:筛选出 name 符合 like 条件的 | 二级索引扫描:只需要扫描 name 索引树 |
磁盘 IO 次数 | 多 : 扫描全数据项,由于单个较大导致一次磁盘 IO 能加载的数量较少 | 少 : 只需要扫描 name 数据项,单个较小一次磁盘 IO 能加载的数量更多 |
回表次数 | 非常多:相当于全回表 | 非常少:在 name 索引树上找到满足条件的,才会回表 |
增益 Buff:chatgpt 优化建议工具
介绍: Chat2DB 是一款有开源免费的多数据库客户端工具,和传统的数据库客户端软件 Navicat、DBeaver 相比 Chat2DB 集成了 AIGC 的能力
能够将自然语言转换为 SQL ,也可以将 SQL 转换为自然语言 ,可以给出研发人员 SQL 的优化建议,极大的提升人员的效率,是 AI 时代数据库研发人员的利器,未来即使不懂 SQL 的运营业务也可以使用快速查询业务数据、生成报表能力。
自然语言转换为SQL:
SQL 优化:
SQL 解释:
个性化测试数据构造:
数据仪表盘搭建: