干后端开发的,谁没写过几千行 SQL?但我观察到一个可怕的事实:大多数人的 SQL 水平,三年前和三年后没有任何区别——不是因为他们没进步,而是因为没人告诉他们写的有多烂。
今天我不讲什么「索引是数据库的灵魂」这种正确的废话。我来讲点真正让人血压飙升的反模式,以及怎么绕过这些坑。
反模式一:SELECT * — 懒狗的万金油
我敢打赌,看这篇文章的人,80% 写过 SELECT * FROM users。别装了,我知道你写过。
问题在哪?
- 网络传输多了好几倍无用数据,你的 DBA 看到监控会想砍人
- 表加了新字段,你的后端莫名其妙崩了——因为 JSON 序列化突然多了个未知属性
- 覆盖索引失效,查询直接从索引查找变成回表,性能腰斩
-- 反模式:SELECT *
SELECT * FROM orders WHERE user_id = 123;
-- 正确姿势:只拿你需要的东西
SELECT order_id, total_amount, created_at
FROM orders
WHERE user_id = 123;
有人会说「我就是要全部字段啊」。行,你赢了。但你确定你真的需要那张表里的全部 47 个字段?连个 deleted_at 和 backup_flag 都要?
反模式二:LIKE '%关键字%' — 索引杀手
这个我必须单独拿出来喷。
-- 索引:请让我好好工作行吗
SELECT * FROM products WHERE name LIKE '%手机%';
前面加了百分号,索引直接报废。B+树在这种情况下一脸懵逼,只能老老实实全表扫描。你的表有 1000 万条数据?好的,请等待 30 秒。
解决方案:
- 全文索引:
FULLTEXT INDEX(name),这个是正经解决方案 - ES/Solr:数据量大的时候,该上搜索引擎就上,别硬撑
- 前缀匹配:如果是
LIKE '手机%',普通索引还是能用的(只是大多数人不知道这个区别)
反模式三:隐式类型转换 — 阴险小人
这个坑特别阴,因为它不报错,甚至看起来完全正常,但性能就是差。
-- user_id 是 BIGINT 类型
-- 反模式:传入字符串
SELECT * FROM users WHERE user_id = '12345';
MySQL 看到字符串和数字比较,会偷偷把字符串转成数字。这个过程发生在每一行,意味着索引失效。
怎么发现?用 EXPLAIN 看执行计划,如果 type 是 ALL 或者 rows 远大于预期,就该怀疑这个了。
反模式四:N+1 查询 — 温水煮青蛙
这个问题特别恶心,因为单个请求看起来没问题,等流量上来才发现数据库被打死了。
// 伪代码,后端语言
const users = db.query('SELECT * FROM users LIMIT 100');
for (user of users) {
// 每次循环都执行一次新查询!!!
user.orders = db.query(
'SELECT * FROM orders WHERE user_id = ?',
user.id
);
}
// 100个用户 = 101次数据库往返
// 1000个用户 = 1001次,你服务器凉了
正确做法是 JOIN 或者批量查询:
-- 方案一:JOIN
SELECT u.*, o.order_id, o.total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id IN (1,2,3...100);
-- 方案二:两次查询 + 程序里组装
const users = db.query('SELECT * FROM users LIMIT 100');
const userIds = users.map(u => u.id);
const orders = db.query(
'SELECT * FROM orders WHERE user_id IN (?)',
[userIds]
);
// 程序里按 user_id 分组组装
方案一简单粗暴,方案二在数据量大的时候性能更好(避免了大表 JOIN 的问题)。
反模式五:没有分页意识 — 内存杀手
我见过最夸张的一个案例:一张表 5000 万数据,前端一个下拉框要显示选项,后端直接 SELECT id, name FROM categories 全部返回。然后 JavaScript forEach 渲染,卡出翔。
-- 反模式:全表返回
SELECT id, name FROM categories;
-- 正确姿势:分页
SELECT id, name FROM categories LIMIT 20 OFFSET 0;
-- 如果是下拉框这种场景,直接缓存吧
-- Redis / 内存缓存,别每次都查数据库
还有一种更阴险的:LIMIT 100000, 20。OFFSET 越大,数据库越慢,因为它要先跳过前 10 万行。如果你的分页慢,试试游标分页(keyset pagination):
-- 游标分页:利用索引,速度恒定
SELECT * FROM orders
WHERE id > last_seen_id
ORDER BY id
LIMIT 20;
反模式六:事务里写网络请求 — 自杀式开发
这个坑,杀伤力极大,但中招的人巨多。
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 调用外部服务,假设网络超时了...
http.post('http://payment-service/api/charge', data); // 网络请求!
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 此时事务还挂着,数据库连接被占用
-- 并发一高,连接池耗尽,服务雪崩
事务里不要有任何网络请求、日志写入、长计算。能拆就拆,拆不了就用消息队列解耦。
反模式七:忽视慢查询日志 — 掩耳盗铃
MySQL 自带慢查询日志,但多少人真的看过?
-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
-- 设置慢查询阈值(毫秒)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过1秒的才记录
-- 查看慢查询日志路径
SHOW VARIABLES LIKE 'slow_query_log_file';
有了数据之后,用 pt-query-digest 分析,这才是正确的打开方式。别告诉我你只会 EXPLAIN 然后「嗯,这个扫了全表,优化一下吧」——你连慢在哪里都不知道,优化个寂寞。
说在最后
SQL 优化这件事,归根结底是三件事:
- 知道你跑的是什么(EXPLAIN + 慢查询日志)
- 知道数据库怎么跑的(索引原理、事务机制、执行计划)
- 知道什么不该写(这些反模式)
很多人连第三条都没做到,就急着去学什么「数据库架构设计」「分库分表实战」——兄弟,路还没学会走呢,别急着跑。
数据库是最诚实的:你糊弄它,它就糊弄你。
有问题欢迎留言,数据库相关的坑我踩过不少,问的人多的话,我再出一篇讲讲生产环境里的 SQL 优化真实案例。