这几年跟 MySQL 打交道,最深的感触就是:大部分慢查询,都不是 MySQL 本身的问题,而是建表建索引时欠下的债,以及写 SQL 时偷的懒。 很多人一遇到线上慢查询,第一反应就是"加机器、搞分库分表",结果花了大力气,瓶颈没解决,还白白增加了系统的复杂度。我个人的经验是,一家公司的业务不到万不得已,压根不需要上分库分表那套方案,绝大多数性能问题靠规范的索引设计和合理的 SQL 写法就能解决九成以上。这篇内容我就把这几年踩过的坑和实操笔记整理出来,聚焦索引、SQL 优化,以及真正到了非分库分表不可的阶段,该怎么动手才不后悔。
1. 先聊聊索引设计:为什么你建了索引还是不生效
索引这东西,从大一开始学数据库就听人提,但真正会用的人真不多。很多人以为"只要给字段加了索引,查询就一定会走索引",这个认知在简单场景下成立,一旦 SQL 里出现函数、类型转换、OR 条件,或者表里的数据分布出了问题,索引说失效就失效。你先别急着吐槽数据库,它本质上是个按成本选路径的程序,当你给的 SQL 让它觉得"扫全表比走索引更划算"时,它就会放弃索引。所以想让索引听话,你得分清哪些写法会干扰优化器的判断。
1.1 索引列被函数或计算包裹的时候,索引当场报废
最常见的坑就是查询条件里对索引列做了函数操作。比如你查订单表里某个月的数据,写成这样:
sql复制SELECT * FROM orders WHERE DATE(create_time) = '2024-06-01'
这个写法从业务逻辑上没毛病,但 DATE() 函数把 create_time 的索引列给"包"住了,优化器无法直接利用 B+ 树上的有序结构做范围或等值匹配,只能把全表所有行的 create_time 都算一遍函数值,再拿去和 '2024-06-01' 比对。这就等于你把一本书的目录撕了,然后从头到尾翻一遍找内容。我自己实测过,一张 500 万行的订单表,这种写法基本在 800 毫秒以上,而用范围查询改写之后,能压到 20 毫秒以内。
正确的写法是改成范围条件:
sql复制SELECT * FROM orders
WHERE create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-02 00:00:00'
顺手说一句,不只是 DATE(),任何对列做计算、加运算、做字符串拼接的写法都会让索引失效。比如 WHERE price + 10 > 200 和 WHERE price > 190,前者索引失效,后者能走索引。这是我在团队 code review 里必查的一个点。规则很简单:索引列保持纯净,让它孤零零地待在比较符的一侧,别让它参与任何表达式运算。
1.2 类型不匹配会让隐式转换吃掉索引
这个问题更容易被忽视,因为它不报错,MySQL 会"好心"地帮你做隐式类型转换,但转换一旦发生在索引列上,索引就没了。经典的翻车现场是:表里 user_id 是 varchar(32),但代码里从接口接收的参数是数字类型,或者直接拼 SQL 时没加引号,写成了:
sql复制SELECT * FROM users WHERE user_id = 123456
MySQL 比较的时候会在底层把字符串类型的 user_id 转成数字去比,等价于 CAST(user_id AS SIGNED) = 123456,索引失效。这种问题在联表 join 的时候也特别常见,两个表的关联字段一个用 int,一个用 varchar,join 的效率瞬间拉胯。
排查方法也不难,用 EXPLAIN 看执行计划,如果某个本来该走索引的查询,type 显示的是 ALL 或者 ref 变成了 func,那基本就是隐式转换在作祟。修的话从源头保证类型一致最靠谱,要么把表结构改了,要么在 SQL 里老老实实加引号。
1.3 复合索引设计:左前缀法则和大坑
复合索引(联合索引)是 MySQL 优化里性价比最高的手段,但它也是最容易理解出错的地方。我见过太多人把高频查询的字段各建一个单列索引,然后跑来问为什么还是慢。MySQL 的 B+ 树索引不是给每个字段都单独建一棵树就完事了,联合索引是把多个字段按顺序拼成一个合并的 key 来组织 B+ 树的,所以它遵循最左前缀法则。
什么意思呢?比如你建了 (uid, status) 这个联合索引,那么:
WHERE uid = ?能走索引WHERE uid = ? AND status = ?能走索引WHERE status = ?走不了这个索引,因为 status 不在最左边
这个背后的原理其实不玄乎:B+ 树里联合索引的数据是按 uid 先排序,uid 相同的行再按 status 排。如果你跳过 uid 直接用 status 查,B+ 树最外层的排序结构就没法用上,等于你想查一本按"姓氏+名字"排序的电话簿里的所有叫"小明"的人,但没提供姓氏,只能从第一页翻到最后一页。
设计复合索引的核心原则是:分析你的业务里最频繁出现的查询条件组合,把这些字段按"等值条件优先、范围条件靠后"的顺序组成联合索引。 但注意,"等值优先、范围靠后"不是绝对的,有时候还得考虑字段区分度。比如一个性别字段,就两个值,区分度极低,把它放联合索引前面只会让每个前缀对应的数据量都很大。我个人习惯的排序是:等值查询的高区分度字段 > 等值查询的低区分度字段 > 范围查询字段。多试几种组合,用 EXPLAIN 看 key_len 和 rows 来判断效果。
1.4 覆盖索引和索引下推:两个白嫖性能的优化点
覆盖索引这个概念值得单独拿出来讲,因为它属于"不用改 SQL、不用改表结构,光靠新建索引就能白嫖"的优化手段。它的核心是:如果查询需要的所有列都包含在索引里,那么 MySQL 在索引树上就能直接拿到结果,不需要回表去主键索引里再查一次。
举个很简单的例子,订单表有 (uid, status, amount) 联合索引,你执行:
sql复制SELECT uid, status, amount FROM orders WHERE uid = 100
所有字段都在索引里,执行计划里 Extra 会显示 Using index,整条查询不需要回表。如果你的 SQL 里还有一个 SELECT *,那不好意思,索引里没存的字段必须回表拿。所以当你发现某个高频查询实际只需要几个固定字段时,把这些字段和查询条件一起放进联合索引,收益非常可观。
至于索引下推(Index Condition Pushdown,ICP),MySQL 5.6 之后默认开启的一个优化。网上有不少文章讲得云里雾里,我用大白话解释:在没有 ICP 的时候,如果联合索引是 (name, age),查询条件是 name LIKE '张%' AND age > 20,存储引擎只能根据 name 模糊匹配把符合条件的记录一个个找出来,然后回表把完整行数据拿出来,Server 层再判断 age 是否满足。有了 ICP 之后,在存储引擎扫描索引的过程中就能直接把 age 这个条件也过滤掉,减少了回表的次数。这个特性不需要任何配置,你要做的就是合理设计联合索引,让过滤条件下推到索引层。实操中我在 MySQL 5.7/8.0 环境下测过,对于筛选率高的查询,ICP 能带来 20%~50% 的查询性能提升。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢 SQL 排查:从 EXPLAIN 开始看懂执行计划
索引聊完了,接下来是排查慢 SQL 的重头戏。每次线上出现慢查询报警,我第一步永远是打开慢查询日志,找到那条 SQL,然后 EXPLAIN。EXPLAIN 输出结果里那些字段,能让你一眼看出 MySQL 是打算怎么执行这条 SQL 的。而大多数刚入门的人,EXPLAIN 打开扫一眼 type 是 ALL,就慌神了,根本不知道下一步该看什么。这里我按我的观察顺序给大家捋一遍。
2.1 EXPLAIN 的 key 字段里藏着最重要的信号
type:访问类型。从好到坏大致是 system > const > eq_ref > ref > range > index > ALL。你重点关注ref、range这种,说明索引用上了;要是ALL,说明 MySQL 在扫全表,这是最大的警报。key:实际用到的索引。如果这里显示是 NULL,说明没走索引。rows:预估扫描的行数。这个值越小越好。很多人只看 type 和 key,却忽略 rows —— 其实 rows 能帮你判断索引的区分度好不好。比如一个查询走了索引,但 rows 显示 50 万行,说明这个索引选择率太差,优化的空间还是有的。Extra:额外信息。看到Using filesort说明排序没走索引,Using temporary说明用了临时表,Using index是好事,说明覆盖索引生效了。
拿我之前排查过的一个线上问题举例。有个运营后台的列表页,每次打开要等 3 秒多,卡得很明显。EXPLAIN 一看,查询语句是这样的:
sql复制SELECT * FROM user_order
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20
执行计划里 type 是 range,理论上走了索引,但 Extra 里有 Using filesort,说明排序列 create_time 没有利用上索引的有序性,MySQL 要把 status=1 的所有记录先捞出来,再在内存或磁盘里做一次排序,再取 20 条。表里 status=1 的数据有百万级,慢就是慢在这里。
我的解决方案是加了一个复合索引 (status, create_time),这样 B+ 树里同一个 status 下的记录天然就按 create_time 排好序了,排序步骤直接消失,查询时间从 3 秒降到了 100 毫秒以内。这类问题的思路可以总结成一句话:SQL 里有 WHERE + ORDER BY 的组合,就让 WHERE 等值条件的字段放在复合索引前面,ORDER BY 的字段紧跟其后,让索引直接输出有序结果,绕开 filesort。
2.2 深分页与 LIMIT 的优化:为什么越往后翻越慢
很多业务系统都踩过这个坑:列表第一页秒开,翻到第 100 页就像老牛拉破车。SQL 长这样:
sql复制SELECT * FROM order_list ORDER BY id LIMIT 100000, 20
MySQL 处理 offset 很大的 limit 时,会把前面 100000 条数据全部查出来,然后丢掉,只返回最后 20 条。扫描 10 万条索引记录再去回表,不慢才怪。优化的思路有几种,我逐个说说我的实测感受。
方案一:延迟关联(延迟 join)。先只查主键,再通过主键关联回原表取完整数据:
sql复制SELECT o.*
FROM order_list o
JOIN (SELECT id FROM order_list ORDER BY id LIMIT 100000, 20) t
ON o.id = t.id
这个方案对 MySQL 5.7 及以下版本很管用,子查询里走的是覆盖索引,只取主键 id,不用回表,数据量小的多,然后再 join 回原表取其他字段。我在一张 1000 万行的表上测过,翻到第 50 万页时,这个写法的耗时大概是直接 LIMIT 的十分之一。
方案二:记住上一页的最大 id。这是我最推荐的做法,尤其适合 App 那种下拉加载更多的场景。
sql复制-- 第一页拿到的最后一条记录的 id 是 12345
SELECT * FROM order_list
WHERE id < 12345
ORDER BY id DESC
LIMIT 20
这个方案本质上把"翻页"变成了"按 id 范围向后取记录",每次查询都走主键索引的范围扫描,速度非常稳定。不过它有一个限制:只适合按 id 或某个单调递增字段排序的场景,如果业务需要按金额排序翻页,就没办法用这种写法了。
2.3 count(*) 到底怎么优化
还有一个高频问题:count() 太慢。在 InnoDB 里,因为 MVCC 多版本并发控制的存在,count() 要逐行读取并判断当前事务可见性,所以它是不能像 MyISAM 那样直接"数字"的。大表上跑 count(*) 就是纯扫描,怎么优化?
我建议分场景看。如果只是需要一个精确总数,而且表特别大,那没有银弹,只能考虑用 Redis 维护计数,或者建一张统计表。如果业务上允许近似值,比如后台列表页显示一个"约 10 万条",那可以用 SHOW TABLE STATUS 里的 rows 字段,这个值是采样估算的,不精确但速度极快,展示用足够了。
至于网上很流行的"用 count(1) 代替 count()"的说法,其实是个历史误区。现代 MySQL(5.7+)里 count() 和 count(1) 的性能差异可以忽略不计。你真正要注意的,是别写成 count(某个可空列),那会额外判断非空,反而慢。
2.4 优化器选错索引:force index 和索引提示怎么用
前面讲的都是"为什么没走索引",还有一种特殊情况是"走了索引但走错了"。MySQL 的优化器是基于统计信息估计行数来选择索引的,当统计信息不准,或者表数据分布严重倾斜时,完全可能选出一个次优索引,导致查询慢得离谱。
遇到这种情况,该怎么判断是选错索引而不是别的问题?最直接的办法是去掉某个索引对比一下,或者用 FORCE INDEX 强制走你预期的索引,看耗时是否有明显改善。如果确实改善显著,说明优化器判断失误了。
实操里我一般先用 ANALYZE TABLE 刷新统计信息,因为很多时候统计信息更新不及时。如果刷新后还是选错,再考虑在 SQL 里用 FORCE INDEX。但这里有个经验之谈:FORCE INDEX 是写死在 SQL 里的,一旦业务数据分布变了,这个强制可能就不合适了。所以能用统计信息修正就用统计信息,FORCE INDEX 只做为临时兜底,或者在代码里通过开关控制。
3. 分库分表:什么时候该做,怎么做才不后悔
分库分表这个话题网上讨论得非常多,但说实话,大部分中小团队根本走不到这一步。我见过太多例子,业务量根本没上来,就因为"听说分库分表是趋势"先上了中间件,结果引入了一堆分布式事务、跨库 join、分布式 ID 的复杂性,开发效率直线下降。所以这一章我重点讲两件事:怎么判断该不该分库分表,以及真到了那天,设计上要注意哪些坑。
3.1 先分清垂直拆分和水平拆分,别混为一谈
很多人把分库分表笼统地说成一个概念,其实它分两条路:
- 垂直拆分:把不同业务的表拆到不同的库里。比如把用户表、订单表、商品表分别放到独立的库。本质上是"按业务边界隔离",降低单库的连接数和磁盘 IO 压力。这个方向实施相对简单,但它的价值很有限,因为单表数据量大的问题依然没解决。
- 水平拆分:把同一张表的数据按某种规则分散到多个库的多个表里,每个库/表只存一部分数据。比如订单表按用户 id 的 hash 值分到 16 张表,每张表只有原来的 1/16 数据量。这才是解决单表数据量过大的根本手段。
我的判断标准是先看单表数据量。如果单表行数已经超过 2000 万,并且还在快速增长,索引优化、SQL 优化都试过了依然顶不住,这时候再开始考虑水平分表。2000 万这个数字不是绝对的,和行的宽度、存储引擎、硬件环境都有关,但算是个经验门槛。如果数据量还在几百万的程度就开始分表,基本属于给自己找麻烦。
3.2 分片键选不好,整个架构后面全完蛋
水平拆分最关键的一步就是选分片键,这是整个方案唯一"定了就改不动"的决策。分片键选择的核心目标就两个:数据分布均匀,并且能覆盖最主要的查询路径。
以订单表为例,最典型的查询路径是什么?用户查自己的订单列表:WHERE user_id = 456。所以用 user_id 作为分片键是首选,它天然按用户维度做隔离,每个用户的订单都落在一个固定的分片上,单用户的查询不需要跨分片,效率最高。
但电商场景里还有一个更常见的需求:后台运营要查某个商家某个时间段内的订单,条件里没有 user_id,只有 seller_id,这时候如果只按 user_id 分片,这个查询就必须广播到所有分片上去查再聚合,一次查询变成几十次查询,性能可想而知。怎么权衡?常见的做法是建一张冗余表,把订单按 seller_id 再同步一份到另一组分片里,相当于"数据双写、双分片";或者引入搜索引擎/宽表来支撑后台复杂查询,线上订单库只服务用户的 C 端查询。这是很现实的做法,很多大厂的分库分表方案背后都有一套"离线同步 + 宽表查询"的组合方案。
至于具体的分片算法,简单点的直接 hash(比如 user_id % 16),均匀是均匀,但扩展表数量时要迁数据,痛苦。所以现在更推荐用一个中间映射表记录某个用户的订单落在哪个分片,或者用一致性哈希的改进方案。不过对多数业务来说,坚持合理的初始分片数(留足未来 3 倍的余量),比花哨的算法更重要。
3.3 分布式 ID:别用自增 id,也别用 UUID
分库分表后,自增主键没法用了,因为多张表的 id 会重复。这时候需要一个全局唯一的分布式 ID 生成方案。网上方案很多,我逐个说说我的取舍。
- UUID:生成简单,全局唯一,但字符串太长,作为主键会让 B+ 树索引变得巨大,而且无序插入会导致页分裂,性能很差。除非场景特殊,否则我不推荐用 UUID 做主键。
- Snowflake(雪花算法):目前最主流的方案,一个 64 位的 long,包含时间戳、机器 id、序列号。趋势递增、全局唯一,而且生成速度极快,完全在应用层实现,不需要额外的组件。如果你们用 Java 技术栈,可以用 mybatis-plus 自带的 ID_WORKER。
- 号段模式:数据库生成 id 但采用"批量取号"的方式,比如一次从库里取 1000 个号段,应用内存里逐个发,发完了再取。这个方案对已有系统改造比较友好,但需要维护一个取号表。
我的建议是:新系统直接用雪花算法,运维成本最低。旧系统改造的话,可以看看号段模式,兼容性更好。
3.4 分库分表后的跨节点查询和事务:接受现实,但别硬扛
这是分库分表最劝退人的部分。分完库后,原本一个 join 搞定的事,现在分布在多个库里,没法直接 join 了。事务也是同理,原本本地事务的一致性保证,现在需要分布式事务来维护,成本高出一大截。
我的经验是:不要试图在一个分片架构里做完整的分布式事务,而是通过业务设计把跨分片的操作降到最少。 比如订单创建和库存扣减,你可以在同一个用户分片内操作,保证本地事务即可;跨分片的场景,用最终一致性方案(消息队列 + 对账补偿)远比强事务方案可维护。
跨 join 的处理思路之一是"冗余字段"。比如订单表里冗余一个用户昵称字段,这样查订单列表时不需要再 join 用户表。或者做宽表,把查询时需要的所有字段都冗余进来,查询就直接查宽表,不回原表。这个思路跟前面的"搜索引擎方案"类似,本质上是空间换时间,也是目前大规模分库分表场景下最务实的解。
3.5 迁移与双写:平滑拆分的心跳过程
即便前面全都想好了,从单库迁移到分库分表也是一件不能停机上线的活。常用的步骤我可以列一条主线。
- 第一步,搭建分库分表环境,中间件(ShardingSphere、MyCat)路由规则配置好。
- 第二步,存量数据双写。应用层改造为写单库的同时同步写一份到分库分表环境,老数据保留。
- 第三步,跑一个数据迁移任务,把老库的历史数据按分片规则,分批灌到分库分表的各个表里。
- 第四步,校验数据一致性,两边对账,比如记录总数、金额汇总、抽样明细比对。
- 第五步,逐步切读。先从预热缓存开始,再灰度一小部分流量到新库,观察慢查询、报错、数据状况,最后全部切过去。
整个过程最耗时的往往是第三步和第四步,如果数据量大到几十亿行,迁移脚本要断点续传,校验也要做成增量对账。这没有银弹,就是慢工出细活。
4. 实战中比优化本身更重要的几件事
技术方案讲了一堆,最后我想聊聊这几年在实战里比术更重要的几件事。这些在很多书里不会写,但直接影响你优化方案的成败。
4.1 监控要前置,别等慢查询发生了才去优化
优化不应该是救火,而应该是日常巡检。我所在的团队现在的做法是:每张线上核心表,都要有慢查询监控看板,阈值设为 200ms,超过就告警;每周 DBA 汇总一次 TOP 慢 SQL,发到技术周会上讨论,看是索引问题还是 SQL 写法问题。很多潜在的性能问题,都通过这种机制提前消掉了。
另一个容易被忽略的指标是"全表扫描次数"。MySQL 的 performance_schema 和 sys.schema_table_statistics 都能查到哪些表被频繁全表扫描。这些表才是你优化优先级最高的表,而不一定是"最大的表"。
4.2 优化时先确认这三层,再动手改
我给新人定的一个排查心法是:遇到慢查询,先按下面这个顺序排查,别跳步,也别一上来就改索引。
- 第一层:是不是表结构设计有问题?比如字段类型不合理、缺索引或者索引设计不合理。
- 第二层:是不是 SQL 写法本身有问题?比如函数包裹导致索引失效、隐式类型转换、深分页、N+1 查询。
- 第三层:是不是数据量真的到了量级瓶颈?比如单表 5000 万行,索引和 SQL 都优化到位了,发现还是慢,这时候才考虑要不要分库分表。
这三层一层层排查下来,90% 的问题都停在第一、二层。如果直接跳到第三层,大概率是在给系统造复杂度。
4.3 小技巧:EXPLAIN 之后别忘了看 WARNINGS
最后分享一个很多人都不知道的小技巧。EXPLAIN 执行完之后,可以再用一句 SHOW WARNINGS,MySQL 会告诉你优化器内部把这条 SQL 重写成了什么样子。比如它会把某些子查询改写为 join、把 IN 转为 EXISTS,你能看到优化器真正的意图。有时候你发现自己的 SQL 写得不理想,但优化器本身已经帮你改写了一遍,看了 WARNINGS 你就知道实际跑的到底是什么,排查问题眼界的开阔程度完全不一样。
我印象最深的一次,是一条子查询的 SQL 性能极不稳定,时快时慢。EXPLAIN 看起来一切正常,但 SHOW WARNINGS 里发现优化器在某种情况下把 IN 子查询改写成了依赖外部行的 DEPENDENT SUBQUERY,相当于每查一行子查询就执行一次,行数一多就崩。后来我把它改写成了显式的 JOIN,性能立刻稳定了。这种案例用普通思路根本排查不到,所以每次分析完都要养成看 SHOW WARNINGS 的习惯。
4.4 关于 SQL 规范,团队里最好有份 Cheat Sheet
优化这种事情,靠一个人盯是盯不过来的,最好在团队里沉淀一份 SQL 规范。我简单列一下我们团队里最常见的几条红线,给各位做个参考:
- 禁止对索引列使用函数或隐式转换。
- 禁止
SELECT *,特别是高频查询必须映射明确字段。 - 禁止在没有索引的列上做
ORDER BY或GROUP BY。 - 多表 JOIN 的表数控制在 3 张以内,且关联字段必须建索引。
- 大表的
IN列表不要超过 500 个,否则拆成多次查询或改 join。 - 禁止对大表直接
COUNT(*),要走统计表或缓存。
这些规范不是死板约束,每一条背后都对应着某个真实事故。让新人先背下来,踩过几次坑之后再理解,效率会高很多。我自己也一度觉得规范束缚手脚,直到亲手把一个全表扫描的 SQL 从 8 秒优化到 30 毫秒之后,才意识到规范本质上是"前人踩坑的浓缩",别白费前人的经验。
