人人都会AI编程

25.5 分页、关联、子查询常见错误写法

更新时间:2026-07-11

这三个话题几乎每个后端开发都会频繁接触,但也正是最容易写出慢 SQL 的地方。很多性能问题,表面看是数据库“扛不住”,实际根源往往就藏在这些常见错误里。

25.5.1 分页查询的常见错误与优化

错误一:深分页直接使用 LIMIT offset, size

这是分页中最经典也最隐蔽的性能陷阱。当你写 SELECT * FROM orders ORDER BY id LIMIT 100000, 20 时,MySQL 需要先读取前 100020 行数据,然后丢掉前 100000 行,只返回最后 20 行。offset 越大,需要扫描和丢弃的行越多,SQL 也就越慢,几千页之后可能直接从毫秒级跌到秒级。

错误原因:没理解 LIMIT offset 的代价,以为它会直接跳到 offset 位置,其实不是。

正确做法:推荐使用“延迟关联”或“记录游标”方案。

延迟关联:先在索引上提取主键做分页,再用主键回表取完整行。

SELECT * FROM orders 
WHERE id >= (
    SELECT id FROM orders ORDER BY id LIMIT 100000, 1
)
ORDER BY id LIMIT 20;

或者等价的两步查询:

SELECT id FROM orders ORDER BY id LIMIT 100000, 20;
-- 拿到这20个id,再到应用层拼成 IN(1,2,3...),或者直接再查
SELECT * FROM orders WHERE id IN (100001, 100002, ...);

记录游标:如果产品允许展示“上一页”“下一页”,那就用记录游标代替页码分页。把上一页最后一条记录的 id 或 time 传过来,查下一页时用 WHERE id > last_id ORDER BY id LIMIT 20。这种方式无论翻到第几页,效率始终是常数级。

错误二:分页时覆盖索引被忽略

很多人知道覆盖索引能避免回表,但在分页场景下忘了合理利用。如果 SELECT * 会造成回表,而你又不需要展示全部字段,不妨只查必要的列并建好联合索引使其覆盖。

-- 错误:深分页加回表,慢
SELECT * FROM orders WHERE status = 1 ORDER BY create_time LIMIT 100000, 20;

-- 优化:如果只需要 id, create_time, amount,建联合索引 (status, create_time, amount)
SELECT id, create_time, amount FROM orders WHERE status = 1 ORDER BY create_time LIMIT 100000, 20;

索引覆盖后,深分页扫描的数据页更少,性能大幅提升。

错误三:分页中排序键不稳定

当排序列没有唯一性保证时,同一排序值的顺序可能不确定,导致翻页出现重复或遗漏数据。

-- 按点赞数排序,但很多记录点赞数相同,返回结果可能前后页重复
SELECT * FROM posts ORDER BY likes DESC LIMIT 0, 20;
SELECT * FROM posts ORDER BY likes DESC LIMIT 20, 20;

正确做法:在 ORDER BY 后面再追加一个唯一键,复合排序。

ORDER BY likes DESC, id ASC

这样顺序完全确定,分页不会乱。

25.5.2 关联查询的常见错误与优化

错误一:驱动表选择不合理

多表连接时,优化器会自动选择驱动表,但统计信息不准或 SQL 写法导致选错时,大表当驱动表会造成大量扫描。

-- 假设 users 10万行,orders 1000万行,如果优化器误选 users 驱动
SELECT * FROM users u JOIN orders o ON u.id = o.user_id WHERE o.status = 1;

如果 orders 的 status 选择性高,应该让 orders 作为驱动表(先通过 status 过滤,再访问 users)。你可以用 STRAIGHT_JOIN 强制表顺序,或者调整 SQL 帮助优化器选择。

-- 明确告诉 MySQL 左侧表做驱动表
SELECT * FROM orders o STRAIGHT_JOIN users u ON o.user_id = u.id 
WHERE o.status = 1;

不过大部分时候只要统计信息和索引正确,优化器能选对。你更该留意的是:被驱动表的连接列一定要有索引,否则会走全表扫描。

错误二:被驱动表连接列无索引

这是造成关联查询巨慢的罪魁祸首。如果在 ON 条件列上没有索引,每驱出一行,就要对被驱动表全表扫描一次。

SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.id;
-- orders.user_id 或 users.id 必须有索引。users.id 是主键,天然有索引,安全。

但如果连接条件复杂,涉及函数运算等导致索引失效,也会触发此问题。

错误三:在 ON 条件中写过滤条件

很多人搞混 ONWHERE 的区别,把本该放在 WHERE 的条件写在 ON 里,导致连接效率低。

