数据库优化三层楼:为何多数工程师卡在索引层

Feng 33 阅读 AI技术

参数、索引、业务:数据库优化的三层楼

前阵子去客户现场,和一位 DBA 聊他们那套库为什么慢。十分钟下来,他排查问题的顺序大致是这样:内存不够就先扩内存;SQL 走全表扫描就加索引;参数不合适就翻文档调一调。

这三句话几乎成了很多数据库优化的固定开场。倒不是说效果最突出,而是成果最容易看见、最容易实现。

做数据库十多年,我见过太多优化最终是这样收场的:内存加了,索引建了,参数也按某份文档调过一轮,报告写得漂亮,可两个月后业务量或数据量涨了一截,慢的地方还是慢。

问题不在这些动作本身——它们都没错,只是属于三层楼里的两层,而不少人始终没上过第三层。这篇文章要谈的就是这三层:参数、索引、业务。还有一个我越来越确信的判断:真正决定一套库能跑多快的,常常是最不愿被碰的那一层。

配图
配图

一层:参数是地基,交付那天就得浇

参数不是调优旋钮,而是交付前就该画好的施工图。它应当在数据库搭建阶段基本确定,而不是上线后再慢慢试。

原因很直接——有些设定一旦定下就很难改:

字符集、大小写敏感与否、日期精度,决定了数据怎么存,改一遍几乎等于全量导出导入;数据块/页大小直接影响一次 IO 取多少数据、内存如何组织,几乎所有数据库都不支持原地修改;重做日志的大小和组数若太小,日志切换频繁到能把业务拖出周期性卡顿,上线后虽能改,但代价远不如当初一次定好;存储类型一旦选错,比如跑 OLTP 的库落在只适合顺序写的存储上(某些分布式存储、开了某种压缩的文件系统),后面怎么调参数都是在补一个先天缺陷。

这类决策有个共同特征:错在前期,痛在后期,且后期修改的成本高得不成比例。打个比方,参数像装修时的水电走线和承重墙位置。房子还在毛坯阶段,怎么排管都行;等瓷砖贴完家具进门,再想挪一个插座,就得砸墙。

这些结构性参数该怎么定?我的做法是回到三个问题:

要回答的问题 对应的那类参数
这台机器上的内存怎么分给库和操作系统 各类缓冲池、共享内存、会话级私有内存的上限
谁来连、连多少、每个连接干多重的话 最大连接数、会话内存、排序与哈希的工作区大小
磁盘能扛多少、能不能扛住突发写入 检查点频率、后台写盘力度、IO 相关上限

新手最容易踩的坑,是恨不得把机器内存全喂给数据库。不是内存不重要,而是数据库跟操作系统在同一个屋檐下。文件缓存、临时文件、连接本身、备份时的 staging,还有同一台机上可能跑着的其他进程,全都要吃内存。把八成以上内存划给共享内存看着很豪气,等哪天来了个大排序,或者操作系统开始回收页缓存,机器一抖,你都不知道抖在哪。

连接数也一样。max_connections 往上堆没有任何代价感——改个数字重启而已。但每个会话背后都挂着一份私有内存,真实连接数一上来,几千个会话能把内存吃得干干净净。这也是我一直建议把连接池压在合理数量上的原因:数据库的并发不是同时开着多少扇门,而是它能多快把门口排队的人处理完(这话题在《数据库管理-第二十六期 数据库设计-使用篇(20220718)》里聊过,后面第六节还要捡回来)。

同一个库,跑了一年之后

我说参数是交付前就该定的东西,不代表上线之后它一成不变——这是很多人对我那句话的误解。参数跟着 workload 走。业务一年前和一年后大概率不是同一个东西,参数凭什么不动?

我惯用的判断节奏是这几条:

  1. 数据量跨数量级时,必须回头看。几百万行的表和几亿行的表,对内存、统计信息采样、优化器判断的影响完全不同。数据量翻了几十倍而你一个参数没动过,那不是稳定,是没人管。
  2. 接入方式变了要重算。比如多了个定时任务每晚批量跑,多了个外部对接方在同一时刻推数据,或者应用那边改了连接池策略。并发模型变了,原来的会话内存配额、后台清理线程数、并行度上限,统统要重新看。
  3. 硬件换了一定跟着改。从机械盘换成闪存后,某些为省 IO 而设的保守默认值可以松一松;反过来,如果底层存储换成了带网络延迟的分布式存储,原来的假设可能全部失效。

参数是给物理环境打工的,环境变了它还端着老架子,就不合适了。

配图

