为什么 SQL 写得好的人,数据库依然慢给你看

2026-06-25 11 0

为什么 SQL 写得好的人,数据库依然慢给你看

SQL 写得贼溜,JOIN、GROUP BY 信手拈来。然后你一看监控——数据库 CPU 99%,查询延迟 2000ms。

问题不在 SQL 本身,而在于写 SQL 的人根本不知道数据库是怎么执行这些语句的。这不是玄学,这是生产环境里真实存在的集体盲区。今天就来扒一扒那些"看起来没问题"的 SQL,是怎么把你的数据库活活拖死的。

反模式一:SELECT * 是怎么废掉你索引的

SELECT * 慢,理由不只是"传数据多"这么简单。

假设你有张订单表,二三十个字段:

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  order_no VARCHAR(32),
  user_id BIGINT,
  amount DECIMAL(10,2),
  created_at DATETIME,
  status TINYINT
  -- ... 二十多个字段
);

你只想查订单号和金额:

-- 好写法:索引直接覆盖
SELECT order_no, amount FROM orders WHERE user_id = 123;

-- 坏写法:索引废了
SELECT * FROM orders WHERE user_id = 123;

好写法如果 user_id 有索引,MySQL 直接从索引树里拿到 order_no 和 amount,完全不需要回表。坏写法呢?索引帮你定位到那一行,然后老老实实回表把所有字段读进内存。你的索引白建了,内存带宽白吃了。

如果你用 MySQL 8.0+,SELECT * 还可能导致索引跳跃扫描无法使用——MySQL 在某些场景下会放弃你这个完美的联合索引。

反模式二:分页 OFFSET 是深分页的噩梦

SELECT * FROM orders ORDER BY id LIMIT 10000, 20;

这条语句 MySQL 实际在做什么?先排序所有数据,然后数到第 10000 行,丢掉前 10000 行,返回第 20 行。为了 20 条数据,实际扫描了 10020 行。翻到第 1000 页?MySQL 扫了十万行只为给你 20 条。这就是为什么分页做到几百页接口就开始超时。

正确的深分页用游标:

-- 后续每一页:用上一页最后一条的 id 作为起点
SELECT * FROM orders WHERE id > ? ORDER BY id LIMIT 20;

不管翻到第多少页,永远只扫描 20 行。代价是前端无法跳页——但"加载更多"这种场景,完全可以强制游标分页。

反模式三:索引建了等于没建

索引列参与运算:

-- 索引废了
SELECT * FROM orders WHERE amount * 1.1 > 1000;

-- 正确
SELECT * FROM orders WHERE amount > 1000 / 1.1;

MySQL 索引 B+ 树存的是原始值。对索引列做运算,MySQL 无法二分查找,只能全表扫描。

类型隐式转换:

-- user_id 是 BIGINT,但查的时候用了字符串
SELECT * FROM users WHERE user_id = '12345';

MySQL 把字符串转成数字,发生在每一行上,索引失效。如果 user_id 存的是手机号,那更惨——每个手机号都要做一次类型转换再比较。

联合索引字段顺序错误:

-- 建了索引 (status, created_at, user_id)
-- 这样查:只用了 user_id,索引部分失效
WHERE user_id = ? AND status = ?

-- 这样查:全部用上
WHERE status = ? AND created_at > ?

联合索引遵从最左前缀原则。没从最左列开始用?后面全白建。

反模式四:子查询在特定场景下是性能灾难

网上流传的 SQL 优化文章,十篇有九篇说"尽量用子查询代替 JOIN"。这句话害死人。

从文章表里找每个分类下最新的一篇:

-- 写法 A:相关子查询(性能灾难)
SELECT * FROM articles a1
WHERE created_at = (
  SELECT MAX(created_at) FROM articles a2
  WHERE a2.category_id = a1.category_id
);

这个子查询会被执行 category_count 次。一万个分类,一万次子查询。Oracle 和 PostgreSQL 会警告你,但 MySQL 5.7 以前会默默跑,让你在监控里看着灾难慢慢发生。

-- 写法 B:窗口函数,一次扫描
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY created_at DESC) as rn
  FROM articles
) t WHERE rn = 1;

写法 B 是 O(n) 复杂度,写法 A 是 O(n*m)。数据量大了之后,这不是优化,是自我毁灭。

反模式五:乐观锁用成了死锁之源

UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = ? AND version = ?;

高并发场景,无数请求同时拿着 version=1 去更新同一行。第一个成功,剩下全部失败重试。如果 QPS 是 10000,同一秒有 5000 个请求要更新同一个账户——5000 次重试,每次重试都是同样的锁冲突。并发越高,冲突越多,数据库就这样被打爆。

解法:重试加随机退避延迟,让重试请求错峰;热点行用 SELECT FOR UPDATE 做短期悲观锁保护,减少冲突概率。

真正的优化,从读懂执行计划开始

说了这么多反模式,最后聊正向思路。每个复杂 SQL 写完,用 EXPLAIN 看三样东西:

type 列:ALL 是全表扫描,range 是范围扫描,ref 是索引查找,const 是最优的。看到 ALL,先查 WHERE 条件有没有可用索引。

Extra 列:出现 Using filesort 说明在文件排序,Using temporary 说明建了临时表。能用索引覆盖的查询会写 Using index。

rows 列:MySQL 估算要扫描多少行。如果 rows=5000000 但你只返回 10 条,中间一定有巨大的优化空间。

数据库性能优化本质上是持续过程,不是背规则。你的数据会增长,查询模式会变化,去年飞起的 SQL 今年可能把服务器拖垮。每个季度跑一次慢查询日志分析,比临时加索引有用一百倍。

记住一件事:数据库是按它理解的方式执行 SQL,不是按你以为的方式。当你开始问"这条 SQL 数据库实际在做什么"的时候,你才真正开始入门了。

相关文章

写了三年API,我把这些坑都踩了一遍
还在为部署AI工具头秃?我来帮你搞定一切
还在为部署AI工具头秃?我来帮你搞定一切
你的服务真的在用UDP吗?——后端工程师不知道的网络盲区
Go语言并发编程:我从’假装会并发’到’真正跑通’的血泪史
为什么你的慢查询死活优化不了?可能被索引骗了

发布评论