写在前面
我们团队这几年从零到一搭建了一个日活千万级的集成自动化平台,数据库层面踩过的坑数不胜数。MySQL 性能问题是最常遇到的——一个慢 SQL 能把整个服务拖垮,连锁反应下游超时、上游重试、数据库连接池爆满,最后全站不可用。
这篇文章不打算写成"MySQL 优化大全"那种面面俱到的教材。我只讲我们真实遇到过的——有些坑看着简单,但在生产环境里真真切切地引发过事故。
希望这些经验能帮你在 Code Review 或者线上排查时,多一些判断依据。
一、索引基础:先搞清楚 B+Tree 在干什么
1.1 为什么是 B+Tree?
MySQL(InnoDB 引擎)的索引底层结构是 B+Tree。很多人知道这个结论,但不知道为什么。
先来看一个对比:
| 数据结构 | 查找时间复杂度 | 磁盘IO次数 | 适合场景 |
|---|---|---|---|
| 哈希表 | O(1) | 1次 | 等值查询 |
| 二叉搜索树 | O(logN) | O(logN)次 | 内存操作 |
| B-Tree | O(logN) | O(logN)次,但N很大 | 磁盘存储 |
| B+Tree | O(logN) | 2-4次(通常) | 磁盘存储+范围查询 |
数据库面对的核心挑战是:数据在磁盘上,而磁盘的随机IO极慢。
一块机械硬盘的随机读取延迟是 5-10ms,SSD 大概是 0.1ms。看起来 SSD 已经很快了,但对比内存的 100ns(0.0001ms),仍然慢了 1000 倍。
所以数据库索引的核心设计目标是:尽量减少磁盘IO次数。
B+Tree 怎么做到的?
- 每个节点存大量Key:一个节点是一个磁盘页(通常 16KB),能存几百到上千个 Key。这意味着树的高度极低——千万级数据,树高通常只有 3-4 层
- 叶子节点有序链表:范围查询时只需要找到起点,然后顺着链表往后读就行,不用回溯
- 非叶子节点不存数据:只存 Key 和指针,能容纳更多分叉,进一步降低树高
一个直观的数字:
假设每行数据 1KB,主键是 bigint(8字节),指针 6字节。一个 16KB 的内部节点能容纳约 16KB / (8+6) ≈1170个分叉。
- 2层:1170 × 16 ≈ 18,720 行
- 3层:1170 × 1170 × 16 ≈2190万行
- 4层:约 250 亿行
也就是说,两千万行数据,只需要3次磁盘IO就能定位到任何一行。这就是 B+Tree 的威力。
1.2 聚簇索引 vs 二级索引
InnoDB 里有两种索引:
聚簇索引(主键索引):
- 叶子节点直接存储完整的行数据
- 每张表只能有一个
- 数据物理上就是按主键顺序存储的
二级索引(非主键索引):
- 叶子节点存储的是索引列的值 + 主键值
- 查到主键值后,还需要"回表"——拿着主键去聚簇索引再查一次
二级索引查找过程: idx_name(name) → 找到 name='张三' → 得到主键 id=1001 ↓ 聚簇索引 → 用 id=1001 查聚簇索引 → 得到完整行数据这个"回表"操作是很多性能问题的根源——如果二级索引筛选出 1 万行,就需要回表 1 万次。每次回表都可能是一次随机IO。
1.3 覆盖索引:干掉回表
如果你的查询只需要索引列本身包含的字段,MySQL 就不需要回表了——直接从索引里就能拿到所有数据。这叫覆盖索引。
-- 假设有索引 idx_name_age(name, age)-- 需要回表(要取address字段,索引里没有)SELECTname,age,addressFROMuserWHEREname='张三';-- 覆盖索引(只查索引里有的字段)SELECTname,ageFROMuserWHEREname='张三';-- EXPLAIN 的 Extra 列会显示 "Using index"1.4 联合索引与最左前缀
联合索引(a, b, c)本质上是先按 a 排序,a 相同再按 b 排序,b 相同再按 c 排序。
这决定了最左前缀原则:
-- 能用上索引(从最左开始连续匹配)WHEREa=1WHEREa=1ANDb=2WHEREa=1ANDb=2ANDc=3-- 用不上索引(跳过了a)WHEREb=2WHEREb=2ANDc=3-- 部分用上(用到a,b的范围查询后c用不上)WHEREa=1ANDb>5ANDc=3-- 只用到 a, b二、慢 SQL 的物理层面原因
在讲索引优化之前,先聊一下不是索引问题的慢 SQL。因为很多时候第一反应是"加索引!",但实际问题压根不在索引上。
2.1 机械盘 → SSD:一次立竿见影的提速
我们早期的数据库跑在机械盘上。当时有一批复杂查询(涉及多表 JOIN + 排序),响应时间在 800ms-2s 之间波动。
排查了很久——索引都有,执行计划看着也合理,就是慢。
最后发现瓶颈在磁盘随机IO。机械盘的随机 IOPS 大概在 150-200,而我们那几个查询涉及大量回表操作(二级索引 → 聚簇索引),每次回表都是一次随机读。当并发上来后,磁盘的 IO 队列堆积,延迟直接飙升。
换 SSD 后:
- 随机 IOPS 从 200 提升到 50000+
- 那批查询从 800ms 降到 50-80ms
- 数据库服务器的 IO Wait 从 40% 降到 2%
这不是优化,是物理层面的代际差距。如果你的 MySQL 还跑在机械盘上,其他优化都是细枝末节——先换盘。
2.2 跨机房访问:一个被忽视的延迟来源
另一个真实案例:我们有段时间应用服务器和数据库不在同一个物理机房。
表面上看"都在一个城市",网络延迟应该很低。但实测下来:
- 同机房内网延迟:0.1-0.3ms
- 跨机房专线延迟:1-3ms
看起来差距不大?算一笔账:
一个业务请求涉及 20 次数据库查询(这在 OLTP 场景里很正常),每次查询的 MySQL 执行时间只有 1ms。
- 同机房总耗时:20 × (0.2 + 1) =24ms
- 跨机房总耗时:20 × (2 + 1) =60ms
仅网络延迟就多了 36ms。如果并发高一点、查询多一点,差距会更加明显。
更隐蔽的问题是——跨机房网络的抖动。专线偶尔会有 5-10ms 的毛刺,在同机房场景下几乎不存在。一旦出现网络毛刺,结合连接池等待、慢查询堆积,很容易形成雪崩。
解决方案:把数据库和应用服务器迁到同一物理机房。迁完之后接口 P99 延迟直降 40%,什么代码都没改。
2.3 CPU 资源紧张
还有一类慢 SQL 不是IO问题,是CPU问题。常见场景:
- 大量的
ORDER BY+ 文件排序(filesort) - 复杂的子查询、嵌套查询
- 大字段的 JSON 解析(MySQL 5.7+ 的 JSON 类型操作)
- GROUP BY 产生临时表
这类问题的特征是:EXPLAIN看着没毛病,索引也用了,但就是慢。SHOW PROCESSLIST一看,State 是Sorting result或Creating tmp table。
遇到这种情况,首先top看一下 MySQL 进程的 CPU 占用。如果已经跑满几个核了,那瓶颈就是算力,加索引救不了。
三、慢 SQL 的逻辑层面原因(真实踩坑)
下面是重头戏——我们在业务开发中真实遇到的逻辑层面慢 SQL 问题。
3.1 索引失效:函数转换杀手
事故场景:
我们有一个"商品处罚记录"表,punish_time字段有索引。业务需要查询某一天的处罚记录。
开发同学写了这样的 SQL:
-- 错误写法 ❌SELECT*FROMproduct_punish_recordWHEREDATE_FORMAT(punish_time,'%Y-%m-%d')='2024-01-15';这条 SQL 在数据量小的时候毫无问题,测试环境 100ms 跑完。但到了线上,表有 800 万行——这条查询直接走了全表扫描,耗时 8 秒。
为什么索引失效了?
B+Tree 索引存储的是原始列值。当你对索引列套了函数(如DATE_FORMAT),MySQL 必须对每一行数据都计算一次DATE_FORMAT(punish_time, '%Y-%m-%d'),然后跟'2024-01-15'比较。它没法利用索引的有序性来快速定位了——因为DATE_FORMAT的结果和原始值的排列顺序虽然一致,但 MySQL 优化器不够聪明,无法推导出这种等价关系。
正确写法:
-- 正确写法 ✅ 用范围查询代替函数转换SELECT*FROMproduct_punish_recordWHEREpunish_time>='2024-01-15 00:00:00'ANDpunish_time<'2024-01-16 00:00:00';这样punish_time列没有被函数包裹,MySQL 可以直接用索引做范围扫描,从 8 秒降到10ms。
其他常见的索引杀手函数:
-- 全部会导致索引失效 ❌WHEREYEAR(create_time)=2024WHERELEFT(name,3)='张三丰'WHERECAST(priceASCHAR)='99.9'WHEREIFNULL(status,0)=1WHEREamount+10>100-- 列参与了运算也不行-- 正确的替代方案 ✅WHEREcreate_time>='2024-01-01'ANDcreate_time<'2025-01-01'WHEREnameLIKE'张三丰%'WHEREprice=99.9WHEREstatus=1-- 业务层保证不插NULL,或用默认值WHEREamount>90一条铁律:索引列必须"裸着"出现在比较运算符的一边,不能被任何函数或表达式包裹。
3.2 N+1 查询:最常见的性能杀手
事故场景:
业务需求:查询某个商家下所有商品的处罚记录,进行违规分析。
第一版代码(简化):
// 错误写法 ❌ 典型的 N+1 查询List<Product>products=productDao.listByMerchantId(merchantId);// 假设有 500 个商品for(Productproduct:products){// 每个商品查一次处罚记录 → 500 次SQL!List<PunishRecord>records=punishDao.listByProductId(product.getId());analyze(product,records);}500 个商品 = 500 次 SQL 查询。即使每条 SQL 只要 5ms,总耗时也是 2.5 秒。加上网络开销和连接池等待,实际更慢。
更致命的是——这 500 条 SQL 各自独立,MySQL 的 Buffer Pool 缓存命中率很低(因为每次查的范围不同),磁盘 IO 被打散成大量小碎片。
正确写法:
// 正确写法 ✅ 合并为一次批量查询List<Product>products=productDao.listByMerchantId(merchantId);List<Long>productIds=products.stream().map(Product::getId).collect(Collectors.toList());// 一次性查出所有商品的处罚记录List<PunishRecord>allRecords=punishDao.listByProductIds(productIds);// 在内存中按商品ID分组Map<Long,List<PunishRecord>>recordMap=allRecords.stream().collect(Collectors.groupingBy(PunishRecord::getProductId));// 在内存中做关联分析for(Productproduct:products){List<PunishRecord>records=recordMap.getOrDefault(product.getId(),Collections.emptyList());analyze(product,records);}对应的 SQL:
-- 替代 500 次单条查询,1次搞定SELECT*FROMproduct_punish_recordWHEREproduct_idIN(1001,1002,1003,...,1500);效果:从 500 次 SQL → 1 次 SQL,总耗时从 2.5s 降到50ms。
进阶优化:如果productIds列表特别大(比如上万个),IN 子句过长也会有问题。这时可以:
// 分批查询,每批 500 个Lists.partition(productIds,500).forEach(batch->{List<PunishRecord>batchRecords=punishDao.listByProductIds(batch);allRecords.addAll(batchRecords);});思维方式的转变:
| 思维方式 | 代码特征 | 性能特征 |
|---|---|---|
| “逐条处理” | 循环里发SQL | N+1,线性增长 |
| “批量思维” | 先收集ID,再一次查,内存分组 | O(1) 次SQL,常量耗时 |
这是后端开发者最容易犯、也最容易修的性能问题。Code Review 时只要看到循环里有数据库调用,基本就是要改的。
3.3 事务锁争用:分布式锁化解热点行
事故场景:
商品违规扣分功能——当检测到商品违规时,需要给商家扣分。多个违规事件可能同时触发,对同一个商家并发扣分。
第一版实现:
// 错误写法 ❌ 数据库行锁 + 长事务@TransactionalpublicvoiddeductScore(LongmerchantId,intpoints,Stringreason){// SELECT ... FOR UPDATE 锁住商家那一行MerchantScorescore=merchantScoreDao.selectForUpdate(merchantId);// 各种业务校验...validateDeduction(score,points);// 调用外部服务(比如发通知)—— 这里可能耗时 200ms+notificationService.notifyMerchant(merchantId,reason);// 更新扣分score.setCurrentScore(score.getCurrentScore()-points);merchantScoreDao.update(score);// 记录扣分流水deductionLogDao.insert(buildLog(merchantId,points,reason));}问题在哪?
SELECT ... FOR UPDATE锁住了商家行- 事务里调了外部服务(200ms+),锁持有时间太长
- 并发场景下,多个线程抢同一个商家的行锁 → 串行执行 → 接口超时
当某个大商家有 50 个商品同时被检测到违规,50 个请求串行等锁,最后的那个要等 50 × 200ms =10秒。
正确做法:用分布式锁替代数据库行锁,并缩短锁粒度:
// 正确写法 ✅ 分布式锁 + 最小化锁范围publicvoiddeductScore(LongmerchantId,intpoints,Stringreason){// 前置操作(不需要加锁的)validateBasicParams(merchantId,points);// 分布式锁:只锁"扣分"这个动作StringlockKey="merchant:deduct:"+merchantId;booleanlocked=redisLock.tryLock(lockKey,3,TimeUnit.SECONDS);if(!locked){thrownewBusinessException("操作太频繁,请稍后再试");}try{// 最小化锁内操作:只做读-改-写MerchantScorescore=merchantScoreDao.selectById(merchantId);validateDeduction(score,points);score.setCurrentScore(score.getCurrentScore()-points);merchantScoreDao.update(score);deductionLogDao.insert(buildLog(merchantId,points,reason));}finally{redisLock.unlock(lockKey);}// 通知等耗时操作放在锁外面(异步更好)asyncNotificationService.notifyMerchant(merchantId,reason);}改进点:
| 对比项 | 改前(DB行锁) | 改后(分布式锁) |
|---|---|---|
| 锁粒度 | 数据库行级 | 业务逻辑级 |
| 锁持有时间 | 整个事务(含外部调用 200ms+) | 仅读-改-写(10-20ms) |
| 并发表现 | 串行等待,阻塞连接池 | 快速失败或短暂等待 |
| 故障影响 | 死锁可能导致事务超时 | 最多等待 3s 超时 |
关键原则:
- 锁的范围尽量小(只锁必须互斥的操作)
- 锁内不要有 IO 调用(RPC、消息发送、文件操作)
- 能异步的操作放到锁外面
- 分布式锁比数据库锁更可控(可以设超时、可以主动释放、不占数据库连接)
四、索引设计的实战原则
基于上面这些坑,总结几条我们实际在用的索引设计原则:
4.1 建索引前先看查询模式
不要拿到表就开始建索引,先搞清楚:
-- 查看哪些SQL最慢SELECT*FROMperformance_schema.events_statements_summary_by_digestORDERBYSUM_TIMER_WAITDESCLIMIT20;-- 或者直接看慢查询日志-- slow_query_log = ON, long_query_time = 1先知道最耗时的 SQL 是什么,再针对性地建索引。盲目建索引只会拖慢写入。
4.2 联合索引的列顺序
联合索引的列顺序直接决定了它能服务哪些查询。设计原则:
- 等值条件的列放前面
- 范围条件的列放后面
- 区分度高的列放前面(选择性 = 不重复值数量 / 总行数)
-- 典型查询:查某商家某时间段的处罚记录SELECT*FROMproduct_punish_recordWHEREmerchant_id=10086ANDpunish_time>='2024-01-01'ANDpunish_time<'2024-02-01';-- 正确的联合索引CREATEINDEXidx_merchant_timeONproduct_punish_record(merchant_id,punish_time);-- merchant_id 是等值条件(放前面),punish_time 是范围条件(放后面)如果反过来建(punish_time, merchant_id),那punish_time做范围查询后,merchant_id的索引就用不上了。
4.3 避免过度索引
每个索引都有代价:
- 写入变慢:每次 INSERT/UPDATE 都要维护索引的 B+Tree
- 占用空间:索引本身也是数据
- 优化器困惑:索引太多,MySQL 优化器可能选错索引
我们的经验法则:
- 单表索引不超过 5-6 个
- 联合索引的列不超过 4-5 个
- 不对低区分度的列单独建索引(比如
status只有 0/1 两个值) - 定期清理不用的索引
-- 查看哪些索引从没被用过SELECT*FROMsys.schema_unused_indexesWHEREobject_schema='your_database';4.4 大表加索引的正确姿势
线上千万级大表加索引,直接ALTER TABLE ... ADD INDEX可能会锁表几分钟。
我们的做法:
-- MySQL 5.6+ 支持 Online DDLALTERTABLEbig_tableADDINDEXidx_name(name),ALGORITHM=INPLACE,LOCK=NONE;如果是更早的版本或者更保守的策略,用pt-online-schema-change:
pt-online-schema-change\--alter"ADD INDEX idx_name(name)"\--execute\D=your_db,t=big_table五、EXPLAIN 实战:读懂执行计划
会看EXPLAIN是 SQL 优化的基本功。核心关注几个字段:
5.1 type 列(访问类型)
从好到差排列:
system > const > eq_ref > ref > range > index > ALL| type | 含义 | 是否可接受 |
|---|---|---|
| const | 主键/唯一索引等值查询 | 最好 |
| eq_ref | JOIN 时使用主键/唯一索引 | 很好 |
| ref | 使用非唯一索引等值查询 | 好 |
| range | 索引范围扫描 | 还行 |
| index | 全索引扫描 | 一般 |
| ALL | 全表扫描 | 大表必须优化 |
5.2 Extra 列的关键信息
Using index → 覆盖索引,好 Using where → Server层过滤,索引可能没完全匹配 Using temporary → 用了临时表,GROUP BY/DISTINCT 需要关注 Using filesort → 文件排序,ORDER BY 没用上索引 Using index condition → 索引下推(5.6+),还行5.3 一个真实的 EXPLAIN 分析
EXPLAINSELECTproduct_id,punish_time,reasonFROMproduct_punish_recordWHEREmerchant_id=10086ANDDATE_FORMAT(punish_time,'%Y-%m')='2024-01'ORDERBYpunish_timeDESCLIMIT20;+----+-------+------+--------+------+---------+------+----------+-----------------------------+ | id | type | key | rows | filtered | Extra | +----+-------+------+--------+----------+-------------------------------+ | 1 | ALL | NULL | 823910 | 10.00 | Using where; Using filesort | +----+-------+------+--------+----------+-------------------------------+type = ALL:全表扫描,灾难key = NULL:没用上任何索引rows = 823910:扫描了 82 万行Extra = Using filesort:还做了文件排序
修复:去掉 DATE_FORMAT,改用范围查询:
EXPLAINSELECTproduct_id,punish_time,reasonFROMproduct_punish_recordWHEREmerchant_id=10086ANDpunish_time>='2024-01-01'ANDpunish_time<'2024-02-01'ORDERBYpunish_timeDESCLIMIT20;+----+-------+---------------------+------+----------+-----------------------+ | id | type | key | rows | filtered | Extra | +----+-------+---------------------+------+----------+-----------------------+ | 1 | range | idx_merchant_time | 156 | 100.00 | Using index condition | +----+-------+---------------------+------+----------+-----------------------+type = range:范围索引扫描rows = 156:只扫描了 156 行- 没有 filesort了(因为联合索引
(merchant_id, punish_time)天然有序)
从 82 万行 → 156 行,性能提升5000 倍。
六、我们的慢 SQL 治理体系
解决单个慢 SQL 不难,难的是系统性地防止慢 SQL 出现。我们建立了一套治理流程:
6.1 事前:开发阶段拦截
代码提交 → SQL审计(自动检查) → Code Review(人工确认)自动检查规则:
- 禁止
SELECT *(强制列出字段) - 禁止无 WHERE 的 UPDATE/DELETE
- 索引列禁止使用函数(正则匹配
WHERE.*函数名(列名)) - IN 子句限制不超过 1000 个
6.2 事中:慢查询实时告警
慢查询日志 → 采集到监控系统 → 超过阈值告警阈值设置:
- 单条 SQL > 1s:日报汇总,标黄
- 单条 SQL > 3s:实时告警,当天处理
- 单条 SQL > 10s:紧急告警,立即处理
6.3 事后:定期巡检
每周跑一次"索引健康检查":
- 未使用的索引 → 评估后删除
- 重复索引 → 合并
- 全表扫描频次最高的 SQL → 分析加索引
- 锁等待超时 TOP10 → 分析事务设计
总结
MySQL 慢 SQL 的治理,说到底就是搞清楚三件事:
- 瓶颈在哪:是磁盘IO?网络延迟?CPU?还是锁等待?→ 对应不同的解法
- 索引为什么没生效:函数包裹?类型不匹配?最左前缀断裂?→ 让索引列"裸"出来
- 设计模式对不对:N+1 查询?长事务?大表无分页?→ 用批量思维、锁粒度最小化
说实话,90% 的慢 SQL 都不复杂——要么是索引列套了函数,要么是循环里发SQL,要么是事务范围太大。真正的难点不在解决,在发现。
建好监控、写好规范、Code Review 时多看一眼 SQL——能避免绝大多数的线上事故。