最后补一个操作层面的习惯,看着朴素但真的救命:一次只改一处,改前留基线,改后有对比。一次调十个参数,有效果了不知道是哪个起了作用;没效果,也不知道该回滚哪个。而且很多参数有联动关系——把写盘力度调大,前台可能更顺,后台 IO 压力就上去了;把并行度放松,单条查询快了,整体吞吐可能反而掉下来。这种互相牵扯的东西,只有对着指标一点点试才说得清楚。

所以第一层的准确定位是:一次性的基础决策,外加周期性的回归审视。别把它当万能药,也别把它当一次性考试——交完卷就再也不看了。

二层:加索引,最容易出成绩的一步

二楼是绝大多数优化停留的地方,因为它见效最快、最直观。

方法论大家都熟:打开数据库自己收集的性能信息(Oracle 的 AWR/ASH、PostgreSQL 的 pg_stat_statements、MySQL 的慢日志与分析表),按耗时或资源消耗排序,挑出消耗最多、耗时最长的几条 SQL,看执行计划是不是走了全表扫描,是的话给谓词列加索引。然后执行时间从 12 秒变成 0.3 秒。

那个瞬间很有成就感。我早年也很享受这个过程,甚至有点上瘾——抓慢 SQL 像钓鱼,加完索引看见那条 SQL 从报表里消失,比什么都痛快。

这条路本身没毛病。数据库厂商把这些信息收集出来给你(TOP SQL、平均逻辑读、等待事件分类),就是让你这么用的。有问题的是只做这一步。

先把好处说完再算账。一条 SQL 缺索引时,数据库能做的事非常有限:把整张表读一遍,把不符合条件的行丢掉。表越小越无所谓(这也是为什么开发环境永远测不出问题),表一大,这个读一遍的成本就直接线性堆上去。索引做的,是把读一遍变成精准点几下,顺带省掉排序——这是实打实的收益。

问题在于,多数优化走到这儿就停了。

每个索引,都要还的账

索引不是白拿的。每建一个,后台就多一本要维护的账。把这笔账摊开列一下,各位对照自己的库看看:

代价项 具体表现
空间占用 大表上的索引体积常常接近表的一半,某些组合索引甚至超过原表
写入放大 每次 INSERT 要往所有索引里插一项;UPDATE 索引列是一次删除加一次插入
优化器选择面变大 可选路径一多,执行计划漂移的概率就上升——昨天走得好好的计划,今天突然变了,而没人动过代码
统计信息收集变慢 索引列越多,收集一次要算的分布就越多,维护窗口被拖长
DDL 与备份跟着变重 加字段、改类型可能触发重建;备份恢复、主备搭建的时间都随索引体积走
无用索引的隐性成本 有些索引建完从没被用过,但每次写入都在为它付费

最后一条最扎心。很多库上线几年后,攒了一堆当年为解决某条 SQL 而建的索引。那条 SQL 可能早下线了,业务可能改版了,索引还挂在那,每一次写入都在为它交税,而没有任何一笔查询享受过它。

好消息是这笔成本可以查出来——各家做法不同,但思路一致:数据库会记录每个索引被使用(或被扫描)的次数。长期零使用又不是唯一约束的索引,就是候选清理对象。我知道很多人不敢删,怕影响线上;安全点的做法是先把索引标记为不可见/暂不可用(各家叫法不同),观察一两个业务周期,没事再真删。

这儿有个很有意思的现象值得单独说一句:自动化工具在这块比人强。抓 TOP SQL、识别缺失索引、给出建议索引,这类事情工具做得又快又全。市面上不少产品能直接吐出建议创建以下索引的清单,甚至带着预估收益。

所以问题就来了:如果优化只剩下抓慢 SQL、加索引这一步,那它的门槛其实很低,低到不需要人来做判断。真正需要人做的,是下一步。

配图

把一张表的 SQL 摊开,再谈索引

这一步,是我在二楼看到的分水岭。

回到第四节的场景:报表里有 20 条慢 SQL,涉及某张业务表。多数人的做法是逐条处理——给这条加一个索引,给那条加一个索引。二十条处理完,这张表上多了几十个索引,每条 SQL 都快了。

然后呢?这张表的写入开始变慢,备份窗口从 40 分钟变成 2 小时,某天夜里优化器突然给一条老 SQL 换了个计划,第二天早上业务炸了,排查了一整天。缺的不是索引,是把这张表当成一个整体来设计的那道工序。

配图

我的做法是把这一条 SQL 缺不缺索引这个问题,换成这张表上的所有 SQL,最少需要几个索引才能覆盖。听起来像绕口令,实际操作就是这几步:

第一步:把这张表相关的所有 SQL 抓出来。不是抓 TOP 10,是抓全。性能视图里能按对象过滤,慢日志里能按表名匹配,把涉及这张表的语句都捞出来。这一步常被跳过,因为大家急着去看最慢的那一条。

