☰
数据库索引优化实战:从避坑指南到千万级查询性能调优
2026/10/15 20:53:48 网站建设 项目流程

做了这么多年数据库优化,我见过太多系统栽在索引上。刚带项目那几年,最怕半夜收到告警:某个曾经秒开的查询突然把数据库拖到濒临打满,排查下来发现,问题往往不在SQL写法本身,而在索引——要么建错了,要么建少了,要么建了一堆根本没用的。说90%开发者踩过索引的坑,我实际感受是,这个比例可能还说低了。这篇文章把我在真实系统中踩过、也看别人踩过的索引坑集中倒出来,从设计选型到执行计划排查,再到一个完整系统的优化过程,最后聊索引维护。无论是刚开始接触数据库的新手,还是被慢查询折磨的开发和运维,都能在里面找到对应的场景。

1. 索引设计与选型的四个典型误判

1.1 加索引就一定能变快?全表扫描也会逆袭

很多开发者遇到慢查询的第一反应就是“加索引”。这个思路不全错,但有个前提经常被忽略:索引不是万能加速器,在某些条件下,数据库引擎会主动放弃索引,走全表扫描反而更快。

我做了一个很简单的对比场景,你大概就理解了。拿一本一千页的书举例,正常情况下通过目录查内容最快,但如果某一页的内容占了全书一半,还要先翻目录、再定位页码、翻过去,就不如直接从第一页翻到那一页更快。数据库的逻辑完全相同:如果查询需要返回的数据行数占全表比例过高,优化器会认为走索引本身需要多次随机IO,反而不如一次顺序IO扫描整个表划算。

这里的经验阈值一般在20%到30%左右。当查询条件命中的行数超过这个比例,别再纠结索引设计,先考虑改查询逻辑或者换一种数据统计方案。我实际排查慢查询时,第一件事不是看索引,而是先算这个查询到底要取多少行——如果取出来的数据占比本身就大,加不加索引都救不了。

1.2 单字段建索引不等于联合索引优化

“最左前缀”是索引优化里被说得最多、也被误解最多的概念。很多人把联合索引当成了多个单列索引的叠加,这是一个根本性的误区。联合索引本质上是一棵按多个列依次排序的B+树,先按第一个列排序,再在第一个列等值的情况下按第二个列排序,依此类推。这就意味着,如果查询条件里跳过了联合索引最左边的列,后面的列在索引中根本没法定位。

举个具体例子,一个表上有联合索引(user_id, create_time),查询条件是where create_time > '2024-01-01' and user_id = 100,这个就能走到索引,因为优化器会先用user_id定位。但如果是where create_time > '2024-01-01',没有user_id条件,那这个联合索引就废了,因为B+树按user_id排好序后,create_time已经切成了一段段碎片,无法利用索引有序性做范围扫描。

这个坑在实际项目里特别常见,尤其是那种“这个列经常查询,那个列也经常查询”的场景。我见过不少表上建了(a)、 (b)、(a,b)三个索引,结果三个索引里只有一个真正被用到,剩下两个是冗余。判断联合索引顺序是否合理,看三点:等值条件放前面;范围条件放后面;区分度高的列放前面。这三点不能单独看,要结合业务实际查询频率来权衡。

1.3 覆盖索引到底覆盖了什么

回表是另一个让新手困惑的概念。InnoDB的索引结构分主键索引和二级索引,二级索引的叶子节点存的是主键值,不是完整行数据。这意味着用二级索引查,先找到一大串主键,再根据这些主键回主键索引去取整行数据,这个额外过程就叫回表。回表是全随机IO,行数越多越慢。

覆盖索引就是让二级索引的叶子节点直接包含你查询所需的全部列,这样查询就只需要遍历索引树,不需要再回表取数据。覆盖索引的价值在真实项目中非常大,尤其是在高频查询、需要返回固定字段的场景。比如用户订单列表页只需要展示订单号、金额、状态,如果联合索引包含了这三列再加查询条件(user_id)和排序字段(create_time),整个查询可以在索引里一次完成。

