先从一个问题开始
为什么给一个字段加了索引,查询还是慢?
这个问题我被问过至少十几次,问的人一般都很委屈:「我明明加了索引啊」。而我自己刚工作那两年,也是这么委屈过来的。后来慢慢想明白,「加了索引」和「用上了索引」和「用上索引之后变快了」,是三件完全不同的事。
B+ 树到底长什么样
抛开教科书的画法,我更愿意用一个具体的数字来理解它。
InnoDB 的一个页是 16KB。假设主键是 bigint(8 字节),加上页指针 6 字节,一个非叶子节点的条目大约 14 字节,一页能放大概 1170 个。叶子节点存的是完整行,假设一行 1KB,一页放 16 行。
于是:树高 2 层能存 1170 × 16 ≈ 1.8 万行;3 层能存 1170 × 1170 × 16 ≈ 2000 万行;4 层能存 250 亿行。
这个计算第一次做出来的时候我挺震撼的——两千万行的表,按主键查一次只需要三次磁盘 IO,而且上两层基本常驻内存,实际就是一次。这也解释了为什么「单表两千万」这个经验数字会被反复提起,它就是从树高 3 变 4 的临界点来的。
回表:那个容易被忽略的成本
二级索引的叶子节点存的不是整行数据,而是主键值。所以走二级索引查非索引字段,要先在二级索引树上找到主键,再回主键树上查一次完整行。这叫回表。
回表的代价是随机 IO。当你要回表的行数达到全表的某个比例(经验上大概 20% 到 30%)时,优化器会算出「还不如全表扫描算了」,直接放弃索引。这就是为什么有些查询你加了索引,EXPLAIN 出来还是 ALL。
我遇到过一个真实案例:一张订单表加了 status 索引,但 status = 1(待处理)占了全表 70%,索引完全不生效。区分度低的单列索引基本等于没加,还白白拖慢了写入。
最左前缀,以及一次 12 秒变 90 毫秒
联合索引 (a, b, c) 相当于按 a 排序,a 相同时按 b 排序,b 相同再按 c。所以能命中 a、a,b、a,b,c,但命中不了 b,c。这是最左前缀,大家都会背。
不太会背的是顺序怎么定。我们有一条查询是这样的:
WHERE tenant_id = ? AND status IN (1,2) AND created_at > ?
ORDER BY created_at DESC LIMIT 20原来的索引是 (status, tenant_id, created_at),执行 12 秒。改成 (tenant_id, status, created_at) 之后,90 毫秒。
差别在于:status 只有 4 个取值,放最左边意味着一上来就要扫掉四分之一的表;tenant_id 有几万个值,放最左边一下就把范围缩到几百行。而 created_at 作为范围条件必须放最后——范围查询后面的列用不上索引排序,把它放中间的话,ORDER BY 就得额外做一次文件排序。
我总结的顺序原则是:等值条件在前,区分度高的在前,范围条件放最后,能顺便满足 ORDER BY 更好。
覆盖索引:便宜的大招
如果查询要的所有字段都在索引里,就不用回表了,这叫覆盖索引。EXPLAIN 的 Extra 里会显示 Using index。
我很喜欢这个优化,因为它成本低见效快。有个列表接口只需要 id、标题、时间三个字段,我把这三个塞进联合索引,接口从 400 毫秒降到 30 毫秒,代码一行没改。
但别贪心。我见过有人把七八个字段全塞进索引,索引体积比表还大,写入性能掉了一半。索引是空间和写入换读取,天下没有白吃的午餐。
几种让索引悄悄失效的写法
- 隐式类型转换。
phone是 varchar,你写WHERE phone = 13800000000,MySQL 会把列转成数字再比较,索引废掉。这个坑我踩过两次,第二次是因为 ORM 自动做了参数类型推断。 - 对列做运算或函数。
WHERE DATE(created_at) = '2026-08-01'用不上索引,改成created_at >= '2026-08-01' AND created_at < '2026-08-02'就可以。 - 前导通配符。
LIKE '%abc'没法用 B+ 树,因为树是按前缀有序的。 - 字符集或排序规则不一致的 JOIN。 两张表关联字段一个 utf8mb4_general_ci 一个 utf8mb4_unicode_ci,索引直接失效,而且极其难发现。
最后一句
我现在写完一条稍微复杂的 SQL,习惯先 EXPLAIN 一下,重点不看 type 列,看 rows 和 filtered。type 是 ref 也可能扫 50 万行。真正告诉你「优化器打算读多少数据」的,是 rows 这个估算值。