第二步:逐条拆解谓词。对每条 SQL,列出它在 WHERE 里用作等值条件的列、用作范围条件的列、ORDER BY/GROUP BY 涉及的列、JOIN 的关联列,以及 SELECT 出来又被频繁用到的那几列。这么一列就会发现一件有意思的事:二十条 SQL 看着五花八门,抽出来的列往往集中在五六个字段上,而且组合方式高度重复。

第三步:按等值在前、范围在后归并。这是复合索引的老规矩——等值的列放在前面把结果集切到最小,范围的列跟在后面。比如 user_id = ? AND create_time > ? 和 user_id = ? ORDER BY create_time DESC,在多数情况下可以被同一个 (user_id, create_time) 覆盖。

于是二十条 SQL 收敛到了三个索引:

-- 归并前:每条慢 SQL 各自加一个索引,累计九个
CREATE INDEX idx_a ON orders (user_id);
CREATE INDEX idx_b ON orders (create_time);
CREATE INDEX idx_c ON orders (user_id, create_time);
CREATE INDEX idx_d ON orders (shop_id);
CREATE INDEX idx_e ON orders (status);
CREATE INDEX idx_f ON orders (shop_id, status);
CREATE INDEX idx_g ON orders (create_time, status);
-- ……以及后面的 idx_h、idx_i
-- 归并后:三条复合索引覆盖绝大多数热点访问路径
CREATE INDEX idx_o1 ON orders (user_id, create_time DESC);
CREATE INDEX idx_o2 ON orders (shop_id, status, create_time);
CREATE INDEX idx_o3 ON orders (status, pay_time) INCLUDE (amount, buyer_id);

第四步:清理前缀冗余。有了 (shop_id, status, create_time) 之后,单独的 (shop_id) 就完全可以退掉了——它是前者的前缀。这一步在空间和维护成本上的收益,常常比新建索引还大。

第五步:剩下的那几条,走别的路。归并完总会剩下几条索引救不了的。这类通常是大范围统计、模糊查询、或者压根就是写得不对。这时候要上的,就是三楼。

为什么我坚持这一步机器做不了?不是说工具不能做归并,而是归并过程中有几个判断必须代入业务:

一是列的选择性会随时间变。status 这种低基数列,现在数据分布均匀,半年后可能 90% 的行都是同一个状态;create_time 的范围谓词在业务爆发期会从一个小时拉到一个季度。这些变化工具看不到,但它直接决定索引还有没有用。

二是业务上真的重要程度。同样慢的两条 SQL,一条是每天跑一次的后台报表,一条是每分钟几百次的交易路径,它们的优化优先级完全不同,而这个信息不在数据库里,在开发同事的脑子里。

三是有些查询本身就是错的。给它建索引,等于给一条走错的路铺沥青。

壮汉挤门

先搬一段旧场景过来。

《数据库管理-第二十六期 数据库设计-使用篇(20220718)》里,为了讲并发,我举过一个例子:

一扇 80 厘米宽的门,要过 1000 个壮汉——是排队一个个走更快,还是让这 1000 人凭实力一起往上挤更快?

(很可能一个都过不去)

那篇文章记录了一个真实案例。某流程类系统用一张表维护唯一 ID,每个流程一行,每次使用就在那一行上 update + 1。没有队列,也没有限流。压力小的时候一切正常,某天某个流程的使用量突然飙升——两个星期的量相当于该项目过去两年的总量——结果就是那一行上的行锁等待堆积如山,一个多用户、多线程的系统,在数据库里硬生生跑成了单线程。

这就是壮汉挤门。

配图

三楼这一整层,本质上处理的都是同一件事:数据库不是跑不动,而是被要求以一种它不擅长的方式去跑。

现场攒下来的一张清单,各位可以对照自己的业务看看中了几条:

业务侧的写法 数据库的感受 换一种思路
所有流程共抢一行 / 一页做计数或取号 行锁等待、缓冲忙等待 拆成多行加标识轮转,或交给序列,或前移缓存
所有操作都往一张日志表里记 单表写热点 + 体积疯长 按时间分区,或交给更适合存日志的系统
循环里一条一条 INSERT / SELECT(N+1) 网络往返 × N,逻辑读 × N 批量提交,一次拿回
SELECT * 拉到应用再做过滤排序 无用 IO + 无用传输 条件下推,只取要用的列
深分页 LIMIT 100000, 20 前面十万行白扫一遍 记住上一次的位置,改成基于键值的翻页
定时任务每晚全量重算统计 每晚一次全表扫描 + 大聚合 增量累加,或用物化视图让数据库自己维护
拿表当队列用(状态字段轮询) 一堆无效扫描 + 反复更新同一批行 交给真正的消息中间件
事务里调外部接口 / 发短信 / 等支付回调 本地事务被拉到几秒甚至几十秒 事务只管自己的一致性,外部调用挪出去异步化
一次 DELETE 几千万行 长事务 + 巨量重做日志 + 回滚段压力 分批删除,或用分区直接截断

