本章目标:零基础也能看懂表结构,用
SELECT、WHERE、ORDER BY、LIMIT精确取数,并理解 NULL 和空值的区别。 前置知识:会打开命令行、运行 Python 脚本;不需要学过数据库。 预计用时:60 分钟。
1. 为什么需要它
数据开发每天面对的不是 Excel,而是几十万行的表。你要回答的问题通常很具体:上个月有多少订单被取消?哪些评价 ID 重复了?最近一周每天的销售额是多少?
这些问题的共同点是:数据太多,不可能靠眼睛看。你需要一种语言,把"我要什么"描述清楚,让数据库去执行。这种语言就是 SQL(Structured Query Language,结构化查询语言)。
SQL 是声明式语言:你只描述结果,不描述步骤。比如"取已取消的订单,按下单时间倒序,只要前 10 条",你写四行 SQL,数据库自己决定先扫哪张表、用不用索引。
数据开发、数据分析、数仓、ETL、报表,几乎所有岗位的第一道面试题都是 SQL。它不是选修课,是工作语言。
本章和后面几章的 SQL 章节只讲语法和刷题,用的是一组几十行的玩具表
demo_*,不涉及电商项目数据。真实数据、完整数仓和业务指标,全部留到项目实战。
2. 核心概念
2.1 数据库、表、行、列
关系型数据库把数据放在表(table)里。一张表就是一张二维表:
| customer_id | customer_name | city |
|---|---|---|
| 1 | 张三 | 北京 |
| 2 | 李四 | 上海 |
- 列(column):字段,比如
city。同一列的数据类型一致。 - 行(row):一条记录,比如一个客户。
- 主键(primary key):能唯一标识一行的字段,比如
customer_id。主键不能重复,也不能为空。
关系型数据库的意思是:多张表可以通过共同的字段关联,而不是把所有信息塞进一张大表。第 03 章会专门讲关联。
2.2 配套的九张玩具表
为了让示例结果可核对,本章准备了一组小表,每张只有几行到二十行:
| 表 | 行数 | 一行代表什么 | 关键字段 |
|---|---|---|---|
demo_customers |
7 | 一个客户 | customer_id、customer_name、city、referrer_id |
demo_orders |
12 | 一个订单 | order_id、customer_id、status、created_at、delivered_at |
demo_order_items |
15 | 订单里的一行商品 | order_id、item_id、product_id、quantity、unit_price |
demo_products |
6 | 一个商品 | product_id、product_name、category |
demo_payments |
13 | 一笔支付流水 | payment_id、order_id、method、amount |
demo_reviews |
10 | 一条订单评价 | review_id、order_id、score |
demo_departments |
3 | 一个部门 | dept_id、dept_name、city |
demo_employees |
7 | 一名员工 | emp_id、dept_id、salary、hire_date、manager_id |
demo_logins |
20 | 一次登录 | user_id、login_date |
表关系可以先用一张图记住:
demo_customers 1--N demo_orders 1--N demo_order_items N--1 demo_products
|
+--N demo_payments
+--N demo_reviews
demo_departments 1--N demo_employees
demo_employees 1--N demo_employees(manager_id 自关联)
一个客户可以下多个订单,一个订单可以有多行明细,一行明细对应一个商品。注意 demo_payments 和 demo_reviews 对订单也是一对多:一个订单可能分多笔支付、有多条评价,直接 JOIN 明细会让金额和评分翻倍,第 03 章会讲怎么避免。
2.2.1 怎么把示例跑起来
玩具库由脚本生成,不需要下载任何数据:
python scripts/make_sql_demo_db.py
duckdb project/sql_demo.duckdb
没有安装 DuckDB 命令行也可以直接用 Python:
python - <<'PY'
import duckdb
con = duckdb.connect('project/sql_demo.duckdb', read_only=True)
print(con.sql("SELECT * FROM demo_orders LIMIT 5").df())
PY
把示例 SQL 粘进去回车即可。方言是 DuckDB,date_trunc、FILTER、窗口函数都能用;Hive/Spark/MySQL 专属写法会单独标注差异。
2.3 一条 SQL 的骨架
本章只用到下面几个关键字:
SELECT 要哪些列
FROM 从哪张表
WHERE 保留哪些行
ORDER BY 按什么排序
LIMIT 只要前几行
书写顺序是 SELECT → FROM → WHERE → ORDER BY → LIMIT,但执行顺序是 FROM → WHERE → SELECT → ORDER BY → LIMIT。先找到表,再筛行,再决定输出哪些列,最后排序和截断。所以 WHERE 里不能用 SELECT 刚起的别名,那时别名还不存在;ORDER BY 里可以,因为它执行得更晚。
2.4 数据类型与 NULL
| 类型 | 例子 | 说明 |
|---|---|---|
| 字符串 | 'paid'、'北京' |
必须用单引号 |
| 整数 | 15、3 |
数量、序号 |
| 浮点数 | 58.90 |
金额、价格 |
| 日期 / 时间 | DATE '2024-03-01'、TIMESTAMP '2024-03-01 10:30:00' |
年月日与时分秒 |
| 布尔 | TRUE、FALSE |
条件的结果 |
NULL 不是 0,也不是空字符串。它表示未知、缺失、没有值。后文会单独讲它,因为它是 SQL 里最容易出错的地方。
3. 动手示例
3.1 先看表结构
python scripts/make_sql_demo_db.py
然后用 DuckDB 打开 project/sql_demo.duckdb。写查询前先 DESCRIBE:
DESCRIBE demo_orders;
字段名拼错是最常见的报错原因,先看一眼能省很多时间。
3.2 SELECT * 与选列
-- 所有列,只要前 5 行
SELECT * FROM demo_orders LIMIT 5;
-- 只要订单号、状态、下单时间
SELECT order_id, status, created_at FROM demo_orders LIMIT 5;
* 表示所有列。练习时用它快速看数据没问题,但工作中要只选需要的列,原因见本章面试延伸。
3.3 别名与表达式
SELECT order_id AS 订单号,
quantity * unit_price AS 明细金额
FROM demo_order_items
LIMIT 5;
AS 起列别名,让结果更好读。quantity * unit_price 是表达式,只影响查询结果,不会修改原表;支持 +、-、*、/、%。别名可以在 ORDER BY 里直接使用,例如 ORDER BY 明细金额 DESC。
3.4 DISTINCT:去重
SELECT DISTINCT status FROM demo_orders ORDER BY status;
结果有 3 行:canceled、paid、refunded。
注意:
DISTINCT作用于整行。SELECT DISTINCT city, customer_name是"城市和姓名的组合不重复",不是"城市不重复"。
3.5 WHERE:过滤行
WHERE 后面跟一个条件,只有结果为 TRUE 的行才会保留。
比较运算:
SELECT order_id, status
FROM demo_orders
WHERE status = 'canceled';
结果只有 1007。= 是等于,<> 或 != 是不等于。字符串要用单引号,数据值区分大小写:写 'Canceled' 一条也查不到。
区间 BETWEEN:
SELECT COUNT(*) AS 三月订单数
FROM demo_orders
WHERE created_at BETWEEN TIMESTAMP '2024-03-01 00:00:00'
AND TIMESTAMP '2024-03-31 23:59:59';
BETWEEN a AND b 等价于 >= a AND <= b,两头都包含。结果是 8。
组合条件 AND / OR / NOT:
-- 三月里,取消或退款的订单
SELECT COUNT(*)
FROM demo_orders
WHERE (status = 'canceled' OR status = 'refunded')
AND created_at BETWEEN TIMESTAMP '2024-03-01 00:00:00'
AND TIMESTAMP '2024-03-31 23:59:59';
结果是 2(1005 退款、1007 取消)。AND 的优先级高于 OR,不加括号会变成"所有取消订单,加上三月的退款订单",含义完全不同。
多选一 IN:WHERE status IN ('canceled', 'refunded') 等价于两个 OR 的简写,全量结果是 2。
取反 NOT:WHERE NOT status = 'paid' 会把条件翻转;这种场景通常直接写 <> 更清楚,但遇到 NOT IN、NOT LIKE 时要留意 NULL。
模糊匹配 LIKE:
SELECT customer_id, customer_name, city
FROM demo_customers
WHERE city LIKE '%京%'
ORDER BY customer_id;
% 代表任意多个字符,_ 代表恰好一个字符。结果是 3 位北京客户(1、3、7)。
判空 IS NULL:
SELECT COUNT(*) AS 没有推荐人的客户
FROM demo_customers
WHERE referrer_id IS NULL;
结果 3(客户 1、5、7 不是被推荐来的)。空值不一定是脏数据:这里"没有推荐人"就是正常业务含义。
3.6 ORDER BY 与 LIMIT
SELECT order_id, created_at
FROM demo_orders
ORDER BY created_at DESC
LIMIT 5;
ASC升序,是默认值,可以不写;DESC是降序。- 多列排序:
ORDER BY status ASC, created_at DESC,先按状态,再按时间。 LIMIT 5取前 5 行。
注意:只想看"前 5 条"时,
ORDER BY和LIMIT要一起写。没有排序的LIMIT只保证"随便给你 5 行"。
3.7 NULL 的三值逻辑
SQL 的条件不只有真、假,还有第三种结果:未知(NULL)。TRUE AND NULL 是 NULL,FALSE AND NULL 是 FALSE,NOT NULL 是 NULL,NULL = NULL 也是 NULL。
WHERE 只保留结果为 TRUE 的行,结果为 NULL 的行会被丢弃。于是有了两个经典坑。
坑一:= NULL 永远查不到数据。
-- 错误写法:结果是 0 行
SELECT COUNT(*) FROM demo_customers WHERE referrer_id = NULL;
-- 正确写法:结果 3
SELECT COUNT(*) FROM demo_customers WHERE referrer_id IS NULL;
NULL 表示"不知道"。"不知道"等于"不知道"吗?SQL 的答案是"不知道",不是"相等"。
坑二:NOT IN 的列表里如果有 NULL,整个条件会失效。
-- 想找"推荐人不是 1 号"的客户,结果却是 0 行
SELECT COUNT(*) FROM demo_customers WHERE referrer_id NOT IN (1, NULL);
因为 referrer_id NOT IN (1, NULL) 会展开成 referrer_id <> 1 AND referrer_id <> NULL,后半句永远是 NULL。稳妥写法是用 NOT EXISTS,第 04 章会讲。
3.8 用 SQL 做数据探查
数据开发的第一项工作不是写报表,而是先看数据长什么样。下面几个查询就是常见的体检动作。
1. 一个订单有多条评价
SELECT order_id, COUNT(*) AS 评价条数
FROM demo_reviews
GROUP BY order_id
HAVING COUNT(*) > 1;
只有订单 1002 出现 2 条评价。这里提前用了 GROUP BY,下一章会系统讲。清洗时先把评价聚合到订单粒度,再 JOIN 订单,否则评分会被重复计算。
2. 已取消但仍有支付
SELECT o.order_id, p.amount
FROM demo_orders o
JOIN demo_payments p ON p.order_id = o.order_id
WHERE o.status = 'canceled';
订单 1007 已取消,却有一笔 89 元的支付。这类记录通常先告警,不直接删除。
3. 支付金额与明细金额对不上
SELECT *
FROM (
SELECT o.order_id,
(SELECT ROUND(SUM(quantity * unit_price), 2)
FROM demo_order_items i WHERE i.order_id = o.order_id) AS item_amount,
(SELECT ROUND(SUM(amount), 2)
FROM demo_payments p WHERE p.order_id = o.order_id) AS pay_amount
FROM demo_orders o
) t
WHERE item_amount IS NOT NULL
AND ABS(item_amount - pay_amount) > 0.01;
订单 1002 明细 258.00、支付 257.90,差 0.10。
4. 员工没有上级
SELECT emp_id, emp_name
FROM demo_employees
WHERE manager_id IS NULL;
结果是 3 位部门负责人。NULL 在这里同样有明确业务含义。
提示:能区分"业务空值"和"脏数据",是数据开发的基本功。判断标准不是字段是否为空,而是状态和事实是否矛盾。
4. 常见坑
- 字符串用了双引号。
"paid"在 SQL 里是列名,会报找不到列。字符串一律单引号。 - 用
= NULL判空。永远匹配不到,必须用IS NULL/IS NOT NULL。 OR不加括号。AND优先级更高,条件含义会悄悄改变。- 以为
BETWEEN不包含边界。它是闭区间,两头都包含。 NOT IN遇到 NULL。列表里有 NULL 时,结果可能为空;优先考虑NOT EXISTS。LIMIT不配ORDER BY。返回顺序不稳定,换一次执行可能换一批数据。- 字段名拼错。先
DESCRIBE 表名;看真实字段,不要凭印象写。
5. 在线练习
SQL 题不在本地贴答案。按顺序在力扣完成,提交通过后再对照下面的自测点。
| 题目 | 考点 |
|---|---|
| 595. 大的国家 | WHERE + OR |
| 584. 寻找用户推荐人 | NULL 与 != |
| 183. 从不订购的客户 | NOT IN / 反连接 |
| 620. 有趣的电影 | WHERE + ORDER BY |
| 596. 超过 5 名学生的课 | GROUP BY + HAVING |
自测:
- 能用
WHERE、ORDER BY、LIMIT组合取数。 - 能区分
= NULL和IS NULL。 - 能用
NOT IN找出“没有关联记录”的行。
6. 面试延伸
面试题:SQL 的书写顺序和执行顺序为什么不一样?
是什么:书写顺序是 SELECT → FROM → WHERE → ORDER BY → LIMIT,逻辑执行顺序是 FROM → WHERE → SELECT → ORDER BY → LIMIT。数据库要先知道数据来自哪张表,才能筛行,筛完才知道输出哪些列。
解决什么问题:理解执行顺序,才能解释"为什么 WHERE 里不能用 SELECT 的别名,而 ORDER BY 可以"。前者执行时别名还没产生,后者已经产生。
我的实操边界:平时按书写顺序写,排查问题时按执行顺序想;聚合和窗口阶段会在后续章节补充。
面试题:为什么工作中不建议写 SELECT *?
是什么:SELECT * 会返回表里的所有列,而不是你真正需要的列。
解决什么问题:只选需要的列能减少 IO 和内存;表结构变化时,下游结果不会因为多出几列而错位;列名写清楚,代码也更容易维护。
我的实操边界:练习和临时看数据时会用 *,写正式查询、ETL 和报表时会显式列出字段。
面试题:= NULL 为什么查不到数据?
是什么:NULL 代表未知,任何与它比较的结果都是 NULL,而 WHERE 只保留结果为 TRUE 的行。
解决什么问题:判空必须用 IS NULL / IS NOT NULL。同理,NOT IN 的子查询结果里如果有 NULL,整个条件可能失效,此时应改用 NOT EXISTS。
我的实操边界:数据探查时用 IS NULL 统计空值;写关联前会确认字段是否允许为空,避免把业务空值误当脏数据。
7. 本章验收
- 能用自己的话解释表、行、列、主键,以及玩具库里九张表各存什么。
- 能用
make_sql_demo_db.py生成玩具库,并用DESCRIBE查到字段名。 - 能独立写出包含
WHERE、ORDER BY、LIMIT的查询,并说清执行顺序。 - 能正确使用
BETWEEN、AND/OR、IN、LIKE、IS NULL。 - 能解释
= NULL为什么查不到数据,并知道NOT IN的 NULL 陷阱。 - 能查出订单 1002 的两条评价、订单 1007 的取消后支付、订单 1002 的 0.10 金额差。
下一章
聚合与分组:从"查行"升级到"算指标",用 COUNT、SUM、GROUP BY 算出月度销售额、品类占比和客单价。