数据库慢得像个拖拉机?先别骂数据库,这 7 个坑有一半是你自己挖的

2026-10-31 9 0

你以为数据库慢是数据库的锅?不,慢是你的锅。没写索引、随手一个 SELECT *、在索引列上套函数——这些骚操作能把一台顶配服务器变成废铁。

这篇不讲玄学,只讲我踩过的坑。每一条都能让你的查询从"等一杯咖啡"变成"秒回"。


一、先搞清楚:数据库到底在找什么

你执行一条 SQL,数据库要做的第一件事不是"返回数据",而是"找到要返回的数据在哪"。B+ 树索引做的事,就是把这个"找"的过程从全表扫描变成有方向的定位。

理解索引之前,得先理解一个常被搞混的概念:回表。

二级索引(辅助索引)叶子节点存的不是完整行数据,而是「索引列的值 + 主键值」。MySQL 拿着你的二级索引找到主键之后,还得再回到聚簇索引(主索引)里把整行数据捞出来——这一步就叫回表。

这一句话解释了你见过的很多奇怪现象:

  • SELECT * FROM user WHERE name = 'x' 走了索引,但因为要回表拿所有字段,慢
  • SELECT id, age FROM user WHERE name = 'x' 同样走索引,但 age 恰好在索引里(覆盖索引),不用回表,快

所以"走没走索引"这个二元判断是偷懒的。你真正该问的是:这次查询是不是覆盖索引?

EXPLAIN 要看哪几列

EXPLAIN SELECT id, age FROM user WHERE name = 'x';

重点看三列:

  1. type:从好到差是 const > ref > range > index > ALL。看到 ALL 基本就是全表扫描了,恭喜,你的中奖机会来了。
  2. key:实际用到的索引。如果为 NULL,说明优化器放弃了索引。
  3. Extra:Using index 是好事(覆盖索引),Using filesort 和 Using temporary 是坏消息,通常意味着排序或分组没走上索引。

Using filesort 并不等于"用了磁盘文件",它的本意是"需要额外排序",可能全在内存里完成。但在千万级数据上,它依然是性能杀手。

最左前缀原则:索引的组队规则

假设有联合索引 idx(a, b, c),它相当于三本词典按顺序装订:先按 a 排,a 相同再按 b 排,a、b 都相同再按 c 排。

这意味着:

WHERE a = 1                    -- ✅ 用到 (a)
WHERE a = 1 AND b = 2          -- ✅ 用到 (a, b)
WHERE a = 1 AND b = 2 AND c = 3 -- ✅ 用到 (a, b, c)
WHERE b = 2                    -- ❌ 跳过了 a,索引失效
WHERE c = 3                    -- ❌ 直接起飞
WHERE a = 1 AND c = 3          -- ⚠️ 只用到 (a),c 用不上

一句话:联合索引是一条链,断一节后面全废。

那 WHERE a = 1 AND c = 3 怎么办?MySQL 8.0 有"索引跳跃扫描"(index skip scan),但那是兜底,不是保障。老老实实考虑把 c 提到前面,前提是 c 的区分度够高——这就要看下一节了。


二、区分度:建索引前必须算的一笔账

一个索引如果区分度太低,优化器会直接放弃它,因为全表扫描走索引更慢。

SELECT COUNT(DISTINCT column_name) / COUNT(*) FROM table_name;

返回值就是区分度。经验值:

  • > 0.9:优秀,性别这种就别建索引了
  • 0.5 ~ 0.9:良好,正常建
  • < 0.5:垃圾字段,建了也白建

举个真实例子:订单表里 status 字段有 20 个值,但 95% 的订单都是"已完成"。

-- 灾难索引
KEY idx_status (status)

-- 更好的选择(把区分度高的字段放前面)
KEY idx_status_user (status, user_id)
-- 这样查 status='已完成' AND user_id=? 就能完美命中

联合索引的字段顺序有个简单原则:区分度高的靠前,等区分度的按查询频率排。 这两个维度打架时,先看真实查询日志,别拍脑袋。


三、那些让索引"凭空消失"的骚操作

这部分是我的血压来源。

1. 在索引列上使用函数

-- ❌ 索引失效
SELECT * FROM orders WHERE DATE(created_at) = '';

-- ✅ 改成范围查询
SELECT * FROM orders
WHERE created_at >= ' 00:00:00'
  AND created_at <  ' 00:00:00';

原因很简单:索引里存的是原始值,你在列上套了函数,优化器没法拿你的表达式去和索引比对,只能放弃。

如果业务上必须用函数包裹,考虑函数索引(MySQL 8.0.13+):

CREATE INDEX idx_created_day ON orders ((DATE(created_at)));
-- 查询时也要用完全一致的表达式才能命中

注意最后那句——"完全一致"。写成 DATE(created_at) = ? 和写成 DATE(created_at) = '',前者走索引,后者可能走也可能不走,取决于常量折叠。稳妥起见两边都写一样的形式。

2. 隐式类型转换

-- phone 是 varchar,id 是 int
SELECT * FROM users WHERE phone = 13800138000;
-- ❌ varchar 列 = 数字,MySQL 会把每行的 varchar 转成数字再比较,索引直接报废

反过来,如果列是 int 而你传字符串,转换发生在常量侧,索引还能用。所以永远给参数用正确的类型,别让数据库替你猜。

3. 前导模糊匹配

SELECT * FROM users WHERE name LIKE '%张';
-- ❌ 索引失效

SELECT * FROM users WHERE name LIKE '张%';
-- ✅ 索引有效(最左前缀)

想解决 `%张` 的检索需求?上全文索引或搜索引擎(Elasticsearch / Meilisearch),别指望 B+ 树能倒着走。

4. OR 连接条件