这张表里没有一条能靠加内存或再加个索引解决。全是写法问题——不是数据库不够快,而是这段活本来就不该这么丢给它。

三楼为什么难?因为它不通向数据库,它通向人。

你得从数据库那边的证据入手——等待事件、热点块、逻辑读离谱的 SQL、长时间不释放的事务——反推出业务在做什么,然后找开发坐下来聊:这段为什么这么写?当初有什么约束?能不能换个写法?

这需要两头都懂:一头看得懂数据库在抱怨什么,一头听得懂业务为什么这么写。而这两头的人,在大多数公司里坐的不是同一排工位,用的术语不一样,KPI 也对不上。

我见过最好的一次配合:DBA 拿着等待事件找到开发,说“你这个更新在等另一个会话,不是慢,是堵”;开发一愣,说“我们这么写是为了保证 ID 不重复”。两人加起来不到十分钟,把 ID 生成方式换掉,第二天那个指标就没了。

三楼的工程量通常不大,但它要求两个人愿意聊十分钟。

顺带提一句,这也是我在《胖头鱼的技术专栏-465 数据库能力上移还是下沉:库变弱了,应用就变重了(20260828)》里说过的老话:该数据库扛的事交给数据库,该应用层自己解决的事拦在应用侧——两边各管一段,别互相倒。业务层解决了不该推给库的活,后面两层的工作量能少一大半。

顺序其实是反的

三层都摆出来了,最后说说我自己的顺序主张。

多数人的实际顺序是:参数 → 索引 → 业务。

这完全可以理解。参数最好动——改个值重启,不改代码,不用找开发排期;索引次之,DBA 自己就能搞定;业务最难,要跨部门、要排期、要说服人。

但收益的量级恰好反过来。

一个 N+1 循环带来的多余访问,是几十倍上百倍的差距;一次改成批量,收益可能超过把内存翻倍。参数调得再精,也只是在既有负载下挤出百分之几十。

所以我主张的顺序是:

配图

具体排查时,可以按症状找入口:

你看到的现象 先怀疑谁 进哪一层
某几条 SQL 逻辑读高得离谱,一看就是全表扫 缺索引 / 写法不对 二层,先按整表归并,再看是否要改写法
CPU、IO 都不高,但就是慢,锁等待时间长 写法扎堆抢同一份资源 三层
全库普遍慢,慢得很均匀,没有特别突出的 SQL 资源配置跟不上当前负载 一层
周期性地卡一阵,其他时候正常 后台任务(检查点、批量、备份、统计信息) 一层 + 三层(有些批量该挪出去)
单条 SQL 时快时慢,计划像抽奖一样变 索引太多 / 统计信息失真 二层,往往是索引泛滥的后遗症
上线时很快,跑了一年后越来越慢 数据量或负载变了 一层回归 + 三层(业务量级的活,参数扛不住)

这张表送给各位,希望能省下几次通宵。

还有一层窗户纸想捅破:为什么厂商来做优化,几乎都从参数开始?

说句公道话,这不全是不负责任。参数是“交付友好”的——不需要读业务代码,不需要改线上对象,风险可控,周期短,还能写进报告里凑成漂亮的一页。而业务优化,做成了是业务的功劳,做砸了是数据库的责任。

激励如此,动作自然往省力的那边偏。

但作为甲方,你得清楚自己买的是什么。

总结

回到开头那次客户现场的对话。

内存、索引、参数,这三样会做的事,我一点也不反对——它们有用,某些时候就是那一味药。

我反对的是把它们当成优化的全部。

三层楼可以这样记:

一层是地基,交付那天就该浇筑好,但地基上面的负载年年在变,所以要周期性回来看;二层是装修,能让某一处立刻好用起来,但每添一样东西都要记账,而且“一处一处加”很快就会变成一团乱麻,真正该做的是把一张表的 SQL 摊开,用尽量少的索引覆盖尽量多的路径——这一步需要业务判断,机器替不了;三层是户型,是多数人不愿碰的那层,因为它通向的不是数据库,而是人,但收益最大的往往就在这儿。

如果只能给一个判断标准,我会这么说:

当你准备给某张表加第四个索引的时候,先回去看看这张表上的 SQL 是怎么写的。

这套顺序能不能让你少熬两个通宵,我不知道。但至少下一次,不会在同一个地方再栽一遍。

ID:4887

Feng
这位作者很神秘,还没有填写简介。