1. 先搞清楚索引到底在解决什么问题
很多开发者写SQL够用,但一到数据量上来就卡壳。你说他没写过SQL吧,增删改查溜得很;你说他懂吧,一条SELECT能把数据库跑死。问题出在哪?绝大多数情况是对索引的理解停留在"这东西能加速查询"的层面上,根本不知道它加速的原理、适用的场景和踩坑的边界。
索引这个东西,本质上是数据库为了减少磁盘IO和数据扫描量而设计的一种附属结构。你可以把它理解成一本书的目录:没有目录,你要找某个知识点得从第一页翻到最后一页;有目录,你直接翻到对应页码,几秒钟搞定。数据库里的全表扫描就是"从第一页翻到最后一页",索引就是那个目录。
但目录也有讲究。不是随便在书最后塞几页纸就能叫目录,数据库里的索引结构、字段选择、创建方式都存在大量细节。我见过太多人CREATE INDEX一把梭,结果查询没快多少,写入倒慢得感人,最后还得删掉重建。
这篇内容我就围绕两条线展开:一条线是SQL本身的进阶写法,另一条线是索引的设计与优化。因为这两件事是绑在一起的,SQL写得好不好,直接决定索引用得上用不上;索引设计得合不合理,直接决定SQL能不能跑得快。先搞懂原理,再看实操,最后讲排查,一层层往下拆。
2. 索引的数据结构:为什么B+树能成为数据库的默认选择
2.1 B+树与其他结构对比
很多初学者第一次接触索引时,会以为索引就是一颗二叉树。这个理解不算全错,但离真相还差很远。数据库里最常用的索引结构是B+树,而不是普通的二叉搜索树,也不是红黑树,更不是哈希表。
先对比几个结构:
二叉树的问题在于树的高度会随着数据量增长而变得非常高。假设有1000万条数据,二叉树的高度大概在20多层,每一层可能对应一次磁盘IO,查一条数据要读20多次磁盘,这个开销是无法接受的。红黑树虽然保证了平衡,但本质还是二叉树,高度同样下不来。
B+树神奇的地方在于,它是一棵"多叉"的平衡树,一个节点能存成百上千个key。同样是1000万条数据,B+树的高度通常只有3到4层。也就是说,绝大多数情况下,你查询一条数据只需要3到4次磁盘IO,这比二叉树的20多次快了一个数量级。
那哈希索引呢?哈希索引的查询速度理论上是O(1),比B+树还快。但哈希索引的致命弱点是不支持范围查询,不支持排序,也不支持前缀匹配。你写一个WHERE age > 18 AND age < 30,哈希索引直接歇菜。实际业务中范围查询太常见了,所以哈希索引只能作为补充,不可能成为主流。
2.2 为什么非叶子节点不存数据
B+树有一个非常关键的设计:非叶子节点只存索引键值,不存数据本身,所有数据都挂在叶子节点上,并且叶子节点之间通过链表相连。这个设计有两层深意。
第一层,每个节点能容纳的key数量大大增加。因为key通常比整行数据小得多,同样的节点空间能塞下更多key,树就变得更矮,IO次数就更少。
第二层,叶子节点之间的链表让范围查询变成了一个"顺序遍历"的操作。比如你查age BETWEEN 18 AND 30,B+树先定位到18,然后顺着叶子链表往后扫,直到超过30为止,整个过程基本是顺序IO,性能非常稳定。
我举个例子帮你理解。假设一棵树只有两层,根节点像个"路牌",告诉你"小于50的去左边,大于50的去右边",真正的"货物"(数据)都在下一层。路牌本身极小,一次磁盘IO就能读进来;下一层根据路牌指示,径直到对应区块取货。这个过程又快又省资源,和现实中在大型仓库里靠分区编号找货的原理一模一样。
2.3 聚集索引与非聚集索引的区别
索引结构说完,紧接着要搞清楚的是一组非常重要的概念:聚集索引和非聚集索引。
聚集索引的叶子节点直接存的是整行数据。也就是说,数据本身的物理顺序和索引顺序是一致的。InnoDB里每张表都必须有一个聚集索引,默认是主键。你按主键查询时,一次IO直接拿到全部数据,效率极高,因为不需要回表。
非聚集索引(也叫二级索引)的叶子节点存的是索引键值加主键值。你通过非聚集索引查询时,先在索引树里找到对应的主键,然后再拿着主键去聚集索引里查整行数据,这个动作叫回表。
这里有个非常重要的性能判断标准:回表次数越少,查询越快。如果你能在非聚集索引的叶子节点里直接拿到想要的字段,连回表都省了,这就是覆盖索引。比如你有idx_user_age这个索引,SQL只查SELECT age FROM user WHERE age = 25,索引里就有age,不用回表,直接返回,效率拉满。
举一个实际的例子。某业务表有几百万行数据,经常要按用户状态统计数量。一开始直接SELECT COUNT(*) FROM orders WHERE status = 'PAID',每次查询都要扫全表,耗时两三秒。后来我在status上建了非聚集索引,由于InnoDB做COUNT时优先选最小的二级索引扫描,这回查询直接降到几十毫秒,原因就是扫描索引树比扫描聚簇索引的叶子节点要小得多。这个优化思路在报表场景里非常实用。
3. SQL进阶写法:从能用到用好的分水岭
3.1 WHERE条件匹配:索引生效的黄金法则
SQL进阶的第一件事,不是学什么高级语法,而是搞清楚你写的WHERE条件能不能命中索引。这一点没搞明白,后面全白搭。
最核心的几条法则先列出来:
- 对索引列做计算、函数操作,会导致索引失效。比如
WHERE DATE(create_time) = '2024-01-01',索引在create_time上建了也没用,因为数据库得先对每一行做DATE计算才能比较。正确写法是WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。 - 隐式类型转换也会让你吃大亏。比如索引列是varchar类型,你写
WHERE phone = 13800138000(数字),数据库会把字符串列转成数字再比较,索引直接失效,老老实实用字符串形式写才能命中。 - LIKE查询的前缀模糊无法使用索引。
WHERE name LIKE '%张%'和WHERE name LIKE '%张'都不会走索引,只有WHERE name LIKE '张%'才能利用索引的范围扫描能力。
在我处理过的慢查询案例里,隐式类型转换是个重灾区。有一次排查一条本应毫秒级返回的接口,实际耗时将近1秒,翻出SQL一看,字段是varchar类型,查询条件传了整型,数据库只能把字段全部CAST一遍再做比较。改成字符串传参后,查询直接走了索引,耗时从1秒降到20毫秒。就改了一个参数类型,天壤之别。
这些细节看着小,但在大表上会直接拉开几十倍的性能差距。SQL进阶的第一步,不是学窗口函数,是先学会让你的WHERE条件命中索引。
3.2 JOIN的驱动表选择:小表驱动大表
多表关联查询是SQL进阶绕不开的点。JOIN本身不难写,难的是写出性能可接受的JOIN。
MySQL里,JOIN的执行逻辑一般是:先选一张驱动表,逐行去匹配另一张被驱动表。驱动表的每一行,都要在被驱动表里找匹配项。这时候被驱动表上有无索引,直接决定匹配效率。这就是经典的"小表驱动大表"原则:拿小表当驱动表,大表当被驱动表,且大表的关联字段必须建索引。
举个例子。订单表有100万行,用户表有1万行,你要查订单中每个用户的信息。正确姿势是拿用户表当驱动表,订单表当被驱动表,订单表的user_id上建索引。这样只用遍历1万个用户,每次去订单表用索引查一下关联记录。反过来的结果是你遍历100万行订单去匹配用户表,就算用户表有索引,开销也大得多。
另外想提醒一句,LEFT JOIN并不是强制左边的表当驱动表,优化器有自己的判断逻辑。你可以用EXPLAIN看第一行是谁,如果不符合预期,必要时用STRAIGHT_JOIN强制指定驱动顺序。我一般在优化器犯傻的时候才用这个,平时尽量让它自己选。
3.3 窗口函数:分组TopN的正确姿势
讲到SQL进阶,窗口函数必须重点说。窗口函数(Window Function)是在不合并行的前提下,对每一行进行一个"窗口范围"内的计算。比如ROW_NUMBER() OVER (PARTITION BY category ORDER BY sale_amount DESC),可以在每个分类内部给数据排个名,同时保留每一行的原始信息。
做一个简单的对比,你就知道窗口函数的价值了:
老式写法,取每个分类销量前三的商品,很多人会写成这样:
SELECT category, product_id, sale_amount FROM products p WHERE sale_amount >= ( SELECT MAX(sale_amount) FROM products WHERE category = p.category AND product_id != p.product_id -- 实际上这写法并不对,这里只是示例 );这种子查询方案,代码丑陋,性能差,逻辑还容易写错。窗口函数一行搞定:
SELECT category, product_id, sale_amount FROM ( SELECT category, product_id, sale_amount, ROW_NUMBER() OVER (PARTITION BY category ORDER BY sale_amount DESC) AS rn FROM products ) t WHERE rn <= 3;这段SQL的核心逻辑是:先按category分组,组内按sale_amount从高到低排个序号,然后外面过滤掉序号大于3的行,最终留下每个分类前三名。整个过程只需要扫描一次表,效率非常高。
窗口函数除了ROW_NUMBER(),常用的还有RANK()(排名可并列,有跳号)、DENSE_RANK()(排名可并列,不跳号)、SUM() OVER(...)(累计求和)、LAG()/LEAD()(取前后行)等。面试题里最常见的"分组TopN""连续登录天数""同比环比计算",基本都是窗口函数的应用场景。
3.4 分页深翻页优化:LIMIT的隐藏陷阱
分页查询大概是所有业务系统里最常用也最容易被忽视性能问题的SQL。LIMIT 100000, 20这样的写法,在数据量小的时候感觉不到问题,但一旦表里数据到了百万级,你就会发现翻页越来越慢。
原因是什么?数据库为了拿到第100001条到第100020条数据,会把前面10万条数据全部读出来,一条一条数到偏移量,再丢掉前10万条,返回最后20条。这个"数前面的10万条"的过程,浪费了大量IO。
推荐的做法是用覆盖索引先定位偏移位置,再关联回原表取数据。延迟关联是业内常用方案:
SELECT t.* FROM orders t INNER JOIN ( SELECT id FROM orders WHERE status = 'PAID' ORDER BY create_time LIMIT 100000, 20 ) tmp ON t.id = tmp.id;这个写法的巧妙之处在于,子查询只查主键id,走覆盖索引,速度极快;找出20个id之后,再回原表取完整数据。整个过程中,真正回表的数据只有20条,而不是10万条,性能提升非常明显。
如果你的业务有"上一页/下一页"这种场景,还有一种更激进的方案:记住上一页最后一条记录的排序字段值,用WHERE create_time < 上一页的最后时间 ORDER BY create_time DESC LIMIT 20来取下一页,可以完全避开OFFSET。不过这个方案有一个限制,就是不允许用户随意跳页。很多C端产品采用"只允许上一页下一页"的策略,正是为了利用这个优化。
4. 索引设计实战:建索引不是顺手的事
4.1 最左前缀原则是怎么起作用的
在讲索引设计之前,你先得把"最左前缀原则"搞懂,因为这是联合索引一切规则的地基。
联合索引(a, b, c),本质上按a先排序,a相同再按b排序,b相同再按c排序。所以这个索引能高效支持的条件组合是:a、a, b、a, b, c。如果你直接查b = 1,对不起,索引用不上,因为树的第一层只按a组织,b的排序只发生在a相等的前提之下,你让数据库怎么在整棵树里快速找b?
这个原理很像手机通讯录。姓是首字母,名是次级排序。你要找一个"张伟",先翻到Z区,再在Z区里找"伟";如果你只告诉我"名字里带伟的人",我没法快速定位,只能把整本通讯录抄一遍。这就叫"中间条件断了,后面条件全废"。
经常有人问我,那把最常用的条件放最前面不就行了?这话对,但不全对。你得结合所有高频SQL来看,选出能被最多查询复用的字段组合。比如你有两个高频查询,一个是WHERE a = ? AND b = ?,另一个是WHERE b = ?。那(a, b)联合索引最多只能服务第一个查询,而a单列索引也服务不了第二个。这时候没有完美解,只能评估频率,优先保高频。
4.2 联合索引字段顺序的实战考量
说到字段顺序,我的建议是遵循一套优先级:查询频率 > 区分度 > 业务稳定性。
举个例子,用户订单表里有user_id、status、create_time三个字段经常一起出现在查询条件里。怎么排?
先看频率:user_id几乎每次都出现(查某用户的历史订单),把它放第一位。 再看区分度:status只有几个枚举值,区分度低,放中间没问题。 最后create_time放最后,还能顺带做排序优化。
有些文章说区分度最高的字段放最前面,这个说法在单条件查询下是对的,但在联合索引中要结合查询频率来权衡。频率是"哪些查询能用上这个索引"的决定因素,区分度只是"索引内部扫描效率"的影响因素。频率上的差距,往往比区分度上的差距重要得多。
4.3 覆盖索引与回表之间如何取舍
我在前面提到过覆盖索引的概念:查询的字段全部包含在索引中,无需回表。这个技术在高频查询里极其有价值,而且实现方式很灵活。
比如业务上有一个高频查询:根据用户ID查最近一笔订单的订单号和金额。你建一个联合索引(user_id, order_no, amount),那么SQL:
SELECT order_no, amount FROM orders WHERE user_id = 128056 ORDER BY create_time DESC LIMIT 1;只要查询字段都在索引里,MySQL就直接从索引树里拿数据,不用回表。索引比数据行小得多,同样的IO能读更多记录,自然快。
但覆盖索引不是没有代价的。你给索引塞的字段越多,索引文件越大,写操作维护成本越高,缓冲池的压力也越大。所以覆盖索引的选用标准是:高频查询、字段量少、内容稳定。低频报表类的需求,不建议用覆盖索引去梭哈,太浪费空间了。
4.4 冗余字段与反范式:用空间换时间的思路
在索引优化之外,SQL进阶还有一个非常实用的思路:在表结构层面做些反范式设计,从根源上减少复杂的JOIN和查询。
举个例子,订单列表页要展示用户名。一开始的做法是订单表只存user_id,查询时JOIN用户表拿用户名。但订单量一大,每次列表页都要JOIN,性能就很紧张。
这时候一个常见做法是在订单表里冗余一个user_name字段,下单时从用户表带过来,查询时就不需要JOIN了。这就是典型的反范式设计:牺牲一定的数据冗余和一致性维护成本,换取查询性能和SQL的简单性。
这个方案当然有它的麻烦,比如用户名改了,历史订单里的名字不会自动变。有些业务能接受"订单快照"的语义(名字以下单时为准),有些业务不能接受。所以反范式不是什么场景都能用的,要想清楚业务语义到底是"最新状态"还是"历史快照"。但从SQL进阶的角度讲,懂得用空间换时间、用冗余换性能,是一个成熟的开发者必备的思维。
5. 执行计划:让数据库亲口告诉你SQL该怎么调
5.1 EXPLAIN输出里哪些信息最重要
写一堆索引理论和SQL技巧,最终还得看数据库买不买账。EXPLAIN就是你和数据库之间的"翻译器",它把SQL的执行计划摊开给你看。
实际执行时重点看这几列:
type:访问类型。性能从好到差依次是system、const、eq_ref、ref、range、index、ALL。如果你的SQL出现了ALL(全表扫描),那基本就是性能瓶颈所在。key:实际用到的索引名。如果显示NULL,说明没走任何索引,这就是要排查的头号目标。rows:预估扫描行数。这个数值越小越好。我通常先看这个,如果预估扫描行数接近全表数量,说明索引选择性不够好或SQL写法有问题。Extra:这里内容最丰富。Using filesort表示需要额外的排序操作,Using temporary表示用了临时表,Using index表示覆盖索引,Using where表示在存储引擎层之后再做过滤。
有一次我排查一条报表SQL,EXPLAIN出来Extra里赫然写着Using filesort。这个排序动作在百万行数据上会导致性能下降得厉害,后来我把排序字段加入索引,让ORDER BY直接走索引的有序性,这次filesort从执行计划里消失了,查询耗时直接降了几个量级。记住一条口诀:ORDER BY的字段如果能安排进联合索引的末尾,十有八九能干掉filesort。
5.2 一个完整慢查询的排查过程
说一个真实的排查过程,方便你把上面的知识串起来。
线上有一个接口,某天开始持续变慢,从原本的200ms涨到了2秒。翻出慢查询日志,定位到一条SQL:
SELECT order_id, amount, status FROM orders WHERE user_id = 123456 AND status = 'PAID' ORDER BY create_time DESC LIMIT 10;先执行EXPLAIN,结果:
type = ALL,全表扫描;key = NULL,没有索引可用;rows = 200万,直接把整张订单表扫了一遍。
再一看表结构,只有主键索引和user_id的单列索引。为什么user_id的索引没被用上?因为优化器评估后觉得,哪怕走了user_id索引,还得回表过滤status,再filesort排序,索性不走了,直接全表全扫。
优化方案分两步走。
第一步,创建联合索引(user_id, status, create_time)。这个索引同时覆盖等值条件(user_id和status)和排序条件(create_time),理论上查询可以直接走索引范围扫描,且排序也能免去。
第二步,再考虑覆盖索引。查询还要取order_id, amount, status,而order_id是主键,二级索引叶子节点自带主键,status在联合索引里,只有amount不在。所以把索引扩成(user_id, status, create_time, amount)就能实现完全覆盖,连回表都省掉。
最终实测,同一个查询从2秒降到了30毫秒,提升了近70倍。整个过程没有改一行业务代码,纯粹是索引设计的问题。
5.3 预估行数与统计信息的关联
还有一个容易忽略的细节:优化器做判断时依赖的是统计信息。InnoDB的统计信息不是实时的,有时候你建了索引但优化器不选,就可能是统计信息陈旧导致的。
遇到这种情况,可以执行ANALYZE TABLE刷新统计信息,让优化器能更准确地估算行数。我见过一个案例,表里数据做了大批量清理之后,统计信息还停留在几百万行的水平,优化器错误地认为走索引比全表扫描更慢,结果每次都做全表扫描。执行ANALYZE TABLE之后,优化器才恢复正常判断。
不过要提醒你,ANALYZE TABLE在超大表上也会锁表,不要在业务高峰期随手执行,要挑低峰期操作,或者干脆让系统自动维护统计信息的周期覆盖这种场景。
6. 索引失效与常见问题的排查技巧
6.1 惯性踩坑场景速查
做了这么久的SQL优化,有些坑是反复出现的,我直接整理成一个列表,你排查的时候可以对照查看。
- 对索引列使用函数或表达式计算,索引失效。
- 隐式类型转换导致索引失效。
- LIKE前缀模糊查询导致索引失效。
- OR条件中有一个字段没索引,整个查询可能转成全表扫描。
- NOT IN、NOT EXISTS等负向查询通常无法走索引。
- 联合索引没有遵循最左前缀原则。
ORDER BY字段不在索引中,出现filesort。LIMIT深分页导致大量无效回表。- 优化器统计信息不准确,导致错误地放弃索引。
- 表数据量过小,优化器认为全表扫描更快,就不走索引了。
这最后一个坑特别有意思。有时候你建了索引,EXPLAIN一看,type = ALL,就以为索引没建上。其实是因为表里就几千行数据,全表扫描比走索引更快,优化器做了正确选择。数据量上来之后,它自然会走索引。所以排查问题时,一定要结合数据量来看执行计划,别一看全表扫描就乱了阵脚。
6.2 计算列带来的隐性问题
很多人会在索引列上做计算、做函数操作、做字符串拼接,比如WHERE YEAR(create_time) = 2023、WHERE price * quantity > 100、WHERE CONCAT(first_name, last_name) = '张三'。这些写法有一个共性:对索引列做了"加工",加工的结果是数据库必须在每一行上先计算一遍,才能参与比较,索引自然就用不上了。
遇到函数操作的场景,MySQL 5.7以上的版本有一种方案,在表里增加一个"计算列",然后在计算列上建索引。比如:
ALTER TABLE user ADD COLUMN year_created YEAR AS (YEAR(create_time)) STORED; CREATE INDEX idx_year_created ON user(year_created);之后查询改成WHERE year_created = 2023,就能命中索引。虽然索引本身占空间,但比起每次全表扫描,优势依然是决定性的。MySQL 8.0也支持函数索引,可以直接CREATE INDEX idx_year ON user ((YEAR(create_time))),写法更简洁。
6.3 多个单列索引的误区:索引合并
我还发现很多人有个直觉:字段多就每个字段建一个单独的索引。这种思路看似合理,实际隐藏问题。当一条SQL的WHERE条件里同时出现多个单列索引字段时,MySQL确实可能做一个叫"索引合并"的操作,把多处扫描结果合并,但这往往是补救性质的行为,性能远不如直接建一个联合索引。
打个比方,你要查"住在某小区的姓张的男生",有两张单独的名单,一张按小区分类、一张按姓氏分类。最笨但常见的做法是先在名单A里找出所有该小区的人,再去名单B里找所有姓张的,最后比对合并两份名单。如果有一份按"小区+姓氏+性别"交叉归档的总表,直接一页翻到目标位置,效率显然高得多。
所以,能用联合索引尽量用联合索引,避免散装多个单列索引。单个索引并不是建得越多越好,索引过多还会拖慢写入性能、占用磁盘空间、增大优化器的决策成本。
7. 一个完整的SQL优化实战复盘
7.1 业务场景还原与问题定义
这里我想完整复盘一个我印象很深的优化案例,把前面所有知识串起来。某业务场景有一张订单流水表trade_log,经过长时间增长已有接近2000万行数据,每天都有大量新写入。某天的报表查询开始频繁超时,慢查询日志里堆了几条明显有问题的SQL,大致汇总如下:
- 按用户ID查最近20笔订单:
SELECT * FROM trade_log WHERE user_id = ? ORDER BY create_time DESC LIMIT 20 - 按时间段统计每日订单总量和金额:
SELECT DATE(create_time) AS day, COUNT(*), SUM(amount) FROM trade_log WHERE create_time BETWEEN ? AND ? GROUP BY day - 后台列表分页:
SELECT * FROM trade_log ORDER BY create_time DESC LIMIT 100000, 20
我拿到这些信息之后,第一反应是先用EXPLAIN,把每条SQL的执行计划拉出来看一眼,而不是急着建索引。只有看到实际执行计划,才知道哪些索引缺了、哪些索引建了也没用。
7.2 逐条SQL的优化过程
针对第一条"按用户查最近订单",建联合索引(user_id, create_time),把等值条件和排序条件一起放进索引。这还不够,查询要返回全字段,意味着不可避免要回表。考虑高频,在索引里加上常用字段order_no, amount,变成(user_id, create_time, order_no, amount),让高频查询实现覆盖索引。实测后,该类查询从原来的几十毫秒到几百毫秒不等,稳定在个位数毫秒。
针对第二条按日统计的SQL,前面提过DATE(create_time)会导致索引失效。这里改写成范围条件:
SELECT DATE(create_time) AS day, COUNT(*), SUM(amount) FROM trade_log WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-31 00:00:00' GROUP BY day;然后在create_time上建普通二级索引。范围条件可以走索引扫描了,但GROUP BY day则需要先把符合范围的数据拿出来再做聚合,数据量大的时候还是有压力。所以更进一步的方案是引入汇总表,每隔一段时间把明细聚合成每日统计,查询直接查汇总表。这个思路叫预聚合,在大数据报表场景里极其常见。
针对第三条深分页SQL,采用延迟关联优化,子查询先按create_time索引取出主键ID,再关联主键取全字段。改造之后,翻到十万页也只回表20条,性能提升非常明显。
7.3 写入性能与索引数量的平衡
这个案例的最后,还面临一个取舍问题:为了查询优化,一口气加了几个索引,结果发现写入变慢了。因为这表本身日写入量就大,每一个索引在写入时都要同步更新,索引越多,写入放大越严重。
我最后做了一件事:重新梳理业务上真正的高频查询,把低频需求从索引设计里拿掉,将原来的7个索引精简到3个联合索引,保证每个索引都能覆盖到多种查询场景。再次测试,写入性能恢复到了可接受的水平,查询性能也保持优秀。
这值得每个做SQL优化的人记住:索引不是多多益善,而是"按需设计"。每多一个索引,都是在用写入性能和存储空间换取查询性能。你要做的是在这两者之间找到平衡点,而不是一味地堆索引。
8. 索引维护与长期健康管理
8.1 索引碎片化
表长期增删改之后,索引页会逐渐产生碎片。碎片意味着索引的逻辑顺序和物理存储顺序不一致,扫描效率会下降。就像你把一本书的章节顺序打乱,页面还在,但你要按顺序读就得来回翻页。
MySQL里OPTIMIZE TABLE可以重建表和索引,整理碎片。要注意的是,这个操作在表数据量大的时候耗时很长,并且会锁表,生产环境务必安排在维护窗口执行。因此我的建议是:
- 在业务低峰期定期执行
OPTIMIZE TABLE; - 或者提前规划表分区、归档旧数据,防止单表无限膨胀;
- 关注表行数增长趋势,建立容量预警机制。
说句实在话,实际业务中很多"查询突然变慢"的案例,并不是SQL变了,而是表数据量涨到了某个临界点,或者碎片化让原本高效的索引退化成了低效的扫描。
8.2 无用索引的识别与清理
业务迭代过程中,表结构会不断变。你可能会发现某些索引已经很久没有出现在任何一条SQL的执行计划里,它们就是所谓的"僵尸索引"。
怎么识别?两个思路。
一是开启performance_schema或者查询慢日志分析工具,看看最近一段时间内各索引的访问次数。索引长期不被使用,就应该考虑移除。
二是定期做一次全量SQL梳理,对比每个索引与当前业务SQL的匹配情况。好处不只是发现无用索引,还能发现索引设计的优化空间,比如多个单列索引可以合并成一个联合索引。
清理无用索引对写入性能的提升立竿见影,因为每次INSERT、UPDATE、DELETE操作都不再需要维护这些多余的结构了。
8.3 生产环境的索引变更流程
最后补一个上线流程的建议,因为这个环节踩坑的代价很高。索引变更虽然比表结构变更简单,但也不是说建就建的。
在超过千万行的大表上直接执行CREATE INDEX,虽然MySQL 5.6以上的版本支持在线DDL,多数情况下不会锁表,但仍然会消耗大量IO和CPU资源,可能影响线上业务。我的习惯流程是:
- 先在测试库上执行,验证索引对查询的实际效果;
- 在预发环境用
EXPLAIN确认执行计划符合预期; - 非高峰期在线上执行,优先使用
ALGORITHM=INPLACE和LOCK=NONE选项,让索引创建过程对业务的影响降到最低; - 上线后持续观察慢查询指标和写入延迟指标,确认没有副作用。
另外,索引变更前记得备份当前表结构信息,需要回滚的时候至少知道原来的索引长什么样。这些看起来琐碎,但真出问题的时候,每一环都能救命。
9. 复盘:这段时间在SQL进阶与索引上踩过的最值的几个坑
文章最后分享几个印象最深的实操体会,希望能帮你少走弯路。
第一个体会:优化SQL之前,先把表和索引的实际情况看清楚。我见过太多人一上来就猜"是不是索引没建",最后发现是慢在排序或者回表上。EXPLAIN这条必杀技一定要养成习惯,分析任何SQL都先看执行计划,没有调查就没有发言权。
第二个体会:别为了"学到某个高级功能"而去用复杂写法。SQL进阶的真正意义,是让你知道有哪些更优的写法可以替代旧的、慢的写法。能用普通JOIN解决就不要用奇怪的子查询,能用简单索引解决就不要引入冗余表。代码越简单,后续维护成本越低。
第三个体会:索引设计不是一次性的工作,而是随着数据量和业务演变需要持续调整的。我见过设计方案时认为很完美的索引,在半年的数据增长之后逐渐失效的情况。定期复盘线上的慢查询日志,观察索引使用情况,应当成为数据库日常运维的一部分。
最后一个我很深的感受:SQL优化的成就感,不在于你用了多高级的语法,而在于你用最简单的手段解决了最大的问题。一条本来要扫2000万行才能出结果的SQL,通过一个联合索引把扫描范围缩到几百行,那种"秒杀"的感觉是实实在在的。希望这篇内容也能帮你找到这种感觉。
单看理论可能记得住,但真真正正要形成直觉,还得靠大量案例积累。建议你从现在开始,每遇到一条慢SQL,都习惯性地做三件事:看执行计划、看表结构、看索引使用情况。重复三个月,你会发现自己对SQL和索引的理解,已经和以前不在一个层次上了。