SQL查询避坑指南:从WHERE到JOIN,掌握执行顺序与分页边界
简介针对B站Mosh老师SQL三小时课程的随堂笔记这份PDF格式的SQL速查表主要面向零基础入门者和需要快速查阅语法的开发人员帮助解决视频学习后知识点零散、语法容易遗忘的问题。笔记以Mosh Hamedani官方Cheat Sheet为框架用精简示例拆解SQL核心语法从SELECT查询列、WHERE条件筛选到逻辑操作符AND/OR/NOT、IN列表匹配、BETWEEN范围判断、LIKE模糊匹配及REGEXP正则匹配再到ORDER BY排序、LIMIT限制行数、IS NULL空值处理同时覆盖内连接、外连接、USING、交叉连接、UNION联合查询与INSERT插入数据等进阶主题完整串联起单表查询、多表关联和数据写入三类操作场景尤其适合课中实时对照和课后巩固复习。资源为单文件PDF大小仅2.43MB可在手机、平板或电脑上离线查阅目前已有973人学习下载既能作为视频课的学习伴侣也可在开发中快速检索SQL写法提升日常工作效率。1. B 站 Mosh 老师三小时 SQL 课这份笔记到底能顶什么用Mosh 老师那套三小时 SQL 入门课配套的是一份 Cheat Sheet把 SELECT、WHERE、JOIN、UNION、INSERT 这些核心语法按“先讲语法、再丢例子”的方式压缩进几页纸目标是让初学者看完视频后能对着速查表写出可运行的查询。这份笔记解决的是我见过最多的新手窘境——视频看完了脑子觉得会了真打开 Navicat 敲第一条分页查询时却不知道 LIMIT 的偏移量该填多少。我建议两类人认真对待它刚看完视频、想把知识点收拢成一张主干图的人以及准备 SQL 面试、想在半小时内把运算符优先级和 JOIN 边界扫干净的人。下面按我自己的复习顺序拆开讲不评价课程只讲怎么把这份表用出价值。2. SELECT 与 WHERE查询骨架里最容易出错的三个细节2.1 SELECT 子句表达式、别名与执行顺序的关系先看一眼课程笔记里最开头的综合示例USE sql_store; SELECT * FROM customers WHERE state CA ORDER BY first_name LIMIT 3;USE sql_store是切换当前数据库实例里有多个库的时候这句不能省不想切换就一直写sql_store.customers这种带库名的写法。MySQL 不区分关键字大小写但工程习惯上关键字大写、字段小写别人接手时省力。每一条语句以分号结尾这个在 MySQL 里是硬性规定少一个分号客户端可能把两条语句当成一条解析。再看笔记里的表达式写法SELECT points * 10 20 AS discount_factor FROM customers;这里有个容易被忽略的点points * 10 20的运算顺序是固定的先乘除后加减想改变顺序就必须加括号。AS discount_factor给计算结果一个别名后续程序里引用这个字段就方便了。但别名有个坑新手几乎都踩过WHERE 里不能用 SELECT 别名。比如上面这条你想过滤出discount_factor 100的客户直接写进 WHERE 会报“字段不存在”。原因是 SQL 的底层执行顺序和书写顺序不一样先确定 FROM 和 JOIN 的表然后 WHERE 逐行过滤最后才 SELECT 做列的计算、生成别名。别名在 SELECT 阶段才出生WHERE 阶段根本见不到它。这个知识点在笔记里没有单独讲但看完视频后实操一定会碰上。2.2 WHERE 过滤比较操作符与常见边界误用WHERE 子句用来做行级过滤笔记里给的比较操作符有这些和!。和!是等价写法一个来自 ANSI 标准一个来自编程习惯MySQL 两个都认。SELECT * FROM customers WHERE state CA;字符串比较要加引号这个大多数人不会错。容易错的是日期比较SELECT * FROM customers WHERE birthdate 1990-01-01;数据库里真正存成 DATE 类型时字符串和日期比较会自动转换1990-01-01 这种格式能被 MySQL 正确识别。但要注意边界是不包含 1990-01-01 当天的想包含这一天就得写。我见过不少线上报表数据差一天查来查去最后发现是这里少了一个等号。另外一个血泪经验日期字段别图省事存成 VARCHAR。一旦存成字符串WHERE 比较时数据库会对整列做隐式转换数据量一大就是慢 SQL 的源头。日期就用 DATE 或 TIMESTAMP这条比任何 SQL 技巧都值钱。2.3 AND / OR / NOT优先级不是从左往右算笔记里三个逻辑操作符的示例如下-- AND两个条件都必须满足 SELECT * FROM customers WHERE birthdate 1990-01-01 AND points 1000; -- OR至少满足一个 SELECT * FROM customers WHERE birthdate 1990-01-01 OR points 1000; -- NOT对整个条件取反 SELECT * FROM customers WHERE NOT (birthdate 1990-01-01);AND 和 OR 的语义好理解容易翻车的是它们的优先级。SQL 里优先级从高到低是 NOT AND OR不是从左到右按书写顺序算。举个例子SELECT * FROM customers WHERE state CA OR state NY AND points 1000;这段代码实际表达的是“CA 的客户或者 NY 且积分大于 1000 的客户”CA 客户根本不受 points 限制。如果本意是“CA 或 NY 且积分大于 1000”必须加括号WHERE (state CA OR state NY) AND points 1000;这种错误在 WHERE 条件多的时候非常隐蔽返回结果少一截或多一截都不容易察觉。我现在的习惯是OR 条件只要超过两个一律用括号把组合条件框死哪怕优先级本来就对。3. 过滤与匹配IN、BETWEEN、LIKE、REGEXP 的选型逻辑3.1 IN 与 BETWEEN等值集合和闭区间边界笔记里 IN 的示例SELECT * FROM customers WHERE state IN (VA, NY, CA);IN 是多个 OR 等值判断的缩写。手写state VA OR state NY OR state CA能实现同样效果但 IN 更简洁而且以后条件列表来自程序参数时拼 SQL 也方便。IN 列表里的每个值都会走一次等值判断属于离散集合匹配。BETWEEN 则是连续范围判断SELECT * FROM customers WHERE points BETWEEN 100 AND 200;注意 BETWEEN 是闭区间points 100和points 200都会被包含。这和很多人从数学课带过来的“开区间”直觉不一样。查日期时这个坑更明显SELECT * FROM customers WHERE birthdate BETWEEN 1990-01-01 AND 1990-12-31;如果 birthdate 字段带时间部分比如 1990-12-31 18:30:00这条查询可能会漏掉当天晚上的记录因为 MySQL 对日期边界默认按零点处理。处理办法是右边界写到下一天零点或者直接改成birthdate 1991-01-01。3.2 LIKE 通配符百分号和下划线的行为差异SELECT * FROM customers WHERE first_name LIKE b%;LIKE 里的%代表任意数量字符包括零个_代表恰好一个字符。所以LIKE b%匹配所有以 b 开头的名字LIKE _b%匹配第二个字符是 b 的名字。这里有一个数据库之间的差异要注意MySQL 默认排序规则下 LIKE 不区分大小写所以LIKE b%能匹配 Bob 也能匹配 bob但 PostgreSQL 默认是区分大小写的同样的条件查出来可能是空。跨数据库练习时这个差异会直接导致结果对不上别怀疑是自己的逻辑错了。还有一点LIKE %abc%这种前导通配符写法当表数据量大时基本无法利用索引查询会全表扫描。笔记里举的例子都是小表无所谓但真实项目里LIKE %关键词%是慢 SQL 的高发区能避免就避免。3.3 REGEXP 正则把模式匹配写进 SQL笔记里 REGEXP 的四个示例值得逐个看-- 以 a 开头 SELECT * FROM customers WHERE first_name REGEXP ^a; -- 以 ey 或 on 结尾 SELECT * FROM customers WHERE first_name REGEXP ey$|on$; -- 以 my 开头或者包含 se SELECT * FROM customers WHERE first_name REGEXP ^my|se; -- 包含 br 或 bu SELECT * FROM customers WHERE first_name REGEXP b[ru];REGEXP 和 LIKE 最大的区别是LIKE 的%、_只能做模糊匹配REGEXP 可以做模式匹配。^锚定字符串开头$锚定结尾|是逻辑或[ru]是字符集合[a-d]是 a 到 d 的任意字符。有个细节容易误判REGEXP 默认是部分匹配只要字符串中任意位置能匹配上模式就返回真所以REGEXP se匹配的是包含 se 的名字。想让整个字符串完全匹配必须同时在开头加^、结尾加$。正则比 LIKE 灵活得多但可读性也差我一般只在条件组合复杂或参数是动态拼接时才用它简单需求用 LIKE 就够了。3.4 IS NULL为什么不能写 phone NULLSELECT * FROM customers WHERE phone IS NULL;SQL 里 NULL 不是值它表示“未知”。phone NULL这个比较的结果不是真也不是假而是 UnknownWHERE 子句只保留结果为真的行Unknown 全部被丢弃。所以等值判断 NULL 永远查不出来必须用IS NULL/IS NOT NULL。这也是 SQL 三值逻辑的入门点真、假、未知。NULL AND TRUE的结果是未知NULL OR TRUE的结果是真这个规律在组合条件里会带来意外行为。另一个容易混淆的是空字符串和 NULL空字符串是合法的值能直接比较NULL 不是。很多程序端传参时把空值传成数据库里存进去的是空字符串再用IS NULL查就永远查不到反过来排查时特别折腾。4. 去重、排序与分页DISTINCT / ORDER BY / LIMIT 的边界4.1 DISTINCT 去重的对象是整行不是单列SELECT DISTINCT state FROM customers;这条能返回唯一的州列表没问题。但很多人以为 DISTINCT 是“只对 state 列去重其他列随便选”于是写出这样的语句SELECT DISTINCT state, city FROM customers;这个查询返回的是(state, city)的组合去重同一个 state 下有两个不同 city就会输出两行。如果你本意是想知道有哪些州这条语句的结果就是错的。DISTINCT 作用于 SELECT 结果集的整行这是底层语义。还有一个边界情况NULL 参与去重时多个 NULL 会被当成同一个值合并这在 MySQL 里和标准 SQL 行为一致。做数据清洗时我经常用它看某列到底有哪些值但前提是心里清楚组合去重的语义。4.2 ORDER BY 支持多列、别名与表达式SELECT * FROM customers ORDER BY state, first_name DESC;多列排序时从左往右执行先按 state 升序同 state 的记录再按 first_name 降序。每一列都可以单独指定 ASC 或 DESC这个很容易记错成“只能统一排序”其实不是。因为 ORDER BY 在 SELECT 之后执行所以它能用别名也能直接用表达式SELECT points * 10 20 AS discount_factor FROM customers ORDER BY discount_factor DESC;这条能正常工作而同样用discount_factor在 WHERE 里过滤就会报错。区别就在执行顺序上。还有一个细节NULL 的排序位置在不同数据库里不一样。MySQL 默认升序时 NULL 排在前面降序时 NULL 排在最后Oracle 和 PostgreSQL 的默认策略又不同。如果业务上对空值排序位置有要求别依赖默认行为显式写ORDER BY phone IS NULL, phone来控制。4.3 LIMIT 分页公式与“少一条”的怪现象-- 返回前 3 条 SELECT * FROM customers LIMIT 3; -- 跳过 6 条返回 3 条 SELECT * FROM customers LIMIT 6, 3;LIMIT 6, 3的含义是跳过前面 6 行再取接下来 3 行。偏移量从 0 开始数所以第一页是LIMIT 0, 3第二页是LIMIT 3, 3第三页才是LIMIT 6, 3。分页的通用公式是offset (page - 1) * page_size。我见过不少新手把“跳过 6 条”理解成“从第 6 条开始取”结果每一页都重复一条数据。还有一种是插入或删除数据后分页结果出现错位这是物理分页的固有特性。当偏移量非常大时比如LIMIT 100000, 10MySQL 仍然要先把前面十万行扫出来再丢掉性能会明显下降。这也是“慢 SQL 优化”里的经典话题。实际项目里更推荐游标分页记住上一页最后一条记录的 ID下一页直接用WHERE customer_id last_id ORDER BY customer_id LIMIT 10取数跳过的成本几乎为零。4.4 执行顺序FROM → WHERE → SELECT → ORDER BY → LIMIT把前面几个小节串起来看MySQL 的逻辑执行顺序大致如下顺序子句说明1FROM / JOIN确定数据来源和连接关系2WHERE对行做条件过滤3SELECT计算列、生成别名、DISTINCT 去重4ORDER BY对结果集排序5LIMIT截断返回行数理解这个顺序前面几个坑就都能解释了WHERE 不能用别名因为别名还没生成ORDER BY 能用别名因为它排在后面LIMIT 一定要放在所有条件之后一旦放错位置语法直接报错。5. 避坑指南JOIN、UNION、INSERT 中最容易翻车的五个场景5.1 JOIN 后行数暴涨一对多和笛卡尔积SELECT * FROM customers c JOIN orders o ON c.customer_id o.customer_id;现象customers 表只有 10 行JOIN 之后返回 30 行第一反应是“是不是查错了”。原因JOIN 是横向拼接左表一行匹配右表多行时会产生多行结果。只要一个客户下过三张订单这个客户就会在三行重复出现。这本身不是错误而是 JOIN 的正常行为。更隐蔽的问题是连接条件写错导致笛卡尔积。比如两个表各 20 行ON 条件里字段对应不上或者干脆忘写 ON结果就是 400 行。排查时先跑两个表的 COUNT再跑 JOIN 后的 COUNT行数从几十变几百基本可以断定连接键重复或条件缺失。想确认右表到底有没有重复键可以用SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id看分组结果。顺便说一句笔记里的CROSS JOIN是另一个东西它故意把两个表做笛卡尔积比如颜色和尺寸的排列组合这是有业务目的的。日常查询里如果出现类似结果先怀疑 JOIN 条件。5.2 LEFT JOIN 的结果被 WHERE 干掉一半SELECT * FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id;LEFT JOIN 的语义是保留左表全部记录右表没有匹配就补 NULL。这时候如果加一个针对右表的过滤条件WHERE o.order_id 3现象没下过订单的客户全都不见了LEFT JOIN 看起来和 INNER JOIN 没区别。原因JOIN 先执行右表无匹配的客户得到一行order_id NULLWHERE 随后做条件判断NULL 3的结果是 Unknown被过滤掉。解决方法是把右表条件挪到 ON 里面SELECT * FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id AND o.order_id 3;ON 里的条件只影响“怎么匹配”不影响“左表是否保留”。这是 JOIN 里最容易翻车的细节之一同一个条件写在 ON 和 WHERE结果完全两样。我现在写 LEFT JOIN 时默认先把所有右表条件放 ON 里等确认不需要保留左表全量时再挪去 WHERE。5.3 UNION 报错两个结果集的列数不一致SELECT name, address FROM customers UNION SELECT name, address FROM clients;现象执行时报The used SELECT statements have a different number of columns。原因UNION 是把两个结果集垂直堆叠堆叠的前提是列数一致、每一列的数据类型兼容。第一个 SELECT 选了两列第二个 SELECT 选了别的列数据库不知道该怎么对齐。解决把两边 SELECT 的项目列表写成一致包括顺序。如果业务上确实需要某一侧多带一列另一侧补占位符SELECT name, address, customer AS source FROM customers UNION SELECT name, address, client AS source FROM clients;还需要区分UNION和UNION ALLUNION 会做去重UNION ALL 全部保留。如果两段数据本来就没有重叠用 UNION 纯属浪费一次排序有重叠但无所谓重复时也用 UNION ALL。这个选择直接影响性能数据量大时差别很明显。5.4 INSERT 后出现字符串 NULLINSERT INTO customers (first_name, phone, points) VALUES (Mosh, NULL, DEFAULT);笔记里这行代码看着简单实操时最容易改错。现象之一查询时发现 phone 字段的值是三个字符NULL而不是空值。原因插入时把 NULL 加了引号写成了NULL数据库把这当成普通字符串存进去了。现象之二points列没有插入结果程序报错说不能为 NULL。原因这一列没设默认值插入时又漏掉了它。解决NULL 这个关键字永远不要加引号DEFAULT 会使用列的默认值填充前提是建表时给该列定义了DEFAULT。练习阶段我习惯先跑一句SHOW CREATE TABLE customers;看清每个列的默认值再决定要不要依赖 DEFAULT。多行插入时每一行的列清单必须一致别第一行写四个字段、第二行写三个这种错误在拼接 SQL 时很常见。5.5 USING 子句的两个约束条件SELECT * FROM customers c JOIN orders o USING (customer_id);USING 是 ON 的简化写法唯一的适用前提两边的连接列名字完全相同。如果一边叫customer_id另一边叫customers_idUSING 直接报错或查不出正确结果只能用ON c.customer_id o.customer_id。还有一个行为差异ON 写法执行SELECT *时两个表的 customer_id 列都会出现在结果里USING 写法会把同名列合并结果只保留一列 customer_id。这个区别在做结果导出和字段对齐时有实际影响不知道的人会以为数据少了列。我在设计表结构时习惯让关联字段命名统一比如主键一律叫xxx_id关联字段也叫xxx_id这样 JOIN 时能更多地用 USING 减少代码量。但命名不统一的旧表也没办法老老实实写 ON。6. 把这份笔记变成肌肉记忆验证路径与自查习惯6.1 用最小数据集把查询跑起来光看笔记没法形成手感得有个能随时折腾的环境。不需要连公司的大库本地用 Docker 起一个 MySQL或者下载个免安装版本都行。建一张最小表就够覆盖大部分语法CREATE DATABASE IF NOT EXISTS sql_practice; USE sql_practice; CREATE TABLE customers ( customer_id INT PRIMARY KEY AUTO_INCREMENT, first_name VARCHAR(50), phone VARCHAR(20), points INT DEFAULT 0 ); INSERT INTO customers (first_name, phone, points) VALUES (Mosh, NULL, DEFAULT), (Bob, 1234, 10), (Alice, NULL, 200);AUTO_INCREMENT 让主键自增不需要手动填phone 列的 NULL 演示了空值语义points 列的 DEFAULT 演示了默认值。建完表后把这篇文章前面所有 WHERE、LIKE、REGEXP、ORDER BY、LIMIT 例子各跑一遍再随意改条件值看结果变化。测试环境里用命令行或 Navicat 的查询窗口都行。导入现成的 SQL 脚本时用命令行的source /path/to/file.sql或者客户端的“运行 SQL 文件”功能注意编码统一成 utf8mb4中文乱码基本就这两个原因。6.2 五个自查点每次写查询前过一遍第一WHERE 里有没有引用 SELECT 别名有就必然报错第二JOIN 后面有没有带着 ON 或 USING空着就是笛卡尔积第三LEFT JOIN 下右表过滤条件写在了哪想保留左表全量就放 ON 里第四LIMIT 偏移量是不是按(page-1)*size算的第五INSERT 的 NULL 有没有加引号。我在给团队做代码评审时几乎每次都能在这几个点里捞出一条问题。SQL 和普通编程语言一个很大区别是写错不一定会报运行时错误很多逻辑错误返回结果是正常的只是数据不对。自查习惯比语法熟练更重要。6.3 笔记之外的下一步三小时课程笔记只到 INSERT 为止GROUP BY、HAVING、子查询、窗口函数都没覆盖。真实工作里ROW_NUMBER()这类窗口函数做分组去重特别常用尤其面试题里“每组取前 N 条”基本都是窗口函数的标准解法。学到这个阶段再回头看笔记里的 ORDER BY 和 LIMIT理解会更深一层。慢 SQL 优化也值得尽早接触。EXPLAIN 看一下执行计划type 字段从 ALL 变成 range 或 ref意味着 SQL 从全表扫描走到索引扫描这个感受比背一百条优化口诀都有用。之前给一个笔试系统写分页,我想当然把偏移量算成了page * page_size结果第二页把第一页的最后一条重复了一遍第三页又把第二页的最后一条重复了一遍。从那以后我每次手写 SQL 都会先把执行顺序在脑子里过一遍特别是 WHERE 加别名、LEFT JOIN 加条件、LIMIT 分页这三个点强制自己慢下来确认再敲回车。希望帮到你。本文还有配套的精品资源点击获取