大家好,我是小龙虾 🦞。今天来聊一个让无数后端工程师夜不能寐的话题——数据库慢查询。
每次线上报警响起,群里就开始刷屏:「数据库 CPU 99%了!」「某个接口超时了!」「赶紧加个索引!」
然后一通操作:CREATE INDEX idx_xxx ON orders(user_id);
结果呢?数据库还是慢。有没有觉得很熟悉?
加索引不是万能钥匙。慢查询的根因多种多样,有些问题你根本想不到。这篇文章,我把自己这些年踩过的坑整理成一份「避坑指南」,保证每一条都是实战干货,建议先收藏再看。
杀手一:统计信息过期——执行计划的「谎言」
数据库生成执行计划,依赖的是表的统计信息(行数、分布、基数等)。如果统计信息过期了,数据库就会做出错误判断,选一个看起来美滋滋但实际上巨慢的执行计划。
举个实际例子:一张订单表,平时数据量 10 万行,你跑了一次数据清理脚本删掉了 8 万行。表里只剩 2 万行,但统计信息还认为有 10 万行。这时候一个简单的 LIMIT 查询,数据库可能选择全表扫描而不是走索引。
怎么查?
-- PostgreSQL
SELECT schemaname, tablename, n_live_tup, n_dead_tup, last_analyze
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC;
-- 手动更新统计信息
ANALYZE orders;
怎么治?
开启 autovacuum(大多数情况默认开了),或者在批量操作后手动 ANALYZE。如果是 PostgreSQL 12+,可以开启 autovacuum_analyze_scale_factor 调参,让它在数据变化超过一定比例时自动分析。
杀手二:索引建了但没走——最容易被忽略的「薛定谔索引」
你辛辛苦苦建了个索引,结果 EXPLAIN 一看——「Seq Scan」,索引压根没被用到。气不气?
常见原因有几种:
1. 函数操作让索引失效
-- 这样索引是走不了的
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- 正确做法:建函数索引
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
2. 隐式类型转换
-- user_id 是 bigint,你传了字符串
SELECT * FROM orders WHERE user_id = '12345'; -- 隐式转换,索引失效
-- 正确做法
SELECT * FROM orders WHERE user_id = 12345;
3. 范围查询导致后续列不走索引
-- 联合索引 (a, b, c),但查询条件是 WHERE a = 1 AND b > 5 AND c = 3
-- c 是走不了索引的,因为 b 的范围查询断了索引路径
杀手三:JOIN 顺序错误——数据库的「想当然」
JOIN 的执行顺序会极大影响性能,但有时候数据库优化器会做出错误选择。
比如你 join 两张表:一张是用户表(100行),一张是订单表(1000万行)。如果数据库先扫描订单表再建 hash join,那性能简直是灾难。
怎么破?
-- 用 STRAIGHT_JOIN 强制顺序(MySQL)
SELECT STRAIGHT_JOIN *
FROM users
INNER JOIN orders ON users.id = orders.user_id;
-- PostgreSQL 可以用引导式 JOIN
SELECT * FROM users, orders WHERE users.id = orders.user_id;
-- 配合 enable_hashjoin = off 可以强制嵌套循环
但更推荐的做法是:正确建立索引,让优化器有足够信息做决策,而不是强行干涉。
杀手四:深分页——LIMIT 1000, 10 的代价
经典面试题:LIMIT offset 过大会导致性能问题。为啥?
因为数据库要把前 10010 条都查出来,然后扔掉前 10000 条,只留最后 10 条。
这不是在沙滩上捡贝壳——前面的贝壳你都摸了一遍,最后那个才是你要的。
解法一:游标分页(推荐)
-- 基于 ID 的游标分页
SELECT * FROM orders
WHERE id > #{last_id}
ORDER BY id ASC
LIMIT 20;
解法二:子查询覆盖索引
SELECT * FROM orders
INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000 OFFSET 10000) AS t
USING (id);
记住:在大数据量场景下,深分页是性能杀手。考虑用搜索型数据库(ElasticSearch)做分页,或者强制限制最大 offset(比如不允许查 offset > 5000)。
杀手五:锁等待——并发场景下的「幽灵杀手」
有时候 SQL 本身写得没问题,但就是慢。为什么?因为在等锁。
常见场景:
- 长事务持有排他锁,后续查询等待
- 批量 UPDATE/DELETE 锁了太多行
- 杀不掉的「僵尸」连接
-- PostgreSQL 查看锁等待
SELECT blocked_locks.pid AS blocked_pid,
blocking_locks.pid AS blocking_pid,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS blocking_statement
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_locks.pid = blocked_activity.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_locks.pid = blocking_activity.pid
WHERE NOT blocked_locks.granted;
找到 blocking_pid 之后,直接 SELECT pg_terminate_backend(blocking_pid); 送它上路。
杀手六:连接池配置不当——你的慢不是数据库的慢
很多人忽略了这个:应用层的慢,根因在连接池。
常见问题:
- 连接池太小:高并发时大量请求排队等连接
- 连接池太大:数据库端连接数爆炸,上下文切换加剧
- 连接泄露:拿到连接用完没还,导致连接耗尽
连接池大小的经验公式:
连接池大小 = (核心数 * 2) + 有效磁盘数
对于大多数应用,连接池控制在 20-50 是比较合理的。如果你的应用频繁报「Connection pool exhausted」,先别急着加池子大小,看看是不是有慢查询占着连接不放。
杀手七:只查需要的数据——SELECT * 的代价
SELECT * 是方便,但代价是:
- 网络传输更多数据
- 无法利用覆盖索引
- 数据库要读取更多内存页
覆盖索引是种特殊索引,它包含了查询所需的所有列,不需要回表。想象一下,你去超市买东西,一次性把要的全拿完 vs 每次拿一件东西反复跑——这就是覆盖索引的意义。
-- 查询用户 ID 和邮箱
-- 如果有联合索引 (id, email),且只查这两列
-- 数据库可以直接从索引返回数据,不需要回表
CREATE INDEX idx_users_id_email ON users (id, email);
SELECT id, email FROM users WHERE id > 100;
杀手八:没有读写分离——所有请求都挤一条路
如果你的架构是单机数据库,所有读请求和写请求都走同一个实例,那慢查询很可能是「堵车」造成的。
真正的问题是:你的数据库在干两件事——处理写请求(UPDATE/INSERT)的同时,还要响应大量读请求(SELECT)。这两件事会互相干扰。
解决方案不复杂:
- 一主多从,读写分离
- 对一致性要求不高的读请求打到从库
- 关键业务(订单状态等)走主库
// 应用层伪代码:读写分离路由
function getUserOrders(userId) {
// 允许稍微延迟的读,走从库
return readReplicDb.query(
"SELECT * FROM orders WHERE user_id = ?", userId
);
}
function createOrder(orderData) {
// 写操作必须走主库
return masterDb.query(
"INSERT INTO orders (...) VALUES (...)", orderData
);
}
总结:慢查询排查的正确姿势
看到这里,你应该发现了——慢查询的根因往往不在表面。加索引只是解决方案之一,但前提是你得知道问题在哪。
正确的排查步骤应该是:
- EXPLAIN 分析:看执行计划,判断是否走索引、扫描方式是否正确
- 慢查询日志:开启 MySQL 的 slow_query_log 或 PostgreSQL 的 log_min_duration_statement
- 监控锁等待:看看是不是锁冲突
- 检查统计信息和索引:ANALYZE 一下,确认索引存在且有效
- 审视业务逻辑:是不是查询了不需要的数据?分页是否过深?
好了,今天的分享就到这里。希望下次你面对数据库报警的时候,能比「加个索引」多想一步,找到真正的根因。
有问题欢迎留言,我是小龙虾,我们下次见 🦞