Skip to content

MySQL 数据库性能优化实战 ​

生产环境的性能问题,十有八九出在 SQL 和索引上。本文以七个最常见的性能问题为切入点,每个问题都讲清楚"为什么慢、怎么改、改完怎么验证"。

一、插入数据优化:批量写入的正确姿势 ​

1. 问题场景 ​

凌晨数据同步任务,500 万条数据插了 4 个小时还没跑完,还拖垮了线上其他业务。排查发现:代码里用的是 for 循环单条 INSERT。

2. 优化方案 ​

方案一:批量插入替代单条插入 ​

sql
-- ❌ 慢:每条一次网络往返 + 一次事务提交
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 过大、占用内存高、失败重试成本高。

方案二:手动控制事务,禁用逐条自动提交 ​

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),插入会随机落在中间某个页,页满了就要页分裂——把一个页拆成两个,数据搬家,既慢又产生碎片。导入数据前按主键排序,能显著提升导入速度。

方案四:大批量导入时关闭唯一性校验 ​

sql
-- 导入前关闭(仅当前会话有效)
SET unique_checks = 0;
-- ...执行大批量导入
SET unique_checks = 1;

原理:每条带唯一索引的插入都要先检查"值是否已存在"。如果数据源本身已保证唯一,这个检查纯属浪费。注意:此操作有风险,仅限可信任的初始化导入场景。

方案五:LOAD DATA INFILE 文件直灌 ​

sql
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. 优化方案 ​

方案一:让排序字段匹配索引顺序 ​

sql
-- 索引: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 ​

sql
-- 查看当前值(默认通常 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. 执行计划验证 ​

sql
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 可以顺着索引依次扫描,扫完一组直接出结果,不需要临时表:

sql
-- 索引: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
-- ❌ 不建议:掩盖了 SQL 逻辑错误,不同版本/不同数据下结果不可预测
SET sql_mode = 'STRICT_TRANS_TABLES';  -- 去掉了 ONLY_FULL_GROUP_BY

替代方案:用 ANY_VALUE() 明确告诉数据库"这个字段我知道每组有多值,随便给我一个":

sql
-- ✅ 语义明确,兼容性好
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. 为什么深分页这么慢 ​

sql
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 10;

这条 SQL 的逻辑是:先排序取出前 1000010 行,然后扔掉前 100 万行,只留 10 行。偏移量越大,白白扔掉的数据越多,还伴随 100 万次回表。

2. 优化方案 ​

方案一:子查询 + 延迟关联(先定位 ID,再取数据) ​

sql
-- ✅ 子查询只走索引拿 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,改用"上一页最后一条的值"作为起点:

sql
-- 第一页
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.5s500 万+
子查询延迟关联~1.2s500 万(索引) + 10(回表)
游标分页~0.003s10

选型建议:能改交互(下拉加载)→ 游标分页;必须保留页码跳转 → 延迟关联;两者都改不了 → 限制最大分页深度。


六、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 的解决方案 ​

方案一:统计表缓存计数(精确场景) ​

sql
-- 单独维护一张计数表
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';

同步要点:插入/删除与计数更新必须放在同一个事务里,否则会出现数据对不上;定时任务每天跑一次全量核对作为兜底。

方案二:近似计数(允许误差的场景) ​

sql
-- 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 的行锁是加在索引上的,不是加在行上的。由此引出几个关键结论:

  1. 更新条件没有走索引:WHERE 字段无索引,MySQL 只能全表扫描,扫描过的每一行都要加锁——等于锁了全表。
  2. 索引失效:条件写法导致索引失效(函数、隐式转换等),同样退化为全表扫描 + 全表加锁。
  3. 单次更新行数过多:即使走了索引,一次更新几十万行意味着持有几十万个行锁,其他事务大面积阻塞,锁等待堆积。
  4. 长事务持有锁:事务里更新了数据但迟迟不提交(比如中间夹杂了调用第三方接口的代码),锁一直被占着,其他事务排队等到超时。

3. 规范做法 ​

sql
-- ✅ 规矩一:更新条件必须命中索引
-- 先确认索引存在
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 调用、文件处理挪到事务外;提交要趁早。

规矩四:合理设置锁等待超时:

sql
-- 默认 50 秒太长,线上建议调小,让冲突快速失败而不是堆积
SET GLOBAL innodb_lock_wait_timeout = 5;

4. 行锁冲突排查(MySQL 8.0) ​

sql
-- 查看当前正在等待锁的事务和持有锁的事务
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,再执行。


八、总结:性能优化的通用心法 ​

七个专题讲下来,套路其实就三条:

  1. 让数据走路,不要让数据搬家:插入有序、主键别动、分页用游标——凡是减少数据物理移动的设计都是好设计。
  2. 让索引干活,不要全表扫描:排序、分组、更新、计数,所有操作的第一反应都是"这条路走索引了吗"。
  3. 先验证,再上线:EXPLAIN 看执行计划、慢查询日志抓问题 SQL、performance_schema 查锁——性能优化不靠猜,靠证据。
问题领域一句话方案
插入慢批量 + 手动事务 + LOAD DATA
主键设计自增/雪花 ID,短而有序,永不更新
排序慢排序字段对齐索引顺序,消灭 filesort
分组慢分组字段对齐索引,消灭 Using temporary
深分页延迟关联兜底,游标分页最优
大表计数业务计数表 + 事务同步,或接受近似值
更新锁表WHERE 必走索引,分批小步快跑,拒绝长事务