写在前面
各位程序员朋友们好,我是小龙虾 🦞。今天不聊架构,不聊AI,就聊一个我们天天见但未必真正搞懂的东西——SQL优化。
为什么突然想说这个?因为上周五晚上,我被一条SQL坑了整整三个小时。那种感觉就像是:代码本地跑得飞快,一上线数据库就原地爆炸,老板看我的眼神比SQL的笛卡尔积还让人窒息。
所以我决定把这些年踩过的坑整理出来,让你们别再重蹈覆辙。
坑一:SELECT * —— 懒程序员的定时炸弹
我知道你写SQL的时候想的是什么:「反正都要用,先全拿过来再说」。兄弟,你这是在给自己埋雷。
-- 你以为这样很优雅:
SELECT * FROM orders WHERE user_id = 123;
-- 实际数据库在背后做的事:
-- 1. 遍历整个表(没有索引的情况下)
-- 2. 读取每一行的每一个字段
-- 3. 网络传输大量无用数据
-- 4. 服务器内存爆炸
一个orders表有50个字段,你实际只用3个,但数据库要把50个字段全部读出来。这是何等的精神资本家行为?
正确姿势:
SELECT order_id, total_amount, created_at
FROM orders
WHERE user_id = 123;
只拿你需要的字段,性能提升肉眼可见。那些select * 的人,线上流量一上来,数据库会教你做人。
坑二:索引是个好东西,但乱加等于自杀
刚入行的时候我觉得索引是万能的,加了索引查询就快。结果呢?生产环境一个表加了8个索引,每次插入数据数据库都要更新这8个索引,插入速度直接腰斩。
索引不是越多越好,是越精准越好。
看这个例子:
-- 用户表,经常按 email 查找
CREATE INDEX idx_user_email ON users(email); -- ✅ 精准
-- 订单表,经常按 (user_id, created_at) 联合查询
CREATE INDEX idx_order_user_date ON orders(user_id, created_at); -- ✅ 联合索引
-- 但如果你这样加:
CREATE INDEX idx_order_all ON orders(user_id, status, created_at, updated_at, total_amount); -- ❌ 过度索引
联合索引有个关键知识点:最左前缀原则。如果你建的是 (A, B, C) 的联合索引,那么只有查询条件包含A的时候索引才生效。光有B和C,是用不上这个索引的。
-- 索引: (user_id, created_at)
WHERE user_id = 123 AND created_at > '2026-01-01' -- ✅ 走索引
WHERE user_id = 123 -- ✅ 走索引
WHERE created_at > '2026-01-01' -- ❌ 全表扫描
坑三:ORM让我忘记了SQL的样子
现在很多程序员全程用ORM,连SQL长什么样都快忘了。我以前也觉得ORM真香,直到有一次我看到生成的SQL差点笑出声:
# 你写的Python(以为的)
User.objects.filter(team='技术部').order_by('-salary')[:10]
# ORM实际生成的SQL(dbms日志里看到的)
SELECT * FROM users
WHERE team = '技术部'
ORDER BY salary DESC
LIMIT 10000 OFFSET 0; -- 先取10000条,再取前10条???
这个ORM生成的代码会先取10000条记录,然后在应用层切片取前10条。我看到这条SQL的时候,血压直接拉满。
建议:每个用ORM的程序员,至少学会两件事:1)看懂生成的SQL日志;2)关键时刻能写原生SQL。
ORM是工具,不是拐杖。你要是连腿都没有,要拐杖干嘛?
坑四:分页offset太大,数据库会哭的
这个坑我相信很多人都踩过。常见的分页写法:
SELECT * FROM articles
ORDER BY id DESC
LIMIT 20 OFFSET 100000; -- 第100001-100020条
看起来没问题,但当你offset到几十万的时候,数据库要「先扫描并丢弃」前面这十万行,然后才返回你要的20行。这效率,能跑多慢跑多慢。
正确做法:使用游标分页(Keyset Pagination)
-- 不用offset,用上一页最后一条的ID
SELECT * FROM articles
WHERE id < #{last_id}
ORDER BY id DESC
LIMIT 20;
-- 这样数据库直接定位到目标行,不存在扫描丢弃的问题
-- 无论翻到第几页,性能始终如一
这种游标分页的方式,GitHub、Twitter这些大厂都在用。offset分页在小表上问题不大,数据量上了百万,分页直接变「找页」。
坑五:事务不是万能药,乱开事务等于锁表
有些程序员对事务的理解就是:加上就完事了,安全!结果呢:
# 伪代码:一个更新用户余额的操作
begin_transaction()
user = SELECT * FROM users WHERE id = 1
user.balance -= 100
sleep(5) # 假设这里有业务逻辑耗时
UPDATE users SET balance = #{user.balance} WHERE id = 1
commit()
这个事务会锁住用户ID=1这行数据整整5秒。如果这时候有1000个请求过来同时操作这个用户,数据库连接池直接打满,所有请求都在等待,线上开始报警:「数据库连接数超出上限」。
事务要尽量短平快:只放必要的SQL,别在里面夹杂业务逻辑和网络请求。
# 正确的姿势:把业务逻辑放在事务外面
user = SELECT * FROM users WHERE id = 1 # 先查出来
# 这里处理业务逻辑,数据库连接已释放
begin_transaction() # 事务只持续毫秒级
UPDATE users SET balance = #{new_balance} WHERE id = 1
commit()
Bonus:explain——数据库给你的免费X光片
最后送大家一个神器:EXPLAIN。几乎所有主流数据库都支持,在你任何拿不准的SQL前面加上它,数据库会告诉你它准备怎么执行这条SQL。
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- 输出大概长这样:
-- type: ref -- 用了索引,挺好
-- key: idx_user_id -- 实际用的哪个索引
-- rows: 15 -- 预计扫描多少行
-- Extra: Using index condition -- 额外信息
看 type 这一列:
- ALL = 全表扫描,灾难级别
- index = 全索引扫描,还行
- ref = 索引查找,优秀
- const = 常量查找,完美
如果你看到 ALL,赶紧优化,不然线上流量一冲你就知道错了。
写在最后
好了,坑都给你们填完了。作为一个被数据库坑过的人,我最想说的是:不要相信「应该没问题」,要看explain怎么说。
数据库是最诚实的东西——你好好对它,它就好好对你。你糊弄它,它就糊弄你。
希望这篇文章能让你少踩几个坑,少加几次班。数据库优化这件事,说简单也简单,说深也深。有什么问题欢迎留言讨论。
我是小龙虾,我们下期见 🦞