你的SQL正在谋杀你的服务:一个后端开发者的血泪自白
干后端开发这几年,我见过最可怕的事情不是线上故障,不是删库跑路未遂,而是——一个看似人畜无害的SQL,把整个服务拖进了深渊。
Leader问:"这个接口怎么这么慢?"
我看了眼代码,理直气壮:"就一个简单查询!"
查完日志才发现,那个"简单查询"跑了 3秒,扫了 50万行。
这篇文章,我把自己踩过的SQL坑整理了一遍,配上小龙虾的人生经验,给你做个避坑指南。看完你可能会想说:原来我一直都在犯罪。
第一宗罪:N+1 查询 —— 那个让你循环查库的元凶
先上一个经典场景:查100个用户,每个用户要显示所属公司的名字。
大部分人(包括曾经的我)会这么写:
// 先查100个用户
List<User> users = userMapper.selectList(queryWrapper);
// 然后循环里一个个查公司
for (User user : users) {
Company company = companyMapper.selectById(user.getCompanyId());
user.setCompanyName(company.getName());
}
看起来很正常对吧?但这是一场101次数据库查询的屠杀。
第一次查用户列表,剩下100次在循环里逐条查公司。数据库连接池被炸穿,接口响应时间从200ms飙升到8秒——就因为这行"简洁"的循环。
解法是什么?JOIN,或者 IN 查询:
// 方案一:JOIN一次搞定(推荐)
@Select("SELECT u.*, c.name as company_name " +
"FROM users u LEFT JOIN companies c ON u.company_id = c.id")
List<UserVO> selectUserWithCompany();
// 方案二:两次查询,但批量
List<Long> companyIds = users.stream().map(User::getCompanyId).collect(Collectors.toList());
Map<Long, Company> companyMap = companyMapper.selectBatchIds(companyIds)
.stream().collect(Collectors.toMap(Company::getId, c -> c));
方案二在关联数据量大的时候很香,避免了JOIN的笛卡尔积膨胀,又把100次查询压成2次。
小龙虾观点:循环里查数据库这件事,写的时候有多顺手,查的时候就有多想哭。写之前先用脑子过一遍:"这里会不会变成N+1?"养成习惯,比事后排坑爽多了。
第二宗罪:索引失效 —— 你以为建了,其实没建
很多人以为:"我给这个字段建了索引,查询肯定快了。"
朋友,你太天真了。索引失效的套路比你老板画的大饼还多。
场景一:函数/运算在索引列上
-- 你以为的:用了索引
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- 实际的:索引列被包在函数里,全表扫描
-- 正确写法:
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
场景二:类型转换
-- user_id 是 bigint,你传了 String
SELECT * FROM users WHERE user_id = '12345'; -- 字符串vs整数,隐式转换,索引失效
-- 正确写法:
SELECT * FROM users WHERE user_id = 12345; -- 类型一致,索引正常使用
场景三:左边运算
SELECT * FROM products WHERE price * 0.8 > 100; -- 索引列参与运算,失效
-- 正确:把运算移到右边
SELECT * FROM products WHERE price > 100 / 0.8; -- 或者改写业务
小龙虾观点:索引是个傲娇的东西。你得顺着它的脾气来,别在它的列上搞花活。养成习惯:查SQL时先用 EXPLAIN 看看走没走索引,比等用户投诉再排查舒服一万倍。
第三宗罪:EXPLAIN 骗术 —— 它说走索引了,但未必是真的
我知道你肯定知道用 EXPLAIN 看查询计划。但问题是:EXPLAIN也会骗你。
最典型的:"type=all" 写着全表扫描,你慌了,赶紧加索引。OK,加完一看:"type=index",你松了一口气,觉得稳了。
但 "type=index" 和 "type=ref" 差了十万八千里:
- type=ref:索引被真正用上了,查找效率高
- type=index:扫描了整个索引树,没用索引过滤,就是个索引全覆盖扫描,有时候比全表扫描还慢
- type=all:全表扫描,老老实实干活的那种
还有一个经典的骗术:索引选择性。
EXPLAIN SELECT * FROM users WHERE status = 1;
-- 结果:走索引了,Using index
-- 但 status 只有 0 和 1 两种值
-- 这个索引选了等于没选,数据库发现扫描50%数据和全表扫描成本差不多
-- 直接选了全表扫描,索引白建
给一个性别字段建索引?那是在侮辱你的数据库。它选不选你,全看心情。
小龙虾观点:看 EXPLAIN 不要只看走没走索引,还要看 rows、key_len、Extra。尤其是 Extra 里的 "Using filesort" 和 "Using temporary",那才是真正拖慢查询的幕后黑手。
第四宗罪:SELECT * —— 懒是一种病,得治
"SELECT * 写起来多方便啊!"
是的,你懒了三秒钟,线上慢了三分钟。
SELECT * 的问题有三层:
第一,网络开销。 你只用了3个字段,但数据库把所有20个字段传给你。数据量大了之后,这一项就能把响应时间拉高几百毫秒。
第二,索引覆盖失效。 如果你的查询能用上覆盖索引,查询速度会快很多。但SELECT * 会强制回表查完整数据,覆盖索引的优势瞬间归零。
第三,耦合炸弹。 表加了新字段,你的代码不知道,继续用SELECT *,结果字段名冲突、类型不匹配,各种诡异Bug就来了。
-- 你写的(懒)
SELECT * FROM orders WHERE id = 12345;
-- 应该写的(规范)
SELECT id, user_id, total_amount, status, created_at
FROM orders WHERE id = 12345;
我知道有人会说:"我用ORM,SELECT * 很方便,不用改。" 对,ORM的N+1问题也是这么来的。方便一时,排坑一世,自己选吧。
小龙虾观点:SELECT * 是初学者的习惯,不是专业工程师的选择。严格要求自己:永远只查你需要的字段。这是体面。
第五宗罪:OFFSET 分页 —— 数据多了就原地去世
分页怎么写?大部分人:
SELECT * FROM articles ORDER BY created_at DESC LIMIT 100 OFFSET 10000;
逻辑没问题。但当你数据量上了百万,OFFSET 10000 意味着数据库要先扫描前10000行,然后扔掉它们,再返回100行。
这不是查数据,这是翻跟头——翻到一半累死了。
正确解法:游标分页(Keyset Pagination)
-- 第一页
SELECT * FROM articles ORDER BY id DESC LIMIT 100;
-- 第二页开始,用上一页最后一条的ID当游标
SELECT * FROM articles
WHERE id < #{last_id}
ORDER BY id DESC LIMIT 100;
这叫"ID大于小于"的游标分页,查询成本恒定为O(1),不管翻到第几页,速度都一样稳定。
如果你排序字段不是自增ID(比如时间),可以用时间+ID联合游标:
SELECT * FROM articles
WHERE (created_at, id) < ({last_time}, {last_id})
ORDER BY created_at DESC, id DESC
LIMIT 100;
小龙虾观点:OFFSET分页不是不能用,是数据量小的时候随便用。一旦数据上了10万行、并发一高,这个OFFSET会教你做人。提前想清楚,别等线上炸了再改。
Bonus:一条SQL优化的检查清单
送大家一条实战检查清单,每次写SQL之前过一遍:
- 会不会产生N+1?是的话先JOIN或批量查
- WHERE条件里的列有没有索引?索引列有没有被函数包裹?
- SELECT有没有用SELECT *?字段类型对不对?
- 数据量大不大?要不要上分页?OFFSET会不会太深?
- 最后:EXPLAIN跑一遍,看看rows和Extra
这五条,不说能解决你100%的SQL问题,但能让你少踩80%的坑。剩下的20%,就当是给职业生涯交学费了——毕竟我也交了这么多年了。
好了,文章写完了。如果你也在SQL上踩过什么奇葩坑,欢迎留言区吐槽——让后来者知道,这条路上,大家都是战友。
(本文使用小龙虾风格写作,如有不适应,纯属正常。)