使用覆盖索引有个直接要求:查询中SELECT的字段必须被索引包含,且不能超过索引列范围。不是所有字段都适合塞进索引,索引也不是列越多越好,列太多会让索引体积变大、写入变慢。合理做法是只把列表页最需要返回的核心字段放进索引,涉及大字段(如备注、详情内容)就拆出去。

1.4 普通索引与唯一索引不能拍脑袋选

唯一索引和普通索引在写入性能上存在明显差异,这是很多人都没意识到的。唯一索引在插入新纪录时,必须先读取索引页来判断唯一性是否冲突;普通索引插入时没有这个检查步骤,可以利用缓冲区的合并机制优化写入。在业务不强制要求数据唯一的情况下,优先用普通索引。

很多业务一开始为了防重复,不管什么字段都上唯一索引,结果写入高峰期经常出现性能瓶颈。我见过一个案例,某个系统的登录日志表在账号字段上建了唯一索引,业务上账号本来就不会重复,但每次写入都需要额外做唯一性校验,结果这个表成了整个系统的写入瓶颈。后来把唯一索引改成普通索引,写入性能明显提升,也没有出现任何数据重复问题。唯一索引要用在真正需要防重的核心场景,比如订单号、交易流水号,而不是到处滥用。

2. 执行计划深挖:索引失效的现场还原

2.1 慢查询日志与EXPLAIN怎么配合使用

排查索引问题不能靠猜。第一步一定是开启慢查询日志,把执行时间超过阈值的SQL记录下来。我通常把阈值设成1秒,业务低峰期再统计,重点看那些频繁出现的慢SQL。拿到慢SQL后,下一步就是对这条SQL执行EXPLAIN,看数据库优化器的执行计划。

EXPLAIN输出里最关键的有几列:type列表示访问类型,从好到差大致是const、eq_ref、ref、range、index、ALL,如果看到ALL,基本就是全表扫描;key列表示实际用到的索引,这一列如果为NULL,说明没用索引;rows列是优化器估计需要扫描的行数;Extra列里的Using filesort或Using temporary,代表查询里有额外的排序或临时表操作,通常是性能隐患。

看EXPLAIN有个经验:不要只盯着key列,要看整体。有些SQL虽然命中了索引,但rows高达几十万,说明索引选择有问题,可能命中了一个区分度很差的索引,这时候性能依然会很差。真正好的执行计划应该是type不是ALL、rows远小于全表行数、Extra里没有filesort和temporary。

2.2 五个最常见的索引失效现场

第一个:对索引列使用函数或运算。比如where date(create_time) = '2024-01-01',这时候create_time上的索引会被跳过。正确做法是改成范围条件:create_time >= '2024-01-01' and create_time < '2024-01-02'。这个看似简单,实际代码里到处都是,因为业务上“按格式化日期查”这种写法太顺手了。

第二个:隐式类型转换。如果字段是varchar类型,查询条件里却写数字,数据库会把字符串列隐式转成数字类型再比较,然后索引就失效了。比如where phone = 13800138000,如果phone是varchar,这个索引几乎必然失效。解决办法是应用层保证传参类型和字段类型一致。

第三个:LIKE前缀模糊。where name like '%张三%',匹配以通配符开头,B+树的有序性就无法被利用,索引直接失效。如果是where name like '张三%',索引还可以用。前缀模糊查询的解法一般是改用全文索引或搜索引擎,但也不是所有场景都值得上搜索引擎,如果可以接受一定的延迟,单独建一张规范化的搜索表也是现实选项。

第四个:OR条件搭配不当。如果where a = 1 or b = 2,其中a和b恰好都建了索引,数据库可能还会合并,但一旦有一侧没索引,整个查询基本就退化成了全表扫描。这种情况最好的方案是拆成两个查询后UNION,或者给两侧都补上索引。

第五个:排序字段与索引顺序冲突。查询条件用了索引,但ORDER BY的字段不在索引里,或者排序方向与索引顺序不一致,就会触发filesort。filesort意味着数据库要把结果重新排序,行数一多性能就崩。解决思路是把排序字段也纳入联合索引,并保持排序方向与索引方向一致。

