入职第一周:老板的第一张表
-
什么是数据库 / 表 / 行 / 列:和 Excel 的对应关系
场景 老板丢来一个订单导出文件,你只会用 Excel 打开它。今天起,同样的数据要换一种存放方式。
讲解 对应关系一句话:数据库 = 整个工作簿文件,表 = 一个 sheet,行 = 一条订单记录,列 = 一个字段(id、金额、时间……)。本质区别在约束和访问方式:Excel 的单元格里塞什么都可以,数据库的每一列有类型(金额必须是数字)和可空性(支付时间可以没有),访问统一走 SQL 而不是鼠标点选。
试试-- 表在数据库里的样子:列有类型,行是一条条订单 select id, user_id, status, total_amount, created_at from orders limit 3;易错 Excel 里「没填」和「填了 0」长得差不多;数据库里 NULL(没发生)和 0(发生了、值为零)是两个世界,D3 整天都在讲它。
-
Docker 起 PostgreSQL 16,psql 连进去
场景 公司连个数据库都没有,你今天要自己搭一个--但不能往笔记本上裸装软件。
讲解 Docker 一条命令起一个干净的 PostgreSQL 16 容器,删掉重来毫无负担。
psql是 PG 官方命令行客户端,连进去就是你的操作台。装完先做三件事:进容器、开 psql、建库。试试docker run -d --name pg16 -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:16 docker exec -it pg16 psql -U postgres CREATE DATABASE shop; \c shop易错 容器删了数据就没了。要持久化就加
-v pgdata:/var/lib/postgresql/data挂个卷;练习期无所谓,崩了重灌反而干净。 -
6 个元命令:\l \dt \d \x \timing
场景 进了 psql 就是一块黑屏--先学会「环顾四周」。
讲解 元命令是 psql 客户端的功能,不是 SQL:
\l列出所有库、\dt列出当前库的表、\d 表名看表结构(字段、类型、可空、默认值)、\x把宽结果竖排显示、\timing on显示每条 SQL 的耗时(W6 优化周全靠它)。试试\dt -- 库里有哪些表 \d orders -- orders 的完整结构 \timing on -- 之后每条 SQL 都带耗时易错 元命令以反斜杠开头、不带分号,只在 psql 里有效。换任何别的客户端(代码里连库)它们都不存在。
-
CREATE TABLE:orders 的字段类型你来定
场景 建表没有标准答案,字段类型是业务判断题:这张表在你眼里是什么。
讲解 每个字段回答两个问题:什么类型、能不能为空。id 用 uuid(导出文件里就是无序编号);金额用
numeric(10,2)定点小数,不是 float;status 用 int 存状态码;created_at 必填,paid_at 允许 NULL--可空性是业务事实:没支付的单就是没有支付时间。试试CREATE TABLE orders ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL, status integer, total_amount numeric(10,2), created_at timestamp NOT NULL, paid_at timestamp );易错 金额用 float / double 会丢精度(二进制存不了 0.1),报表对不上账。钱永远 numeric / decimal。
-
跑 seed.sql 的「D01」段:灌入 5 万行(里面埋了脏数据)
场景 空表什么都查不了,把老板的订单灌进去。
讲解
psql -f 文件或进去之后\i 文件执行整个脚本;今天只跑 §A 段两条语句(先 INSERT 5 万行,再 UPDATE 埋脏数据)。灌完select count(*)核对行数,数据里埋着负金额、时间倒挂、空状态--都是本周的练习素材。试试-- 方式一:psql -f 整个文件,或编辑后只留 §A 段 -- 方式二:psql 里逐条粘贴执行 select count(*) from orders; -- 50000易错 同一段 INSERT 跑两遍 = 数据翻倍(或主键冲突)。发现数字不对劲,先怀疑自己重复灌过。
- Docker 起
pg16,建库shop并连进去 - 建 orders 表:字段类型自己定,定完对照本页下方的结构检查
- 跑 seed.sql 的「D01」段灌入 5 万行
\dt和\d orders看结构,输出贴进 notes.md- 开
\timing,跑select count(*) from orders,记下行数与耗时
-
SELECT 的解剖:选哪些列、从哪张表、* 与具体列
场景 老板问昨天卖了多少。第一条 SQL 之前,先把 SQL 的「主谓宾」拆清楚。
讲解
select 列决定输出什么,from 表决定从哪拿。*是全部列,探索数据时用它看长相;正式查询写具体列--少传无用数据,读 SQL 的人一眼看出结果长什么样。试试-- 探索:先看看数据长什么样 select * from orders limit 10; -- 正式:要什么写什么 select id, total_amount, created_at from orders limit 10;易错 生产代码里 select * 配上表结构变更会悄悄多传/错传数据,报错都懒得报。
-
WHERE 比较运算符与日期字面量
场景 5 万行订单,老板只要昨天的。全表看一遍是人的活,不是 SQL 的。
讲解
where逐行判断表达式,结果为 TRUE 的行才保留。日期写法用「左闭右开」区间:>= 昨天 0 点且< 今天 0 点,一秒不多不少。不硬编码日期用current_date - 1。试试select count(*) from orders where created_at >= current_date - 1 -- 昨天 0 点起 and created_at < current_date; -- 今天 0 点止易错 时间戳别用等值判断:
created_at = current_date - 1只匹配「昨天 0 点整」那一瞬间。也别写 between(含两头,边界多算一天)。 -
ORDER BY 与 LIMIT:找「最大 / 最新」的标准姿势
场景 老板要看金额最大的 10 单、最新的 10 单。
讲解 标准姿势 =
order by 排序键 desc把想要的行顶到最上面,limit n截取前 n 行。Top N 问题在 SQL 里永远这么解,没有别的语法。试试select id, total_amount from orders order by total_amount desc limit 10;易错 金额或时间相同的行,顺序是未定义的--补第二排序键(如
order by total_amount desc, id desc)才稳定,这是面试加分点。 -
排序不稳定:LIMIT 10 不加 ORDER BY 结果每次都不一样
场景 你
limit 10连跑 5 次,5 看似随机的结果,以为数据库坏了。讲解 SQL 标准就不承诺无 ORDER BY 时的返回顺序:物理存储顺序、缓存、并行扫描都会影响先吐出哪 10 行。数据库没坏,是你的查询没定义「前 10」的依据。
试试-- 连跑 5 次,对比输出 select id, total_amount from orders limit 10; select id, total_amount from orders limit 10; select id, total_amount from orders limit 10;易错 不带 order by 的 limit = 「随便给我 10 行」,业务上几乎一定是 bug。
select * from orders limit 10先看看数据长什么样- 查昨天的订单数(先手写日期字面量,再想怎么不硬编码)
- 查金额 > 1000 的订单数
- 按金额从大到小取前 10 单;再按 created_at 取最新 10 单
LIMIT 10不加ORDER BY连跑 5 次,观察差异并解释
status <> 2 把它们全漏掉了。 -
三值逻辑:TRUE / FALSE / UNKNOWN,以及 WHERE 只保留 TRUE
场景 你用 status 不等于 2 统计「非已支付」,漏掉了一大批单。漏掉的那批 status 是 NULL--这不是 bug,是 SQL 的设计。
讲解 NULL 参与任何比较,结果既不是 TRUE 也不是 FALSE,而是第三个值 UNKNOWN。而 WHERE 只放行 TRUE--UNKNOWN 和 FALSE 一样被丢弃。所以
status <> 2查不到 status 为 NULL 的行:不是「不等于 2」不成立,是「结果未知」就不放行。试试select null = null, -- NULL(UNKNOWN) null <> null, -- NULL(UNKNOWN) not null; -- NULL(UNKNOWN) -- TRUE AND NULL = NULL;TRUE OR NULL = TRUE;FALSE OR NULL = NULL易错 结论先背下来:NULL 和任何值比较(包括和自己)都得 UNKNOWN,永远不会是 TRUE。
-
IS NULL、IS NOT NULL、IS DISTINCT FROM
场景 既然比较运算符对 NULL 失灵,判空就得有专门的语法。
讲解
is null/is not null是唯一能对 NULL 做出 TRUE/FALSE 判断的写法。进阶是is distinct from:把 NULL 当成一个具体值参与比较,「不等于某值(连 NULL 也算)」的正确写法。试试select count(*) from orders where status is null; -- 状态为空的脏数据 select count(*) from orders where status <> 2; -- 漏掉 NULL 行 select count(*) from orders where status is distinct from 2; -- 正确的「非已支付」易错 「不等于某值」的需求永远先想到 IS DISTINCT FROM,<> 只对非 NULL 行负责。
-
NULL 参与比较、算术、排序时分别得到什么
场景 脏数据的 NULL 会顺着各种运算悄悄传染,得知道传到哪一步会变成什么。
讲解 算术:
null + 1 = null,传染。聚合:sum/avg把 NULL 当「这行不参与」。排序:PG 默认升序 NULL 排最后、降序排最前,可以用nulls first / last显式指定。试试select null + 1; -- null select 1 + 2 + null; -- null:一个环节空,整条链空 select id, status from orders order by status desc nulls last; -- 脏数据固定垫底易错 各数据库对 NULL 排序位置不统一(MySQL 恒排最前),写报表想稳就显式写 nulls first/last。
-
count(*) vs count(col) 初见:NULL 不被 count(col) 数到
场景 同一张表,三个 count 数出三个数,老板以为你在做假账。
讲解
count(*)数行,一行算一个;count(列)只数该列非空的行。两个数字相减 = 该列为 NULL 的行数--这本身就是个查脏数据的小技巧。试试select count(*), -- 50000:总行数 count(status), -- ≈49478:有状态的 count(paid_at) -- ≈34968:付过钱的 from orders;易错 avg / sum 跳过 NULL 意味着「平均值」的分母变小了--分母口径问题 D4 展开。
- 用
select null = null, null <> null, not null验证三值逻辑,做出 AND/OR 真值表 - 查 status 为空的订单数;对比
status <> 2的行数,解释差额来自哪 - 对比
count(*)/count(status)/count(paid_at)三个数字,解释差异 - paid_at 为 NULL 的订单在业务上是什么含义?写一句话进 notes.md
- 用
coalesce(status, '未知')把脏数据标出来,统计各状态订单数
col <> 'A' 为什么查不到 col 为 NULL 的行」,并写出正确写法。-
聚合函数 count / sum / avg / min / max 的输入输出;count 的三种语义
场景 老板要的是「分布」,不是某一个数字--把 5 万行压成几个数的运算。
讲解 聚合函数吃很多行、吐一个值:
count数个数、sum求和、avg均值、min / max极值。count 有三种语义要分清:count(*)数行、count(列)数非空、count(distinct 列)数去重后的非空值。试试select count(*), -- 总行数 count(distinct user_id) -- 多少个不同用户下过单 from orders;易错 count(distinct a, b) 这种多列组合写法 PG 不直接支持,要写 count(distinct (a, b))。
-
GROUP BY:把很多行压成「每组一行」
场景 「按状态分类」= 先按 status 把 5 万行分堆,再对每堆各算一个 count。
讲解 group by status 之后,5 万行变成 4 行(每个状态一行)。理解它最好的方式:把表想象成按状态码堆好的几摞,select 里的聚合函数对每一摞各算一次。status 为 NULL 的行不会消失,自己占一组。
试试select status, count(*) as 单数, sum(total_amount) as 总金额 from orders group by status order by 单数 desc;易错 分组后每组只剩一行,select 里出现「既不在 group by、也没被聚合」的列会报错--W2 D11 专门拆这个约束。
-
WHERE(分组前过滤)vs HAVING(分组后过滤)
场景 「找出订单数超过 100 的日期」--这个 100 是数出来的,数之前它还不存在。
讲解 执行顺序说了算:WHERE 在分组前作用于原始行,HAVING 在分组后作用于组。判断条件用到聚合结果(每组数出来的那个数)的,只能进 HAVING;HAVING 里可以放心写
count(*),WHERE 里写它直接报错。试试select created_at::date as 日期, count(*) as 单数 from orders group by 1 having count(*) > 100 -- 过滤的是「组」 order by 1;易错 报错 aggregate functions are not allowed in WHERE 就是把聚合条件写错了地方。
-
sum / avg 自动跳过 NULL 带来的分母问题
场景 运营质疑你算的「平均金额」:和 Excel 里算的不一样。
讲解 sum / avg 遇到 NULL 行直接跳过:sum 没影响(加零而已),但 avg 的分母变小了--Excel 的 AVERAGE 其实也一样,但没人意识到。想「NULL 按 0 参与平均」要自己
avg(coalesce(列, 0)),两个口径都对,错的是不说明口径。试试select avg(total_amount), -- 分母 = 非空行数 avg(coalesce(total_amount, 0)) -- 分母 = 全部行数 from orders;易错 报「平均值」之前先回答:分母是谁?这是口径问题,不是语法问题。
-
CASE 两种写法(简单式 / 搜索式):把值映射成标签、把金额分档
场景 老板看不懂状态码,报表要输出「已支付 / 待支付」;还要把连续的金额切成 0-100 / 100-500 / 500+ 三档。
讲解 简单式
case 列 when 值 then ...做等值映射;搜索式case when 条件 then ...做任意判断(分档、多条件)。条件从上往下,第一个命中生效,所以分档的条件按从小到大排;else兜住没列到的值和 NULL。试试-- 简单式:值 -> 标签 select case status when 2 then '已支付' when 1 then '待支付' when 3 then '已取消' else '未知' end as 状态 from orders limit 5; -- 搜索式:金额分档(条件顺序就是判断顺序) select case when total_amount < 100 then '0-100' when total_amount < 500 then '100-500' else '500+' end as 金额档, count(*) from orders group by 1;易错 分档条件写反(先 < 500 后 < 100),所有小单都进了第一档--顺序就是逻辑。
- 按状态统计订单数和总金额
- 算每种状态的订单占比(总单数用子查询再除)
- 用 CASE 把状态映射成中文标签(如「已支付」「待支付」)再分组
- 按金额分档(0–100 / 100–500 / 500+)统计订单数与占比
- 用 HAVING 找出订单数 > 100 的日期,并解释为什么这题不能用 WHERE
-
字符串函数:concat / substring / split_part / trim / lpad
场景 老板要「ORD-000001」格式的业务单号--拼接、截取、补零,全是字符串函数的活。
讲解 一句话各记一个用途:
concat/||拼接、substring按位置截取、split_part(串, 分隔符, 第几段)按分隔符拆、trim去首尾空白、lpad(文本, 总长, 补什么)左侧补字符到指定长度。试试select 'ORD-' || lpad('123', 6, '0'); -- ORD-000123 select split_part('ORD-000123', '-', 2); -- 000123 select substring('ORD-000123' from 5 for 3); -- 000易错 lpad 的参数是文本,数字要先
::text;数字直接塞进去会报类型错。 -
类型转换 :: 与 cast(),转换失败怎么办
场景 字符串 '123' 要参与计算、'000123' 要变回数字--类型不转,SQL 寸步难行。
讲解
::是 PG 的简写(cast(x as 类型)是标准写法),'123'::int、123::text随手转。要命的是转换失败直接报错:整条查询挂掉,一行脏数据毁一锅。清洗脏数据时先用~正则验格式,确认能转再转。试试select '123'::int + 1; -- 124 select 'ORD-000123' ~ '^ORD-\d{6}$'; -- true:格式合法才转 select split_part('ORD-000123', '-', 2)::int; -- 123易错 PG 没有内置的「转换失败返回 NULL」函数(别的库叫 try_cast),要么先验格式,要么自定义函数。
-
COALESCE / NULLIF / GREATEST / LEAST
场景 负金额要抬到 0、除法分母可能为 0、展示时 NULL 要换文字--四个小工具各治一病。
讲解
coalesce(a, b, ...)取第一个非空(NULL 兜底);nullif(a, b)在 a=b 时返回 NULL(专治除零:x / nullif(y, 0)分母为 0 得 NULL 而不是崩);greatest / least取多值中的最大 / 最小(负金额抬零用 greatest(金额, 0))。试试select coalesce(paid_at, created_at) as 最后动作时间 -- NULL 兜底 from orders limit 5; select greatest(total_amount, 0) as 修正展示 -- 负数抬 0 from orders where total_amount < 0 limit 5;易错 除法永远配 nullif(分母, 0) 防御--报表崩在线上多半是没写它。
-
round / abs 修数值:整数除法的坑
场景 算占比输出全是 0,数据明明没错。
讲解
round(x, n)四舍五入到 n 位,n 可以是负数(-1 舍到十位);abs绝对值。占比输出为 0 的元凶几乎总是整数除法:1 / 2在 SQL 里得 0,写100.0 * a / b先把一边变浮点。试试select round(1234.56, -1); -- 1230:舍到十位 select 1 / 2, 100.0 * 1 / 2; -- 0 vs 50 select abs(-42.5); -- 42.5易错 整数 / 整数 = 整数(向零截断),参与除法前先写 100.0 或 ::numeric。
-
清洗的正确姿势:先「展示时修正」,别急着 UPDATE 原表
场景 老板说「洗成能看的样子」--你想直接把负金额 update 成 0。
讲解 原表是事实记录:负金额是「导出工具出了问题」这个事实的证据,UPDATE 掉它,问题就永远查不到了。正确姿势是在查询层修正展示(greatest、coalesce、case),原表保持原样;什么时候真的要改表、怎么改才安全,W5 专门讲。
试试-- 展示时修正:原表不动 select id, total_amount as 原始值, greatest(total_amount, 0) as 展示值 from orders where total_amount < 0 limit 5;易错 动手 UPDATE 原表 = 销毁证据。清洗的第一原则:能不动原表就不动。
- 找出负金额订单,用
greatest(total_amount, 0)修正展示 - 查出 paid_at 早于 created_at 的异常单(数据里埋了,找出几条)
- 金额四舍五入到十位(
round(x, -1)),统计各档订单数 - 用
row_number()按时间顺序给订单编号,再拼出「ORD-000001」格式的业务单号(lpad) - 用
split_part把第 4 题的单号反解回数字,验证与编号一致
-
date / timestamp / interval;date_trunc 按天周月截断、extract 取分量
场景 周报要按天、按周汇总--不搞清楚时间类型,分组都是错的。
讲解
date只有日期,timestamp带时分秒,interval是时间段(加减日期用)。两个主力函数:date_trunc('week', 列)截断到所在周周一零点(按周分组就靠它);extract('year' from 列)取出年 / 月 / 日等分量。试试select date_trunc('week', current_date); -- 本周一 0 点 select extract('dow' from current_date); -- 星期几(0=周日) select current_date + interval '1 day'; -- 明天易错 date_trunc('week') 的周从周一开始;业务如果按周日切周,得自己算偏移。
-
generate_series 生成日期序列左连接补零
场景 近 30 天日报交上去,老板发现没单的日期整行消失--不是没卖,是没卖的日子连行都没有。
讲解 解法的骨架:用
generate_series(起, 止, interval '1 day')造出完整的 30 行日期,把它当左表,left join orders。没订单的日期那行 o.* 全是 NULL,靠count(o.id)数出 0、coalesce把 NULL 金额补 0。试试select d::date as 日期, count(o.id) as 订单数, coalesce(sum(o.total_amount), 0) as gmv from generate_series(current_date - 29, current_date, interval '1 day') d left join orders o on o.created_at >= d and o.created_at < d + interval '1 day' group by d order by d;易错 日期序列是驱动表(左表),写在 from 里、orders 去 join 它--方向反了补零就失效。
-
查「上周一到上周日」不硬编码日期的写法
场景 写死日期的报表下周就作废,老板每周都要--日期必须算出来。
讲解 公式就一行:
date_trunc('week', current_date)是本周一零点,减 7 天得上周一零点,左闭右开到本周一。所有「上一个周期」的需求都是这个模板:先锚定本周期的起点,再平移。试试where created_at >= date_trunc('week', current_date) - interval '1 week' and created_at < date_trunc('week', current_date)易错 时间戳别用 between:它含两头,上周日 23:59:59.999 之后、周一零点之前的毫秒归属说不清。
-
九步逻辑执行顺序:FROM -> JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> LIMIT
场景 本周背不下来这个,下周的多表查询会处处撞墙。它是理解一切「为什么这样写不行」的钥匙。
讲解 书写顺序和执行顺序是两回事:先有表(FROM/JOIN),再筛行(WHERE),再分组(GROUP BY)、筛组(HAVING),然后才算 SELECT 里写的东西,DISTINCT、ORDER BY、LIMIT 依次收尾。你在 WHERE 里用不了 SELECT 的别名、在 WHERE 里写不了聚合,全是这一个原因。
试试select status, -- 6. SELECT 决定输出哪些列 count(*) as n -- 6. 聚合在这里算出来 from orders where total_amount > 100 -- 3. WHERE 过滤行 group by status -- 4. 分组,每组之后只剩一行 having count(*) > 10 -- 5. HAVING 过滤组 order by n desc -- 8. 排序(此时才能用别名 n) limit 3; -- 9. 最后才取前 3易错 面试必背。默写不出来的话,把它当成「一句话说明书」:先拿数据,再筛数据,再算数据,最后摆盘。
-
别名可见性:为什么 WHERE 用不了 SELECT 的别名,ORDER BY 却可以
场景 你在 where 里写了 select 定义的别名,报错 column does not exist--明明拼写没错。
讲解 还是执行顺序:WHERE 是第 3 步、SELECT 是第 6 步--WHERE 执行时别名还没出生;ORDER BY 在 SELECT 之后,别名已经存在。一句话:一个子句只能用它执行时已经存在的东西。
试试select total_amount as amt from orders where amt > 100; -- ERROR: column "amt" does not exist -- order by amt 却没问题易错 PG 的 group by 其实允许用别名(group by amt 可以),但这是方言、可移植性差--按「别名只有 ORDER BY 能用」记最安全。
- 近 30 天每日订单数和 GMV,没有订单的日期要补 0(generate_series 左连接)
- 按周统计订单数与 GMV(
date_trunc('week')) - 查「上周」的订单,不许硬编码日期
- 写一条包含全部九个子句的查询,在注释里标注每步之后大约剩多少行
- 故意在 WHERE 里用 SELECT 定义的别名,记下报错并解释原因
- 翻一遍本周 notes.md,标出还讲不利索的点
- 70 分钟闭卷做 30 道单表查询题(LeetCode 简单难度 + 自出题)
- 错题全部重写一遍,写进 mistakes.md
- 错题归因:分成「没懂概念 / 记不住语法 / 看错题意」三类统计
- 写出给老板的周报:总单量 / GMV / 状态分布 / 近 7 天趋势(每日一行,缺日补 0)
- 把「逻辑执行顺序」和「NULL 三值逻辑」各写成 5 句话的口述稿