为什么 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 数据库实际在做什么"的时候,你才真正开始入门了。