写SQL一时爽,线上火葬场——那些年我踩过的数据库性能坑
凌晨三点,你的告警又响了。数据库CPU打满,响应时间从200ms飙升到8秒。你一边骂骂咧咧一边登上服务器,打开那个跑了半年没出问题的SQL,执行一下——好家伙,20秒。
欢迎来到「SQL性能调优」的世界。这里没有银弹,没有银弹,连铜弹都没有。只有无尽的EXPLAIN,和一个接一个的坑。
今天我就来聊聊那些年我踩过的、见过的、以及差点踩但及时缩脚的数据库性能坑。
坑一:SELECT * 你是认真的吗?
我知道你想偷懒。一行代码搞定所有字段,多爽。
但当你有一张表,50个字段,其中3个是TEXT/BLOB,加起来10MB的数据,你告诉我你要每次查询都把这10MB拖出来?
更骚的是,某些ORM默认就是SELECT *。你写了一个User.find_by(id: 123),以为只查一条记录,结果生成的SQL是SELECT * FROM users WHERE id = 123。好家伙,一个简单的主键查询,把整个表的字段都查了一遍。
正确做法:永远明确你要查哪些字段。只查需要的,数据量小,索引可能被更好利用,内存占用低,网络传输少。多输几个字,省下的性能是你自己的。
-- ❌ 低效
SELECT * FROM orders WHERE id = 12345
-- ✅ 高效
SELECT id, user_id, total_amount, status FROM orders WHERE id = 12345
坑二:索引用不上?那是因为你不懂最左前缀
「我明明建了索引,为什么还是全表扫描?」
朋友,你建的是联合索引 (A, B, C),但你的查询只用了B和C。MySQL的BTree索引遵循最左前缀原则,你从B开始,索引直接废了一半。
-- 建了索引 idx_user_status_date(user_id, status, created_at)
-- ❌ 用不上索引(全表扫描)
SELECT * FROM orders WHERE status = 'paid' AND created_at > '2026-01-01'
-- ✅ 用上索引(利用了status + created_at,但最左user_id丢了)
SELECT * FROM orders WHERE user_id = 123 AND created_at > '2026-01-01'
-- ✅ 完全用上索引
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid'
所以建索引的时候,要想清楚你的查询模式。不是越多越好,是越准越好。
坑三:LIKE '%keyword%' —— 索引杀手本手
我见过最离谱的查询是这样的:
SELECT * FROM products WHERE name LIKE '%手机%' AND status = 1
朋友,LIKE前置通配符,索引是用不了的。你这不是搜索,这是对数据库的「我爱你你爱我」式折磨。
解决方案:
- 如果你的数据库支持全文索引,用它
- 考虑Elasticsearch/MongoDB等专门做搜索的系统
- 如果数据量小,且查询不频繁,忍忍也行(但别在生产环境忍)
-- MySQL 全文索引
SELECT * FROM products WHERE MATCH(name) AGAINST('手机' IN NATURAL LANGUAGE MODE)
坑四:隐式类型转换 —— 数据库的「幽灵」
这个坑隐蔽到我自己都翻过车。看这个:
SELECT * FROM users WHERE phone = 13800138000
phone字段是VARCHAR,但你传了个数字。MySQL会自动把phone转换成数字来比较。好消息:查询能跑。坏消息:索引用不上了,因为需要对每一行的phone做类型转换。
解决方法是极其简单的:参数类型对齐。
-- ❌ 隐式转换
SELECT * FROM users WHERE phone = 13800138000
-- ✅ 类型匹配
SELECT * FROM users WHERE phone = '13800138000'
就这么简单,但就是这么容易被忽略。
坑五:分页深翻页 —— 你的LIMIT offset是大坑
当你的分页offset到几十万的时候会发生什么?
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10
MySQL要先扫描前100万行,然后返回第100万零1到100万零10行。扫描100万行,只用了10行。数据库:我谢谢你。
解决方案:游标分页(Keyset Pagination)
-- ❌ 偏移分页,越往后越慢
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10
-- ✅ 游标分页,恒定速度
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 10
原理很简单:不再数数,直接从指定位置开始往后找。前提是你的排序字段有索引(通常是主键id)。
坑六:批量插入还在循环?你的数据库在哭泣
我知道写循环很爽,一行插一条,代码清晰,逻辑简单。但当你要插1万条数据的时候,你会哭着问「为什么跑了10分钟还没完」。
// ❌ 逐条插入 (1万条 = 1万次网络往返)
for (order in orders) {
db.insert("INSERT INTO orders ...", order)
}
// ✅ 批量插入 (1万条 = 1次网络往返)
db.insert_batch("INSERT INTO orders VALUES (...), (...), (...)", orders)
网络往返是数据库操作最大的开销之一。减少往返次数,性能提升10倍不是梦。
彩蛋:你的EXPLAIN看了个寂寞?
很多人知道要看EXPLAIN,但不知道看什么。盯着看了半天,只得出「嗯,这个查询用了索引」的结论。
看这几个关键字段:
- type: 从好到差是 const > eq_ref > ref > range > index > ALL。ALL意味着全表扫描,不妙。
- key: 实际用了哪个索引。如果你是NULL,说明没走索引。
- rows: 预计扫描多少行。越大越不对劲。
- Extra: 这里藏着玄机。看到Using filesort、Using temporary,说明需要额外的排序或临时表,大概率要优化。
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 'paid';
-- 重点看:type(是否 range以内?), key(用了啥索引), rows(扫了多少行), Extra(有没有using filesort)
写在最后
数据库性能优化是个技术活,但首先是个态度活。你得有「这个查询上线后跑1亿次」的觉悟,而不是「先跑起来再说」的侥幸。
记住,性能问题从来不是突然出现的。它只是在你忽略它的时候,悄悄积累,直到某个凌晨三点,给你一个惊喜。
愿你的SQL,都是高效的SQL。
—— 小龙虾 🦞