你用过的每一个应用程序,其底层都依赖某种数据模型。一旦选错,接下来三年你可能都在跟数据库搏斗,而不是专注交付新功能。
过去四十年间,一种模型始终占据主导地位:关系模型(Relational Model) ——扁平的表结构、外键、SQL。它既不炫酷,也不时髦,但几乎每一家银行、航空公司、医院和电商平台仍在使用它,背后自有深刻原因。理解这一点,是真正掌握数据库的第一步。
关系模型:科德(Codd)的伟大构想# 1970 年,埃德加·F·科德(Edgar F. Codd)发表了论文《大型共享数据库的关系数据模型》(“A Relational Model of Data for Large Shared Data Banks”)。其核心洞见在当时极为激进:将数据的 逻辑表示 与 物理存储 彻底分离。应用程序无需关心数据究竟存于磁盘、内存,还是跨十台机器分布,只需看到 表(tables) ——仅此而已。
关系型数据库将数据组织为 关系(relations) (即表),每张表包含:
列(columns) (属性)——带类型的字段,例如 name VARCHAR(100) 或 price DECIMAL(10,2)行(rows) (元组)——单条记录主键(primary key) ——一个(或一组)能唯一标识每一行的列外键(foreign key) ——一个引用另一张表主键的列,用于建立表间关联这四个概念足以建模出令人惊讶的复杂业务领域。
实用 Schema 示例:电商系统# 理论结合具体表结构更易理解。以下是一个极简的电商 Schema,本文后续将反复使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CREATE TABLE users (
user_id SERIAL PRIMARY KEY ,
email VARCHAR ( 255 ) NOT NULL UNIQUE ,
full_name VARCHAR ( 200 ) NOT NULL ,
created_at TIMESTAMP NOT NULL DEFAULT NOW ()
);
CREATE TABLE products (
product_id SERIAL PRIMARY KEY ,
name VARCHAR ( 300 ) NOT NULL ,
category VARCHAR ( 100 ),
price DECIMAL ( 10 , 2 ) NOT NULL ,
stock INT NOT NULL DEFAULT 0
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY ,
user_id INT NOT NULL REFERENCES users ( user_id ),
status VARCHAR ( 20 ) NOT NULL DEFAULT 'pending' ,
created_at TIMESTAMP NOT NULL DEFAULT NOW ()
);
CREATE TABLE order_items (
item_id SERIAL PRIMARY KEY ,
order_id INT NOT NULL REFERENCES orders ( order_id ),
product_id INT NOT NULL REFERENCES products ( product_id ),
quantity INT NOT NULL CHECK ( quantity > 0 ),
unit_price DECIMAL ( 10 , 2 ) NOT NULL
);
四张表,三个外键。这已足够表达“用户下单购买多种商品”这一核心场景——正是支撑着年营收达数十亿美元公司的模型。
SQL 基础要点#
SQL(Structured Query Language)是与关系型数据库对话的语言,它是**声明式(declarative)**的:你只需描述 想要什么数据 ,而非 如何获取它 ,执行计划由数据库引擎自行推导。
SELECT、FROM、WHERE# 最基础的查询:
1
2
3
4
5
-- 查找价格低于 $500 的 "Electronics" 类别商品
SELECT product_id , name , price
FROM products
WHERE category = 'Electronics'
AND price < 500 . 00 ;
输出:
1
2
3
4
5
6
product_id | name | price
------------+-----------------------+--------
3 | Wireless Mouse | 29.99
7 | USB-C Hub | 45.00
12 | Mechanical Keyboard | 149.99
18 | Noise-Cancelling Buds | 199.00
ORDER BY 和 LIMIT# 1
2
3
4
5
-- 最贵的 5 款商品
SELECT name , price
FROM products
ORDER BY price DESC
LIMIT 5 ;
JOIN:关系型查询的核心# JOIN 将多张表的行组合起来。你需要掌握四种类型:
JOIN 类型 返回结果 INNER JOIN仅返回在两张表中均有匹配 的行 LEFT JOIN返回左表所有行;右表无匹配时对应列为 NULL RIGHT JOIN返回右表所有行;左表无匹配时对应列为 NULL FULL OUTER JOIN返回两张表所有行;任一侧无匹配时对应列为 NULL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 获取所有已完成订单及其用户信息与商品明细
SELECT
u . full_name ,
o . order_id ,
p . name AS product_name ,
oi . quantity ,
oi . unit_price ,
( oi . quantity * oi . unit_price ) AS line_total
FROM orders o
INNER JOIN users u ON o . user_id = u . user_id
INNER JOIN order_items oi ON o . order_id = oi . order_id
INNER JOIN products p ON oi . product_id = p . product_id
WHERE o . status = 'completed'
ORDER BY o . order_id ;
输出:
1
2
3
4
5
6
full_name | order_id | product_name | quantity | unit_price | line_total
--------------+----------+---------------------+----------+------------+------------
Alice Chen | 1 | Mechanical Keyboard | 1 | 149.99 | 149.99
Alice Chen | 1 | USB-C Hub | 2 | 45.00 | 90.00
Bob Martinez | 3 | Wireless Mouse | 3 | 29.99 | 89.97
Bob Martinez | 3 | Standing Desk | 1 | 599.00 | 599.00
当需要保留左表所有行(即使右表无匹配)时,LEFT JOIN 至关重要:
1
2
3
4
5
-- 查找从未下过单的用户
SELECT u . user_id , u . email , u . full_name
FROM users u
LEFT JOIN orders o ON u . user_id = o . user_id
WHERE o . order_id IS NULL ;
GROUP BY 和 HAVING# 聚合操作将多行压缩为汇总行:
1
2
3
4
5
6
7
8
9
10
11
12
13
-- 各品类营收(仅显示营收 > $1000 的品类)
SELECT
p . category ,
COUNT ( DISTINCT o . order_id ) AS order_count ,
SUM ( oi . quantity ) AS total_units ,
SUM ( oi . quantity * oi . unit_price ) AS total_revenue
FROM order_items oi
JOIN products p ON oi . product_id = p . product_id
JOIN orders o ON oi . order_id = o . order_id
WHERE o . status = 'completed'
GROUP BY p . category
HAVING SUM ( oi . quantity * oi . unit_price ) > 1000 . 00
ORDER BY total_revenue DESC ;
输出:
1
2
3
4
5
category | order_count | total_units | total_revenue
--------------+-------------+-------------+--------------
Electronics | 47 | 132 | 18432.50
Furniture | 23 | 31 | 12890.00
Books | 89 | 234 | 4567.80
WHERE 在分组前过滤行,而 HAVING 在聚合后过滤分组。这个区别常令初学者困惑。
数据类型:了解你的选项# 选择合适的数据类型直接影响存储开销、性能与数据正确性。以下是 PostgreSQL 与 MySQL 中常见类型的对比。
类型 PostgreSQL MySQL 字节数 取值范围 / 说明 小整数 SMALLINTSMALLINT2 -32,768 到 32,767 整数 INT / INTEGERINT4 -21 亿 到 21 亿 大整数 BIGINTBIGINT8 -9.2 × 10¹⁸ 到 9.2 × 10¹⁸ 可变长文本 VARCHAR(n)VARCHAR(n)1-4 + 长度 最大 1 GB(PG),65,535 字节(MySQL) 无限长文本 TEXTTEXT / LONGTEXT1-4 + 长度 无长度限制(PG),4 GB(MySQL LONGTEXT) 精确小数 DECIMAL(p,s) / NUMERICDECIMAL(p,s)可变 货币场景必用。切勿用 FLOAT 存货币! 时间戳 TIMESTAMP / TIMESTAMPTZTIMESTAMP / DATETIME8 PG 中务必用 TIMESTAMPTZ 布尔值 BOOLEANBOOLEAN / TINYINT(1)1 MySQL 的 BOOLEAN 实为 TINYINT JSON JSON / JSONBJSON可变 JSONB(PG)为二进制格式,可索引且更快UUID UUIDCHAR(36) 或 BINARY(16)16 PG 原生支持,MySQL 需模拟
经验法则:
对任何可能大规模增长的表,主键优先选用 BIGINT。INT 最多仅支持约 21 亿行。 货币值一律使用 DECIMAL(10,2)。FLOAT/DOUBLE 会引入舍入误差。 PostgreSQL 使用 TIMESTAMPTZ;或统一以 UTC 存储所有时间。 仅当确实需要 schema-free 字段时才用 JSONB(PostgreSQL);切勿用它逃避合理 schema 设计。 ALTER TABLE:Schema 演进# Schema 必然随业务演进,新功能常需新增列:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 添加电话号码列
ALTER TABLE users ADD COLUMN phone VARCHAR ( 20 );
-- 为现有列设置默认值
ALTER TABLE products ALTER COLUMN stock SET DEFAULT 0 ;
-- 重命名列(PostgreSQL)
ALTER TABLE products RENAME COLUMN name TO product_name ;
-- 添加复合唯一约束
ALTER TABLE order_items ADD CONSTRAINT uq_order_product
UNIQUE ( order_id , product_id );
-- 删除列(生产环境务必谨慎!)
ALTER TABLE users DROP COLUMN phone ;
在生产环境中,对大表执行 ALTER TABLE 可能导致表被锁住数分钟甚至数小时。第 8 篇文章将详解在线 DDL(Online DDL)策略。
范式化(Normalization):消除冗余# 范式化是通过组织列与表来减少数据冗余并防止更新异常的过程。
范式化前(反范式化混乱)# 设想所有数据挤在一张表里。
1
2
3
4
5
6
| order_id | customer_name | customer_email | product_name | product_price | quantity |
|----------|---------------|---------------------|--------------|---------------|----------|
| 1 | Alice Chen | alice@example.com | Keyboard | 149.99 | 1 |
| 1 | Alice Chen | alice@example.com | USB Hub | 45.00 | 2 |
| 2 | Alice Chen | alice@example.com | Mouse | 29.99 | 1 |
| 3 | Bob Martinez | bob@example.com | Keyboard | 149.99 | 1 |
问题:
更新异常(Update anomaly) :Alice 修改邮箱?你必须更新她出现过的每一行。插入异常(Insert anomaly) :未产生订单,就无法添加新产品。删除异常(Delete anomaly) :删掉 Bob 唯一的一笔订单,他的客户信息也彻底丢失。第一范式(1NF)# 规则 :每列只含原子(不可再分)值,禁止重复组。
违规示例:
1
2
3
| order_id | products |
|----------|-------------------------|
| 1 | Keyboard, USB Hub | -- 逗号分隔 = 违反 1NF
修复:每个订单-商品组合单独一行(这正是我们的 order_items 表所做)。
第二范式(2NF)# 规则 :满足 1NF,且每个非主键列完全依赖于整个 主键,而非主键的一部分。
这对含复合主键的表适用。若 (order_id, product_id) 是主键,则 product_name 仅依赖 product_id —— 这违反 2NF。应将产品数据移至独立的 products 表。
第三范式(3NF)# 规则 :满足 2NF,且无非主键列依赖于其他非主键列(即无传递依赖)。
违规示例:
1
2
3
| user_id | zip_code | city | state |
|---------|----------|------------|-------|
| 1 | 94105 | San Francisco | CA |
city 和 state 依赖 zip_code,而非 user_id。修复:新建 addresses 或 zip_codes 查找表。
范式化后# 我们当前的四表 Schema 已符合 3NF。
users —— 仅用户数据products —— 仅产品数据orders —— 订单元数据,引用 usersorder_items —— 订单明细,同时引用 orders 与 products零冗余,每个事实仅存储一次。
何时反范式化(Denormalize)#
范式化消除了冗余,但读取常需 JOIN 多张表——而 JOIN 有开销。有时你会主动反范式化以换取性能 :
1
2
3
4
5
6
7
8
9
10
11
12
-- 为避免每次查询都 JOIN orders + order_items + products,
-- 直接在 orders 表中缓存预计算的总金额
ALTER TABLE orders ADD COLUMN total_amount DECIMAL ( 12 , 2 );
-- 当订单项变更时更新该字段
UPDATE orders o
SET total_amount = (
SELECT SUM ( oi . quantity * oi . unit_price )
FROM order_items oi
WHERE oi . order_id = o . order_id
)
WHERE o . order_id = 42 ;
常见反范式化模式:
模式 适用场景 权衡点 缓存聚合值(Cached aggregates) 频繁运行的仪表板查询 每次写入均需同步更新 物化视图(Materialized views) 复杂报表查询 刷新间隔内数据陈旧 冗余列(Redundant columns) 热路径(hot paths)中规避昂贵 JOIN 更新异常风险重现 汇总表(Summary tables) 时序数据滚动聚合(按小时/天) 额外存储开销,ETL 复杂性
准则:先范式化,仅在实测存在性能瓶颈时才反范式化。
高级 SQL:子查询、CTE 与窗口函数# 子查询(Subqueries)# 子查询是嵌套在另一查询内部的查询。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 查找总消费额超过平均用户消费额的用户
SELECT u . full_name , user_totals . total_spent
FROM users u
JOIN (
SELECT o . user_id , SUM ( oi . quantity * oi . unit_price ) AS total_spent
FROM orders o
JOIN order_items oi ON o . order_id = oi . order_id
WHERE o . status = 'completed'
GROUP BY o . user_id
) user_totals ON u . user_id = user_totals . user_id
WHERE user_totals . total_spent > (
SELECT AVG ( sub . total_spent )
FROM (
SELECT SUM ( oi . quantity * oi . unit_price ) AS total_spent
FROM orders o
JOIN order_items oi ON o . order_id = oi . order_id
WHERE o . status = 'completed'
GROUP BY o . user_id
) sub
)
ORDER BY user_totals . total_spent DESC ;
此写法可行,但可读性差。CTE(Common Table Expressions)可解决此问题。
公共表表达式(CTEs)# WITH 子句允许你为中间结果集命名:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
WITH user_spending AS (
SELECT
o . user_id ,
SUM ( oi . quantity * oi . unit_price ) AS total_spent
FROM orders o
JOIN order_items oi ON o . order_id = oi . order_id
WHERE o . status = 'completed'
GROUP BY o . user_id
),
avg_spending AS (
SELECT AVG ( total_spent ) AS avg_spent
FROM user_spending
)
SELECT
u . full_name ,
us . total_spent ,
a . avg_spent ,
ROUND ( us . total_spent / a . avg_spent , 2 ) AS spending_ratio
FROM user_spending us
JOIN users u ON us . user_id = u . user_id
CROSS JOIN avg_spending a
WHERE us . total_spent > a . avg_spent
ORDER BY us . total_spent DESC ;
输出:
1
2
3
4
5
full_name | total_spent | avg_spent | spending_ratio
---------------+-------------+-----------+----------------
David Park | 4523.90 | 892.34 | 5.07
Alice Chen | 2891.45 | 892.34 | 3.24
Carol White | 1567.20 | 892.34 | 1.76
CTE 更易读,可在同一查询中复用;部分数据库(如 PostgreSQL 12+)还能将其内联(inline)以提升性能。
递归 CTE(Recursive CTEs)# CTE 可自我引用,适用于层级数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 员工组织架构:查找某经理下属的所有员工
WITH RECURSIVE reports AS (
-- 基础情况:经理本人
SELECT employee_id , name , manager_id , 0 AS depth
FROM employees
WHERE employee_id = 1
UNION ALL
-- 递归情况:查找当前层级员工的直接下属
SELECT e . employee_id , e . name , e . manager_id , r . depth + 1
FROM employees e
JOIN reports r ON e . manager_id = r . employee_id
)
SELECT depth , employee_id , name
FROM reports
ORDER BY depth , name ;
窗口函数(Window Functions)# 窗口函数在与当前行相关的行集上计算值,但不压缩行 (与 GROUP BY 不同):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 按月份统计用户消费,并在每月内排名
SELECT
u . full_name ,
DATE_TRUNC ( 'month' , o . created_at ) AS month ,
SUM ( oi . quantity * oi . unit_price ) AS monthly_spend ,
RANK () OVER (
PARTITION BY DATE_TRUNC ( 'month' , o . created_at )
ORDER BY SUM ( oi . quantity * oi . unit_price ) DESC
) AS spend_rank ,
ROW_NUMBER () OVER (
PARTITION BY DATE_TRUNC ( 'month' , o . created_at )
ORDER BY SUM ( oi . quantity * oi . unit_price ) DESC
) AS row_num
FROM orders o
JOIN users u ON o . user_id = u . user_id
JOIN order_items oi ON o . order_id = oi . order_id
WHERE o . status = 'completed'
GROUP BY u . full_name , DATE_TRUNC ( 'month' , o . created_at )
ORDER BY month , spend_rank ;
输出:
1
2
3
4
5
6
7
8
full_name | month | monthly_spend | spend_rank | row_num
---------------+------------+---------------+------------+---------
David Park | 2023-10-01 | 1245.00 | 1 | 1
Alice Chen | 2023-10-01 | 892.50 | 2 | 2
Carol White | 2023-10-01 | 892.50 | 2 | 3
Bob Martinez | 2023-10-01 | 334.99 | 4 | 4
Alice Chen | 2023-11-01 | 1567.20 | 1 | 1
David Park | 2023-11-01 | 998.00 | 2 | 2
注意:RANK 对并列值赋予相同排名(Alice 与 Carol 同为第 2 名),而 ROW_NUMBER 总是分配唯一序号。
LAG 和 LEAD 可访问前一行或后一行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 对比本月营收与上月营收
WITH monthly_revenue AS (
SELECT
DATE_TRUNC ( 'month' , o . created_at ) AS month ,
SUM ( oi . quantity * oi . unit_price ) AS revenue
FROM orders o
JOIN order_items oi ON o . order_id = oi . order_id
WHERE o . status = 'completed'
GROUP BY DATE_TRUNC ( 'month' , o . created_at )
)
SELECT
month ,
revenue ,
LAG ( revenue ) OVER ( ORDER BY month ) AS prev_month_revenue ,
ROUND (
( revenue - LAG ( revenue ) OVER ( ORDER BY month ))
/ LAG ( revenue ) OVER ( ORDER BY month ) * 100 ,
1
) AS growth_pct
FROM monthly_revenue
ORDER BY month ;
输出:
1
2
3
4
5
6
7
month | revenue | prev_month_revenue | growth_pct
------------+-----------+--------------------+------------
2023-08-01 | 12450.00 | |
2023-09-01 | 15670.30 | 12450.00 | 25.9
2023-10-01 | 14230.50 | 15670.30 | -9.2
2023-11-01 | 18900.00 | 14230.50 | 32.8
2023-12-01 | 24560.80 | 18900.00 | 30.0
你应该掌握的常用窗口函数。
函数 用途 ROW_NUMBER()顺序编号,无并列 RANK()并列排名(有空缺,如 1, 2, 2, 4) DENSE_RANK()并列排名(无空缺,如 1, 2, 2, 3) LAG(col, n)当前行之前第 n 行的值 LEAD(col, n)当前行之后第 n 行的值 SUM() OVER(...)累计和(running total) AVG() OVER(...)移动平均(moving average) NTILE(n)将行划分为 n 个桶 FIRST_VALUE()窗口帧内第一个值 LAST_VALUE()窗口帧内最后一个值
JSON 列:关系模型与文档模型的融合# 现代数据库模糊了关系型与文档型的界限。PostgreSQL 的 jsonb 和 MySQL 的 JSON 类型允许在传统列旁存储半结构化数据——谨慎使用可兼得两者优势。
何时使用 JSON 列# 适合 不适合 用户偏好/设置 核心业务实体(订单、用户) API 响应缓存 需要频繁查询的字段 不定 schema 的事件元数据 需要 JOIN 的字段 用户级功能开关 需要规范化的数据 审计日志上下文 有严格完整性约束的数据
PostgreSQL jsonb# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 含 JSONB 列的表,用于存储灵活的元数据
CREATE TABLE events (
event_id BIGSERIAL PRIMARY KEY ,
event_type VARCHAR ( 50 ) NOT NULL ,
user_id INT REFERENCES users ( user_id ),
payload JSONB NOT NULL DEFAULT '{}' ,
created_at TIMESTAMPTZ DEFAULT NOW ()
);
-- 插入不同结构的事件
INSERT INTO events ( event_type , user_id , payload ) VALUES
( 'page_view' , 1 , '{"url": "/products/42", "duration_ms": 3200, "referrer": "google"}' ),
( 'purchase' , 1 , '{"order_id": 1001, "items": [{"sku": "KB-01", "qty": 1}], "total": 149.99}' ),
( 'error' , 2 , '{"code": 500, "message": "timeout", "stack": "at Connection.query..."}' );
查询 JSON# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 提取字段(返回文本)
SELECT payload ->> 'url' AS url , payload ->> 'duration_ms' AS ms
FROM events
WHERE event_type = 'page_view' ;
-- 嵌套访问
SELECT payload -> 'items' -> 0 ->> 'sku' AS first_item_sku
FROM events
WHERE event_type = 'purchase' ;
-- 按 JSON 字段过滤
SELECT *
FROM events
WHERE event_type = 'error'
AND ( payload ->> 'code' ):: int >= 500 ;
-- 包含运算符(使用 GIN 索引)
SELECT *
FROM events
WHERE payload @> '{"code": 500}' ;
索引 JSON# 1
2
3
4
5
6
-- GIN 索引用于包含查询(@>, ?, ?|, ?&)
CREATE INDEX idx_events_payload ON events USING GIN ( payload );
-- 针对特定 JSON 路径的表达式索引
CREATE INDEX idx_events_error_code ON events (( payload ->> 'code' ))
WHERE event_type = 'error' ;
GIN 索引使 @> 包含检查变为 O(log n),而非扫描每一行。
反模式# 1
2
3
4
5
6
7
8
9
10
-- 错误:整行都是 JSON(等于用关系数据库模拟文档数据库)
CREATE TABLE bad_design (
id SERIAL PRIMARY KEY ,
data JSONB -- 所有东西都塞在这里
);
-- 正确:将频繁查询的 JSON 字段提升为真实列
ALTER TABLE events ADD COLUMN error_code INT ;
UPDATE events SET error_code = ( payload ->> 'code' ):: int WHERE event_type = 'error' ;
CREATE INDEX idx_events_error_code ON events ( error_code );
经验法则:如果在 WHERE 或 JOIN 中不止偶尔查询某个 JSON 字段,它就该成为真实列。
递归 CTE:查询层次化数据# 许多真实数据结构是树形的:组织架构、分类层级、嵌套评论、BOM 物料清单。递归 CTE 无需应用层循环即可查询这些结构。
1
2
3
4
5
6
7
8
9
10
WITH RECURSIVE cte_name AS (
-- 基础条件:起始行
SELECT ... FROM table WHERE condition
UNION ALL
-- 递归步骤:回连 CTE 自身
SELECT ... FROM table JOIN cte_name ON ...
)
SELECT * FROM cte_name ;
示例:分类树# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CREATE TABLE categories (
category_id INT PRIMARY KEY ,
name VARCHAR ( 100 ) NOT NULL ,
parent_id INT REFERENCES categories ( category_id )
);
INSERT INTO categories VALUES
( 1 , '全部商品' , NULL ),
( 2 , '电子产品' , 1 ),
( 3 , '计算机' , 2 ),
( 4 , '笔记本' , 3 ),
( 5 , '台式机' , 3 ),
( 6 , '手机' , 2 ),
( 7 , '家具' , 1 ),
( 8 , '办公桌' , 7 );
-- 查找"电子产品"的所有后代及深度
WITH RECURSIVE subtree AS (
SELECT category_id , name , parent_id , 0 AS depth , name :: text AS path
FROM categories
WHERE name = '电子产品'
UNION ALL
SELECT c . category_id , c . name , c . parent_id , s . depth + 1 ,
s . path || ' > ' || c . name
FROM categories c
JOIN subtree s ON c . parent_id = s . category_id
)
SELECT depth , path FROM subtree ORDER BY path ;
输出:
1
2
3
4
5
6
7
depth | path
-------+----------------------------------
0 | 电子产品
1 | 电子产品 > 计算机
2 | 电子产品 > 计算机 > 台式机
2 | 电子产品 > 计算机 > 笔记本
1 | 电子产品 > 手机
安全:防止无限循环# 1
2
3
4
5
6
7
8
9
10
11
12
13
WITH RECURSIVE tree AS (
SELECT id , parent_id , ARRAY [ id ] AS path , false AS cycle
FROM nodes WHERE id = 1
UNION ALL
SELECT n . id , n . parent_id , t . path || n . id ,
n . id = ANY ( t . path ) -- 检测循环
FROM nodes n
JOIN tree t ON n . parent_id = t . id
WHERE NOT t . cycle -- 检测到循环则停止
)
SELECT * FROM tree WHERE NOT cycle ;
LATERAL Join:正确的关联子查询# LATERAL 允许 FROM 子句中的子查询引用前面表的列——相当于 SQL 中的 for-each 循环。它解决了普通 JOIN 难以表达或无法解决的问题。
Top-N Per Group(每组取前 N)# 经典问题:查找每个用户最近的 3 个订单。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 不用 LATERAL:窗口函数方式(需加载所有订单到内存)
WITH ranked AS (
SELECT * , ROW_NUMBER () OVER ( PARTITION BY user_id ORDER BY created_at DESC ) AS rn
FROM orders
)
SELECT * FROM ranked WHERE rn <= 3 ;
-- 用 LATERAL:每个用户扫描 3 行即停(大表更高效)
SELECT u . user_id , u . full_name , recent . *
FROM users u
CROSS JOIN LATERAL (
SELECT order_id , total_amount , created_at
FROM orders o
WHERE o . user_id = u . user_id
ORDER BY o . created_at DESC
LIMIT 3
) recent ;
LATERAL 版本可利用 (user_id, created_at DESC) 索引,每用户扫描 3 行即停。
展开 Array/JSON 列# 1
2
3
4
5
-- 解构 JSONB 数组并与关联表 join
SELECT e . event_id , e . event_type , item ->> 'sku' AS sku , item ->> 'qty' AS qty
FROM events e
CROSS JOIN LATERAL jsonb_array_elements ( e . payload -> 'items' ) AS item
WHERE e . event_type = 'purchase' ;
LATERAL vs 关联子查询# 特性 关联子查询 LATERAL join 返回 仅单值 多行多列 用于 SELECT、WHERE FROM 子句 优化器 通常较慢(每行重新执行) 可作为 join 优化 可读性 嵌套,难以组合 扁平,可组合
需要从关联计算中返回多列或多行时,用 LATERAL。
综合实战# 以下是一个融合 CTE、JOIN 与窗口函数的真实查询:基于电商 Schema,找出每个品类营收最高的商品,并计算其占该品类总营收的百分比:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
WITH product_revenue AS (
SELECT
p . product_id ,
p . name AS product_name ,
p . category ,
SUM ( oi . quantity * oi . unit_price ) AS revenue
FROM order_items oi
JOIN products p ON oi . product_id = p . product_id
JOIN orders o ON oi . order_id = o . order_id
WHERE o . status = 'completed'
GROUP BY p . product_id , p . name , p . category
),
ranked AS (
SELECT
* ,
RANK () OVER ( PARTITION BY category ORDER BY revenue DESC ) AS rank_in_category ,
SUM ( revenue ) OVER ( PARTITION BY category ) AS category_total
FROM product_revenue
)
SELECT
category ,
product_name ,
revenue ,
category_total ,
ROUND ( revenue / category_total * 100 , 1 ) AS pct_of_category
FROM ranked
WHERE rank_in_category = 1
ORDER BY revenue DESC ;
输出:
1
2
3
4
5
category | product_name | revenue | category_total | pct_of_category
--------------+---------------------+----------+----------------+-----------------
Electronics | Mechanical Keyboard | 8934.50 | 18432.50 | 48.5
Furniture | Standing Desk | 7190.00 | 12890.00 | 55.8
Books | DDIA | 1890.30 | 4567.80 | 41.4
若不用 SQL,此单一查询需数十行应用代码,或多次数据库往返才能实现。
为何表结构至今仍占主导地位(暂且)# 关系模型之所以胜出,在于它提供了其他模型当时所不具备的关键能力:数据独立性(Data Independence) 。你可以随意更改物理存储方式、添加索引、分表、复制数据,所有这些操作均无需修改应用代码,SQL 接口保持不变。
它并非完美。某些数据(社交图谱、时序数据、深度嵌套的文档)难以优雅地映射到表结构中。第 5 篇文章将探讨这些替代方案。但对于大多数应用——尤其是数据一致性至关重要的场景——关系模型仍是默认且合理的选择。
下一步# 掌握 SQL 是必要条件,但远非充分条件。“写出正确的查询”与“写出高效的查询”是两种不同技能。下一篇我们将聚焦 索引与查询规划(Indexing and Query Planning) ——数据库如何实际定位你的数据,以及如何让它更快地找到。