问题1:LEFT JOIN后数据缺失(预期保留左表所有行,实际仅返回匹配行)
在 LEFT JOIN 后,我们使用 WHERE 子句对右表字段进行过滤(如:WHERE o.order_time >= '2024-01-01'),这会导致左表中未匹配到右表的记录被意外排除。这是因为 WHERE 子句在 JOIN 操作之后执行,此时右表的字段值为 NULL,不满足 WHERE 条件,从而被过滤掉。
正确做法:将针对右表的筛选条件移至 ON 子句中,确保在关联阶段就完成过滤,从而真正保留左表的全部行。如果需要同时对左表字段进行过滤(例如,只想查询活跃用户),该条件则应保留在 WHERE 子句中,因为它是对最终结果集的筛选,不影响 LEFT JOIN 的语义。
示例:
错误写法:
SELECT u.user_id, o.order_id
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.order_time >= '2024-01-01';
正确写法:
SELECT u.user_id, o.order_id
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id AND o.order_time >= '2024-01-01'
WHERE u.user_status = 'active';
注意:如果右表字段本身有非空约束(NOT NULL),那么 ON 与 WHERE 的效果可能一致。但在通用场景下,我们仍应严格遵循“筛选右表用 ON”的原则,以保证代码意图清晰和跨数据兼容性。
验证方法:可以分别执行 SELECT COUNT(*) FROM users WHERE user_status = 'active' 与 SELECT COUNT(*) FROM (上述 LEFT JOIN 查询) AS t 进行对比,确认左表的有效行数没有减少。
问题2:GROUP BY后结果“随机”或报错
在 MySQL 中,当 sql_mode 未启用 ONLY_FULL_GROUP_BY 时,它允许 SELECT 列表中出现非 GROUP BY 字段,系统会返回该分组中某一行的值(看起来是“随机”的)。但从 MySQL 5.7 起,该模式默认开启,如果 SELECT 字段既不在 GROUP BY 中,又非聚合函数,则会直接报错。
因此,常说的“升级后报错”需要修正为:“当 sql_mode 包含 ONLY_FULL_GROUP_BY 时会报错”,并非所有 5.7+ 版本的实例都必然报错。
解决方法:针对非分组字段,我们必须使用聚合函数(如 MAX、MIN)或 GROUP_CONCAT 来合并多值。对于更复杂的“取整行”需求,在支持窗口函数的数据库(如 MySQL 8.0+、PostgreSQL)中,可以使用 ROW_NUMBER() 来精准获取。
示例:
错误写法(依赖数据库的非标准宽松模式):
SELECT user_id, order_id
FROM orders
GROUP BY user_id;
正确写法:
-- 方法一:取最新订单ID(使用聚合函数)
SELECT user_id, MAX(order_id) AS latest_order_id
FROM orders
GROUP BY user_id;
-- 方法二:合并所有订单ID(适用于数据量小的场景)
SELECT user_id, GROUP_CONCAT(order_id ORDER BY order_time DESC) AS all_orders
FROM orders
GROUP BY user_id;
-- 方法三:精准取最新订单的整行信息(MySQL 8.0+ 使用窗口函数)
SELECT user_id, order_id, order_time
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn
FROM orders
) t
WHERE rn = 1;
验证方法:结合业务逻辑,确认聚合结果是否符合预期。可以通过执行 SELECT user_id, COUNT(*) FROM orders GROUP BY user_id 来验证分组数量的一致性。
问题3:LIMIT OFFSET 分页越往后越慢
像 LIMIT 100000, 10 这样的查询,数据库需要先扫描并丢弃前 100000 行,这导致性能随着偏移量 (OFFSET) 的增大而急剧下降。
游标分页(也叫“seek method”,基于主键或唯一索引进行过滤)是高效的替代方案,但它有局限性:仅适用于按单调递增主键排序,且数据没有频繁删除的场景。如果主键不连续(如使用 UUID),或者需要按其他字段(如 order_time DESC)排序,就不能简单地用 id > last_id。此外,游标分页不支持跳页,只适用于“上一页/下一页”式的连续翻页。
更通用的优化建议:
-
场景1:按主键排序
这是最理想的情况,可以直接使用游标分页。
SELECT user_id, order_id
FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 10;
-
场景2:按非主键字段排序(如 order_time DESC)
此时可以采用“延迟关联” (deferred join) 技术,并结合覆盖索引来优化。
SELECT o.user_id, o.order_id
FROM orders o
INNER JOIN (
SELECT id
FROM orders
ORDER BY order_time DESC
LIMIT 100000, 10
) tmp ON o.id = tmp.id
ORDER BY o.order_time DESC;
关键前提:必须在 (order_time, id) 上建立复合索引,且顺序为“排序字段在前,主键在后”。这样,子查询可以完全通过索引完成(无需回表),实现高效定位。
重要提示:尽管子查询已经按 order_time DESC 排序并返回了有序的 id 列表,但 SQL 不保证 JOIN 操作会保留输入的顺序。因此,主查询中我们仍然需要显式使用 ORDER BY o.order_time DESC 来确保最终结果的顺序正确。即使 id 与 order_time 在业务上存在严格的单调关系,也建议保留此排序,以避免因执行计划变更或数据异常导致结果乱序。
验证方法:使用 EXPLAIN 或 EXPLAIN ANALYZE 查看执行计划,确认子查询是否使用了索引扫描(显示 Using index)。同时,对比查询不同页码时的执行时间,确保响应时间稳定(例如,始终小于 100ms)。
问题4:ORDER BY NULL 排序位置在不同数据库中不一致
这个问题的核心在于不同数据库对 ORDER BY 中 NULL 值的默认处理方式不同:
MySQL 默认将 NULL 视为最小值(排在最前面)。
PostgreSQL 默认将 NULL 视为最大值(排在最后面)。
如果不加处理,跨数据库迁移或协作时,查询结果的排序就会出现差异。
解决方法一(推荐,遵循 PostgreSQL/Oracle/标准SQL):显式控制 NULL 的位置。
SELECT category_id, COUNT(*)
FROM products
GROUP BY category_id
ORDER BY category_id NULLS FIRST; -- 或 NULLS LAST
解决方法二(追求跨库兼容):使用 COALESCE 函数将 NULL 替换为一个特定值。注意,替换值(如 -1)必须不在原字段的有效取值范围内,以免造成错误分组。
SELECT COALESCE(category_id, -1) AS cate_id, COUNT(*)
FROM products
GROUP BY COALESCE(category_id, -1)
ORDER BY cate_id;
解决方法三(MySQL 兼容性模拟):通过条件表达式来控制排序优先级。
-- 让NULL排在最后
SELECT category_id, COUNT(*)
FROM products
GROUP BY category_id
ORDER BY IF(category_id IS NULL, 1, 0), category_id;
-- 让NULL排在最前
ORDER BY IF(category_id IS NULL, 0, 1), category_id;
验证方法:在 MySQL 和 PostgreSQL 中分别执行优化后的语句,确认 NULL 分组(或其替换值)的排序位置是否一致。
问题5:递归 CTE 导致 CPU 飙升或无限循环
根本原因通常是数据中存在环形引用(例如:部门 A 的父部门是 B,而部门 B 的父部门又是 A),导致递归无法自行终止。
一个常见的错误是使用 path NOT LIKE CONCAT('%', d.dept_id, '%') 来检测环路。这种方法存在子串误匹配风险,例如路径 '1,2,3' 会被误判为包含部门 ID 12。正确的做法是在路径的首尾都添加分隔符,以确保精确匹配。
正确写法(适用于 MySQL 8.0+ / PostgreSQL):
WITH RECURSIVE dept_tree AS (
SELECT
dept_id,
parent_id,
CAST(CONCAT(',', dept_id, ',') AS VARCHAR(1000)) AS path,
1 AS level
FROM dept
WHERE dept_id = 1 -- 递归起点
UNION ALL
SELECT
d.dept_id,
d.parent_id,
CONCAT(t.path, d.dept_id, ','),
t.level + 1
FROM dept d
INNER JOIN dept_tree t ON d.parent_id = t.dept_id
WHERE t.path NOT LIKE CONCAT('%,', d.dept_id, ',%')
AND t.level < 100 -- 设置递归深度上限,防止失控
)
SELECT dept_id, parent_id FROM dept_tree;
说明与注意事项:
- 路径字段类型:使用
VARCHAR(1000) 而非 CHAR(255),因为固定长度的 CHAR 在路径过长时会被截断,可能导致环路检测失效。VARCHAR(1000) 的长度应根据业务中可能的最大层级进行预估(例如:100层 * 每个ID最多10位字符 + 分隔符 ≈ 1100字符)。
- 分隔符选择:如果部门 ID 本身是字符串且可能包含逗号(如
”A,B”),则应选用一个绝对不会在 ID 中出现的字符作为分隔符,例如竖线 |、井号 # 或不可见字符 \x01。
- 数据库支持:此语法需要数据库支持公用表表达式(CTE),
MySQL 5.7 及以下版本不支持。
- 递归成员:递归部分必须使用
UNION ALL,且递归成员中只能引用一次 CTE 表名。
验证方法:执行查询时,应在合理时间内返回有限的结果集(例如,部门层级 ≤ 10),并且没有观察到数据库 CPU 或内存资源耗尽的现象。验证的标准是查询能够安全终止,且返回的层级结构符合业务预期。
掌握这些 SQL 查询的“潜规则”,能帮助你在 MySQL、PostgreSQL 等数据库中进行技术文档编写和性能调优时更加得心应手,避免踩坑。如果你想深入学习更多数据库内核原理或实战案例,可以到技术社区交流和查找资料。