2.3 优化器选错索引:rows并不总是可靠

完全相同的SQL,有时候走索引很快,有时候却很慢,这背后往往是统计信息在悄悄变化。数据库的优化器不是真的去扫描整个表再决定怎么查,而是依赖表的统计信息做判断。统计信息长期不更新,优化器就会基于过时的数据做出错误选择。

我遇到过最典型的一次:某业务表从几十万涨到了几千万行,但统计信息一直没刷新,优化器始终认为“全表扫描只需要扫几十万行”,于是走了全表扫描,结果线上查询几十秒超时。刷新统计信息后,优化器发现全表行数已经几千万,主动选择了正确的索引,慢查询立刻消失。这个教训很深刻,如果业务表的数据量波动比较大,或者定期有大批量导入,就要养成定期刷新统计信息的习惯。

遇到优化器选错索引的应急手段是用FORCE INDEX强制走某个索引,但这只是救火方案,治本还是要刷新统计信息,或者调整SQL让优化器更准确地估算。长期依赖FORCE INDEX有风险,一旦数据分布继续变化,强制索引可能从一个错误走向另一个错误。

3. 实战复盘:一个千万级订单查询的优化全过程

3.1 现象与第一步定位

某系统订单表在数据量达到千万级之后,用户查询自己订单列表的接口突然变慢。明确的现象是:首页查询还好,翻到十几页之后越来越慢,最深层页面能拖到十秒以上。业务方一开始怀疑是数据库负载太高,但检查后发现CPU和内存都不紧张,于是怀疑到了SQL和索引上。

我拿到这个接口的SQL之后,先做的是把执行计划拉出来。当时的SQL大概长这样:select order_id, amount, status from orders where user_id = ? order by create_time desc limit 100000, 20。EXPLAIN显示,user_id上的索引确实命中了,rows估计有几十万行,Extra里出现了Using filesort,这意味着数据库在索引里找到用户所有订单后,还要再做一次全量排序,然后才能取第100001到100020行。

这里慢的根源有三层:全量表扫描了该用户的所有订单;每行都要回表取完整数据(因为查询字段里有amount、status等不在二级索引里的列);深分页导致limit 100000要遍历前十万行才能定位,这个开销不会因为数据少而减少。

3.2 第一轮优化:先把联合索引做对

发现user_id上的单列索引不够用以后,我做的第一件事是调整索引结构,把这个查询需要的数据全部考虑进去。设计的联合索引为(user_id, create_time, order_id, amount, status),这样查询条件里的user_id能精确命中,insert时间create_time天然有序,排序不再需要filesort,select里需要的字段也在索引里面,可以直接覆盖索引输出。

这一步做完之后,同一个EXPLAIN出来的结果变化很明显:type从ref变成了index或者ref,rows大幅减少,Extra里不再出现Using filesort。实际接口响应时间从原来的七八秒降到了几百毫秒。但仅仅改到这一步还没完,因为我发现翻页到很后面的时候,耗时又开始上升,虽然整体还算可控,但这是一个信号:深分页还藏着一个隐患。

3.3 第二轮优化:延迟关联解决深分页问题

深分页慢的核心原因是数据库要把前N行的结果都扫描一遍才能返回第N+1行,这个成本在线性增长。limit 100000, 20意味着要先找到十万条之后才开始返回,而前面这十万条的排序和回表开销全部白费。

延迟关联方案的思路是:先只查出当前页需要的主键,然后再用主键去关联原表取完整数据。改成两步:第一步select id from orders where user_id = ? order by create_time desc limit 100000, 20,这一部分如果联合索引覆盖了id和create_time,就完全在索引里完成;第二步select order_id, amount, status from orders where id in (刚才拿到的主键列表)。这样深分页时的前十万次扫描不再涉及回表和数据传输,性能提升非常大。

