前四篇文章,我们从底层的物理磁盘 I/O 讲起,跨越了"回表"、"联合索引失效",最后剖开了优化器的大脑,学会了看懂 EXPLAIN 这张 B 超单。
很多同学走到这一步,觉得日常的 CRUD 已经没有任何挑战了。直到有一天,公司的业务迎来了大爆发,你们的核心订单表突破了 2000 万行。
产品经理笑眯眯地提了个需求:"加个翻页功能吧,让用户能翻到几年前的订单。" 你觉得这简直是送分题,随手写下了一句带索引的翻页 SQL: SELECT * FROM orders ORDER BY create_time LIMIT 1000000, 10;
代码刚上线,警报狂鸣。DBA 冲过来指着监控大喊:"数据库 CPU 直接被打到 100%,所有线上写操作全部卡死!" 你拿出上一篇学的 EXPLAIN 一查:走索引了啊!没有 filesort 啊!为什么系统还是死得这么惨?
今天,作为本专栏的大结局,我们将跨越写 SQL 的战术层面,来到架构设计的战略层面。为你彻底剖析深分页灾难,并揭晓千万级大表索引设计的终极经济学法则。
一、 深分页(Deep Paging)黑洞:为什么 LIMIT 会杀崩数据库?
很多人对 LIMIT offset, N 有一个极其致命的误解:他们以为 MySQL 会像数组下标一样,瞬间"跳"到第 100 万行,然后往后拿出 10 条数据。
在 MySQL 底层,根本没有"跳跃"这种魔法!
当你执行 SELECT * FROM orders ORDER BY create_time LIMIT 1000000, 10; 时,MySQL 那个死心眼的底层逻辑是这样的:
-
跑去
create_time的二级索引树上,顺着叶子节点的链表,老老实实地扫描 1,000,010 条记录,拿到 100 万零 10 个主键 ID。 -
拿着这 1,000,010 个 ID,跑回主键树里做 1,000,010 次极其昂贵的"回表"查询!
-
把这 100 万多条完整的订单数据捞到内存里。
-
最后,极其败家地把前面的 100 万条数据全部扔掉! 只把最后 10 条返回给你。
为了 10 条数据,做 100 万次随机磁盘 I/O 的回表操作,数据库不死才怪。优化器这时候如果机灵点,甚至会直接放弃索引,去跑全表扫描,结果一样是死。
【架构级破局方案】
面对深分页,中大厂的架构师通常会祭出两把屠龙刀:
解法 1:延迟关联(Deferred Join)------ 榨干"索引覆盖"的剩余价值
核心思想:既然"回表"太贵,那我们就想办法只给这 10 条数据回表!
优化后的 SQL:
SQL
SELECT a.* FROM orders a
INNER JOIN (
SELECT id FROM orders ORDER BY create_time LIMIT 1000000, 10
) b ON a.id = b.id;
底层奇迹: 看里面的子查询 SELECT id ...。既然只要 ID,它在 create_time 的二级索引树上就能凑齐资料!完美触发咱们第二篇讲过的"索引覆盖(Using index)"! 它同样扫了 100 万条,但它是在极小的索引页里顺序扫的,没有发生一次回表 ,速度极快。最后拿到 10 个 ID 后,再去跟主表 JOIN。100 万次回表,瞬间被锐减成了 10 次!
解法 2:游标(Cursor)/ 书签法 ------ 真正的 O(1) 翻页
核心思想:别让 MySQL 傻傻地去数前 100 万个苹果了,你直接告诉它从哪里开始拿!
记住上一页最后一条记录的 id(假设是 9999990),下一页直接这么查:
SQL
SELECT * FROM orders WHERE id > 9999990 ORDER BY id LIMIT 10;
底层奇迹: 因为主键是有序的 B+ 树,MySQL 直接通过 B+ 树的快速检索,3 次 I/O 瞬间空降到 id=9999990 的位置,往后撸 10 条。这才是真正的神级优化,缺点是只能"上一页/下一页",不能直接跳到第 N 页(但 C 端产品其实极少需要跳页功能)。
二、 索引经济学:为什么 DBA 总是拒绝你"建个索引"的请求?
解决了深分页,你又发现了一个长得很慢的查询,准备提个工单加个索引。DBA 却残忍地拒绝了你。
初学者总是把索引当成免费的万能药:"查得慢?加个索引不就完了吗!" 但在大厂的数据库军规里,"如无必要,勿增实体" 是索引设计的第一铁律。
我们要算一笔"写入成本"的经济账: 每一次 INSERT、UPDATE、DELETE,MySQL 都不只在修改数据。 如果你表上有 5 个非主键索引,那么你插入 1 行数据,底层实际上要同时去维护 6 棵 B+ 树! (1 棵主键树 + 5 棵二级索引树)。 为了保持树的平衡,InnoDB 不断地在进行极其耗时的页分裂(Page Split)和页合并,产生大量的磁盘碎片。
当你在一张千万级大表上建了太多索引,你的查询确实变快了,但你的写入性能会当场雪崩。在秒杀、抢购等高并发写入场景下,多一个无用索引,就多一分宕机的风险。
三、 区分度(Cardinality)法则:为什么在"性别"上建索引是纯交智商税?
老板说:"我们的后台系统要经常按男女去筛选用户,去给 gender 字段建个索引吧。" 如果你照做了,你就是典型的被 B+ 树洗脑了。
索引界有一个绝对的衡量指标:区分度(Cardinality) = 不同的值的个数 / 表的总行数。 区分度越接近 1,索引越高效(比如身份证号、手机号,每个人都不一样)。 区分度越接近 0,索引就是个废物。
死因推演: 假设表里有 1000 万人,男女各一半。 你在 gender 上建了索引,执行 SELECT * FROM users WHERE gender = '男'。 MySQL 跑到二级索引树上一看,好家伙,符合条件的有 500 万人!拿到这 500 万个杂乱无章的 ID,跑去主键树做 500 万次随机回表! 这时候,项目经理(优化器)看了一眼图纸,骂了一句脏话,果断放弃你的索引,直接去跑全表扫描了。
铁律:绝不在只有少数值的状态字段(性别、支付状态、是否删除)上建独立的单列索引! 如果非要建,必须把它和区分度高的字段绑在一起做联合索引。
四、 空间折叠魔法:前缀索引(Prefix Indexing)
最后一个架构难题:如果我非要在一个很长的字符串字段上建索引怎么办?比如用户的个人主页 URL(VARCHAR(255))或者长邮箱地址。
上一篇我们算过,B+ 树之所以矮胖,是因为它非叶子节点不存数据,一页能塞下上千个索引键。 如果你的索引键长达 255 个字符,那一页能塞下的目录条数就会暴跌!树的分叉少了,树就会被迫变高,磁盘 I/O 次数增加,查询变慢。
【架构解法:前缀索引】 其实,区分一个邮箱或者 URL,往往不需要完整的字符串,只要前面十几二十个字符就足够具有唯一性了。
我们可以这么建: ALTER TABLE users ADD INDEX idx_email (email(15)); 告诉 MySQL,只拿 email 的前 15 个字符去建 B+ 树! 收益: 树的体积骤减,一页又能塞下海量目录,I/O 性能回归! 代价: 这种索引永远无法触发"索引覆盖"。因为树上只有残缺的字符串,MySQL 找到后,必须回表去原数据里确认一下完整的邮件地址是不是匹配。但这在空间节省面前,绝对是值得的权衡。
💡 大结局:索引的哲学,就是"平衡"
至此,我们的《穿透 MySQL 索引》五部曲,终于落下了帷幕。
从老奶奶推车的磁盘 I/O,到聚簇索引的双树联动;从最左前缀的严丝合缝,到 EXPLAIN B超单的望闻问切;最后到今天千万级大表的深分页与经济学博弈。
如果我们用一句话来总结整个索引专栏的核心思想,那就是两个字:平衡。
-
空间与时间的平衡: 索引的本质,就是极其奢侈地消耗磁盘空间,去换取极速的查询时间。
-
读与写的平衡: 每一棵加快查询的树,都在反噬着写入的性能。
-
精准与全貌的平衡: 小块查找(索引)与大块装载(回表/全表扫描)之间的永恒博弈。
真正的数据库高手,脑子里从来不是几条死记硬背的八股文规则,而是底层物理机器齿轮咬合的画面。当你在敲下每一个 CREATE INDEX 的时候,愿你能听到那几十万颗数据在 B+ 树叶子上跳动的声音。
完结散花!祝大家在未来的架构之路上,代码永无 BUG,线上永不宕机!