预见猿份
主题
首页面试题模拟面试SQL练习在线工具关于我们老苗一对一私教学员评价
实战项目
零基础学习路线项目前置基础创新WMS项目Java微服务框架与实战云岚到家项目闪聚支付项目学成在线项目青橙电商项目JVM原理与实战调优分布式事务专题Java高频面试题MySQL从入门到精通达梦数据库从入门到实战MongoDB从入门到实战PostgreSQL从入门到实战TiDB从入门到实战Java数据结构与算法Java 并发编程(JUC)实战课程老苗一对一私教学员评价
blog我的会员
  • PostgreSQL从入门到实战|Java 程序员的进阶关系数据库课程

    • 课程介绍
    • 第1章 认识PostgreSQL与环境搭建
    • 第2章 SQL基础(MySQL对照)
    • 第3章 高级SQL与编程对象
    • 第4章 事务、锁与MVCC
    • 第5章 Java集成实战
    • 第6章 索引与性能优化
    • 第7章 MySQL迁移与日常运维







----- 到底线了 -----

第3章 高级 SQL 与编程对象 ​

🎯 本章学习目标 ​

  1. 把 CTE(WITH) 当成「可命名的中间结果」来组织复杂查询,会写递归 CTE 处理树形结构(菜单、部门、评论嵌套)。
  2. 熟练使用窗口函数(ROW_NUMBER/RANK/SUM OVER/分区帧),并知道 PG 对窗口支持的完整度高于早期 MySQL。
  3. 理解 LATERAL join:让右侧子查询能引用左侧每一行,替代 MySQL 里难以表达的「每组的 Top-N」。
  4. 会用 GROUPING SETS / CUBE / ROLLUP 一次算出多维小计,替代一堆 UNION ALL。
  5. 区分视图与物化视图,会 CREATE MATERIALIZED VIEW + REFRESH,并知道它适合「报表预计算」。
  6. 能读写 PL/pgSQL 函数与存储过程:变量、IF/LOOP、异常处理、RETURNS TABLE,并与 MySQL 存储过程语法对照;会写行级触发器用 NEW/OLD。
  7. 认识 PG 独有生产力工具:generate_series、RETURNING 结合 CTE、以及扩展生态(PostGIS/pg_trgm/uuid-ossp)。

3.1 CTE:用 WITH 把复杂查询分步命名 ​

CTE(Common Table Expression)在 MySQL 8 也有,但 PG 支持得更早更完整,是 PG 社区的首选组织方式。

sql
-- 非递归 CTE:把「先算活跃用户,再算他们的订单额」分步命名,可读性远超嵌套子查询
WITH active_users AS (
    SELECT id, name FROM t_user WHERE last_login > now() - INTERVAL '30 days'
),
user_amount AS (
    SELECT o.user_id, SUM(o.amount) AS total
    FROM t_order o
    JOIN active_users a ON a.id = o.user_id
    GROUP BY o.user_id
)
SELECT name, total FROM user_amount ORDER BY total DESC LIMIT 20;
1
2
3
4
5
6
7
8
9
10
11

递归 CTE——查一棵部门树(MySQL 8 语法几乎相同):

sql
WITH RECURSIVE dept_tree AS (
    SELECT id, name, parent_id, 1 AS level
    FROM department WHERE parent_id IS NULL          -- 锚点:根节点
    UNION ALL
    SELECT d.id, d.name, d.parent_id, t.level + 1    -- 递归:子节点
    FROM department d
    JOIN dept_tree t ON d.parent_id = t.id
)
SELECT repeat('  ', level - 1) || name AS tree, level FROM dept_tree ORDER BY path;
1
2
3
4
5
6
7
8
9

PG 特有优化点:非递归 CTE 在 PG12 起默认可被「内联(inline)」优化,老版本里 CTE 是「优化屏障(merge fence)」。如果你在 MySQL 习惯了「CTE 只当可读性包装、不影响性能」,在 PG 里遇到 CTE 变慢时要知道可通过 MATERIALIZED/NOT MATERIALIZED 关键字显式控制。

3.2 窗口函数:分区的排序与累计 ​