WHERE a = 1 OR b = 2 这种,优化器可能会全表扫描。但如果你给 b 也建了合适索引,MySQL 8.0 的 index merge 通常能救回来。

更稳的写法是改写成 UNION:

SELECT * FROM t WHERE a = 1
UNION
SELECT * FROM t WHERE b = 2;

前提是 a、b 都有索引,并且 union 的结果集不会让 MySQL 去重时炸掉。

5. 字符集不一致

两张表 join,一个用 utf8mb4 一个用 utf8,或者 collation 不同,隐式转换就又来了。跨库 join 前先对一下字符集,这是最容易被忽略的性能陷阱。


四、慢查询日志:别猜,去看

上面这些坑,凭直觉猜是猜不出来的。答案在慢查询日志里。

# my.cnf
slow_query_log = ON
long_query_time = 0.5
slow_query_log_file = /var/log/mysql/slow.log

或者在线临时开:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;

有了日志之后,重点看 Query_time 和 Rows_examined 两个字段的比值:

如果一条查询扫了 100 万行只返回 10 行,那它一定有问题。这个比值越大,问题越严重。Rows_examined / Rows_sent 是我判断索引健康度的第一指标。

生产环境别开 long_query_time = 0,日志膨胀能把磁盘写满。我一般设 0.5 秒,配合 pt-query-digest 做定期汇总。


五、分页:别用 OFFSET

这是我见过的最经典的自杀式写法:

-- ❌ 第 10000 页要扫 10000 行全扔掉
SELECT * FROM articles ORDER BY id LIMIT 10000, 20;

正确的姿势是游标分页(Keyset Pagination):

-- ✅ 记住上一页最后一条的 id
SELECT * FROM articles
WHERE id > 10000
ORDER BY id
LIMIT 20;

无论翻到第几页,扫描量都是恒定的 20 行。这里的原理是:联合索引建在 (created_at, id) 上时,定位本身就是一次有序索引扫描,不需要额外排序。

有个坑要注意:如果排序字段不唯一,单纯用 id > ? 会漏数据或重复。得用两个字段做游标:

SELECT * FROM articles
WHERE (created_at, id) > ('', 5000)
ORDER BY created_at, id
LIMIT 20;

MySQL 支持行值比较,会用上 (created_at, id) 这个联合索引。


六、几个立竿见影的优化

覆盖索引:让查询不回表

-- 需要 name 和 age,创建覆盖索引
CREATE INDEX idx_name_age ON user (name, age);

SELECT age FROM user WHERE name = 'x';
-- 命中 idx_name_age,全程不回表

覆盖索引是"读多写少"表型的免费午餐。代价是索引变胖,写入变慢——所以别一口气把所有字段都塞进去。

批量写入:别一条一条 INSERT

-- ❌ 1000 条数据 = 1000 次网络往返 + 1000 次事务提交
-- ✅ 一条语句批量插入
INSERT INTO log (uid, action) VALUES
  (1, 'login'), (2, 'logout'), (3, 'click');

更狠一点可以用 INSERT ... ON DUPLICATE KEY UPDATE 做 upsert,避免"先查再插"的两步开销和并发竞态。

事务要短,但也不要太短

有人迷信"事务越短越好",于是把一个业务操作拆成几百个小事务。问题在于:

  • 每次事务提交都要 fsync 磁盘,拆太碎,IO 反而爆炸
  • 事务太长则锁持有久,死锁概率上升

原则是:只包住必须原子的那部分数据库操作,不要把 HTTP 调用、文件 IO 塞进事务里。

COUNT 别乱用

COUNT(*)        -- 精确计数,需要扫行
COUNT(1)        -- 和 COUNT(*) 基本一样,MySQL 会优化
COUNT(主键)      -- 也是精确计数
COUNT(索引列)    -- 非 NULL 值个数,可以用索引
COUNT(DISTINCT 列) -- 更慢

关键区别在于:COUNT(*) 和 COUNT(1) 在 InnoDB 里都要扫行(因为 MVCC 下无法用一个快照值),而 COUNT(索引列) 能在索引里数,不用回表。

如果只是想判断"有没有",用 EXISTS 或 LIMIT 1:

-- EXISTS 找到第一条就停
SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = 5);
-- 比 COUNT 靠谱得多

七、写在最后

数据库优化的核心其实只有一句话:

让数据库做尽可能少的工作。

索引设计得再好,SQL 写得再优雅,如果你的业务逻辑本身就要全表遍历,那谁也救不了你。真正的性能优化发生在设计阶段,而不是调优阶段。

所以我有个不太受欢迎的建议:上线前用生产数据的量级做一次压测。开发环境几百条数据时跑得飞快的东西,放到生产上就是灾难——因为索引选择性、缓存命中率、锁竞争这些因素的比重全变了。

顺带一句:不要迷信"某某数据库就是快"。MySQL 单表千万级数据配合合理索引,性能远超绝大多数应用的实际需求。你项目里 90% 的性能问题,用一条 EXPLAIN 就能定位。剩下的 10%,可能得换掉整个架构。

而在那之前,先去看看你的慢查询日志。它比任何一篇优化文章都更懂你的系统。

祝你的查询快到不需要 EXPLAIN。

相关文章

Raft 里那个 300ms 的随机超时,可能是分布式系统里最反直觉的一行代码
你的服务每秒在 JSON 上烧掉的 CPU,比你以为的多一个数量级
Go 的调度器凭什么敢让十万个 goroutine 狂飙?GMP 三件套,一次讲透
你删掉了 100 万行,磁盘却没释放:数据库里的「尸体」不会自己消失
一个表情符号能把你的数据库搞瘫?MySQL 字符集 utf8mb4 的六宗罪
你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写

发布评论