SQL 本身接近自然语言,读得懂不意味着写得对。同一句查询在不同数据量、不同表结构下表现可能完全不同,而这些问题往往藏在很小的书写习惯里。以下按照实际开发中的排查顺序,整理成一份可对照的 SQL 编写 Checklist。

一、条件判断与 NULL 处理
SQL 中的 NULL 表示未知值,不能参与普通等值比较。如果写成 WHERE status = NULL,条件结果不是 true 也不是 false,而是 unknown,最终不会被返回。正确方式是使用 IS NULL 或 IS NOT NULL。这个差异在单表查询里还好发现,一旦嵌套到子查询或 NOT IN 中,排查成本会成倍增加。
最典型的坑是 NOT IN 子查询。比如想找出没有退款记录的订单,查询订单表的主键不在退款表里。如果退款表的 order_id 中出现 NULL,整个 NOT IN 的结果可能为空。原因是任何值与 NULL 比较都会变成 unknown,外层条件无法确认。更稳妥的做法是使用 NOT EXISTS,或者先在子查询中过滤掉 NULL。
-- 错误:NOT IN 子查询含 NULL 时可能返回空
SELECT id FROM orders
WHERE id NOT IN (SELECT order_id FROM refunds);
-- 正确:过滤掉 NULL
SELECT id FROM orders
WHERE id NOT IN (SELECT order_id FROM refunds WHERE order_id IS NOT NULL);
-- 更推荐:使用 NOT EXISTS
SELECT o.id FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM refunds r WHERE r.order_id = o.id
);
聚合函数对 NULL 的处理也不一致。COUNT(*) 会统计所有行,COUNT(column) 会忽略该列值为 NULL 的行。SUM、AVG 也会忽略 NULL,这可能符合业务也可能导致统计偏差。面对可空字段统计时,需要明确是否需要 COALESCE 补默认值。例如 COALESCE(amount, 0) 可以避免求和结果为 NULL 的问题。
-- COUNT(*) 与 COUNT(column) 的区别
SELECT COUNT(*) AS total_rows,
COUNT(refund_status) AS non_null_status,
SUM(COALESCE(amount, 0)) AS total_amount
FROM orders;
- 不要用
=或!=判断 NULL - 子查询
NOT IN前先确认结果集是否可能含 NULL - 聚合统计时清楚
COUNT(*)与COUNT(column)的差异
二、索引利用与隐式转换
索引列是最容易写出问题的位置。数据库为了使用索引,需要保证条件中的列没有被函数、计算或隐式类型转换包裹。例如 phone 字段是 varchar 类型,查询时传入一个数字,数据库可能将列隐式转换成数字再比较,导致索引失效。字符串字段比较时务必加引号。
-- 错误:phone 是 varchar,传入数字会触发隐式转换 SELECT * FROM users WHERE phone = 13800138000; -- 正确:统一使用字符串 SELECT * FROM users WHERE phone = '13800138000';
日期和 LIKE 也是高频地雷。对 created_at 使用 DATE() 函数会把索引列转换成函数结果,优化器无法利用索引。应改成范围查询。LIKE 查询如果以 % 开头,B-tree 索引无法定位前缀,会退化为全表扫描。这类需求应优先考虑全文索引或专门的搜索引擎。
-- 错误:对索引列使用函数 SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01'; -- 正确:使用范围条件 SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'; -- LIKE 前导通配符无法走索引 SELECT * FROM products WHERE name LIKE '%手机%';
联合索引遵循最左前缀原则。创建 (user_id, status, created_at) 这种索引后,只查 status 或 created_at 的 SQL 无法命中索引。另外尽量减少 SELECT *,只取必要字段,增加覆盖索引的命中概率,减少回表开销。
- 索引列不参与函数计算或隐式类型转换
- 避免
LIKE前导通配符 - 联合索引注意最左前缀匹配
三、分页、排序与数据量控制
分页是线上慢查询高发区。使用 LIMIT offset, size 翻到很深的页时,数据库需要扫描并丢弃大量前面的行,offset 越大越慢。比如 LIMIT 100000, 20 会扫描 100020 行。可以通过延迟关联缩小扫描范围,或者改为基于游标的分页,记录上一页最后一条主键 id,用 WHERE id > last_id 继续取下一页。
-- 传统大偏移量分页,越往后越慢
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 延迟关联:先只取主键,再回表取完整数据
SELECT o.*
FROM orders o
JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) t ON o.id = t.id;
-- 游标分页:记录上一页最后一条 id
SELECT * FROM orders
WHERE id > 123456
ORDER BY id
LIMIT 20;
排序结果不稳定也需要提前处理。如果 ORDER BY 后面只有 create_time,并且这个时间存在大量重复值,分页时可能出现同一条记录出现在两页。最好在排序字段后追加一个唯一键,比如 ORDER BY create_time DESC, id DESC。
大数据量更新和删除不要一次性执行。一条 UPDATE 或 DELETE 影响上百万行,不仅会让事务日志暴增,还可能长时间锁表。可以按 id 或时间分批处理,每批几千条,每次批处理后提交事务。IN 子查询若结果集过大,可改用 EXISTS 或临时表关联。
- 大偏移量分页优先考虑延迟关联或游标分页
- 排序字段后追加唯一键保证结果稳定
- 大批量更新删除分批执行并控制事务大小
四、安全与事务边界
SQL 注入仍然是最严重的安全问题之一。用户输入直接拼接到 SQL 语句中,攻击者可以改变原始语句结构。无论后端框架多成熟,只要存在字符串拼接,就可能出现漏洞。所有用户输入都应该通过参数化查询传递,框架会自动处理转义和类型绑定。
// 错误:拼接用户输入 String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"; // 正确:使用 PreparedStatement String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery();
事务边界要尽可能短,锁范围要尽量小。开启事务后不要包含远程调用、文件读写或人工交互。更新多条数据时注意执行顺序一致,降低死锁概率。UPDATE 和 DELETE 必须带 WHERE 条件,防止误更新全表。必要时在测试环境先通过 SELECT 确认影响范围。动态 SQL 中的表名、排序字段无法参数化,只能使用白名单校验。
-- 转账示例:短事务只包住必要的数据变更 START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;
- 所有外部输入使用参数化查询,杜绝字符串拼接
- 动态表名、排序字段使用白名单校验
- 事务内只保留必要的数据库操作,避免长事务
- 更新和删除必须带
WHERE条件
把这四组检查项放进日常开发流程,不必每次逐句分析执行计划,也能提前规避掉大部分 SQL 正确性和性能问题。更重要的是,这些细节一旦形成习惯,写出的 SQL 会在数据量增长后依然保持稳定表现。