加了索引反而慢?深度复盘2个让CPU飙升的“见鬼”现场

早些年我刚带团队那会儿,有个执念:“只要SQL慢,加个索引准没错。”

直到有次大促前的压测,一个核心查询接口响应时间突然从200ms飙到了2秒,DBA那边的CPU报警响个不停。我自信满满地打开代码,指着屏幕说:“这字段肯定没加索引。”结果一查,索引健在,而且是独立索引。

那种“明明做了优化,数据库却不买账”的挫败感,相信很多人都体会过。

后来我才明白,索引不是万能药,有时候它反而是拖慢系统的毒药。今天不聊那些教科书上的B+树原理,我想结合这几年踩过的坑,复盘两个真实的“反常识”案例,聊聊怎么透过EXPLAIN看透数据库的“小心思”。

配图

隐形杀手:因为“懒”而引发的全表扫描

很多时候,性能问题不是因为技术多难,而是因为开发习惯上的“想当然”。

观点: 类型隐式转换是索引失效的头号隐形杀手,且往往在测试环境很难发现(数据量不够大)。

真实案例: 大概两年前,我们要上线一个用户触达系统。有个逻辑是根据用户的手机号查询用户ID。表结构里 phone 字段是 VARCHAR(20),并且建了唯一索引。

代码上线后,平时跑得好好的。结果某天下午,运营搞了个活动,瞬间涌入几十万请求,数据库直接被打挂了。

排查过程: 我们抓到了那条慢SQL,长这样:

SELECT user_id, status FROM users WHERE phone = 13800138000;

咋一看没毛病对吧?但当你执行 EXPLAIN 时,诡异的事情发生了:

  • type: ALL (全表扫描)
  • key: NULL (没用索引)
  • rows: 500w+ (扫描了整张表)

原因复盘: 罪魁祸首在于那个手机号参数。开发小哥为了省事,在MyBatis里或者传参时,直接把手机号当成了数字(Long/Integer) 传进去了。

MySQL有个潜规则:当字符串索引字段与数字进行比较时,MySQL会自动把字符串转换成数字再比较。 这就意味着,数据库必须把每一行的 phone 字段都拎出来转一下格式,索引自然就失效了,直接退化成全表扫描。

解决方法: 这种问题改起来最快,把参数类型强转成 String 就行。但更重要的是防范。我现在习惯在Code Review时专门盯着 WHERE 后面的字段类型看。

-- 优化后,强制使用字符串字面量
SELECT user_id, status FROM users WHERE phone = '13800138000';

一看执行计划:

  • type: const
  • rows: 1
  • Extra: Using index

这就舒服了。

深度分页:跑得越远,死得越惨

做ToB业务或者后台管理系统的朋友,大概率都遇到过“列表页点到第1000页就转圈圈”的现象。

观点: 传统的 LIMIT offset, size 在大数据量下是性能杀手,必须改变“先查后丢”的逻辑。

真实案例: 这是我接手的一个老项目,有一张 orders 表,数据量大概在800万左右。业务方反馈,导出最近一个月的订单数据时,经常超时报错。

我看了一下导出逻辑,它是通过循环分页来跑的,大概逻辑是这样:

-- 第1页很快
SELECT * FROM orders WHERE create_time > '2023-01-01' LIMIT 0, 20;

-- ...到了第10万页
SELECT * FROM orders WHERE create_time > '2023-01-01' LIMIT 2000000, 20;

深度分析: 大家要理解MySQL执行 LIMIT 2000000, 20 是怎么干的。它不是直接跳到第200万行,而是先扫描读取200万+20行数据,然后把前200万行全部扔掉,只给你最后那20行。

配图

这纯属“由于太老实而累死”。那200万行数据的IO开销和CPU转换开销,全是无用功。

落地优化: 我们当时采用了**“延迟关联”(Deferred Join)** 的方法,效果立竿见影,查询时间从8秒优化到了0.5秒。

核心思路是:先利用覆盖索引快速把目标ID找出来,再去回表查完整数据。

-- 优化方案:
SELECT t1.* 
FROM orders t1
INNER JOIN (
    -- 这一步只查ID,走索引覆盖,不用回表,速度极快
    SELECT id 
    FROM orders 
    WHERE create_time > '2023-01-01' 
    LIMIT 2000000, 20
) t2 ON t1.id = t2.id;

配图

如果你的ID是连续自增的(虽然现在很少见),甚至可以记录上一次查询的最大ID,直接用 WHERE id > last_max_id LIMIT 20,那性能更是起飞。

索引选择困难症:优化器也会“脑抽”

有时候你索引建得好好的,SQL也没写错,但MySQL就是不用你的索引,非要去全表扫描,或者选了个更烂的索引。

观点: 优化器是根据“统计信息”来估算成本的。如果统计信息不准(比如数据分布倾斜),优化器就会误判。

真实案例: 有个任务表 jobs,里面有个字段 status(0:待处理, 1:处理中, 2:已完成)。这就好比漏斗,绝大多数数据都是“已完成”。 有个定时任务要捞出“待处理”的数据:

SELECT * FROM jobs WHERE status = 0;

我们给了 status 索引。起初很快,后来随着历史数据积累到千万级,这语句突然慢了。

原因复盘: 通过 EXPLAIN 发现,MySQL竟然走了全表扫描! 原因是当时表中99%的数据都是 status=2(已完成)。但由于某些原因(比如频繁的大批量更新),导致MySQL的统计信息出现了偏差(Cardinality估算错误),它认为 status=0 的数据也很多,觉得“反正都要回表查那么多数据,不如直接全表扫描算了”。

解决方法:

  1. 紧急手段: 使用 FORCE INDEX 强制指定索引(不推荐作为长期方案,因为业务逻辑变了容易埋雷)。
  2. 常规手段: 执行 ANALYZE TABLE jobs; 重新计算统计信息。
  3. 架构优化: 这种状态驱动的表,历史数据如果不归档,索引效率注定越来越低。我们后来做了冷热分离,把已完成的任务移到历史表,主表只留活跃数据,索引效率瞬间满血复活。

总结与行动指南

做了这么多年架构,我最大的感触是:SQL优化本质上是在和数据库的IO作斗争。

所有的技巧,归根结底就是三个字:少干活

  • 少扫描行数(精准索引);
  • 少回表(覆盖索引);
  • 少做类型转换(规范开发)。

最后,给各位兄弟几个马上就能落地的行动建议:

  1. 打开慢查询日志:别等用户投诉了才去查,设置阈值(比如1秒),每天早上花10分钟扫一眼昨天的慢SQL,这事儿我坚持了2年,收益巨大。
  2. Review 必看 Key_len:在看 EXPLAIN 时,别光看有没有用索引,还要算一下 key_len,看看是不是联合索引只用到了最左边的一小截。
  3. 甚至可以写个脚本:我们运维搞了个脚本,自动抓取线上 Type=ALL 且行数超过1万的查询,直接发钉钉群报警,倒逼开发去优化。

最后搞个小互动: 面对上面提到的“深度分页”问题,除了“延迟关联”和“记录最大ID”,你们团队还有什么野路子吗?是上ES?还是限制用户只能看前100页? 欢迎在评论区聊聊你的方案,或者是你遇到过的最奇葩的慢SQL。