sql
SELECT
    user_id,
    amount,
    created,
    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn,   -- 组内排名
    RANK()       OVER (PARTITION BY user_id ORDER BY amount DESC) AS rk,   -- 并列跳号
    DENSE_RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) AS drk,  -- 并列不跳号
    SUM(amount)  OVER (PARTITION BY user_id ORDER BY created
                       ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total  -- 累计
FROM t_order;
1
2
3
4
5
6
7
8
9
10

经典用法「每个用户金额最高的 3 单」,配合子查询过滤 rn <= 3(PG 也支持 QUALIFY 风格?——不,PG 目前没有 QUALIFY,需外层 WHERE rn<=3,这点和 MySQL 一致)。

PG 窗口能力亮点:支持 GROUPS 帧模式、PERCENTILE_CONT、mode()、有序集聚合(string_agg(... ORDER BY ...))、FILTER (WHERE ...) 条件聚合等,比 MySQL 8 的窗口函数集合更宽。

sql
-- 条件聚合 FILTER:一行内同时统计「已支付」和「全部」(MySQL 要写一堆 CASE WHEN)
SELECT
    user_id,
    COUNT(*) FILTER (WHERE status = 'paid') AS paid_cnt,
    SUM(amount)                              AS all_amount
FROM t_order GROUP BY user_id;
1
2
3
4
5
6

3.3 LATERAL:让子查询引用左表的每一行 ​

需求:每个用户 + 他最近 3 笔订单。普通 JOIN 做不到「每人取 N 条」,LATERAL 可以:

sql
SELECT u.id, u.name, recent.*
FROM t_user u
JOIN LATERAL (
    SELECT o.order_id, o.amount, o.created
    FROM t_order o
    WHERE o.user_id = u.id            -- ← 这里能引用左侧 u 的每一行,这就是 LATERAL 的威力
    ORDER BY o.created DESC
    LIMIT 3
) recent ON TRUE;                      -- ON TRUE:LATERAL 配合的惯用连接条件
1
2
3
4
5
6
7
8
9

MySQL 没有 LATERAL(直到 8.0.14 才支持且限制多)。这是 PG 表达「分组 Top-N / 相关子查询连接」的招牌工具,务必记住。

3.4 GROUPING SETS / CUBE / ROLLUP:多维汇总一次搞定 ​

需求:同时得到「按分类+年份」「按分类」「按年份」「总计」四层小计。MySQL 里要么写多个 UNION ALL,要么用 WITH ROLLUP(功能较弱)。PG 语法直白:

sql
SELECT
    category,
    EXTRACT(YEAR FROM created) AS yr,
    SUM(amount) AS total
FROM t_order
GROUP BY GROUPING SETS (
    (category, yr),   -- 明细级
    (category),       -- 按分类小计
    (yr),             -- 按年份小计
    ()                -- 总计
)
ORDER BY category, yr;
1
2
3
4
5
6
7
8
9
10
11
12
  • ROLLUP((a,b)):从右往左逐层去掉,生成 (a,b)(a)() 阶梯小计。
  • CUBE(a,b):所有组合 (a,b)(a)(b)()。
  • 配 GROUPING(category) 函数可判断某行是「真值」还是「被汇总的 NULL」,用来输出「合计」标签行。

3.5 视图与物化视图 ​

sql
-- 普通视图:保存的查询,每次访问实时执行(和 MySQL 视图一样)
CREATE VIEW v_active_order AS SELECT * FROM t_order WHERE status <> 'cancelled';

-- 物化视图:把结果"落盘"存一份,访问快,但数据是快照,需手动/定时刷新
CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT date_trunc('day', created) AS day, category, SUM(amount) AS total
FROM t_order GROUP BY 1, 2;

REFRESH MATERIALIZED VIEW mv_daily_sales;                       -- 全量刷新(会短暂锁读)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;          -- 并发刷新(需视图有唯一索引)
1
2
3
4
5
6
7
8
9
10

对照 MySQL:PG 物化视图是一等公民,可加索引、可 CONCURRENTLY 刷新,是「报表/大屏预计算」的主力。MySQL 没有原生物化视图(需自建汇总表 + 触发器/定时任务)。

提醒:物化视图刷新期间读到的是旧快照,CONCURRENTLY 模式要求定义里有一个覆盖 SELECT 列表的唯一索引,且不能含某些聚合限制——第一次建就要把唯一索引设计好。

3.6 PL/pgSQL:函数与存储过程 ​

PG 的过程语言叫 PL/pgSQL。现代 PG(11+)区分函数(function) 和存储过程(procedure):函数返回值、可在 SQL 里调用;存储过程用 CALL、能自己控制事务提交。

3.6.1 一个简单函数(对照 MySQL 的 FUNCTION) ​

sql
CREATE OR REPLACE FUNCTION add_tax(price numeric, rate numeric)
RETURNS numeric AS $$
BEGIN
    RETURN price * (1 + rate);
END;
$$ LANGUAGE plpgsql;

SELECT add_tax(100, 0.13);   -- 直接在 SQL 里用,13.00
1
2
3
4
5
6
7
8

3.6.2 带回表参数的存储过程(对照 MySQL 的 PROCEDURE) ​

sql
CREATE OR REPLACE PROCEDURE p_archive_order(p_before date)
LANGUAGE plpgsql AS $$
DECLARE
    v_cnt int;                         -- 变量集中在 DECLARE 段声明(对应 MySQL 的 BEGIN...END 内 DECLARE)
BEGIN
    DELETE FROM t_order WHERE created < p_before;   -- 批量归档删除
    GET DIAGNOSTICS v_cnt = ROW_COUNT;              -- 受影响行数(对应 MySQL 的 ROW_COUNT())
    RAISE NOTICE '归档了 % 行', v_cnt;  -- 打印日志(%s/% 占位,对应 MySQL 的 SELECT 输出调试)
END;
$$;

CALL p_archive_order('2025-01-01');
1
2
3
4
5
6
7
8
9
10
11
12

3.6.3 MySQL 存储过程 → PL/pgSQL 改写口诀 ​

关注点MySQLPostgreSQL
定界符需要 DELIMITER // 包裹不需要,用 $$ ... $$ 引住函数体即可
变量声明BEGIN 段里散着 DECLARE独立 DECLARE 段,且都在 BEGIN 之前
赋值SET x = 1; / SELECT ... INTO xx := 1;(冒号赋值)或 SELECT ... INTO x
分支IF ... THEN ... ELSEIF ... END IF基本一致
循环WHILE/REPEAT/LOOP + LEAVELOOP ... EXIT WHEN / FOR i IN 1..n / WHILE
异常DECLARE HANDLEREXCEPTION WHEN ... THEN ... 块(对应 Java try-catch,很自然)
返回结果集复杂(临时表)直接 RETURNS TABLE(...) 或 RETURNS SETOF
调用CALL p() / SELECT f()过程 CALL,函数当表达式用

RETURNS TABLE 示例(函数直接返回一张表,业务层像查表一样用它):

sql
CREATE OR REPLACE FUNCTION top_users(n int)
RETURNS TABLE(user_id int, total numeric) AS $$
BEGIN
    RETURN QUERY
    SELECT o.user_id, SUM(o.amount)
    FROM t_order o GROUP BY o.user_id ORDER BY 2 DESC LIMIT n;
END;
$$ LANGUAGE plpgsql;

SELECT * FROM top_users(10);   -- 像查表一样调用
1
2
3
4
5
6
7
8
9
10

3.7 触发器:NEW / OLD ​

sql
-- 需求:t_user 每次更新自动刷新 updated_at
CREATE OR REPLACE FUNCTION trg_touch_updated()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = now();       -- 行级触发器用 NEW/OLD 引用新旧行(同 MySQL)
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER user_before_update
BEFORE UPDATE ON t_user
FOR EACH ROW
EXECUTE FUNCTION trg_touch_updated();     -- MySQL: EXECUTE PROCEDURE / BEGIN...END 内联;PG: EXECUTE FUNCTION 调独立函数
1
2
3
4
5
6
7
8
9
10
11
12
13

差异记忆点:MySQL 触发器把逻辑直接写在 CREATE TRIGGER ... BEGIN ... END 里;PG 触发器必须先有一个 RETURNS TRIGGER 的函数,触发器只是「绑定」这个函数。变量引用同样是 NEW.col/OLD.col(MySQL 是 NEW.col 但不同方言大小写敏感,PG 全小写)。

3.8 PG 独有生产力工具与扩展生态 ​

sql
-- generate_series:造测试数据、生成日期序列,MySQL 无此原生函数
SELECT generate_series(1, 5);                        -- 输出 1..5 五行
SELECT date_trunc('day', d)::date AS day
FROM generate_series('2026-01-01'::date, '2026-01-31'::date, '1 day') AS d;  -- 一整月日期

-- 造 10 万行测试数据(配合第6章性能实验)
INSERT INTO t_order(user_id, amount, created)
SELECT (random()*1000)::int,
       round((random()*1000)::numeric, 2),
       now() - (random() || ' days')::interval
FROM generate_series(1, 100000);
1
2
3
4
5
6
7
8
9
10
11

扩展(Extension)是 PG 的「插件市场」,CREATE EXTENSION 一句话装能力:

扩展提供什么何时用
PostGIS地理空间类型与函数LBS、地图、范围检索(业界标杆)
pg_trgm三元组相似度LIKE '%xx%'/模糊匹配走索引(第6章)
uuid-ossp / pgcrypto生成 UUIDgen_random_uuid()(PG13+ 已内置)
jsonb_set 等函数JSONB 原地改动态属性更新
pg_stat_statementsSQL 级性能统计找最耗时 SQL(第7章)
sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;    -- 装模糊匹配扩展
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
1
2

3.9 本章小结 ​

  1. CTE 是组织复杂查询的首选,WITH RECURSIVE 处理树;注意 PG 里 CTE 的 MATERIALIZED 语义。
  2. 窗口函数集合比 MySQL 更全,FILTER (WHERE) 条件聚合能省一堆 CASE WHEN。
  3. LATERAL 解决「每组 Top-N」,GROUPING SETS/CUBE/ROLLUP 一次算多维小计——都是 MySQL 的短板。
  4. 物化视图是 PG 的报表利器,可加索引、可并发刷新,MySQL 无原生对应。
  5. PL/pgSQL 区分函数/存储过程;改写口诀:不用 DELIMITER、变量集中 DECLARE、:= 赋值、EXCEPTION 块、触发器需独立 RETURNS TRIGGER 函数。
  6. generate_series 造数据/日期序列,扩展生态(PostGIS/pg_trgm)是 PG 的能力护城河。

✏️ 课后练习 ​

  1. 用递归 CTE 打印评论表 t_comment(id, parent_id) 的完整楼层树,带缩进层级。
  2. 写一条 LATERAL 查询:每个商品分类下「销量最高的 2 个 SKU」。
  3. 建一张 mv_category_sales 物化视图按天+分类汇总,加唯一索引后用 REFRESH ... CONCURRENTLY 刷新,对比全量刷新的区别。
  4. 把一个你在 MySQL 里写过的存储过程用 PL/pgSQL 重写:注意变量声明、:= 赋值、异常处理三处改写点。

📖 参考答案 ​

本章答案用一套自包含表(前缀 t_3x_),在任意库(如第 1 章的 mydb)里跑即可,不依赖 3.1~3.8 示例里的 t_order/t_user(那两张是概念演示表)。每道题都附「播种 → SQL → 输出对账」三段。

第 1 题:用递归 CTE 打印评论表 t_comment(id, parent_id) 的完整楼层树,带缩进层级

第 1 步,建表并播种(故意做成两棵主帖 + 深度不均的子回复,方便验证递归真的跑到底):

sql
CREATE TABLE t_3x_comment (
  id        int PRIMARY KEY,
  parent_id int REFERENCES t_3x_comment(id),   -- 自引用外键,顶层为 NULL
  body      text NOT NULL
);

INSERT INTO t_3x_comment(id, parent_id, body) VALUES
  (1, NULL, '活动预告'),                       -- 根 A
  (2, 1,    '问:报名截止哪天'),                 -- A 的子
  (3, 2,    '答:本周五 18:00'),                 -- A 的孙(最深一层)
  (4, 1,    '顶一个'),                           -- A 的另一个子
  (5, 4,    '回复顶帖:你也来啦'),               -- A 的孙
  (6, NULL, '发票问题'),                         -- 根 B
  (7, 6,    '我的还没收到');                     -- B 的子
1
2
3
4
5
6
7
8
9
10
11
12
13
14

第 2 步,写递归 CTE:锚点取根、递归部分拿「自己的结果集」去 JOIN 子节点,同时带两个辅助列——level(缩进用)与 path(排序用):

sql
WITH RECURSIVE tree AS (
    -- ① 锚点:根节点(parent_id 为 NULL),路径初始化为自己的 id
    SELECT id, parent_id, body, 1 AS level, ARRAY[id] AS path
      FROM t_3x_comment
     WHERE parent_id IS NULL

    UNION ALL

    -- ② 递归:用上一轮结果 tree 去 join 未访问的子行,层级 +1、路径追加
    SELECT c.id, c.parent_id, c.body, t.level + 1, t.path || c.id
      FROM t_3x_comment c
      JOIN tree t ON c.parent_id = t.id
)
SELECT repeat('│   ', level - 1) || '├─ ' || body AS floor_tree,
       level, path
  FROM tree
 ORDER BY path;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

第 3 步,输出对账(缩进逐级变深、path 就是从根到自己的 id 链,可手工核对):

text
     floor_tree      | level |  path
---------------------+-------+---------
 ├─ 活动预告         |     1 | {1}
 │   ├─ 问:报名截止哪天 |     2 | {1,2}
 │   │   ├─ 答:本周五   |     3 | {1,2,3}
 │   ├─ 顶一个           |     2 | {1,4}
 │   │   ├─ 回复顶帖     |     3 | {1,4,5}
 ├─ 发票问题         |     1 | {6}
 │   ├─ 我的还没收到   |     2 | {6,7}
(7 rows)
1
2
3
4
5
6
7
8
9
10

第 4 步,两个必验点:总数等于原表行数(证明没丢枝),以及最大 level 等于最深链长(证明真的递归了):

sql
-- 把上面的 CTE 包一层即可:
SELECT count(*) AS total, max(level) AS max_depth FROM tree;   -- → 7 | 3
-- 与 SELECT count(*) FROM t_3x_comment; → 7 一致,无丢行;3 = 活动预告→问→答 这条链长
1
2
3

第 5 步,三个真实报错(踩过就一眼能认):

sql
-- ① 忘了写 RECURSIVE:
WITH tree AS (SELECT ... UNION ALL SELECT ... FROM t_3x_comment c JOIN tree t ON ...) ...
-- ERROR:  relation "tree" does not exist        (SQLSTATE 42P01,PG 还不认识自己)

-- ② 锚点与递归部分列数不一致:
-- ERROR:  each UNION query must have the same number of columns

-- ③ 数据里有环(1 的子是 2、2 的子又是 1)时会无限递归:
-- ERROR:  infinite recursion detected
1
2
3
4
5
6
7
8
9

第 6 步(推荐生产写法),用 PG14+ 的 CYCLE 子句声明式防环,比手写 level < 20 或 path 判重更清晰:

sql
WITH RECURSIVE tree AS (
    SELECT id, parent_id, body, 1 AS level, ARRAY[id] AS path
      FROM t_3x_comment WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.parent_id, c.body, t.level + 1, t.path || c.id
      FROM t_3x_comment c JOIN tree t ON c.parent_id = t.id
) CYCLE id SET is_cycle USING path           -- 同一个 id 再出现就标 is_cycle,不再往下展
SELECT level, body, is_cycle FROM tree ORDER BY path;
1
2
3
4
5
6
7
8

对照 MySQL 8:同样的 WITH RECURSIVE 写法能直接平移(MySQL 8.0 也支持);但 MySQL 要防环只能自己加 WHERE depth < N,PG 的 CYCLE ... USING path 是它独家的声明式写法。

第 2 题:写一条 LATERAL 查询——每个商品分类下「销量最高的 2 个 SKU」

第 1 步,播种(三个分类,故意让「耳机」只有 1 个 SKU,用来验证不足 N 个时的行为):

sql
CREATE TABLE t_3x_sale (id serial PRIMARY KEY, category text, sku text, qty int);
INSERT INTO t_3x_sale(category, sku, qty) VALUES
  ('手机', 'A1', 100), ('手机', 'A2',  90), ('手机', 'A3',  95),   -- 手机取 A1(100) A3(95)
  ('电脑', 'B1',  60), ('电脑', 'B2',  70),                         -- 电脑取 B2(70) B1(60)
  ('耳机', 'C1',  50);                                              -- 耳机只有 1 个,照出
1
2
3
4
5

第 2 步,正文——LATERAL 子查询里引用左侧 c.category,每个分类各跑一次「按销量倒序取 2」:

sql
WITH cat AS (SELECT DISTINCT category FROM t_3x_sale)
SELECT c.category, t.sku, t.qty
  FROM cat c
  JOIN LATERAL (
        SELECT s.sku, s.qty
          FROM t_3x_sale s
         WHERE s.category = c.category      -- ★ 引用左行,普通子查询做不到
         ORDER BY s.qty DESC
         LIMIT 2
       ) t ON TRUE                          -- ★ ON TRUE 是 LATERAL 的惯用连接条件
 ORDER BY c.category, t.qty DESC;
1
2
3
4
5
6
7
8
9
10
11

第 3 步,输出对账(按上面括号里的预期逐行核):

text
 category | sku | qty
----------+-----+-----
 手机   | A1  | 100
 手机   | A3  |  95
 耳机   | C1  |  50
 电脑   | B2  |  70
 电脑   | B1  |  60
(5 rows)
1
2
3
4
5
6
7
8

第 4 步,跟窗口函数写法对照(两种都能做 Top-N,适用场景不同):

sql
SELECT category, sku, qty FROM (
  SELECT s.*, ROW_NUMBER() OVER (PARTITION BY category ORDER BY qty DESC) rn FROM t_3x_sale s
) x WHERE rn <= 2 ORDER BY category, qty DESC;   -- 输出与第 3 步完全一致
1
2
3
写法什么时候选它
ROW_NUMBER() + 外层过滤分类多、要一次扫完全表;还需要并列排名(换 RANK)
JOIN LATERAL ... LIMIT 2分类少、每分类行海量,且建了 (category, qty DESC) 索引 → 每分类只走一次索引前 2 行,不扫全表

第 5 步,用索引证据坐实「每分类取 2」的效率(这是 LATERAL 真正的价值):

sql
CREATE INDEX ON t_3x_sale(category, qty DESC);
EXPLAIN (COSTS OFF)
WITH cat AS (SELECT DISTINCT category FROM t_3x_sale)
SELECT c.category, t.sku, t.qty FROM cat c
  JOIN LATERAL (SELECT s.sku, s.qty FROM t_3x_sale s
                 WHERE s.category = c.category ORDER BY s.qty DESC LIMIT 2) t ON TRUE;
1
2
3
4
5
6
text
 Nested Loop
   ->  Unique  ->  Seq Scan on t_3x_sale
   ->  Limit
         ->  Index Scan using t_3x_sale_category_qty_idx on t_3x_sale
               Index Cond: (category = c.category)     ← 每个分类只扫索引上的 2 行,没有 Sort
1
2
3
4
5

两个坑:① JOIN LATERAL 是内连接语义,某分类一行都取不到时整个分类会消失——要「分类一行不漏」就写成 LEFT JOIN LATERAL (...) t ON TRUE(取不到时 sku/qty 为 NULL);② LATERAL 关键字只写一次即可作用于后面的子查询,但子查询必须有表别名(t),否则 ON TRUE 无处挂。

第 6 步(回扣第 5 章的工程):同样的 SQL 放进 MyBatis-Plus 的 XML 里时,c.category 这种「引用左表」的写法与参数占位符 #{} 无冲突,但 jsonb 的 ? 运算符会和 ? 占位符撞车(见第 2 章答案),LATERAL 没这个问题,可放心用。

第 3 题:建 mv_category_sales 物化视图(按天+分类汇总),加唯一索引后用 REFRESH ... CONCURRENTLY 刷新,并对比全量刷新

第 1 步,播种一张可手算的订单表(5 行,两天、两个分类):

sql
CREATE TABLE t_3x_ord (id serial PRIMARY KEY, category text NOT NULL,
                       amount numeric(10,2) NOT NULL, created timestamptz NOT NULL);
INSERT INTO t_3x_ord(category, amount, created) VALUES
  ('手机', 100.00, '2026-09-01 10:00+08'),
  ('手机',  50.00, '2026-09-01 15:00+08'),
  ('电脑', 200.00, '2026-09-01 09:00+08'),
  ('手机',  80.00, '2026-09-02 10:00+08'),
  ('电脑', 120.00, '2026-09-02 11:00+08');
-- 手算预期:09-01 手机 2 单 150.00、电脑 1 单 200.00;09-02 手机 1 单 80.00、电脑 1 单 120.00(共 4 组)
1
2
3
4
5
6
7
8
9

第 2 步,建物化视图并查内容(注意:列必须具名,所以 date_trunc(...) 要写别名):

sql
CREATE MATERIALIZED VIEW mv_category_sales AS
SELECT date_trunc('day', created)::date AS day,
       category,
       count(*)        AS ord_cnt,
       sum(amount)     AS total
  FROM t_3x_ord
 GROUP BY 1, 2;

SELECT * FROM mv_category_sales ORDER BY day, category;
--    day       | category | ord_cnt | total
-- -------------+----------+---------+--------
--  2026-09-01  | 手机     |       2 | 150.00
--  2026-09-01  | 电脑     |       1 | 200.00
--  2026-09-02  | 手机     |       1 |  80.00
--  2026-09-02  | 电脑     |       1 | 120.00
-- 总计 150+200+80+120 = 550.00,与 SELECT sum(amount) FROM t_3x_ord; 一致 ✓
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

第 3 步,先故意为一次失败:不建索引直接并发刷新,报错拍清楚(这是初学者最常撞的第一堵墙):

sql
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_sales;
-- ERROR:  cannot refresh materialized view "public.mv_category_sales" concurrently:
--         rel has no UNIQUE index that can be used
1
2
3

第 4 步,建覆盖全部输出列的唯一索引,再刷就过了(先插一行变化量,验证真的重算了):

sql
CREATE UNIQUE INDEX ON mv_category_sales(day, category);

INSERT INTO t_3x_ord(category, amount, created) VALUES ('电脑', 30.00, '2026-09-02 18:00+08');
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_sales;

SELECT * FROM mv_category_sales WHERE day = '2026-09-02' ORDER BY category;
--    day       | category | ord_cnt | total
-- -------------+----------+---------+--------
--  2026-09-02  | 手机     |       1 |  80.00    ← 没变
--  2026-09-02  | 电脑     |       2 | 150.00    ← 120+30,只有这一组被重算
1
2
3
4
5
6
7
8
9
10

第 5 步,用可量化的指标对比两种刷新(而不是只背概念),看的是表级统计计数器:

sql
SELECT n_tup_ins, n_tup_del, n_live_tup
  FROM pg_stat_user_tables WHERE relname = 'mv_category_sales';

-- ① 先重置计数,再走全量刷新:REFRESH MATERIALIZED VIEW mv_category_sales;
--    → 整表重建:旧 4 行全删、新 4 行全插,n_tup_ins / n_tup_del 至少各 +4
-- ② 再重置,走并发刷新:REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_sales;
--    → 只动 diff:没变化的行物理上原封不动,只有改过的那一组 1 增 1 删
1
2
3
4
5
6
7

第 6 步,双会话实测「读是否被阻塞」(小表刷得太快看不出来,先把数据放大):

sql
-- 会话 A:灌到 500 万行,让刷新真的需要几秒
INSERT INTO t_3x_ord(category, amount, created)
SELECT (ARRAY['手机','电脑','耳机'])[1 + (random()*2)::int],
       round((random()*500)::numeric, 2),
       '2026-09-01 00:00+08'::timestamptz + (random() || ' days')::interval
  FROM generate_series(1, 5000000);

-- 会话 A:REFRESH MATERIALIZED VIEW mv_category_sales;      -- 全量
-- 会话 B(同时):SELECT count(*) FROM mv_category_sales;    -- ← 挂住,等 A 刷完才返回

-- 会话 A:REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_sales;   -- 并发
-- 会话 B(同时):SELECT count(*) FROM mv_category_sales;    -- ← 立即返回旧快照,不阻塞
1
2
3
4
5
6
7
8
9
10
11
12

第 7 步,三个必须知道的限制/坑:

现象原因做法
it has not been populated用 WITH NO DATA 建的视图还没填充过先跑一次不带 CONCURRENTLY 的 REFRESH
刷新报 duplicate key value violates unique constraint新结果里有两组落在同一 (day, category)唯一索引列必须真覆盖聚合粒度;GROUP BY 改了就要同步改索引
报表查到「昨天之前的旧数」物化视图是快照,不会自动跟着基表变定时任务(第 7 章 pg_cron)或写入后触发 DBMS_REFRESH 风格作业

MySQL 对照:MySQL 没有物化视图,只能自建汇总表 + 触发器/事件维护;PG 的 CREATE MATERIALIZED VIEW + CONCURRENTLY 是原生能力,也是从 MySQL 过来最值得用上的一个报表优化项。

第 4 题:把一个 MySQL 存储过程用 PL/pgSQL 重写,重点改「变量声明、:= 赋值、异常处理」三处

第 1 步,先摆出输入(一个典型的 MySQL 过程:结算已支付订单金额入余额):

sql
-- MySQL 原版
DELIMITER //
CREATE PROCEDURE p_settle(IN p_user BIGINT, OUT p_total DECIMAL(10,2))
BEGIN
  DECLARE v_amt DECIMAL(10,2);
  DECLARE v_cnt INT;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION          -- ③ 异常处理靠 HANDLER
  BEGIN
    ROLLBACK;
    SET p_total = -1;
  END;

  START TRANSACTION;
  SELECT SUM(amount), COUNT(*) INTO v_amt, v_cnt
    FROM t_order WHERE user_id = p_user AND status = 'paid';
  IF v_amt IS NULL THEN SET v_amt = 0; END IF;
  WHILE v_cnt > 0 DO
    SET v_cnt = v_cnt - 1;                       -- ② SET 赋值
  END WHILE;
  IF (SELECT COUNT(*) FROM t_user WHERE id = p_user) = 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'user not found';
  END IF;
  UPDATE t_user SET balance = balance + v_amt WHERE id = p_user;
  SET p_total = v_amt;
  COMMIT;
END //
DELIMITER ;
CALL p_settle(1, @t); SELECT @t;
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

第 2 步,PG 侧建实体并播种(与第 3 题同前缀,单独两张表方便重跑):

sql
CREATE TABLE t_3x_user (id serial PRIMARY KEY, name text, balance numeric(10,2) NOT NULL DEFAULT 0);
INSERT INTO t_3x_user(id, name, balance) VALUES (1, 'amy', 100.00), (2, 'bob', 0.00);

CREATE TABLE t_3x_order (id serial PRIMARY KEY, user_id int, amount numeric(10,2), status text);
INSERT INTO t_3x_order(user_id, amount, status) VALUES
  (1, 50.00, 'paid'), (1, 30.00, 'paid'), (1, 99.00, 'unpaid'), (2, 10.00, 'paid');
-- 手算预期:amy 已付 = 50+30 = 80.00(2 单,unpaid 的 99.00 不算),结算后余额 100+80 = 180.00
1
2
3
4
5
6
7

第 3 步,PL/pgSQL 改写成品(三处关键改动已标 ①②③):

sql
CREATE OR REPLACE PROCEDURE p_settle(p_user int)
LANGUAGE plpgsql
AS $proc$                                        -- 不需 DELIMITER,$$ / $tag$ 就是定界
DECLARE
  v_amt numeric(10,2) := 0;                     -- ① 变量全部集中在 BEGIN 之前的 DECLARE 段,还能给初值
  v_cnt int := 0;
BEGIN
  IF NOT EXISTS (SELECT 1 FROM t_3x_user WHERE id = p_user) THEN
    RAISE EXCEPTION '用户 % 不存在', p_user;      -- 对应 SIGNAL SQLSTATE '45000'
  END IF;

  SELECT coalesce(sum(amount), 0), count(*)
    INTO v_amt, v_cnt                           -- INTO 直接落到声明变量,不用先建临时结果集
    FROM t_3x_order
   WHERE user_id = p_user AND status = 'paid';

  WHILE v_cnt > 0 LOOP                          -- MySQL:WHILE ... DO ... END WHILE
    v_cnt := v_cnt - 1;                         -- ② 赋值用 :=(不是 SET x =)
  END LOOP;

  UPDATE t_3x_user SET balance = balance + v_amt WHERE id = p_user;
  GET DIAGNOSTICS v_cnt = ROW_COUNT;            -- 对应 MySQL 的 ROW_COUNT()
  RAISE NOTICE '用户 % 结算 % 元', p_user, v_amt;

  COMMIT;                                       -- 过程里可以自己控制提交(PG11+);函数里绝对不行
EXCEPTION WHEN OTHERS THEN                      -- ③ 独立的 EXCEPTION 块(不是 HANDLER)
  RAISE NOTICE '结算失败(已自动回滚):%', SQLERRM;
END;
$proc$;
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

第 4 步,正常路跑一遍,逐笔对账:

sql
CALL p_settle(1);
-- NOTICE:  用户 1 结算 80.00 元
-- CALL

SELECT id, name, balance FROM t_3x_user ORDER BY id;
--  id | name | balance
-- ----+------+---------
--   1 | amy  |  180.00     ← 100.00 + 80.00,与第 2 步手算一致 ✓
--   2 | bob  |    0.00     ← 未被触碰
1
2
3
4
5
6
7
8
9

第 5 步,异常路:拿一个不存在的用户调用,验证 RAISE EXCEPTION 被 EXCEPTION 块接住、且整个事务回滚:

sql
CALL p_settle(999);
-- NOTICE:  结算失败(已自动回滚):用户 999 不存在
-- CALL

SELECT count(*) FROM t_3x_user WHERE id = 999;    -- → 0,数据没脏
1
2
3
4
5

第 6 步,三处改写点总结(本题的得分项,背这张表就够):

关注点MySQLPL/pgSQL备注
① 变量声明BEGIN 里散着 DECLARE,不能在 DECLARE 赋初值集中的一个 DECLARE 段,可直接 := 给初值还能 %TYPE / %ROWTYPE 跟着表结构走,改字段不用改过程
② 赋值SET x = 1;x := 1;SELECT ... INTO x 两边都有
③ 异常DECLARE ... HANDLER独立的 EXCEPTION WHEN ... THEN 块块内自动建 savepoint,报错时只回滚这一段,写法上更接 Java try-catch
定界符必须 DELIMITER //不要,$$ / $proc$ 即可函数体内还要嵌 $$ 时外层改用带 tag 的形式
抛错SIGNAL SQLSTATE '45000'RAISE EXCEPTION '…', 参数要指定码用 USING ERRCODE = '…'
受影响行数ROW_COUNT()GET DIAGNOSTICS v = ROW_COUNT;还能取 PG_STATEMENT
日志/调试SELECT 'xx'RAISE NOTICE / DEBUG / INFO客户端能看到,psql 里最直观

第 7 步,两个迁移时必改的思路差异:

sql
-- ① psql 里 CALL 拿不到 OUT 参数的值(MySQL 习惯用 OUT + @var 接)。两种解法:
--    a) 过程序只做事,结果靠 RAISE NOTICE 或写进结果表;
--    b) 要「返回值」就改成函数,直接当表达式用(这是 MySQL 里做不到的写法):
CREATE OR REPLACE FUNCTION f_settle(p_user int) RETURNS numeric
LANGUAGE plpgsql AS $fn$
DECLARE v_amt numeric(10,2);
BEGIN
  SELECT coalesce(sum(amount), 0) INTO v_amt FROM t_3x_order
   WHERE user_id = p_user AND status = 'paid';
  RETURN v_amt;
END;
$fn$;

SELECT id, name, balance + f_settle(id) AS after_settle FROM t_3x_user ORDER BY id;
--  id | name | after_settle
-- ----+------+--------------
--   1 | amy  |       180.00
--   2 | bob  |        10.00      ← 函数像列一样嵌在 SELECT 里,一条 SQL 算完全表

-- ② 函数里写 COMMIT/ROLLBACK 会直接报错:
-- ERROR:  transaction control statements are not allowed within a function
--    (过程可以,函数不可以——因为函数可能被外层语句/查询包着跑)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
← 第2章 SQL基础(MySQL对照)第4章 事务、锁与MVCC →








如果发现文档内容有错误或排版错乱,请及时联系站长老苗修改,不胜感激。联系我们
关于我们 | 隐私政策 | 豫ICP备2026003386号-4 | 豫公网安备41010202004008号
目录

本页无章节