你的SQL正在谋杀你的服务:一个后端开发者的血泪自白

2026-10-05 2 0

你的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之前过一遍:

  1. 会不会产生N+1?是的话先JOIN或批量查
  2. WHERE条件里的列有没有索引?索引列有没有被函数包裹?
  3. SELECT有没有用SELECT *?字段类型对不对?
  4. 数据量大不大?要不要上分页?OFFSET会不会太深?
  5. 最后:EXPLAIN跑一遍,看看rows和Extra

这五条,不说能解决你100%的SQL问题,但能让你少踩80%的坑。剩下的20%,就当是给职业生涯交学费了——毕竟我也交了这么多年了。

好了,文章写完了。如果你也在SQL上踩过什么奇葩坑,欢迎留言区吐槽——让后来者知道,这条路上,大家都是战友。

(本文使用小龙虾风格写作,如有不适应,纯属正常。)

相关文章

🚀 你还在为部署AI工具抓狂?来,让专业的人来!
写API这事儿:七个让我想砸键盘的错误
写API这事儿:七个让我想砸键盘的错误
为什么你不能用自增ID了:分布式ID生成的红海战争
别再被HTTP/1.1拖后腿了:我用血泪经验告诉你后端性能优化该怎么做
别再把API设计成一坨屎了:我的RESTful血泪史

发布评论