PostgreSQL 的强大不仅在于存储数据,更在于查询数据的能力。从基础的 SELECT ... WHERE 到声明式窗口函数、递归 CTE、LATERAL 连接,PostgreSQL 的 SQL 引擎几乎实现了所有高级分析能力——这些能力在 MySQL 和 MongoDB 中要么缺失,要么要等好几个版本才补齐。
一句话总结:掌握 CTE + 窗口函数这两个利器,你的查询能力就跨越了 80% 开发者。
一、窗口函数(Window Functions)
窗口函数允许在不聚合行的情况下对结果集中的某个"窗口"进行计算。通俗理解:给每行数据一个"还能看到哪些邻居"的视角。
1.1 排名类窗口函数
-- ROW_NUMBER:每行唯一排名(不并列)
SELECT
name,
sales,
ROW_NUMBER() OVER (ORDER BY sales DESC) AS rank_num,
RANK() OVER (ORDER BY sales DESC) AS rank_equal, -- 并列跳号
DENSE_RANK() OVER (ORDER BY sales DESC) AS rank_dense -- 并列不跳号
FROM employees;
-- 结果:
-- name | sales | rank_num | rank_equal | rank_dense
-- Alice | 1000 | 1 | 1 | 1
-- Bob | 1000 | 2 | 1 | 1
-- Charlie | 800 | 3 | 3 | 2
1.2 值类窗口函数
-- LEAD/LAG:查看前/后行的值(无上/下行返回 NULL)
SELECT
date,
revenue,
LAG(revenue, 1) OVER (ORDER BY date) AS prev_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY date) AS delta,
LEAD(revenue, 1) OVER (ORDER BY date) AS next_revenue
FROM daily_sales;
-- FIRST_VALUE / LAST_VALUE:窗口内的首尾值
SELECT
department,
name,
salary,
FIRST_VALUE(name) OVER (PARTITION BY department ORDER BY salary DESC) AS top_earner,
LAST_VALUE(name) OVER (
PARTITION BY department ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS lowest_earner
FROM employees;
1.3 聚合类窗口函数
-- 计算部门平均与员工差异
SELECT
department,
name,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg,
SUM(salary) OVER (PARTITION BY department) AS dept_total,
salary / SUM(salary) OVER (PARTITION BY department) * 100 AS pct_of_dept
FROM employees;
1.4 窗口函数常用场景
| 场景 | 函数 | 说明 |
|---|---|---|
| 分页去重 | ROW_NUMBER() OVER (PARTITION BY x ORDER BY y) | 取每组最新一行 |
| 同比环比 | LAG(x, 12) OVER (ORDER BY month) | 与去年同期对比 |
| 累计求和 | SUM(x) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) | 运行总计 |
| 移动平均 | AVG(x) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) | 7 日均线 |
| 分类占比 | x / SUM(x) OVER (PARTITION BY group) | 组内百分比 |
二、CTE(公用表表达式):让查询可组合
CTE 用 WITH 关键字把子查询命名化,让复杂 SQL 可读性提升数倍。
2.1 非递归 CTE
-- 找出每个类目中评分最高的 3 本书
WITH ranked_books AS (
SELECT
id, title, category, rating,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY rating DESC, id) AS rn
FROM books
)
SELECT * FROM ranked_books WHERE rn <= 3;
2.2 多 CTE 串联
WITH
monthly_sales AS (
SELECT DATE_TRUNC('month', order_date) AS month, SUM(amount) AS total
FROM orders
GROUP BY 1
),
growth AS (
SELECT
month,
total,
LAG(total) OVER (ORDER BY month) AS prev_total,
(total - LAG(total) OVER (ORDER BY month)) / NULLIF(LAG(total) OVER (ORDER BY month), 0) * 100 AS growth_rate
FROM monthly_sales
)
SELECT * FROM growth WHERE month >= '2025-01-01';
2.3 递归 CTE:遍历层级结构
-- 查找某员工的全部下属(组织架构)
WITH RECURSIVE org_tree AS (
-- 锚点:从 CEO 开始
SELECT id, name, manager_id, 0 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 递归步:找到下属的下属
SELECT e.id, e.name, e.manager_id, ot.level + 1
FROM employees e
JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT id, name, level, REPEAT(' ', level) || name AS indented_name
FROM org_tree
ORDER BY level, name;
-- 结果:
-- id | name | level | indented_name
-- 1 | Alice | 0 | Alice
-- 2 | Bob | 1 | Bob
-- 3 | Charlie | 1 | Charlie
-- 4 | Dave | 2 | Dave
2.4 递归 CTE 查找路径
-- 文件系统路径拼接(层级目录树)
WITH RECURSIVE path_tree AS (
SELECT id, name, parent_id, name AS full_path
FROM folders
WHERE parent_id IS NULL
UNION ALL
SELECT f.id, f.name, f.parent_id, pt.full_path || '/' || f.name
FROM folders f
JOIN path_tree pt ON f.parent_id = pt.id
)
SELECT full_path FROM path_tree WHERE id = 42;
-- 结果:Root/Projects/Backend/Database
三、LATERAL JOIN:子查询当列用
LATERAL JOIN 允许子查询引用主表的列,相当于"为每行执行一次子查询"。
3.1 获取每个用户最近 3 篇博客
-- 不用 LATERAL:自连接 + 子查询,复杂且慢
-- 用 LATERAL:清晰直观
SELECT u.id, u.name, p.title, p.created_at
FROM users u
LEFT JOIN LATERAL (
SELECT id, title, created_at
FROM posts
WHERE author_id = u.id
ORDER BY created_at DESC
LIMIT 3
) p ON true
ORDER BY u.id, p.created_at DESC;
3.2 LATERAL + 计算列
-- 计算每个订单的运费(按重量分区对应不同费率)
SELECT o.id, o.total, o.weight,
COALESCE(fr.rate * o.weight, 0) AS shipping_cost,
o.total + COALESCE(fr.rate * o.weight, 0) AS grand_total
FROM orders o
LEFT JOIN LATERAL (
SELECT rate FROM freight_rates
WHERE o.weight >= min_weight AND o.weight < max_weight
LIMIT 1
) fr ON true;
四、数组操作:列级集合处理
PostgreSQL 的数组类型支持完整的集合运算。
4.1 数组 CRUD
-- 创建含数组列的表
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT,
tags TEXT[]
);
-- 插入数组
INSERT INTO articles (title, tags) VALUES
('Postgres Arrays', ARRAY['sql', 'advanced', 'postgres']),
('Window Functions', '{sql, window}'); -- 两种语法
-- 追加/删除元素
UPDATE articles SET tags = array_append(tags, 'tutorial') WHERE id = 1;
UPDATE articles SET tags = array_remove(tags, 'old') WHERE id = 1;
-- 包含某个标签
SELECT * FROM articles WHERE tags @> ARRAY['sql'];
-- 包含任意标签(交集)
SELECT * FROM articles WHERE tags && ARRAY['window', 'advanced'];
-- 数组长度
SELECT title, array_length(tags, 1) AS tag_count FROM articles;
-- 展开为行(unnest)
SELECT unnest(tags) AS tag FROM articles;
-- 结果 → sql, advanced, postgres, sql, window
-- 唯一标签统计
SELECT unnest(tags) AS tag, COUNT(*) AS cnt
FROM articles
GROUP BY tag
ORDER BY cnt DESC;
4.2 数组 + GIN 索引
CREATE INDEX idx_articles_tags ON articles USING GIN(tags);
-- 含 'postgres' 标签的查询用 GIN 索引
SELECT * FROM articles WHERE tags @> ARRAY['postgres'];
五、JSONB 高级查询
PostgreSQL JSONB 不只是存储,它支持完整的查询与索引。
5.1 JSONB 操作速查
| 操作符 | 含义 | 示例 |
|---|---|---|
| -> | 取 JSONB 子对象 | data->'user' |
| -» | 取文本值 | data->>'name' |
| #> | 按路径取对象 | data#>'{address,city}' |
| #» | 按路径取文本 | data#>>'{address,city}' |
| @> | 包含(左包含右) | data @> '{"active": true}' |
| <@ | 被包含 | '{"role":"admin"}' <@ data |
| ? | 键存在 | data ? 'email' |
| ? | 任意键存在 | |
| ?& | 所有键都存在 | data ?& ARRAY['email', 'phone'] |
5.2 JSONB 深度查询
-- 查找订单中购买了苹果产品的
SELECT * FROM orders
WHERE items @> '[{"product": {"brand": "Apple"}}]';
-- 按嵌套属性排序
SELECT * FROM products
ORDER BY (metadata->'specs'->>'rating')::NUMERIC DESC;
-- JSONB 聚合(多条记录聚合为单个 JSONB)
SELECT user_id,
JSONB_AGG(
JSONB_BUILD_OBJECT('title', title, 'created_at', created_at)
ORDER BY created_at DESC
) AS post_history
FROM posts
GROUP BY user_id;
-- JSONB 列值批量更新(给所有用户的 metadata 加字段)
UPDATE users
SET metadata = metadata || '{"newsletter_opt_in": true}'::jsonb;
5.3 JSONB + GIN 路径索引
-- 为特定 JSONB 路径建索引,避免全表扫描
CREATE INDEX idx_orders_items ON orders (
(items->0->>'product_id')
);
-- 更通用的 GIN 索引
CREATE INDEX idx_products_metadata ON products USING GIN(metadata jsonb_path_ops);
六、正则表达式与文本搜索
6.1 正则表达式匹配
-- POSIX 正则
SELECT * FROM users WHERE email ~ '^[a-zA-Z0-9._%+-]+@gmail\.com$';
-- 不区分大小写
SELECT * FROM users WHERE name ~* '^alice';
-- 正则替换
SELECT REGEXP_REPLACE(phone, '^(\d{3})-(\d{4})$', '(\1) \2-XXXX');
-- 分组提取
SELECT REGEXP_MATCHES(email, '(.+)@(.+)', 'i') FROM users;
6.2 全文搜索进阶
-- 多语言混合搜索(中文需要额外扩展)
SELECT id, title, ts_rank(tsv, query) AS rank
FROM articles,
to_tsquery('english', 'postgres & optimization') query
WHERE tsv @@ query
ORDER BY rank DESC;
-- 创建加权 tsvector(标题权重 A,正文 B)
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
SETWEIGHT(to_tsvector('english', COALESCE(title, '')), 'A') ||
SETWEIGHT(to_tsvector('english', COALESCE(body, '')), 'B')
) STORED;
CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);
-- 高亮摘要
SELECT id, title,
ts_headline('english', body, plainto_tsquery('english', 'database')) AS highlight
FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'database');
七、综合案例:构建报表查询
7.1 用户活跃度漏斗分析
WITH monthly_stats AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
user_id,
COUNT(*) AS session_count
FROM sessions
GROUP BY 1, 2
),
engagement AS (
SELECT
month,
COUNT(DISTINCT user_id) AS total_users,
COUNT(DISTINCT CASE WHEN session_count >= 5 THEN user_id END) AS power_users,
COUNT(DISTINCT CASE WHEN session_count = 1 THEN user_id END) AS one_time_users,
AVG(session_count)::NUMERIC(10,2) AS avg_sessions
FROM monthly_stats
GROUP BY month
)
SELECT
month,
total_users,
power_users,
ROUND(power_users::NUMERIC / total_users * 100, 2) AS power_user_rate,
one_time_users,
avg_sessions
FROM engagement
ORDER BY month;
7.2 商品类目层级路径与深度
WITH RECURSIVE category_path AS (
SELECT id, name, parent_id, name AS path, 0 AS depth
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, cp.path || ' > ' || c.name, cp.depth + 1
FROM categories c
JOIN category_path cp ON c.parent_id = cp.id
)
SELECT * FROM category_path ORDER BY path;
常见问题(FAQ)
窗口函数能用 CTE 替代吗?
不能。CTE 让查询结构化,窗口函数让每行获得邻行视角,两者互补。复杂报表通常是 CTE 定义中间集 + 窗口函数计算排名/占比。
递归 CTE 会无限循环吗?
可能,如果层级数据成环(A 管 B,B 又管 A)。PostgreSQL 默认有 max_recursive_iterations 限制,或通过 CYCLE 检测:WITH RECURSIVE ... CYCLE id SET is_cycle USING path。
LATERAL JOIN 和普通 JOIN 有什么区别?
普通 JOIN 先求两个表的笛卡尔积后过滤;LATERAL JOIN 先取左表一行,再执行右子查询(可引用左表列),最后连接。适合"每行一个动态子集"的应用。
相关阅读
- PostgreSQL 详解 — MVCC、WAL、索引类型
- PostgreSQL 性能优化 — EXPLAIN、索引选择、VACUUM
- PostgreSQL 扩展生态 — pgvector、TimescaleDB
- PostgreSQL 性能优化 EXPLAIN 分析 — 窗口函数的性能影响
- PostgreSQL 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。