同样是查一张订单表,有人几十毫秒就能返回结果,有人一条 SQL 跑下去,数据库 CPU 直接飙高,接口跟着超时。
最气人的是什么?SQL 看起来还挺正常,代码能跑,测试环境也没问题,偏偏一到生产环境就露馅。
我平时排查 Java 项目的慢 SQL,有些问题真不是数据库不行,而是 SQL 写得太随意了。下面这 8 种写法,尤其是前几种,建议你提交代码前多看一眼。
1. 给索引字段套函数,索引可能白建了
比如根据手机号查询用户,手机号明明建了索引,却写成这样:
SELECT id, username
FROM user_info
WHERE SUBSTRING(phone, 1, 3) = '138';
问题在哪?
数据库需要对字段进行函数计算。普通 B+ 树索引通常无法直接利用这个条件定位数据,可能退化成全表扫描。
改成范围查询,或者直接传完整手机号:
SELECT id, username
FROM user_info
WHERE phone = '13800138000';
如果业务确实要查手机号前三位,可以考虑生成列、函数索引等方案,具体得看数据库版本。
记住,字段上有索引,不代表这条 SQL 就能走索引。
2. 隐式类型转换,自己给自己挖坑
Java 里经常遇到这种情况:数据库的 user_id 是 VARCHAR,接口传过来的参数却是 Long,最后 SQL 写成:
SELECT *
FROM user_info
WHERE user_id = 10086;
假设这是 MySQL,字符串列和数字比较时可能触发隐式类型转换,导致索引无法按预期使用。
正确做法是统一字段类型和参数类型:
SELECT id, username
FROM user_info
WHERE user_id = '10086';
Java 代码里也别偷懒,数据库字段是什么类型,参数就尽量保持一致。
线上遇到明明有索引却扫描大量数据的情况,我会把字段类型、参数类型和执行计划一起检查。
3. 模糊查询开头加百分号
后台管理系统经常有个搜索框,开发图省事,直接拼接:
SELECT id, order_no
FROM orders
WHERE order_no LIKE '%202610%';
普通 B+ 树索引很难利用这种前缀不确定的条件进行定位。
如果业务只需要按订单号前缀查询,可以改成:
SELECT id, order_no
FROM orders
WHERE order_no LIKE 'ORD202610%';
如果必须支持任意位置搜索,就别指望普通索引能解决所有问题,可以评估全文索引、专用搜索引擎等方案。
这里别为了走索引强行改业务语义,查出来的数据不对,比查询慢更麻烦。
4. SELECT *,查一条数据也不放过
不少 Java 项目里,Mapper 随手就是:
SELECT *
FROM orders
WHERE user_id = 10086;
订单表字段越来越多,地址、备注、扩展 JSON 全往外查。结果接口只需要订单号、金额和状态,却把一堆暂时用不到的数据搬回应用层。
改成明确指定字段:
SELECT order_no, amount, status
FROM orders
WHERE user_id = 10086;
这样能减少数据传输,也可能让查询利用覆盖索引,避免额外回表。
不过要注意,指定字段不代表一定更快,最终还是得结合表结构和执行计划判断。
5. 在 WHERE 中使用 OR,执行计划可能变复杂
比如订单列表需要查某个用户创建的订单,或者某个指定订单号:
SELECT id, order_no, status
FROM orders
WHERE user_id = 10086
OR order_no = 'ORD20261020001';
当两个条件涉及不同索引时,优化器可能选择索引合并,也可能判断其他执行计划更合适。数据量大、选择性差时,查询就可能变慢。
有些场景可以拆成两条查询,再使用 UNION:
SELECT id, order_no, status
FROM orders
WHERE user_id = 10086
UNION
SELECT id, order_no, status
FROM orders
WHERE order_no = 'ORD20261020001';
但别看到 OR 就机械替换。UNION 需要去重,可能增加额外开销;如果两个条件能走得很好的索引,原写法反而可能更合适。
先看执行计划,再决定要不要拆。
6. 深分页,越往后翻越慢
这是后台列表的常见问题。
SELECT id, order_no, amount
FROM orders
ORDER BY id DESC
LIMIT 500000, 20;
你以为只查 20 条,数据库就只处理 20 条?
并不是。为了跳过前面的记录,数据库可能需要扫描、读取大量索引记录,再返回目标数据。偏移量越大,成本通常越高。
如果页面支持按上一页最后一条记录继续翻,可以使用游标式分页:
SELECT id, order_no, amount
FROM orders
WHERE id < 880000
ORDER BY id DESC
LIMIT 20;
这里假设 id 是唯一递增主键,上一页最后一条记录的 ID 是 880000。
这种方式不需要每次跳过几十万条记录,适合连续翻页。但如果要支持任意跳到第 N 页,就得结合业务重新设计。
7. 对大表执行 COUNT(*),别当成没有成本
有些页面只是想显示一个总数,却每次请求都执行:
SELECT COUNT(*)
FROM orders
WHERE status = 1;
如果订单表非常大,而且查询条件选择性不高,这个统计就可能消耗不少 I/O 和 CPU。
更麻烦的是,开发在 Java 循环里反复调用它:
for (Long userId : userIds) {
long count = orderMapper.countByUserId(userId);
// 继续处理业务
}
这就不是单条 COUNT 的问题了,而是典型的 N+1 查询。
如果只是批量统计用户订单数,可以考虑一次分组查询:
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE user_id IN (1001, 1002, 1003)
GROUP BY user_id;
如果总数是高频展示指标,还可以根据一致性要求考虑缓存或维护统计表。
别为了页面上一个数字,让数据库反复干重活。
8. 在大事务里批量更新,锁和日志都可能扛不住
最后这个坑,经常藏在定时任务和数据修复脚本里。
UPDATE orders
SET status = 2
WHERE created_at < '2026-01-01';
SQL 本身没有语法问题,但如果命中几百万条记录,一次更新就可能产生大量 undo/redo 日志,持有锁的时间也可能很长,并影响其他事务。
如果业务允许分批处理,可以按主键范围推进:
UPDATE orders
SET status = 2
WHERE id > 100000
AND id <= 110000
AND created_at < '2026-01-01'
AND status <> 2;
Java 定时任务每次处理一批,确认事务提交成功后,再推进到下一批。
注意,这只是示意 SQL,实际还要处理失败重试、批次进度和并发更新。也不要简单地把所有批次塞进同一个大事务,否则分批的意义就打折了。
最后说一句
SQL 优化别靠猜,执行计划才是证据。
遇到慢 SQL,我一般会先拿到真实参数,再看 EXPLAIN,必要时结合慢查询日志确认扫描行数、索引使用情况和实际耗时。
尤其要留意 type、key、rows、Extra 这些信息。它们不一定能直接告诉你全部答案,但比盯着 SQL 文本反复琢磨靠谱得多。
还有,标题里说性能降低 100 倍,并不是每种错误写法都会慢 100 倍。数据量、索引、数据库版本、执行计划都会影响最终结果。有些 SQL 在测试环境跑得飞快,换到千万级数据的生产表上,就完全是另一回事了。
代码提交前多看一眼,可能省下的不是几毫秒,而是一次半夜起来排查慢 SQL 的机会。如果你也在系统性地梳理 SQL 优化思路,不妨逛逛云栈社区,里面有不少同行踩坑后的实战复盘。