news 2026/10/21 7:55:07

SQL查询手册:从SELECT到JOIN的实战避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL查询手册:从SELECT到JOIN的实战避坑指南

刚接触数据工作的人,多半是从一份 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开始练,逐步把过滤、排序、聚合、关联、函数、子查询都用熟,日常数据需求基本都能覆盖。以后遇到复杂报表,思路也会比照抄别人的语句要清晰得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 7:54:52

STM32+TouchGFX+QSPI嵌入式GUI资源外置加速方案

简介&#xff1a;本资源是一套面向STM32嵌入式开发者的完整工程实践方案&#xff0c;聚焦于解决TouchGFX图形界面中大容量资源&#xff08;如图片、字库&#xff09;在Flash空间受限场景下的部署难题&#xff0c;适用于已掌握STM32CubeMX基础配置与TouchGFX入门开发的中级以上工…

作者头像 李华
网站建设 2026/10/21 7:54:43

MySQL 8检查约束:从假约束到数据完整性最后一道闸门的实战指南

一说起MySQL 8的检查约束&#xff0c;不少开发者的第一反应是&#xff1a;这不就是一个普通语法吗&#xff0c;建表的时候顺手写一行就行。但如果你经历过MySQL 5.7时代的长期维护&#xff0c;就会明白这个“顺手一行”背后经历了什么。早年间MySQL对CHECK约束只做语法解析、不…

作者头像 李华
网站建设 2026/10/21 7:54:37

逻辑回归到底在回归什么?对数几率与sigmoid的本质解析

几乎所有学机器学习的人&#xff0c;第一次看到“逻辑回归”这四个字都会愣一下。名字里写着“回归”&#xff0c;教材里却把它整整齐齐地放在分类那一章&#xff1b;说它是个分类器&#xff0c;名字却清清楚楚带着“回归”二字。这种拧巴感不是错觉&#xff0c;很多教材默认你…

作者头像 李华
网站建设 2026/10/21 7:54:03

C#读取电子秤重量:串口协议解析与防抖动采集实战

简介&#xff1a;本资源是一份面向C#初学者与工业自动化开发者的串口通信实战项目&#xff0c;聚焦电子秤重量数据的实时采集与解析。通过完整可运行的WinForm工程&#xff0c;演示如何利用SerialPort类配置串口参数、注册DataReceived委托事件、在回调中安全读取并解析ASCII格…

作者头像 李华
网站建设 2026/10/21 7:53:50

WPF界面切换特效:基于TransitioningContentControl的丝滑导航方案

简介&#xff1a;本资源是一套完整的WPF界面切换特效实现方案&#xff0c;面向C#桌面应用开发者及WPF进阶学习者&#xff0c;聚焦解决多页面导航中视觉过渡生硬、交互体验单一等实际问题。压缩包共47个文件&#xff0c;涵盖15个核心C#逻辑代码&#xff08;如UserControl与MainW…

作者头像 李华
网站建设 2026/10/21 7:53:47

Anaconda多环境管理:AI编程智能体开发的环境隔离实战指南

1. 为什么Python多环境管理是AI编程的第一道坎搞AI编程智能体开发的人&#xff0c;十有八九都在环境问题上栽过跟头。你兴冲冲地从代码仓库拉下来一个项目&#xff0c;照着说明文档一顿操作&#xff0c;结果pip install -r requirements.txt跑完&#xff0c;原本跑得好好的另一…

作者头像 李华