MySQL 数据库性能优化实战
生产环境的性能问题,十有八九出在 SQL 和索引上。本文以七个最常见的性能问题为切入点,每个问题都讲清楚"为什么慢、怎么改、改完怎么验证"。
一、插入数据优化:批量写入的正确姿势
1. 问题场景
凌晨数据同步任务,500 万条数据插了 4 个小时还没跑完,还拖垮了线上其他业务。排查发现:代码里用的是 for 循环单条 INSERT。
2. 优化方案
方案一:批量插入替代单条插入
-- ❌ 慢:每条一次网络往返 + 一次事务提交
INSERT INTO orders (user_id, amount) VALUES (1001, 99.00);
INSERT INTO orders (user_id, amount) VALUES (1002, 59.00);
INSERT INTO orders (user_id, amount) VALUES (1003, 79.00);
-- ✅ 快:一次网络往返搞定
INSERT INTO orders (user_id, amount) VALUES
(1001, 99.00),
(1002, 59.00),
(1003, 79.00);原理:单条插入的开销大头不在执行本身,而在网络往返、SQL 解析、事务提交日志刷盘。批量插入把这些固定成本摊薄到每一行上。建议每批 500~1000 条,批太大反而会导致单条 SQL 过大、占用内存高、失败重试成本高。
方案二:手动控制事务,禁用逐条自动提交
-- ❌ 默认 autocommit=1,每条 INSERT 都是独立事务,都要刷一次 redo log
SET autocommit = 1;
-- ✅ 一个事务包裹一批插入,只刷一次盘
SET autocommit = 0;
INSERT INTO orders ...;
INSERT INTO orders ...;
-- ...累积一批后
COMMIT;原理:每次 COMMIT 都伴随 redo log 的 fsync 刷盘,这是最贵的操作。把 N 条插入合并到一个事务里,刷盘次数从 N 次降为 1 次。
方案三:主键有序插入,减少页分裂
InnoDB 数据按主键顺序组织在 B+ 树的叶子页上。如果主键自增,新数据永远追加到最后一页,简单又高效;如果主键是乱序的(如 UUID),插入会随机落在中间某个页,页满了就要页分裂——把一个页拆成两个,数据搬家,既慢又产生碎片。导入数据前按主键排序,能显著提升导入速度。
方案四:大批量导入时关闭唯一性校验
-- 导入前关闭(仅当前会话有效)
SET unique_checks = 0;
-- ...执行大批量导入
SET unique_checks = 1;原理:每条带唯一索引的插入都要先检查"值是否已存在"。如果数据源本身已保证唯一,这个检查纯属浪费。注意:此操作有风险,仅限可信任的初始化导入场景。
方案五:LOAD DATA INFILE 文件直灌
LOAD DATA INFILE '/var/lib/mysql-files/orders.csv'
INTO TABLE orders
FIELDS TERMINATED BY ','
LINES TERMINATED BY '\n'
(user_id, amount, create_time);原理:LOAD DATA INFILE 跳过 SQL 解析层,直接按格式解析文件灌入引擎,是 MySQL 官方提供的最快导入方式,通常比批量 INSERT 快 5~20 倍。注意文件需放在 secure_file_priv 指定目录,客户端导入用 LOAD DATA LOCAL INFILE。
方案六:海量数据的终极手段——分库分表 / 分区表
单表数据量达到千万级以上、写入压力持续上升时,单机优化已到极限:
- 分区表:按时间范围分区(如订单表按月分区),写入只落到最新分区,历史分区查询也不受拖累。适合"数据有时间属性、定期归档"的场景。
- 分库分表:用 ShardingSphere、MyCat 等中间件按用户 ID 取模拆分,把写入压力分散到多个实例。适合写入量真正触及单机瓶颈的场景。
3. 性能对比(参考值,100 万行数据,普通 SSD 环境)
| 插入方式 | 耗时 | 相对 QPS |
|---|---|---|
| 单条 INSERT + autocommit | ~40 分钟 | ~400/s |
| 单条 INSERT + 手动事务 | ~8 分钟 | ~2000/s |
| 批量 INSERT(1000 条/批) | ~1 分钟 | ~16000/s |
| LOAD DATA INFILE | ~15 秒 | ~60000/s |
| LOAD DATA + 关闭唯一校验 | ~10 秒 | ~90000/s |
4. 验证方法
用 SHOW STATUS LIKE 'Innodb_rows_inserted' 观察插入速率,用慢查询日志确认无单条大事务超时。
二、主键优化:聚簇索引决定了主键的设计规矩
1. 先理解聚簇索引
InnoDB 的表数据本身就是一棵按主键组织的 B+ 树——主键索引的叶子节点直接存放整行数据,这叫聚簇索引。这意味着:主键长什么样,直接决定了数据在磁盘上的物理排布方式。主键设计错了,后面所有的查询和写入都会替它买单。
2. 主键设计的四条规矩
规矩一:优先使用自增主键
- 自增主键插入时永远追加到 B+ 树末尾,不引发页分裂,写入最快。
- 自增主键只需 8 字节(BIGINT),而所有二级索引的叶子节点都要存一份主键值——主键越短,所有索引越省空间。
规矩二:分布式 ID(UUID / 雪花 ID)的适配
分库分表场景下自增 ID 用不了,常见替代方案对比:
| 方案 | 优点 | 致命缺点 |
|---|---|---|
| UUID 字符串 | 全局唯一、无中心依赖 | 36 字符超长、完全无序,页分裂严重 |
| UUID 转 BINARY(16) | 体积减半 | 仍然无序 |
| 雪花 ID | 趋势递增、BIGINT 8 字节 | 依赖时钟,时钟回拨需特殊处理 |
结论:分布式场景优先选雪花 ID(趋势递增 + 体积小),避免直接存 UUID 字符串。
规矩三:永远不要更新主键
更新主键 = 在聚簇索引里"删除旧位置 + 插入新位置",数据要物理搬家,还要同步更新所有二级索引(因为二级索引存的是主键值)。一次主键更新的成本是普通字段更新的 N 倍。业务上需要变的"编号",请做成普通唯一索引字段,不要当主键。
规矩四:复合主键要谨慎
复合主键 (a, b) 意味着聚簇索引按两个字段组织,主键体积变大,且所有二级索引都要跟着携带这两个字段。除非业务确实需要(如多租户表的 (tenant_id, id)),否则单字段 BIGINT 主键永远是首选。
3. 反面案例:UUID 主键导致的索引膨胀
某系统用 VARCHAR(36) 的 UUID 做主键,表上有 4 个二级索引。500 万行数据时:
- 主键占用:36 字节 vs 自增 BIGINT 的 8 字节,单行主键多花 28 字节;
- 每个二级索引每行都要额外携带 36 字节主键 → 4 个索引多花
36×4=144字节/行; - 全表索引空间白白多出约
(28+144)×500万 ≈ 860MB; - 加上 UUID 无序插入导致的页分裂,页利用率约 70%,实际浪费超过 1.2GB,且插入 QPS 只有自增主键的约 1/3。
验证方法:SHOW TABLE STATUS 查看 Data_length 和 Index_length,对比改回自增主键前后的空间变化。
三、ORDER BY 优化:让排序走索引,而不是 filesort
1. 两种排序方式
MySQL 执行 ORDER BY 有两条路:
- Using index(索引排序):索引本身是有序的,按索引顺序读出来天然就是排好序的,零额外成本。
- Using filesort(文件排序):把数据捞出来在内存(不够就用磁盘临时文件)里重新排序。注意 filesort 不一定真的用文件,但一定是"额外排了一次序"。
filesort 内部又分两种算法:
- 双路排序(老算法):先读排序字段+主键排序,排完再回表取完整行。回表两次,IO 高。
- 单路排序(现代默认):一次性把查询需要的所有字段读进
sort_buffer,排完直接返回。省 IO 但更费内存——如果行太大撑爆 sort_buffer,会退化为磁盘排序,性能骤降。
2. 优化方案
方案一:让排序字段匹配索引顺序
-- 索引:idx_user_time (user_id, create_time)
-- ✅ Using index:过滤和排序都被索引覆盖
SELECT * FROM orders WHERE user_id = 1001 ORDER BY create_time DESC;
-- ❌ Using filesort:排序方向与索引顺序混乱(一个升一个降)
SELECT * FROM orders WHERE user_id = 1001 ORDER BY user_id DESC, create_time ASC;
-- ❌ Using filesort:排序字段不在索引里
SELECT * FROM orders WHERE user_id = 1001 ORDER BY amount;方案二:控制参与排序的数据量
先过滤再排序。排序 100 行和排序 100 万行完全是两个概念——确保 WHERE 条件能借助索引把结果集收窄。
方案三:合理调整 sort_buffer_size
-- 查看当前值(默认通常 256KB~2MB,注意这是每个连接一份!)
SHOW VARIABLES LIKE 'sort_buffer_size';
-- 会话级调大(不要盲目全局调大,高并发下内存会爆)
SET SESSION sort_buffer_size = 4 * 1024 * 1024;阈值判断:观察 SHOW STATUS LIKE 'Sort_merge_passes',如果该值持续增长,说明排序频繁拆成多路归并(内存不够),可以考虑调大;反之就不要乱动。
方案四:避免 SELECT * 参与排序
大字段(如 TEXT 内容)被塞进 sort_buffer,很容易撑爆内存触发磁盘排序。只 SELECT 需要的字段,或者改用"延迟关联"(见 LIMIT 优化一节)。
3. 执行计划验证
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 ORDER BY create_time DESC;
-- Extra 列出现 "Using where" 或 "Using index":正常
-- Extra 列出现 "Using filesort":需要优化四、GROUP BY 优化:消灭临时表
1. 底层执行逻辑
GROUP BY 默认会先对分组字段排序(老版本)或在临时表中做聚合。当分组数据量大时,内存临时表放不下就会转成磁盘临时表,性能雪崩。执行计划里看到 Using temporary; Using filesort 就是这个问题的信号。
2. 核心思路:用索引的天然有序性消除临时表
如果 GROUP BY 的字段顺序与某个索引的顺序一致,MySQL 可以顺着索引依次扫描,扫完一组直接出结果,不需要临时表:
-- 索引:idx_category_status (category_id, status)
-- ✅ 顺着索引分组,Extra 无 Using temporary
SELECT category_id, COUNT(*) FROM article GROUP BY category_id;
-- ✅ 多字段分组顺序匹配索引
SELECT category_id, status, COUNT(*)
FROM article GROUP BY category_id, status;
-- ❌ 分组顺序与索引不一致,触发临时表
SELECT category_id, status, COUNT(*)
FROM article GROUP BY status, category_id;3. 松散索引扫描 vs 紧凑索引扫描
- 松散索引扫描(Loose Index Scan):只扫索引中每个分组的少量关键条目,不用遍历组内所有行。触发条件苛刻:聚合函数仅支持
MIN()/MAX()/COUNT(DISTINCT)等少数几个,且查询不能带其他索引范围条件。执行计划 Extra 显示Using index for group-by。 - 紧凑索引扫描(Tight Index Scan):条件不满足松散扫描时退而求其次,仍需扫描索引中相关范围的所有条目,但因为只扫索引不扫数据,依然远快于临时表方案。
4. 关于 ONLY_FULL_GROUP_BY
MySQL 5.7+ 默认开启 ONLY_FULL_GROUP_BY,要求 SELECT 的非聚合字段必须出现在 GROUP BY 中。有人为了绕过报错直接全局关闭:
-- ❌ 不建议:掩盖了 SQL 逻辑错误,不同版本/不同数据下结果不可预测
SET sql_mode = 'STRICT_TRANS_TABLES'; -- 去掉了 ONLY_FULL_GROUP_BY替代方案:用 ANY_VALUE() 明确告诉数据库"这个字段我知道每组有多值,随便给我一个":
-- ✅ 语义明确,兼容性好
SELECT category_id, ANY_VALUE(title), COUNT(*)
FROM article GROUP BY category_id;5. 验证方法
EXPLAIN 后确认 Extra 列不再出现 Using temporary;用 SHOW STATUS LIKE 'Created_tmp_disk_tables' 对比优化前后磁盘临时表的数量。
五、LIMIT 优化:深分页的救命方案
1. 为什么深分页这么慢
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 10;这条 SQL 的逻辑是:先排序取出前 1000010 行,然后扔掉前 100 万行,只留 10 行。偏移量越大,白白扔掉的数据越多,还伴随 100 万次回表。
2. 优化方案
方案一:子查询 + 延迟关联(先定位 ID,再取数据)
-- ✅ 子查询只走索引拿 10 个主键,再回表取 10 行完整数据
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
ORDER BY create_time DESC
LIMIT 1000000, 10
) t ON o.id = t.id;原理:子查询只查主键,可以用覆盖索引完成,不回表;最后只对 10 行回表。
方案二:游标分页(推荐,体验最好的方案)
不用 OFFSET,改用"上一页最后一条的值"作为起点:
-- 第一页
SELECT * FROM orders ORDER BY create_time DESC, id DESC LIMIT 10;
-- 下一页:记住上一页最后一条的 create_time 和 id
SELECT * FROM orders
WHERE (create_time, id) < ('2026-08-01 10:00:00', 98765)
ORDER BY create_time DESC, id DESC
LIMIT 10;原理:WHERE 条件直接通过索引定位到起点,扫描行数恒等于页大小,翻到第 1 页和第第 100 万页速度完全一样。App 的"下拉加载更多"天然适合这种方案。
方案三:业务层面限制最大页码
搜索引擎也只给你看前几十页。如果业务上没人真的会翻到第 100 万页,直接限制"最多查看前 1000 条"是最省事的方案。
3. 性能对比(1000 万行表,取 LIMIT 5000000, 10)
| 方案 | 耗时 | 扫描行数 |
|---|---|---|
| 原始 LIMIT OFFSET | ~8.5s | 500 万+ |
| 子查询延迟关联 | ~1.2s | 500 万(索引) + 10(回表) |
| 游标分页 | ~0.003s | 10 |
选型建议:能改交互(下拉加载)→ 游标分页;必须保留页码跳转 → 延迟关联;两者都改不了 → 限制最大分页深度。
六、COUNT 优化:大表数数的学问
1. 四种 COUNT 写法的效率差异
先说结论(InnoDB 下):COUNT(*) ≈ COUNT(1) > COUNT(主键) > COUNT(字段)。
COUNT(*):MySQL 专门优化过,不取行数据,直接计数。官方推荐写法。COUNT(1):效果和COUNT(*)基本一致,都是数行数。COUNT(主键):需要取主键值再判断非空(主键必然非空,多一步取值动作),略慢。COUNT(字段):需要取出字段值并判断是否为 NULL,最慢。
InnoDB 的痛点:因为支持事务和 MVCC,不同事务看到的行数可能不同,所以 InnoDB 的 COUNT(*) 无法像 MyISAM 那样直接读一个预存的总数,必须逐行扫一遍(优化器会选择最小的索引来扫)。1000 万行的表全表 COUNT 可能要好几秒。
2. 大表 COUNT 的解决方案
方案一:统计表缓存计数(精确场景)
-- 单独维护一张计数表
CREATE TABLE orders_count (
stat_key VARCHAR(50) PRIMARY KEY,
cnt BIGINT NOT NULL DEFAULT 0
);
-- 业务写入订单的同一事务中维护计数(保证一致性)
START TRANSACTION;
INSERT INTO orders (user_id, amount) VALUES (1001, 99.00);
INSERT INTO orders_count (stat_key, cnt) VALUES ('total', 1)
ON DUPLICATE KEY UPDATE cnt = cnt + 1;
COMMIT;
-- 查询时直接读计数表,毫秒级返回
SELECT cnt FROM orders_count WHERE stat_key = 'total';同步要点:插入/删除与计数更新必须放在同一个事务里,否则会出现数据对不上;定时任务每天跑一次全量核对作为兜底。
方案二:近似计数(允许误差的场景)
-- EXPLAIN 的 rows 列就是优化器的估算行数,毫秒级返回
EXPLAIN SELECT * FROM orders;偏差可能 10%~50%,适合"总数据量展示"这种没人较真的场景(比如后台仪表盘)。SHOW TABLE STATUS 的 Rows 列同理。
方案三:分表汇总
按天分表的场景(如 orders_20260901、orders_20260902),总数 = 各分表计数之和,每个分表单独 COUNT 都很快,可以并行统计。
七、UPDATE 优化:守住行锁,别让它拖垮全表
1. 事故场景
运营后台执行了一条 UPDATE orders SET status='已关闭' WHERE remark LIKE '%超时%',执行 10 分钟没结束,期间整个订单系统全部写入卡死——行锁变成了事实上的全表锁定。
2. 锁升级的核心触发条件
InnoDB 的行锁是加在索引上的,不是加在行上的。由此引出几个关键结论:
- 更新条件没有走索引:
WHERE字段无索引,MySQL 只能全表扫描,扫描过的每一行都要加锁——等于锁了全表。 - 索引失效:条件写法导致索引失效(函数、隐式转换等),同样退化为全表扫描 + 全表加锁。
- 单次更新行数过多:即使走了索引,一次更新几十万行意味着持有几十万个行锁,其他事务大面积阻塞,锁等待堆积。
- 长事务持有锁:事务里更新了数据但迟迟不提交(比如中间夹杂了调用第三方接口的代码),锁一直被占着,其他事务排队等到超时。
3. 规范做法
-- ✅ 规矩一:更新条件必须命中索引
-- 先确认索引存在
EXPLAIN UPDATE orders SET status = '已关闭' WHERE user_id = 1001 AND status = '待付款';
-- ✅ 规矩二:大批量更新分批执行,每批控制在小几千行
UPDATE orders SET status = '已关闭'
WHERE status = '待付款' AND create_time < '2026-08-01'
ORDER BY id LIMIT 2000;
-- 应用层循环执行直到影响行数为 0,批与批之间 sleep 100ms规矩三:避免长事务——事务里只放数据库操作,把 RPC 调用、文件处理挪到事务外;提交要趁早。
规矩四:合理设置锁等待超时:
-- 默认 50 秒太长,线上建议调小,让冲突快速失败而不是堆积
SET GLOBAL innodb_lock_wait_timeout = 5;4. 行锁冲突排查(MySQL 8.0)
-- 查看当前正在等待锁的事务和持有锁的事务
SELECT * FROM performance_schema.data_lock_waits;
-- 查看锁的详细情况(哪些表、哪些索引、什么锁模式)
SELECT * FROM performance_schema.data_locks;
-- 查看长事务
SELECT * FROM information_schema.innodb_trx
ORDER BY trx_started;
-- 实在不行,找到源头杀掉
KILL <trx_mysql_thread_id>;5. 反面案例的性能影响
上面的事故 SQL 实测对比(500 万行订单表):
- 无索引字段更新:锁全表,执行 600s+,期间所有订单写入超时,故障直接升级为 P0;
- 加索引后同样的更新:命中 300 行,行锁 300 个,耗时 0.02s,对其他业务零影响。
一句话:UPDATE 的 WHERE 条件,必须当作 SELECT 一样认真对待——先 EXPLAIN,再执行。
八、总结:性能优化的通用心法
七个专题讲下来,套路其实就三条:
- 让数据走路,不要让数据搬家:插入有序、主键别动、分页用游标——凡是减少数据物理移动的设计都是好设计。
- 让索引干活,不要全表扫描:排序、分组、更新、计数,所有操作的第一反应都是"这条路走索引了吗"。
- 先验证,再上线:EXPLAIN 看执行计划、慢查询日志抓问题 SQL、
performance_schema查锁——性能优化不靠猜,靠证据。
| 问题领域 | 一句话方案 |
|---|---|
| 插入慢 | 批量 + 手动事务 + LOAD DATA |
| 主键设计 | 自增/雪花 ID,短而有序,永不更新 |
| 排序慢 | 排序字段对齐索引顺序,消灭 filesort |
| 分组慢 | 分组字段对齐索引,消灭 Using temporary |
| 深分页 | 延迟关联兜底,游标分页最优 |
| 大表计数 | 业务计数表 + 事务同步,或接受近似值 |
| 更新锁表 | WHERE 必走索引,分批小步快跑,拒绝长事务 |