刚接触数据工作的人,多半是从一份 SQL 查询手册开始入门的。这个标题看着简单,其实背后覆盖的东西不少——从最基础的SELECT到让人头疼的JOIN,从单表过滤到分组汇总,几乎每个数据岗位的日常工作都离不开这些语法。这份笔记我不打算写成教科书,而是按我实际写查询时的思考顺序来整理:先搭骨架,再填细节,最后补上那些文档里不写、但实战里一定会踩的坑。适合正在学 SQL 的初学者,也适合做了几年开发、偶尔需要写复杂报表时回来查一下语法的同学。
1. 先搞清楚 SQL 到底在做什么
1.1 一份查询语句的固定骨架
SQL 的核心动作其实就一个:从表里拿数据。无论查询多复杂,最终都能拆回这个骨架:
SELECT 列名 FROM 表名 WHERE 条件我第一次带新人时就反复强调:先把这三行写对,再谈别的。SELECT负责决定“要哪些列”,FROM决定“从哪张表拿”,WHERE决定“只保留哪些行”。这个顺序也是执行顺序的简化版本。
一个容易忽略的点:实际执行顺序并不是SELECT → FROM → WHERE。数据库引擎先读FROM定位表,再执行WHERE过滤行,最后才根据SELECT里的列名做投影和计算。这也是为什么某些数据库里,WHERE里不能用SELECT中定义的别名——执行到WHERE时,别名还没生成。
提示:写查询遇到“为什么报错说找不到这一列”时,先想想是不是自己在
WHERE里用了SELECT层的别名。这个问题我见过不下十次。
1.2 把条件收窄:WHERE 的三种基础玩法
WHERE看起来只是加条件,实际用起来有几个层次。
第一层是直接比较。数字用=、<>、>、<,字符串用单引号包起来比较。比如查所有状态为“已支付”的订单:
SELECT order_id, amount FROM orders WHERE status = 'paid';第二层是范围判断。金额在 100 到 500 之间,可以用BETWEEN:
SELECT * FROM orders WHERE amount BETWEEN 100 AND 500;注意BETWEEN是闭区间,包含边界值。这在出报表时会影响统计口径,我曾经因为没注意边界,给运营的数据差了十几条记录,排查半天才发现是对“包含”的理解不一致。
第三层是集合判断。订单状态是“已支付”或“已发货”,用IN会比写一串OR清晰得多:
SELECT * FROM orders WHERE status IN ('paid', 'shipped');这里有个新手常犯的错:IN后面的列表里如果有NULL,那部分永远不会匹配上。因为NULL参与比较的结果是“未知”,不是“真”。后续我会专门讲NULL的处理方式。
2. 过滤数据时那些容易翻车的细节
2.1 LIKE 模糊匹配的实战用法
LIKE用来做模糊匹配,%代表任意多个字符,_代表单个字符。查所有姓“张”的客户:
SELECT * FROM customers WHERE name LIKE '张%';如果要查名字里第二个字是“三”的:
SELECT * FROM customers WHERE name LIKE '_三%';用到具体业务里,有两个容易被忽略的问题。第一个是大小写敏感度。不同数据库默认不一样,有的默认不区分,有的区分。如果线上库和本地开发库行为不一致,你写的模糊匹配结果很可能对不上。第二个是性能。LIKE '%关键词'这种写法通常没法走索引,数据量一大就慢。我见过一个统计系统,每天晚上跑一次全表模糊匹配,表才 200 万行,跑了 40 秒。后来把需求改成了前缀匹配,加上索引,直接降到 200 毫秒。
2.2 NULL 必须单独处理
NULL是 SQL 里最容易坑人的概念。它表示“不知道”“没填”,不是空字符串,也不是 0。
判断某列是否为空的正确写法是IS NULL,不能写= NULL:
SELECT * FROM customers WHERE phone IS NULL;也正因为NULL的存在,很多聚合结果和你预想的不一样。比如一张订单表里discount列有很多NULL,你想求出平均折扣,直接写:
SELECT AVG(discount) FROM orders;AVG会自动忽略NULL。如果业务上NULL代表“没有折扣,即折扣为 0”,那这个平均值就会偏大,甚至会算出一个让你误判的结果。
注意:处理含
NULL的数据时,先想清楚NULL的业务含义,再决定是保留还是用COALESCE转成默认值。
2.3 DISTINCT 去重到底去的是什么
DISTINCT用于去掉完全重复的行。注意是整行所有列都相同才去重,不是某一列相同就合并。
SELECT DISTINCT city FROM customers;这条语句返回所有出现过的城市。但如果多查一列:
SELECT DISTINCT city, age FROM customers;结果会是城市、年龄的每一种组合。很多新手以为加了DISTINCT后城市能去重,结果发现同一城市出现多次,就是因为DISTINCT作用在所有列上。
如果要看某个列有多少种取值,可以直接用COUNT(DISTINCT 列名),这比先查出来再数更高效:
SELECT COUNT(DISTINCT city) FROM customers;这也是排查数据质量的常用手段:如果业务上认为客户城市只有 50 个,这条语句返回 80,说明数据里有多余值。
3. 排序和分页:报表里最常用的两个操作
3.1 ORDER BY 的排序逻辑
排序用ORDER BY,默认从小到大,加DESC变成从大到小:
SELECT product_id, sales_amount FROM sales ORDER BY sales_amount DESC;排序可以指定多列,先按第一列排,第一列相同的再按第二列排:
SELECT city, sales_amount FROM sales ORDER BY city ASC, sales_amount DESC;这里有个实践习惯:排序的列最好也出现在SELECT列表里,尤其在排查问题时,你能直观看到数据是怎么排的。如果排序完全靠一个看不见的字段,后面出了问题很难定位。
数字排序没问题,但字符串排序需要注意。很多数据库默认按字典序排,比如 “10” 会排在 “9” 前面,因为先比较的是第一个字符。如果排的是编号列且该列是字符串类型,就会出现 10、100、11、2 这种诡异顺序。
3.2 LIMIT / OFFSET 实现分页
查询结果太多时用LIMIT限制行数,配合OFFSET跳过前面的行:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;这是第一页;跳 20 行取下一页就是OFFSET 20:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 20;分页必须配合稳定的排序规则,否则第二次请求时数据顺序可能变化,导致某些行被重复显示或遗漏。这个坑踩过的人不在少数。特别是在数据持续写入的在线系统里,按时间倒序分页时,同一行数据可能因为新数据的插入而跑到另一页去。
提示:大数据量分页不要用
OFFSET无限翻,越翻越慢。更稳妥的做法是记住上一页最后一条记录的标识,用条件替换OFFSET,比如WHERE id < 上一页最小id ORDER BY id DESC LIMIT 20。
4. 聚合与分组:从明细变成汇总
4.1 五个最常用的聚合函数
聚合函数把多行压成一行。最常用的五个:
| 函数 | 作用 | 对 NULL 的处理 |
|---|---|---|
COUNT(*) | 统计行数 | 不忽略,统计所有记录 |
COUNT(列名) | 统计非空值个数 | 忽略 NULL |
SUM(列名) | 求和 | 忽略 NULL |
AVG(列名) | 求平均 | 忽略 NULL |
MAX / MIN | 取最大/最小值 | 忽略 NULL |
差别最容易出现在COUNT(*)和COUNT(列名)上。业务逻辑里要数“有多少个订单”,用COUNT(*);要数“有多少订单填了备注”,才用COUNT(remark)。
SUM在全表为空的场景下值得留意:没有任何行满足WHERE时,SUM返回NULL,不是 0。如果报表需要显示 0,就得用COALESCE(SUM(列名), 0)包一层。
4.2 GROUP BY 分组背后的执行逻辑
GROUP BY把同一值的行分到一个组里,然后对每个组做聚合:
SELECT city, COUNT(*) AS user_cnt FROM customers GROUP BY city;这条语句返回每个城市的用户数。执行过程可以理解为:先按city分组,再对每个组数行数。
使用GROUP BY有一条铁律:SELECT里出现的非聚合列,必须全部出现在GROUP BY里。下面这个写法在大多数数据库里会报错:
SELECT city, age, COUNT(*) -- 错误的写法 FROM customers GROUP BY city;因为age既没有被聚合,也不在分组键中,数据库不知道应该显示哪一条。虽然少数数据库允许这样写并随机返回一条,但结果不可控,千万别依赖这种特性。
分组时的NULL也很有意思。所有NULL会分成同一组,也就是说GROUP BY会把缺失值归成一个桶,报表里常见到一行NULL汇总,那并不意外。
4.3 HAVING:对分组结果做二次筛选
WHERE在分组之前过滤行,HAVING在分组之后过滤组。比如找出用户数超过 1000 的城市:
SELECT city, COUNT(*) AS user_cnt FROM customers GROUP BY city HAVING COUNT(*) > 1000;常见错误是把HAVING当WHERE用,或者在WHERE里写聚合函数。WHERE user_cnt > 1000会直接报错,因为WHERE执行时还没有生成user_cnt这一列。
我的习惯是:能用WHERE过滤的先过滤掉,减少分组压力;HAVING只留真正需要“分组后判断”的条件。比如只统计“最近 30 天注册”的用户数,那时间条件放在WHERE,城市组人数判断放在HAVING,性能会更好,逻辑也更清晰。
5. 多表查询:JOIN 的几种常见姿势
5.1 INNER JOIN 只保留能匹配上的行
业务数据几乎不会只存在一张表里。订单表里只有用户编号,用户名在用户表里,这时候就需要关联查询:
SELECT orders.order_id, customers.name FROM orders INNER JOIN customers ON orders.customer_id = customers.id;INNER JOIN只返回两边都匹配得上的记录。客户如果被删了,那这批订单在这个查询里就看不到。数据审计时,这种“看不到”往往就是问题线索。
写JOIN时有一件事必须坚持:查询涉及多个表,列名一定要带表名前缀。我遇到过同事写JOIN时直接写SELECT id,结果两个表都有id列,数据库报“不明确”,改了半天才发现是两个表的字段重名了。
5.2 LEFT JOIN 会保留左表的全部数据
现实业务里更常用的是LEFT JOIN,左表所有记录都会出现在结果里,右表匹配不上的用NULL补位。
SELECT customers.name, orders.order_id FROM customers LEFT JOIN orders ON customers.id = orders.customer_id;这条语句的结果里,没下过单的客户也会出现,orders.order_id记为NULL。统计“有多少客户从未下单”就可以在这个基础上加条件:
WHERE orders.order_id IS NULL这是排查客户留存的经典查询。但要注意,LEFT JOIN后如果右表是一对多关系,左表记录会翻倍。一张订单表里客户下了 3 单,关联后客户会出现 3 次。如果不清楚这一点,统计客户数时就容易被多算。
5.3 多表关联的一次实操拆解
多表 JOIN 的顺序最能看出一个开发者的功底。我处理过一个模拟场景,三张表:用户表users、订单表orders、订单明细表order_items,要查每个用户最近下单的品类。
先拆思路:用户订单明细在order_items,要拿最近订单,可以用MAX(order_date)先定位每单时间,再关联明细:
SELECT u.name, oi.category, o.order_date FROM users u LEFT JOIN orders o ON u.id = o.user_id LEFT JOIN order_items oi ON o.id = oi.order_id WHERE o.order_date = ( SELECT MAX(order_date) FROM orders o2 WHERE o2.user_id = u.id );这里有个细节:关联条件写对很容易,但过滤条件放错位置很致命。如果把o.order_date = ...放进JOIN ... ON里而不是WHERE,结果会完全不同。放ON里相当于先筛选右表再关联,放WHERE里是关联完之后再过滤。前者会保留没有匹配订单的用户,后者会把这类用户全部过滤掉,两种写法业务含义不一样。
注意:多表查询先明确“保留哪一侧的全部数据”,再决定用
LEFT JOIN还是INNER JOIN,最后考虑过滤条件放ON还是WHERE。
6. 写查询时必会的表达式与条件逻辑
6.1 字符串函数与日期函数速查
实际写查询时,很少直接拿原始列当结果输出,通常要做加工。字符串和日期是最常打交道的两类。
字符串处理高频率的用法:
-- 拼接 SELECT CONCAT(first_name, last_name) AS full_name FROM users; -- 取长度 SELECT LENGTH(name) FROM users; -- 截取 SELECT SUBSTRING(id_card, 7, 8) AS birth_date FROM users; -- 身份证里取生日 -- 大小写转换 SELECT UPPER(email) FROM users; -- 替换 SELECT REPLACE(phone, '-', '') FROM users;日期处理是报表的重灾区。不同数据库函数差别很大,但逻辑一致。以常规 SQL 为例:
-- 取当前日期 SELECT CURRENT_DATE; -- 日期加减 SELECT order_date + INTERVAL 7 DAY AS week_after FROM orders; -- 两个日期相差天数 SELECT DATEDIFF(CURRENT_DATE, order_date) AS days_since_order FROM orders; -- 提取年月 SELECT EXTRACT(YEAR FROM order_date) AS year FROM orders;这里建议形成自己的规范:日期字段不要用字符串代替,存成DATE或TIMESTAMP类型,函数才能正常使用。凡是发现order_date变成'2024-01-01 00:00:00'这种字符串拼接结果,基本就是类型转换出了问题。
6.2 CASE WHEN 实现业务逻辑判断
CASE WHEN是在 SQL 里写 if-else 的唯一标准方式。把订单金额分成高、中、低三档:
SELECT order_id, amount, CASE WHEN amount >= 1000 THEN '高' WHEN amount >= 500 THEN '中' ELSE '低' END AS level FROM orders;CASE表达式要特别注意分支顺序,数据库会从上到下匹配第一个满足的条件。如果把amount >= 500写在amount >= 1000前面,那 1000 以上的订单也会被归到“中”。顺序错了,结果就错了。
需要基于百分比或累计值做判断时,CASE WHEN往往要和聚合函数配合:
SELECT customer_id, SUM(amount) AS total_amount, SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount FROM orders GROUP BY customer_id;这种写法比“先汇总再判断”少一步子查询,逻辑更直观。
7. 子查询与 CTE:处理更复杂的取数逻辑
7.1 标量子查询:在 SELECT 里放一个“单值”
子查询就是嵌套在主查询里的查询语句。标量子查询返回单行单列,可以直接放在SELECT后面当列用。比如把每个用户最近一次下单日期作为一列:
SELECT u.id, u.name, ( SELECT MAX(o.order_date) FROM orders o WHERE o.user_id = u.id ) AS last_order_date FROM users u;这种写法直观,但在大表上性能可能很差,因为每一行用户都要执行一次内部子查询。数据量小没事,上了几万用户后就要小心了。
7.2 表子查询:把结果集当临时表用
IN、EXISTS、FROM后面都可以接子查询结果。经典场景:查“下单超过 5 次的用户”。
先用分组查出符合条件的人,再用IN过滤:
SELECT * FROM users WHERE id IN ( SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(*) > 5 );也可以用EXISTS语义实现。IN和EXISTS的差别在大量数据下体现得比较明显,但更关键的是理解语义:
IN:先执行子查询,拿结果集合去匹配EXISTS:逐行判断“是否存在”满足条件的记录
EXISTS因为可以提前终止匹配,在子查询结果集很大时往往更快。不过现代优化器会做重写,不必太纠结,先把语义写对。
7.3 CTE 让复杂查询像搭积木
子查询嵌套超过两层,代码就很难读了。这时候用 CTE(公用表表达式)把中间结果一层层存起来:
WITH recent_orders AS ( SELECT * FROM orders WHERE order_date >= CURRENT_DATE - INTERVAL 30 DAY ), user_stats AS ( SELECT customer_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM recent_orders GROUP BY customer_id ) SELECT u.name, s.order_cnt, s.total_amount FROM user_stats s LEFT JOIN users u ON s.customer_id = u.id ORDER BY s.total_amount DESC;中间每一步都可以单独调试,逻辑拆得越细越不容易出错。这是我在写长报表时最爱用的工具,比层层套子查询好维护得多。
CTE 还有一个隐藏优势:同一个 CTE 可以在一句查询里被引用多次。以前遇到“要在多个过滤条件里使用同一段统计结果”,免不了一式两份地复制子查询,用了 CTE 后只要定义一次,不仅少写代码,还确保两处结果严格一致。
8. 写入与更新:INSERT、UPDATE、DELETE 的注意事项
8.1 INSERT 的两种常用写法
查询练熟了,写入更新也要会用。插入数据有两种格式。
完整列插入,需要按表定义顺序给值:
INSERT INTO users VALUES (1, '张三', '13800000000');推荐写法是显式列出列名,这样即使表结构未来增加列,语句也不容易出错:
INSERT INTO users (id, name, phone) VALUES (1, '张三', '13800000000');批量插入时,可以一次给多组值:
INSERT INTO users (id, name) VALUES (2, '李四'), (3, '王五');很多数据库还支持INSERT INTO ... SELECT,从查询结果直接写入新表,这是做数据迁移和分层表的利器:
INSERT INTO user_copy (id, name) SELECT id, name FROM users WHERE id > 100;8.2 UPDATE 与 DELETE 永远要带 WHERE
更新和删除数据,最重要的原则只有一条:先确认条件,再执行。
UPDATE users SET status = 'inactive' WHERE last_login_at < CURRENT_DATE - INTERVAL 90 DAY;没有WHERE的UPDATE会更新所有行。没有WHERE的DELETE会清空整张表。这两个操作一旦执行,如果没有事务保护,数据基本找不回来。
我的习惯是:涉及生产数据的UPDATE或DELETE,先写一条SELECT用同样的条件查一遍,确认影响行数和目标吻合,再改写成更新语句执行。多花一分钟,省掉十小时恢复数据的痛苦。
删除数据时有一个更安全的替代方案:很多系统会采用逻辑删除,即加一个deleted标记列,查询时统一过滤掉。虽然不是所有场景都适合,但审计和回溯成本会低得多。
注意:执行写操作之前确认是否由事务包裹。
BEGIN TRANSACTION开启,执行完检查结果再COMMIT,发现异常就ROLLBACK。这也是生产环境的基本素养。
9. 我平时排查 SQL 问题的一套小方法
写了不少年 SQL,总结下来,排查报错和异常结果的思路大多是同一套。
先看执行顺序。FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。出问题时,按这个顺序往上推。结果集数量不对,问题和WHERE或JOIN有关;聚合结果不对,问题基本出在GROUP BY和HAVING。
再看列的类型。字符串和数字比较时,数据库做了隐式转换;日期字段存成了文本,排序直接乱掉。很多诡异结果,最后都指向类型问题。
最后做减法。把一条长查询拆成小块,每次只跑一段。比如先跑子查询看结果对不对,再去掉GROUP BY看明细,最后加回聚合。这个习惯帮我快速定位过很多问题,特别是那种“明明逻辑没错但数据就是对不上”的场合。
SQL 语法本身不难,难的是把一个业务问题翻译成正确的查询逻辑。对照这份手册,从最简单的SELECT开始练,逐步把过滤、排序、聚合、关联、函数、子查询都用熟,日常数据需求基本都能覆盖。以后遇到复杂报表,思路也会比照抄别人的语句要清晰得多。