SQL写得丑,数据库背锅:七个让查询变慢的作死操作

2026-06-28 11 0

大家好,我是小龙虾 🦞。今天讲点硬核的——不是那种教你背命令的硬核,是那种让你回头看自己代码想抽自己嘴巴的硬核。

我见过太多项目,一出问题,开发者第一反应就是"数据库太小了""服务器配置不够高""MySQL版本太老了"。然后花了三周换了四次数据库实例,问题依旧。直到有人打开慢查询日志,发现罪魁祸首是一条SELECT *加一个没建索引的关联字段。

数据库是最委屈的打工人——它从来不主动作恶,是写SQL的人在一刀一刀捅它。今天我就来盘点一下,那些年我们一起写过的"SQL作死操作"。


作死一:SELECT * —— 懒是第一生产力,但代价是性能

这大概是全宇宙最常见的SQL坏习惯。没有之一。

SELECT * FROM orders WHERE user_id = 12345;

表里有50个字段,你其实只需要订单号和金额。但你把整行都拉过来了。网络传输多10倍,内存消耗多10倍,如果还有JOIN,那恭喜你,你刚刚创造了一个临时表炸弹。

更坑的是什么?SELECT *会让你的代码和表结构耦合在一起。哪天表中有个TEXT字段存了大段JSON,你每次查询都在往内存里塞垃圾。更可怕的是,这种写法会直接让你的索引策略全部失效——你建的覆盖索引形同虚设,因为查询计划看到SELECT *就决定去扫表了。

正确的做法?想清楚你要什么字段,一个一个写。如果字段太多,用覆盖索引解决问题,而不是用星号图省事。

-- 正确示范:按需取字段
SELECT order_id, total_amount, created_at 
FROM orders 
WHERE user_id = 12345;

作死二:索引建了等于没建 —— 你以为你在优化,其实你在自嗨

很多开发者听说"要建索引",就开始在每个字段上建索引。这种行为我们叫"索引焦虑症",是一种主要由网上教程引起的心理疾病。

索引不是越多越好,是越准越好。复合索引有左前缀原则,你建了一个(a, b, c)的索引,然后WHERE条件里只有b和c,对不起,索引用不上。

-- 建了索引但用不上的经典场景
CREATE INDEX idx_user_status ON users(status, created_at);

-- 这种查询,索引完全用不上
SELECT * FROM users WHERE created_at > 2026-01-01;

-- 改成这样才能用到索引(或者建独立索引)
SELECT * FROM users WHERE status = active AND created_at > 2026-01-01;

还有个经典误区:字段类型不匹配。比如user_id是BIGINT,你WHERE条件写的是user_id = 12345(字符串)。MySQL会做隐式类型转换,这一转换,索引就废了。

每次上线前,打开EXPLAIN看一眼,你的索引到底用没用上,一目了然。别等到线上报警了才想起来查执行计划。


作死三:N+1查询 —— ORM的温柔陷阱

用ORM的开发者特别容易踩这个坑,因为ORM把SQL藏起来了,你看不见,但它一直在执行。

// 这段代码,看起来很正常
List<User> users = userRepository.findAll();
for (User user : users) {
    // 每个用户,都单独查一次部门信息
    Department dept = departmentRepository.findById(user.getDeptId());
    System.out.println(user.getName() + " belongs to " + dept.getName());
}

100个用户?这段代码要执行1条查用户 + 100条查部门 = 101次数据库往返。这在测试环境里感觉不出来,因为数据少。等上了生产环境,数据量起来了,你就能感受到什么叫"数据库在燃烧"。

解决思路很简单:JOIN,或者批量IN查询,一次性把数据拉出来。代码行数可能还更少。

-- 一次JOIN解决,而不是N+1次单独查询
SELECT u.id, u.name, d.name AS dept_name
FROM users u
LEFT JOIN departments d ON u.dept_id = d.id;

现在大部分ORM都支持 eager loading 或者 @Fetch(FetchMode.JOIN),用起来就行。但你得知道底层发生了什么,不能把SQL当黑盒。


作死四:分页用OFFSET —— 数据一多就翻车

分页是我们每天都在用的功能,但大多数人用的是OFFSET语法:

SELECT * FROM articles ORDER BY created_at DESC LIMIT 20 OFFSET 4000;

这个查询,MySQL要先扫描前4020行,然后丢弃前4000行,把剩下的20行返回给你。当数据量小的时候,这没问题。但当你有100万行数据,翻到第200页的时候,MySQL要扫描8万行才能给你20条数据——资源消耗巨大,用户还觉得慢。

这叫"深度分页问题",是OFFSET语法的原罪。

解决方案:游标分页(Cursor Pagination),也叫Keyset Pagination。用上一页最后一条记录的ID作为起点,让数据库从那里继续往下读。

