SQL 性能问题有一个共同特征:在数据量小的时候完全正常,数据量增长后突然崩。这让很多人误以为是数据库配置问题,其实根子在 SQL 写法本身。本篇 4 个坑,每个都附上定位工具和根治手段。
环境说明
| 项目 | 版本 |
|---|---|
| MySQL | 5.7.x / 8.0.x |
| 操作系统 | Ubuntu 20.04/22.04 |
坑 1:没有索引,百万数据秒变全表扫描
现象: 某个查询在数据量 1 万行时响应 10ms,数据增长到 100 万行后响应超过 10 秒,CPU 飙满,甚至把整个数据库拖垮。
根本原因: 查询字段没有索引,MySQL 必须逐行扫描整张表(type: ALL),数据量和扫描时间线性正比。
第一步:用 EXPLAIN 定位问题
sql
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND status = 'paid'
ORDER BY created_at DESC;
重点看这几列:
| 列名 | 危险信号 | 含义 |
|---|---|---|
| type | ALL | 全表扫描,最差 |
| rows | 数字很大 | 预计扫描行数,越大越糟 |
| Extra | Using filesort | 排序没用上索引,额外排序操作 |
| Extra | Using temporary | 用了临时表,GROUP BY / ORDER BY 常见 |
第二步:加正确的索引
sql
-- 复合索引:把等值查询列放前面,区分度高的列放前面
-- 这条 SQL 的最优索引:(user_id, status, created_at)
ALTER TABLE orders
ADD INDEX idx_user_status_time (user_id, status, created_at);
-- 加完之后再 EXPLAIN 验证
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND status = 'paid'
ORDER BY created_at DESC;
-- type 应该变成 ref 或 range,rows 大幅减少
复合索引的设计原则(最左前缀法则)
css
-- 假设索引是 (a, b, c)
-- ✅ 能用索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
-- ❌ 不能用索引(跳过了最左列 a)
WHERE b = 2
WHERE c = 3
-- ⚠️ 只能用 a 部分(遇到范围查询后面的列失效)
WHERE a = 1 AND b > 5 AND c = 3 -- c 的索引失效
索引失效的常见写法
sql
-- ❌ 对索引列做函数运算,索引失效
WHERE DATE(created_at) = '2024-01-15'
-- ✅ 改成范围查询
WHERE created_at >= '2024-01-15 00:00:00'
AND created_at < '2024-01-16 00:00:00'
-- ❌ 隐式类型转换,索引失效
-- user_id 是 INT,却传了字符串
WHERE user_id = '100'
-- ✅ 类型匹配
WHERE user_id = 100
-- ❌ LIKE 前缀通配,索引失效
WHERE name LIKE '%张%'
-- ✅ 只有后缀通配才能走索引
WHERE name LIKE '张%'
-- 全文搜索场景用 FULLTEXT 索引或 Elasticsearch
坑 2:SELECT * 把带宽和内存撑爆
现象: 接口响应慢,数据库服务器内存高,网络监控显示流量异常,但 SQL 看起来很简单。
根本原因: SELECT * 把表里所有列的数据全部拉到应用层,包括 TEXT、BLOB 等大字段,即使业务逻辑只用其中 2~3 个字段。当表有图片、富文本、JSON 大字段时,这个问题尤为严重。
解决方案:
sql
-- ❌ 不要这样,尤其是有大字段的表
SELECT * FROM articles WHERE category_id = 5 LIMIT 20;
-- ✅ 只查需要的列
SELECT id, title, author, published_at
FROM articles
WHERE category_id = 5
LIMIT 20;
N+1 查询:SELECT * 的升级版灾难
ini
# ❌ N+1 查询:查 1 次订单列表,再查 N 次用户信息
orders = db.query("SELECT * FROM orders LIMIT 100")
for order in orders:
user = db.query(f"SELECT * FROM users WHERE id = {order['user_id']}")
# 循环 100 次,发了 101 条 SQL
# ✅ 用 JOIN 一次查完
orders = db.query("""
SELECT o.id, o.amount, o.status,
u.name AS user_name, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
LIMIT 100
""")
-- 或者用 IN 批量查(适合不方便 JOIN 的场景)
-- 先查订单列表
SELECT id, user_id, amount FROM orders LIMIT 100;
-- 把 user_id 列表收集起来,一次查所有用户
SELECT id, name, email FROM users WHERE id IN (1, 2, 3, ...);
坑 3:事务没提交没回滚,锁住整张表
现象: 某个操作之后,数据库写入全部 hang 住,SHOW PROCESSLIST 里大量线程显示 Waiting for table metadata lock 或 Lock wait timeout exceeded,重启应用或 kill 某个进程才恢复。
根本原因: 一个事务开启后,因为代码 bug(异常未捕获、连接未关闭)导致没有提交或回滚,它持有的锁无法释放,后续所有涉及同一行或同一表的写操作只能等待。
第一步:定位 blocking 事务
sql
-- 查看当前所有活跃事务
SELECT * FROM information_schema.INNODB_TRX\G
-- 查看锁等待关系(谁在等谁)
SELECT * FROM sys.innodb_lock_waits\G
-- 找到 blocking 线程 ID,强制终止
KILL 线程ID;
第二步:代码层面根治
php
# Python - 用 try/finally 确保事务一定关闭
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
cursor.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
conn.commit() # 成功时提交
except Exception as e:
conn.rollback() # 任何异常都回滚
raise e
finally:
conn.close() # 无论如何都要释放连接
// Java - 用 try-with-resources 自动管理连接
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try {
// 执行业务 SQL
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
}
// conn 会被自动关闭
}
配置层面的辅助防护
ini
[mysqld]
# 锁等待超时时间,超过后自动报错而不是永久 hang(默认 50s,可以调短)
innodb_lock_wait_timeout = 10
# 开启死锁检测日志
innodb_print_all_deadlocks = 1
坑 4:AUTO_INCREMENT 在 MySQL 5.7 重启后 ID 复用
现象: 删除了几条记录后重启 MySQL,新插入的数据 ID 和已删除记录的 ID 重合,导致业务数据关联错乱(外键、日志、缓存全部对不上)。
根本原因: MySQL 5.7 把 AUTO_INCREMENT 计数器存在内存里,重启后从表中最大 ID 重新推算。如果最大 ID 的那条记录恰好被删了,计数器会从更小的值开始,就会复用已被删除记录的 ID。
sql
-- 复现步骤:
INSERT INTO test (name) VALUES ('a'),('b'),('c'); -- ID: 1,2,3
DELETE FROM test WHERE id = 3; -- 删掉 ID=3
-- 重启 MySQL
INSERT INTO test (name) VALUES ('d'); -- 5.7 里 ID 是 3(复用了!)
-- 8.0 里 ID 是 4(正确)
MySQL 8.0 的修复: 计数器写入 redo log 持久化,重启后准确恢复,不再复用。
解决方案(5.7 环境):
sql
-- 方法一:手动设定 AUTO_INCREMENT 起始值(适合迁移/恢复场景)
ALTER TABLE your_table AUTO_INCREMENT = 10000;
-- 方法二:查看当前 AUTO_INCREMENT 值
SHOW TABLE STATUS LIKE 'your_table'\G
根本解法是升级到 MySQL 8.0。如果暂时不能升级,业务逻辑必须对 ID 复用做防御:不能假设历史 ID 一定对应历史数据,每次操作前先验证记录是否存在。
快速自检清单(SQL 性能)
上线前必查:
x 所有 WHERE 条件列是否有索引(用 EXPLAIN 确认 type 不是 ALL)
x 是否消除了 EXPLAIN 结果里的 Using filesort 和 Using temporary
x代码里是否存在循环内执行 SQL(N+1 查询)
x 所有查询是否指定了具体列,没有 SELECT *(尤其是有大字段的表)
x事务代码是否有 try/catch/finally 保证提交或回滚
x生产 MySQL 是否已升级到 8.0(规避 5.7 的 AUTO_INCREMENT 复用问题)
x 慢查询日志是否开启(long_query_time = 1),用于持续发现问题
小结
这 4 个坑有一个共同点:开发时因为数据量小完全感知不到,上线后随着数据增长逐渐暴露 。它们不是低级错误,而是在不了解 MySQL 执行机制的情况下自然写出来的代码。EXPLAIN 是每个开发者都要会用的工具,建议把它变成写 SQL 的习惯动作。