-- 错误:将 status 过滤写在 ON 中
SELECT * FROM orders o LEFT JOIN users u ON o.user_id = u.id AND o.status = 1;

-- 这种写法对于左连接,会保留所有 orders 行,只是不匹配的 users 字段为 NULL
-- 但如果你本意是想筛选 status=1 的订单,就应该用 WHERE
SELECT * FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.status = 1;

内连接(INNER JOIN)时,条件写在 ON 或 WHERE 结果相同,但为了可读性,连接条件放 ON,过滤条件放 WHERE 是通用规范。

错误四:不必要的多表关联

“联表越多,性能越差”是铁律。有些开发者喜欢一次查出所有关联数据,三四个表连在一起,结果 SQL 执行计划变得异常复杂,还可能产生笛卡尔积。

SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_details d ON o.id = d.order_id
JOIN products p ON d.product_id = p.id
WHERE o.status = 1;

如果不需要所有表的字段,不妨拆解查询:先查订单列表,再根据订单 ID 批量查商品信息,在应用层组装。这样不仅数据库压力小,代码也更清晰。

25.5.3 子查询的常见错误与优化

错误一:在 IN 后面使用非关联子查询导致性能低下
-- 打算查询有订单的用户,错误的写法
SELECT * FROM users WHERE id IN (
    SELECT DISTINCT user_id FROM orders
);

MySQL 5.6 及之前版本对此类子查询优化不佳,可能导致子查询先全量执行或外部重复执行。8.0 已经有很多优化,但仍可能不如用 EXISTS 或直接连接。

更好的写法

-- 使用 EXISTS,语义清晰且通常执行计划更好
SELECT * FROM users u WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- 或者直接用 JOIN
SELECT DISTINCT u.* FROM users u JOIN orders o ON u.id = o.user_id;

实际中,JOIN + DISTINCT 一般性能最优,因为可以利用索引去重,但会产生重复行,要评估对业务的影响。

错误二:SELECT 列表中使用标量子查询
SELECT u.id, u.name,
    (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u;

每输出一行用户,子查询就会执行一次。如果 users 有 10000 行,子查询就执行 10000 次。这通常被称为 N+1 问题。

解决方法:改用 JOINGROUP BY,一次性聚合。

SELECT u.id, u.name, COALESCE(o.cnt, 0) AS order_count
FROM users u
LEFT JOIN (
    SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id
) o ON u.id = o.user_id;

这样只需要一次关联聚合,效率有质的提升。

错误三:在 WHERE 中对索引字段使用子查询导致无法使用索引
SELECT * FROM products WHERE price > (
    SELECT AVG(price) FROM products
);

这里子查询的结果可以缓存,但外层 price > 比较会扫描全表,因为子查询结果是变量,不走索引(除非使用特定优化)。如果能预先计算出平均值,直接作为常量写进 SQL 会更好。

SELECT AVG(price) INTO @avg_price FROM products;
SELECT * FROM products WHERE price > @avg_price;

这样第二条查询就可以利用 price 索引进行范围搜索。

错误四:NOT IN 碰上 NULL 值
SELECT * FROM users WHERE id NOT IN (
    SELECT user_id FROM blacklist
);

如果 blacklist.user_id 列中存在 NULL 值,那么 NOT IN (..) 的结果整表都会变成空。因为 NULL 与任何值比较结果都是 UNKNOWNNOT IN 要求所有比较都为 TRUE,一旦遇到 UNKNOWN 就会否定整个结果。这是典型的“无声陷阱”。

解决方法:要么确保子查询结果不含 NULL,要么用 NOT EXISTS 替代。

SELECT * FROM users u WHERE NOT EXISTS (
    SELECT 1 FROM blacklist b WHERE b.user_id = u.id
);

NOT EXISTS 不受 NULL 影响,语义更安全。

错误五:子查询未加别名或引用错误

MySQL 5.7 及之前版本,子查询作为派生表时必须要有别名,否则报错。

-- 错误,缺少别名
SELECT * FROM (SELECT id, name FROM users WHERE status = 1);
-- 正确
SELECT * FROM (SELECT id, name FROM users WHERE status = 1) AS t;

这是一个只堵住新人几分钟的小坑,但从 8.0 开始,派生表理论上必须用别名,实际中为清晰起见依然建议加上。


分页、关联、子查询这三大块,错误写法往往有共同的根源:没有了解数据库的实际执行方式,凭直觉写 SQL。养成用 EXPLAIN 查看执行计划的习惯,留意 rowsExtratype,就很容易识别出这些常见错误并及时修正。