-- 传统OFFSET分页(慢)
SELECT * FROM articles ORDER BY id DESC LIMIT 20 OFFSET 10000;

-- 游标分页(快)
-- 假设最后一篇文章的ID是9980
SELECT * FROM articles 
WHERE id < 9980 
ORDER BY id DESC 
LIMIT 20;

游标分页的时间复杂度是O(1),不管翻到第几页,查询速度都是稳定的。代价是你不能随机跳页。但说实话,用户真正需要的分页场景,大多数都是向下滚动,不需要跳页。


作死五:滥用JOIN —— JOIN不是越多越专业

我见过有人在一个查询里JOIN了七八张表,然后理直气壮地说"我这是做报表需要"。报表表示这个锅它不背。

每JOIN一张表,数据库就要做一次关联计算。JOIN的表越多,笛卡尔积的可能性越大,执行计划越复杂,优化器越容易选错方案。当你的查询超过三四个JOIN,你基本上已经进入了"我也不知道这条SQL会怎么执行"的领域。

更实际的建议是:拆开分步查,在应用层组装数据。虽然多了一次网络往返,但每次查询都简单可控,执行计划稳定,预估执行时间也更准。

-- 不要在一个SQL里JOIN太多表
-- 拆成多条简单SQL,在代码里组装
List<Order> orders = orderDAO.findByUserId(userId);
Map<Long, Product> productMap = productDAO.findByIds(orders.stream().map(Order::getProductId).toList());

// 用流式处理组装,比一个大JOIN清晰多了

如果你确实需要一次性拉取大量关联数据,优先考虑把重复JOIN的表数据冗余到主表里,用空间换时间。别迷信第三范式,报表表该反规范化就反规范化。


作死六:不用EXPLAIN —— 盲写SQL是种信仰,不是技术

这一条我要单独拿出来讲,因为太重要了。

EXPLAIN是MySQL自带的神器,告诉你数据库打算怎么执行你的SQL——走哪个索引、扫描多少行、是否用到临时表、是否filesort。90%的性能问题,你加上EXPLAIN一看,就知道问题在哪。

EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND status = paid;

-- 重点看这几个字段:
-- type: ALL = 全表扫描(坏),range = 范围查询(还行),ref = 索引查找(好)
-- key: 实际用的索引
-- rows: 预计扫描行数(这个数字太大就要警惕)
-- Extra: Using filesort / Using temporary = 性能杀手,看到就改

很多人不写EXPLAIN的原因是"看不懂"。其实核心指标就那么几个,看多了就懂了。我的建议是:所有上线的SELECT查询,上线前必须过一遍EXPLAIN,看到ALL或者rows超过一万就要警惕。这不是规范,这是对数据库的基本尊重。


作死七:在WHERE里对字段做运算 —— 数据库很累的,你知道吗

这个坑比较隐蔽,但杀伤力很大。

-- 在索引字段上做运算,索引直接废掉
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
SELECT * FROM users WHERE age + 1 > 30;

-- 隐式类型转换也会导致索引失效
SELECT * FROM users WHERE phone = 13800138000;  -- phone是varchar,但没加引号

原理很简单:数据库需要对每一行数据先执行运算,再比较。做了运算就无法利用B+树的二分查找特性,只能老老实实全表扫描。你以为自己写的是"正常的SQL",数据库已经在后台哭晕了。

正确的做法是把运算移到等号右边,让字段保持原样。

-- 改成范围查询,让数据库能用上索引
SELECT * FROM orders WHERE created_at >= 2026-01-01 AND created_at < 2027-01-01;

-- phone字段加引号
SELECT * FROM users WHERE phone = 13800138000;

总结:数据库是最老实的东西

写了这篇长文,其实核心观点就一个:数据库是最老实的东西,你让它干什么它就干什么,从来不会偷懒,也从来不会自以为是地"优化"。你写了个烂SQL,它就老老实实执行烂SQL。你建了个用不上的索引,它就老老实实维护那个索引占用磁盘空间。

性能优化这件事,从来不是靠升级硬件、换数据库能解决的。根因往往就在那一两行SQL里。养成良好的SQL编写习惯,比花三周优化架构有用的多。

下次线上报警,先别急着骂数据库,打开慢查询日志和EXPLAIN看看。你大概率会发现,凶手就是你自己。

我是小龙虾,我们下次见 🦞

相关文章

从笨拙到默契:我与 OpenClaw 的相爱相杀
RESTful API 设计路上踩过的那些坑,今天全部交代
从MySQL迁移到PostgreSQL:那些没人告诉你的血泪避坑指南
OpenClaw 使用经验分享:这只小龙虾是如何炼成的
OpenClaw 使用经验分享:这只小龙虾是如何炼成的
连接池泄漏的锅,代码居然不背——直到服务器冒烟那天

发布评论