数据库(一):数据模型与 SQL —— 为何关系型表结构至今仍占主导地位

从零开始深入关系模型、SQL 基础、范式化设计及高级查询模式——掌握流利使用 SQL 所需的一切知识。

你用过的每一个应用程序,其底层都依赖某种数据模型。一旦选错,接下来三年你可能都在跟数据库搏斗,而不是专注交付新功能。

过去四十年间,一种模型始终占据主导地位:关系模型(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)**的:你只需描述 想要什么数据,而非 如何获取它,执行计划由数据库引擎自行推导。

SQL 查询执行流程

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 将多张表的行组合起来。你需要掌握四种类型:

SQL 连接类型可视化

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 中常见类型的对比。

类型PostgreSQLMySQL字节数取值范围 / 说明
小整数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 / DATETIME8PG 中务必用 TIMESTAMPTZ
布尔值BOOLEANBOOLEAN / TINYINT(1)1MySQL 的 BOOLEAN 实为 TINYINT
JSONJSON / JSONBJSON可变JSONB(PG)为二进制格式,可索引且更快
UUIDUUIDCHAR(36)BINARY(16)16PG 原生支持,MySQL 需模拟

经验法则:

  • 对任何可能大规模增长的表,主键优先选用 BIGINTINT 最多仅支持约 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 |

citystate 依赖 zip_code,而非 user_id。修复:新建 addresseszip_codes 查找表。

范式化后#

我们当前的四表 Schema 已符合 3NF。

  • users —— 仅用户数据
  • products —— 仅产品数据
  • orders —— 订单元数据,引用 users
  • order_items —— 订单明细,同时引用 ordersproducts

零冗余,每个事实仅存储一次。

何时反范式化(Denormalize)#

SQL 查询处理引擎的机械齿轮和数据管道

范式化消除了冗余,但读取常需 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、WHEREFROM 子句
优化器通常较慢(每行重新执行)可作为 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)——数据库如何实际定位你的数据,以及如何让它更快地找到。

  1. 01 数据库(一):数据模型与 SQL —— 为何关系型表结构至今仍占主导地位 当前
  2. 02 数据库(二):索引与查询规划——数据库如何找到你的数据
  3. 03 数据库(三):事务与并发控制——ACID、隔离级别与锁机制
  4. 04 数据库(四):存储引擎——数据如何落盘
  5. 05 数据库(五):NoSQL——文档型、键值型、列式与图数据库
  6. 06 数据库(六):复制与分片——突破单机限制的扩展之道
  7. 07 数据库(七):分布式事务——两阶段提交、Saga 模式,以及为何共识如此困难
  8. 08 数据库(八):实战中的数据库——迁移、监控与故障案例

读有所得?

GitHub 关注我 → 新文周更

GitHub