第3章 高级 SQL 与编程对象
🎯 本章学习目标
- 把 CTE(
WITH) 当成「可命名的中间结果」来组织复杂查询,会写递归 CTE 处理树形结构(菜单、部门、评论嵌套)。 - 熟练使用窗口函数(
ROW_NUMBER/RANK/SUM OVER/分区帧),并知道 PG 对窗口支持的完整度高于早期 MySQL。 - 理解
LATERALjoin:让右侧子查询能引用左侧每一行,替代 MySQL 里难以表达的「每组的 Top-N」。 - 会用
GROUPING SETS / CUBE / ROLLUP一次算出多维小计,替代一堆UNION ALL。 - 区分视图与物化视图,会
CREATE MATERIALIZED VIEW+REFRESH,并知道它适合「报表预计算」。 - 能读写 PL/pgSQL 函数与存储过程:变量、
IF/LOOP、异常处理、RETURNS TABLE,并与 MySQL 存储过程语法对照;会写行级触发器用NEW/OLD。 - 认识 PG 独有生产力工具:
generate_series、RETURNING结合 CTE、以及扩展生态(PostGIS/pg_trgm/uuid-ossp)。
3.1 CTE:用 WITH 把复杂查询分步命名
CTE(Common Table Expression)在 MySQL 8 也有,但 PG 支持得更早更完整,是 PG 社区的首选组织方式。
-- 非递归 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;递归 CTE——查一棵部门树(MySQL 8 语法几乎相同):
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;PG 特有优化点:非递归 CTE 在 PG12 起默认可被「内联(inline)」优化,老版本里 CTE 是「优化屏障(merge fence)」。如果你在 MySQL 习惯了「CTE 只当可读性包装、不影响性能」,在 PG 里遇到 CTE 变慢时要知道可通过
MATERIALIZED/NOT MATERIALIZED关键字显式控制。
3.2 窗口函数:分区的排序与累计
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;经典用法「每个用户金额最高的 3 单」,配合子查询过滤 rn <= 3(PG 也支持 QUALIFY 风格?——不,PG 目前没有 QUALIFY,需外层 WHERE rn<=3,这点和 MySQL 一致)。
PG 窗口能力亮点:支持 GROUPS 帧模式、PERCENTILE_CONT、mode()、有序集聚合(string_agg(... ORDER BY ...))、FILTER (WHERE ...) 条件聚合等,比 MySQL 8 的窗口函数集合更宽。
-- 条件聚合 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;3.3 LATERAL:让子查询引用左表的每一行
需求:每个用户 + 他最近 3 笔订单。普通 JOIN 做不到「每人取 N 条」,LATERAL 可以:
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 配合的惯用连接条件MySQL 没有 LATERAL(直到 8.0.14 才支持且限制多)。这是 PG 表达「分组 Top-N / 相关子查询连接」的招牌工具,务必记住。
3.4 GROUPING SETS / CUBE / ROLLUP:多维汇总一次搞定
需求:同时得到「按分类+年份」「按分类」「按年份」「总计」四层小计。MySQL 里要么写多个 UNION ALL,要么用 WITH ROLLUP(功能较弱)。PG 语法直白:
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;ROLLUP((a,b)):从右往左逐层去掉,生成(a,b)(a)()阶梯小计。CUBE(a,b):所有组合(a,b)(a)(b)()。- 配
GROUPING(category)函数可判断某行是「真值」还是「被汇总的 NULL」,用来输出「合计」标签行。
3.5 视图与物化视图
-- 普通视图:保存的查询,每次访问实时执行(和 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; -- 并发刷新(需视图有唯一索引)对照 MySQL:PG 物化视图是一等公民,可加索引、可 CONCURRENTLY 刷新,是「报表/大屏预计算」的主力。MySQL 没有原生物化视图(需自建汇总表 + 触发器/定时任务)。
提醒:物化视图刷新期间读到的是旧快照,
CONCURRENTLY模式要求定义里有一个覆盖 SELECT 列表的唯一索引,且不能含某些聚合限制——第一次建就要把唯一索引设计好。
3.6 PL/pgSQL:函数与存储过程
PG 的过程语言叫 PL/pgSQL。现代 PG(11+)区分函数(function) 和存储过程(procedure):函数返回值、可在 SQL 里调用;存储过程用 CALL、能自己控制事务提交。
3.6.1 一个简单函数(对照 MySQL 的 FUNCTION)
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.003.6.2 带回表参数的存储过程(对照 MySQL 的 PROCEDURE)
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');3.6.3 MySQL 存储过程 → PL/pgSQL 改写口诀
| 关注点 | MySQL | PostgreSQL |
|---|---|---|
| 定界符 | 需要 DELIMITER // 包裹 | 不需要,用 $$ ... $$ 引住函数体即可 |
| 变量声明 | BEGIN 段里散着 DECLARE | 独立 DECLARE 段,且都在 BEGIN 之前 |
| 赋值 | SET x = 1; / SELECT ... INTO x | x := 1;(冒号赋值)或 SELECT ... INTO x |
| 分支 | IF ... THEN ... ELSEIF ... END IF | 基本一致 |
| 循环 | WHILE/REPEAT/LOOP + LEAVE | LOOP ... EXIT WHEN / FOR i IN 1..n / WHILE |
| 异常 | DECLARE HANDLER | EXCEPTION WHEN ... THEN ... 块(对应 Java try-catch,很自然) |
| 返回结果集 | 复杂(临时表) | 直接 RETURNS TABLE(...) 或 RETURNS SETOF |
| 调用 | CALL p() / SELECT f() | 过程 CALL,函数当表达式用 |
RETURNS TABLE 示例(函数直接返回一张表,业务层像查表一样用它):
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); -- 像查表一样调用3.7 触发器:NEW / OLD
-- 需求: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 调独立函数差异记忆点:MySQL 触发器把逻辑直接写在 CREATE TRIGGER ... BEGIN ... END 里;PG 触发器必须先有一个 RETURNS TRIGGER 的函数,触发器只是「绑定」这个函数。变量引用同样是 NEW.col/OLD.col(MySQL 是 NEW.col 但不同方言大小写敏感,PG 全小写)。
3.8 PG 独有生产力工具与扩展生态
-- 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);扩展(Extension)是 PG 的「插件市场」,CREATE EXTENSION 一句话装能力:
| 扩展 | 提供什么 | 何时用 |
|---|---|---|
PostGIS | 地理空间类型与函数 | LBS、地图、范围检索(业界标杆) |
pg_trgm | 三元组相似度 | LIKE '%xx%'/模糊匹配走索引(第6章) |
uuid-ossp / pgcrypto | 生成 UUID | gen_random_uuid()(PG13+ 已内置) |
jsonb_set 等函数 | JSONB 原地改 | 动态属性更新 |
pg_stat_statements | SQL 级性能统计 | 找最耗时 SQL(第7章) |
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- 装模糊匹配扩展
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;3.9 本章小结
- CTE 是组织复杂查询的首选,
WITH RECURSIVE处理树;注意 PG 里 CTE 的 MATERIALIZED 语义。 - 窗口函数集合比 MySQL 更全,
FILTER (WHERE)条件聚合能省一堆CASE WHEN。 - LATERAL 解决「每组 Top-N」,GROUPING SETS/CUBE/ROLLUP 一次算多维小计——都是 MySQL 的短板。
- 物化视图是 PG 的报表利器,可加索引、可并发刷新,MySQL 无原生对应。
- PL/pgSQL 区分函数/存储过程;改写口诀:不用 DELIMITER、变量集中 DECLARE、
:=赋值、EXCEPTION块、触发器需独立RETURNS TRIGGER函数。 generate_series造数据/日期序列,扩展生态(PostGIS/pg_trgm)是 PG 的能力护城河。
✏️ 课后练习
- 用递归 CTE 打印评论表
t_comment(id, parent_id)的完整楼层树,带缩进层级。 - 写一条
LATERAL查询:每个商品分类下「销量最高的 2 个 SKU」。 - 建一张
mv_category_sales物化视图按天+分类汇总,加唯一索引后用REFRESH ... CONCURRENTLY刷新,对比全量刷新的区别。 - 把一个你在 MySQL 里写过的存储过程用 PL/pgSQL 重写:注意变量声明、
:=赋值、异常处理三处改写点。
📖 参考答案
本章答案用一套自包含表(前缀
t_3x_),在任意库(如第 1 章的mydb)里跑即可,不依赖 3.1~3.8 示例里的t_order/t_user(那两张是概念演示表)。每道题都附「播种 → SQL → 输出对账」三段。
第 1 题:用递归 CTE 打印评论表 t_comment(id, parent_id) 的完整楼层树,带缩进层级
第 1 步,建表并播种(故意做成两棵主帖 + 深度不均的子回复,方便验证递归真的跑到底):
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 的子第 2 步,写递归 CTE:锚点取根、递归部分拿「自己的结果集」去 JOIN 子节点,同时带两个辅助列——level(缩进用)与 path(排序用):
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;第 3 步,输出对账(缩进逐级变深、path 就是从根到自己的 id 链,可手工核对):
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)第 4 步,两个必验点:总数等于原表行数(证明没丢枝),以及最大 level 等于最深链长(证明真的递归了):
-- 把上面的 CTE 包一层即可:
SELECT count(*) AS total, max(level) AS max_depth FROM tree; -- → 7 | 3
-- 与 SELECT count(*) FROM t_3x_comment; → 7 一致,无丢行;3 = 活动预告→问→答 这条链长第 5 步,三个真实报错(踩过就一眼能认):
-- ① 忘了写 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第 6 步(推荐生产写法),用 PG14+ 的 CYCLE 子句声明式防环,比手写 level < 20 或 path 判重更清晰:
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;对照 MySQL 8:同样的
WITH RECURSIVE写法能直接平移(MySQL 8.0 也支持);但 MySQL 要防环只能自己加WHERE depth < N,PG 的CYCLE ... USING path是它独家的声明式写法。
第 2 题:写一条 LATERAL 查询——每个商品分类下「销量最高的 2 个 SKU」
第 1 步,播种(三个分类,故意让「耳机」只有 1 个 SKU,用来验证不足 N 个时的行为):
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 个,照出第 2 步,正文——LATERAL 子查询里引用左侧 c.category,每个分类各跑一次「按销量倒序取 2」:
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;第 3 步,输出对账(按上面括号里的预期逐行核):
category | sku | qty
----------+-----+-----
手机 | A1 | 100
手机 | A3 | 95
耳机 | C1 | 50
电脑 | B2 | 70
电脑 | B1 | 60
(5 rows)第 4 步,跟窗口函数写法对照(两种都能做 Top-N,适用场景不同):
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 步完全一致| 写法 | 什么时候选它 |
|---|---|
ROW_NUMBER() + 外层过滤 | 分类多、要一次扫完全表;还需要并列排名(换 RANK) |
JOIN LATERAL ... LIMIT 2 | 分类少、每分类行海量,且建了 (category, qty DESC) 索引 → 每分类只走一次索引前 2 行,不扫全表 |
第 5 步,用索引证据坐实「每分类取 2」的效率(这是 LATERAL 真正的价值):
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; 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两个坑:①
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 行,两天、两个分类):
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 组)第 2 步,建物化视图并查内容(注意:列必须具名,所以 date_trunc(...) 要写别名):
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; 一致 ✓第 3 步,先故意为一次失败:不建索引直接并发刷新,报错拍清楚(这是初学者最常撞的第一堵墙):
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第 4 步,建覆盖全部输出列的唯一索引,再刷就过了(先插一行变化量,验证真的重算了):
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,只有这一组被重算第 5 步,用可量化的指标对比两种刷新(而不是只背概念),看的是表级统计计数器:
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 删第 6 步,双会话实测「读是否被阻塞」(小表刷得太快看不出来,先把数据放大):
-- 会话 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; -- ← 立即返回旧快照,不阻塞第 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 过程:结算已支付订单金额入余额):
-- 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;第 2 步,PG 侧建实体并播种(与第 3 题同前缀,单独两张表方便重跑):
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第 3 步,PL/pgSQL 改写成品(三处关键改动已标 ①②③):
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$;第 4 步,正常路跑一遍,逐笔对账:
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 ← 未被触碰第 5 步,异常路:拿一个不存在的用户调用,验证 RAISE EXCEPTION 被 EXCEPTION 块接住、且整个事务回滚:
CALL p_settle(999);
-- NOTICE: 结算失败(已自动回滚):用户 999 不存在
-- CALL
SELECT count(*) FROM t_3x_user WHERE id = 999; -- → 0,数据没脏第 6 步,三处改写点总结(本题的得分项,背这张表就够):
| 关注点 | MySQL | PL/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 步,两个迁移时必改的思路差异:
-- ① 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
-- (过程可以,函数不可以——因为函数可能被外层语句/查询包着跑)