实际改完以后,深翻页的耗时也被压到了几百毫秒以内。这里有个细节值得注意:如果业务场景是固定客户翻订单列表,翻几十页已经是极端行为,延迟关联优化到这个程度基本够用。如果是内部后台系统查全量数据,建议换成分页游标方式,用上一次查询返回的最后一条记录的主键值作为下一次查询的起始位置,从机制上彻底消灭深分页。

3.4 上线前必做的风险控制

索引优化不只改SQL,表结构变更本身在线上也是高风险操作。大表加索引时,如果直接执行标准DDL,可能长时间持锁,导致业务写入阻塞。MySQL的DDL在多数情况下支持在线变更,但依然会带来主从延迟,尤其是大表的索引重建操作,延迟可能从秒级放大到分钟级。我每次做这种变更都会选择业务低峰期,并且分阶段执行:先在一套从库上把索引建好,确认执行计划和性能变化,再逐步切到主库。

还有一点是加索引会影响写入路径。联合索引列数越多,每次插入和更新时要维护的索引树就越多,热点表的写入性能必然下降。上线前必须观察一段时间的主从延迟和写入延迟指标。如果系统是订单量很大的写入密集型业务,新索引上线前务必评估一下写入放大问题,避免捡了查询的芝麻、丢了写入的西瓜。

4. 索引的“后事”:清理、统计与长期维护

4.1 冗余索引比没索引更可怕

很多表的索引是时间一长自然堆积出来的,A同事加了一个、B同事补了一个,谁也不知道哪些还在实际使用。冗余索引的危害在于:每次写入要维护多棵B+树,磁盘占用增加,查询优化还可能搞不清该用哪个索引。索引(a)和索引(a,b)就是典型的冗余关系,前者完全能被后者替代,但因为没人处理,它就一直默默消耗资源。

排查冗余索引有一个简单办法:翻阅数据库字典信息,找出所有索引列开头相同的组合。然后再配合慢查询日志和性能监控,逐个确认哪些索引最近一周、一个月有没有被执行计划选中。如果一个索引长时间没有出现在explain里,又没有明确业务逻辑约束,就基本可以认为是僵尸索引了,删除前做好备份和回滚预案即可。

4.2 统计信息过期和碎片化:索引时灵时不灵的隐形元凶

数据库优化器的决策精度依赖统计信息。周期性执行的统计信息刷新任务非常重要,特别是在表的行数发生大幅变化之后。大批量导入或删除数据之后,无论如何都应该手动刷新一次统计信息。这一步操作很轻,但收益可能非常大,能让优化器从“瞎猜”变成“有理有据”。

频繁随机插入和删除会让索引页产生碎片。索引的物理存储不是完全紧凑的,页分裂和合并会留下空洞,导致实际扫描的块数比理论值多很多,访问性能自然下降。碎片严重时需要用表优化操作重建索引。这里要慎重,表优化在大表上非常耗时且可能加锁,不要把它当日常任务来跑,通常做法是定期监控碎片率,超过一定阈值(比如30%)再在低峰期统一处理。

4.3 如何监控索引的真实使用情况

监控索引有没有被用到,不能靠“我觉得”,要靠数据库自带的能力。数据库的performance_schema会记录各种IO等待事件,可以从表级别看到某个索引是否发生大量读写。sys库下面有一张视图专门列出从未被使用的索引,这些就是清理候选对象。

我长期维护索引的方法是定一个季度周期:每个季度导出所有表和索引的统计信息,比对慢查询日志中出现的SQL,给每条SQL打上具体使用的索引标号;然后对一段周期内完全没被使用过的索引进行下线评审;评审时看两个维度,一个是该索引有没有不可替代的业务约束,另一个是它对应的SQL是不是已经被业务废弃。确认没有用途的索引,直接移除。

这个流程坚持下来,整个系统的索引数量不仅没有膨胀,反而越来越精简。索引不是越多越稳,真正的稳定来自对线上行为的持续理解和清理。我个人觉得,索引优化的本质不是技术炫技,而是对数据读写代价的深刻理解和权衡。每个索引都该有它服务的SQL、它解决的问题和它被保留的理由,没有理由的索引,终究会在某一天成为线上事故的伏笔。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询