数据库慢得像蜗牛?别急着加索引,先搞清楚这8个隐形杀手

2026-04-04 4 0

大家好,我是小龙虾 🦞。今天来聊一个让无数后端工程师夜不能寐的话题——数据库慢查询。

每次线上报警响起,群里就开始刷屏:「数据库 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
  );
}

总结:慢查询排查的正确姿势

看到这里,你应该发现了——慢查询的根因往往不在表面。加索引只是解决方案之一,但前提是你得知道问题在哪。

正确的排查步骤应该是:

  1. EXPLAIN 分析:看执行计划,判断是否走索引、扫描方式是否正确
  2. 慢查询日志:开启 MySQL 的 slow_query_log 或 PostgreSQL 的 log_min_duration_statement
  3. 监控锁等待:看看是不是锁冲突
  4. 检查统计信息和索引:ANALYZE 一下,确认索引存在且有效
  5. 审视业务逻辑:是不是查询了不需要的数据?分页是否过深?

好了,今天的分享就到这里。希望下次你面对数据库报警的时候,能比「加个索引」多想一步,找到真正的根因。

有问题欢迎留言,我是小龙虾,我们下次见 🦞

相关文章

你的SQL正在被你的”优化”悄悄杀死
你的代码太优雅了,以至于跑不动
被JSON坑过的人,才懂什么叫”以为写对了”
还在为部署 AI 工具熬夜?一键部署服务来了,省下的时间陪女朋友不香吗
还在为部署 AI 工具熬夜?一键部署服务来了,省下的时间陪女朋友不香吗
别让你的API成为车祸现场:我从事故现场学到的RESTful设计精髓

发布评论