PostgreSQL数据库技术 第 2 篇:SQL高级查询与数据操作

本篇属于「PostgreSQL数据库技术」学习系列,建议先阅读前序文章以获得最佳学习效果。

全文摘要

本文将带你深入掌握SQL的高级查询技术,帮助你处理复杂的数据分析需求。你将学到多表连接的各种类型与应用场景、子查询的嵌套使用技巧、聚合函数与分组的强大功能、窗口函数的排名与数据分析能力、公用表表达式CTE的优雅写法、视图的安全封装,以及事务处理的深入理解。通过阅读本文,你将能够编写复杂的业务查询,优化查询性能,处理并发访问问题。

学习目标

阅读完本文后,你将能够:

  • 多表查询:熟练使用INNER/LEFT/RIGHT/FULL JOIN处理关联数据
  • 嵌套查询:理解子查询的执行逻辑,优化子查询性能
  • 数据分析:使用聚合函数和GROUP BY进行数据统计
  • 高级分析:使用窗口函数解决排名、累积求值等复杂问题
  • 代码优化:使用CTE简化复杂查询,提高代码可读性
  • 数据封装:创建视图简化查询,提供安全的数据访问接口
  • 并发控制:理解事务隔离级别,避免常见的并发问题

一、多表连接:关联查询的核心

在实际应用中,数据通常分布在多个表中。多表连接(JOIN)是SQL最强大的功能之一,它允许我们从多个相关表中组合数据。

连接的基本概念

连接操作的目的是基于两个表之间的相关字段,将它们的数据组合在一起。这个相关字段通常是外键关系,但也可以是任何有逻辑关联的字段。

假设场景:我们有一个简单的电商数据库模型:

  • users表:用户信息(id, username, email)
  • orders表:订单信息(id, user_id, order_date, total)
  • order_items表:订单明细(id, order_id, product_id, quantity, price)
  • products表:产品信息(id, name, category, price)

INNER JOIN:内连接

内连接是最常用的连接类型,只返回两个表中匹配的行。

-- 基本语法
SELECT users.username, orders.id, orders.order_date, orders.total
FROM users
INNER JOIN orders ON users.id = orders.user_id;
 
-- 简写(省略INNER)
SELECT users.username, orders.id, orders.order_date, orders.total
FROM users
JOIN orders ON users.id = orders.user_id;
 
-- 使用表别名简化查询
SELECT u.username, o.id, o.order_date, o.total
FROM users u
JOIN orders o ON u.id = o.user_id;
 
-- 多表连接:查询订单的详细信息
SELECT
    u.username,
    o.id AS order_id,
    o.order_date,
    oi.quantity,
    p.name AS product_name,
    oi.price
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;

LEFT JOIN:左外连接

左外连接返回左表的所有行,即使右表没有匹配的行。右表没有匹配的行时,右表的列显示为NULL。

-- 查询所有用户及其订单(包括没有订单的用户)
SELECT
    u.username,
    u.email,
    o.id AS order_id,
    o.order_date,
    o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
 
-- 找出没有订单的用户
SELECT u.username, u.email
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
 
-- 统计每个用户的订单数量(包括没有订单的用户)
SELECT
    u.username,
    COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username;

RIGHT JOIN:右外连接

右外连接与左外连接相反,返回右表的所有行。实际应用中较少使用,因为可以用LEFT JOIN通过交换表位置实现。

-- 右连接示例(不推荐,通常改用左连接)
SELECT u.username, o.id, o.order_date
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
 
-- 改为左连接(推荐)
SELECT u.username, o.id, o.order_date
FROM orders o
LEFT JOIN users u ON u.id = o.user_id;

FULL JOIN:全外连接

全外连接返回两个表的所有行,无论是否匹配。没有匹配的行在对方的列显示为NULL。

-- 全连接示例
SELECT u.username, o.id, o.order_date
FROM users u
FULL JOIN orders o ON u.id = o.user_id;
flowchart TB
    subgraph Joins[四种连接类型对比]
        direction LR

        subgraph Inner[INNER JOIN 内连接]
            direction TB
            A[表A匹配行] --> R[结果集<br/>只返回匹配的行]
            B[表B匹配行] --> R
        end

        subgraph Left[LEFT JOIN 左连接]
            direction TB
            A2[表A所有行] --> R2[结果集<br/>包含表A所有行<br/>不匹配时NULL]
            B2[表B匹配行] --> R2
        end

        subgraph Right[RIGHT JOIN 右连接]
            direction TB
            A3[表A匹配行] --> R3[结果集<br/>包含表B所有行<br/>不匹配时NULL]
            B3[表B所有行] --> R3
        end

        subgraph Full[FULL JOIN 全连接]
            direction TB
            A4[表A所有行] --> R4[结果集<br/>包含两表所有行<br/>不匹配时NULL]
            B4[表B所有行] --> R4
        end
    end

    style Inner fill:#c8e6c9
    style Left fill:#fff9c4
    style Right fill:#ffcdd2
    style Full fill:#e3f2fd

图表讲解:这张图直观地比较了四种连接类型的差异——这是选择正确连接类型的参考指南。

INNER JOIN是最严格的连接类型,只返回两个表中都有匹配的行。如果我们只想查询有订单的用户,使用INNER JOIN是合适的。INNER JOIN是默认的连接类型,在大多数情况下使用。

LEFT JOIN返回左表的所有行,即使右表没有匹配。这在需要”即使关联数据不存在也要显示主记录”的场景下非常有用。例如,显示所有用户及其订单(包括没有订单的用户),使用LEFT JOIN。没有订单的用户在订单相关列显示NULL。

RIGHT JOIN与LEFT JOIN相反,返回右表的所有行。实际应用中很少使用,因为可以通过交换表位置用LEFT JOIN实现。图示用红色标记是为了提醒这种不对称性,实际编码时应优先使用LEFT JOIN。

FULL JOIN返回两个表的所有行,无论是否匹配。这在我们需要完整看到两表数据的场景下有用。例如,找出哪些用户没有订单、哪些订单没有对应用户(数据异常)。FULL JOIN返回的数据量最大,在数据处理和数据质量检查中很有用。

自连接

自连接是将表与自身连接,用于比较同一表中的行。需要使用表别名区分同一表的两个”实例”。

-- 创建员工表示例
CREATE TABLE employees (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    manager_id INTEGER REFERENCES employees(id),
    department TEXT
);
 
-- 插入示例数据
INSERT INTO employees (name, manager_id, department) VALUES
    ('张三', NULL, 'IT部'),
    ('李四', 1, 'IT部'),
    ('王五', 1, 'IT部'),
    ('赵六', 2, '销售部'),
    ('孙七', 2, '销售部');
 
-- 自连接:查询员工及其经理
SELECT
    e.name AS employee_name,
    e.department,
    m.name AS manager_name
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
 
-- 自连接:查找同部门的同事
SELECT
    e1.name AS employee1,
    e2.name AS employee2,
    e1.department
FROM employees e1
JOIN employees e2 ON e1.department = e2.department AND e1.id < e2.id;

交叉连接与条件连接

交叉连接(CROSS JOIN):返回两个表的笛卡尔积(每一行与每一行的组合)。如果没有WHERE条件,结果集大小是两表行数的乘积。

-- 交叉连接示例(慎用,结果可能非常大)
SELECT u.username, p.name
FROM users u
CROSS JOIN products p;
 
-- 带条件的交叉连接(等同于INNER JOIN)
SELECT u.username, p.name
FROM users u
CROSS JOIN products p
WHERE u.id = p.user_id;

USING子句:当连接的两个表有相同名称的列时,可以使用USING简化连接条件。

-- 使用ON
SELECT orders.id, users.username
FROM orders
JOIN users ON orders.user_id = users.id;
 
-- 使用USING(当两表都有user_id时)
SELECT orders.id, username
FROM orders
JOIN users USING (user_id);

二、子查询:嵌套查询的艺术

子查询是嵌套在其他查询中的查询。子查询可以出现在SELECT、FROM、WHERE、HAVING子句中,为复杂查询提供了强大的表达能力。

WHERE子句中的子查询

标量子查询:返回单个值的子查询,通常与比较运算符配合使用。

-- 查找价格高于平均价格的产品
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
 
-- 查找与"张三"同部门的所有员工
SELECT name, department
FROM employees
WHERE department = (SELECT department FROM employees WHERE name = '张三');

IN子查询:检查值是否在子查询返回的列表中。

-- 查询有订单的用户
SELECT username, email
FROM users
WHERE id IN (SELECT DISTINCT user_id FROM orders);
 
-- 等价于JOIN方式(通常性能更好)
SELECT DISTINCT u.username, u.email
FROM users u
JOIN orders o ON u.id = o.user_id;

EXISTS子查询:检查子查询是否返回任何行,只关心”存在”而非”具体值”。

-- 查询有订单的用户
SELECT username, email
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
 
-- 查询没有订单的用户
SELECT username, email
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

EXISTS通常比IN更高效,因为它找到第一个匹配就停止。IN需要扫描整个列表。当子查询结果集很大时,优先考虑EXISTS。

FROM子句中的子查询

FROM子句中的子查询创建临时结果集,可以像表一样使用。这种子查询被称为”派生表”。

-- 查找每个用户的最新订单
SELECT recent_orders.username, recent_orders.order_date, recent_orders.total
FROM (
    SELECT
        u.username,
        o.id,
        o.order_date,
        o.total,
        ROW_NUMBER() OVER (PARTITION BY u.id ORDER BY o.order_date DESC) AS rn
    FROM users u
    JOIN orders o ON u.id = o.user_id
) recent_orders
WHERE recent_orders.rn = 1;

相关子查询

相关子查询引用外部查询的列,为外部查询的每一行执行一次。相关子查询通常性能较差,应谨慎使用。

-- 查找价格高于同类产品平均价格的产品
SELECT p1.name, p1.category, p1.price
FROM products p1
WHERE p1.price > (
    SELECT AVG(p2.price)
    FROM products p2
    WHERE p2.category = p1.category
);
 
-- 用JOIN改写(性能更好)
SELECT p1.name, p1.category, p1.price
FROM products p1
JOIN (
    SELECT category, AVG(price) AS avg_price
    FROM products
    GROUP BY category
) p2 ON p1.category = p2.category
WHERE p1.price > p2.avg_price;

ANY、ALL操作符

ANY和ALL与子查询配合使用,提供灵活的比较语义。

-- ANY:满足任一条件即可
SELECT name, price
FROM products
WHERE price > ANY (SELECT price FROM products WHERE category = 'Electronics');
 
-- 等价于
SELECT name, price
FROM products
WHERE price > (SELECT MIN(price) FROM products WHERE category = 'Electronics');
 
-- ALL:满足所有条件
SELECT name, price
FROM products
WHERE price > ALL (SELECT price FROM products WHERE category = 'Electronics');
 
-- 等价于
SELECT name, price
FROM products
WHERE price > (SELECT MAX(price) FROM products WHERE category = 'Electronics');
sequenceDiagram
    participant Client as 客户端
    participant DB as 数据库引擎
    participant Outer as 外层查询
    participant Inner as 内层查询

    Note over Client,Inner: 相关子查询的执行过程

    Client->>DB: 执行查询(包含相关子查询)

    Outer->>Outer: 从users表取第一行<br/>user_id=1
    Outer->>Inner: 执行子查询<br/>WHERE user_id=1
    Inner-->>Outer: 返回结果:存在
    Outer->>Outer: 判断条件满足<br/>保留该行

    Outer->>Outer: 从users表取第二行<br/>user_id=2
    Outer->>Inner: 执行子查询<br/>WHERE user_id=2
    Inner-->>Outer: 返回结果:不存在
    Outer->>Outer: 判断条件不满足<br/>丢弃该行

    Outer->>Inner: 继续处理下一行...

    Note over Client,Inner: 子查询为外部查询的每一行执行一次

图表讲解:这张时序图展示了相关子查询的执行过程——这是理解子查询性能的关键。

相关子查询的特殊之处在于它引用了外部查询的列(如user_id)。这意味着子查询不能只执行一次,而是为外部查询的每一行执行一次。从性能角度看,这类似于嵌套循环,时间复杂度是O(n*m),其中n是外部查询的行数,m是子查询的行数。

执行过程如下:数据库引擎从users表读取第一行,将user_id=1传递给子查询;子查询执行,检查orders表中是否有user_id=1的记录;子查询返回结果(存在或不存在);外层查询根据子查询结果判断是否保留这一行。

这个过程为外部查询的每一行重复。如果users表有1000行,子查询执行1000次。这就是为什么相关子查询可能性能很差。

优化方法包括:使用JOIN替代相关子查询(如果可能);使用EXISTS替代IN(当子查询结果集大时);在子查询中建立适当的索引;改用窗口函数(如COUNT() OVER ())。


三、聚合函数与分组统计

聚合函数对一组值进行计算,返回单个值。结合GROUP BY子句,可以按照分组进行统计分析,这是SQL数据分析的核心功能。

常用聚合函数

-- COUNT:计数
SELECT COUNT(*) AS total_rows FROM orders;           -- 所有行,包括NULL
SELECT COUNT(order_date) AS not_null_dates FROM orders;  -- 非NULL值
SELECT COUNT(DISTINCT user_id) AS unique_users FROM orders;  -- 唯一值计数
 
-- SUM:求和
SELECT SUM(total) AS total_sales FROM orders;
SELECT SUM(quantity) AS total_items FROM order_items;
 
-- AVG:平均值
SELECT AVG(price) AS avg_price FROM products;
SELECT AVG(total) AS avg_order_value FROM orders;
 
-- MIN/MAX:最小值/最大值
SELECT MIN(order_date) AS first_order, MAX(order_date) AS last_order
FROM orders;
SELECT MIN(price) AS min_price, MAX(price) AS max_price
FROM products;

GROUP BY子句

GROUP BY将结果集按照一个或多个列分组,聚合函数对每个组返回一个值。

-- 按单个列分组:统计每个用户的订单数
SELECT
    user_id,
    COUNT(*) AS order_count,
    SUM(total) AS total_spent
FROM orders
GROUP BY user_id
ORDER BY total_spent DESC;
 
-- 按多个列分组:按部门和职位统计员工数
SELECT
    department,
    position,
    COUNT(*) AS employee_count
FROM employees
GROUP BY department, position
ORDER BY department, employee_count DESC;

HAVING子句

HAVING用于过滤分组结果,类似于WHERE,但WHERE过滤行,HAVING过滤分组。

-- 查找订单总数超过5的用户
SELECT
    u.username,
    COUNT(o.id) AS order_count
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username
HAVING COUNT(o.id) > 5
ORDER BY order_count DESC;
 
-- WHERE vs HAVING的执行顺序
-- WHERE:在分组前过滤行(更高效)
-- HAVING:在分组后过滤分组
 
-- 示例:查找2024年的大客户(总消费超过10000)
SELECT
    u.username,
    SUM(o.total) AS total_spent
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.order_date >= '2024-01-01'  -- 分组前过滤
GROUP BY u.id, u.username
HAVING SUM(o.total) > 10000           -- 分组后过滤
ORDER BY total_spent DESC;

GROUPING SETS、CUBE、ROLLUP

PostgreSQL提供高级分组功能,生成多个层级的聚合结果。

-- GROUPING SETS:指定多个分组级别
SELECT
    department,
    position,
    COUNT(*) AS employee_count
FROM employees
GROUP BY GROUPING SETS (
    (department, position),  -- 部门和职位都分组
    (department),            -- 只按部门分组
    ()                       -- 总计
);
 
-- ROLLUP:生成分层的汇总(从细到粗)
SELECT
    region,
    city,
    SUM(sales) AS total_sales
FROM sales_data
GROUP BY ROLLUP (region, city);
-- 等价于 GROUPING SETS ((region, city), (region), ())
 
-- CUBE:生成所有可能的组合
SELECT
    region,
    city,
    product_category,
    SUM(sales) AS total_sales
FROM sales_data
GROUP BY CUBE (region, city, product_category);
-- 等价于 GROUPING SETS (
--     (region, city, product_category),
--     (region, city),
--     (region, product_category),
--     (city, product_category),
--     (region),
--     (city),
--     (product_category),
--     ()
-- )

四、窗口函数:高级数据分析

窗口函数(Window Functions)是PostgreSQL最强大的功能之一,它允许我们在不聚合行的情况下执行跨行计算。窗口函数结合聚合函数和排名函数,可以解决很多复杂的分析问题。

窗口函数的基本语法

窗口函数的基本语法是:函数名 OVER (窗口规范)。窗口规范定义了函数如何分区窗口、如何排序窗口、窗口的范围。

-- 窗口函数的基本语法
函数名 OVER (
    [PARTITION BY 分区列]
    [ORDER BY 排序列]
    [窗口框架]
)

PARTITION BY:将结果集分成多个分区,函数在每个分区内独立计算。

ORDER BY:在每个分区内对行排序,窗口函数按照这个顺序计算。

窗口框架:定义当前行相对于分区的范围(ROWS BETWEEN … AND …)。

排名函数

-- 创建示例表
CREATE TABLE student_scores (
    student_name TEXT,
    subject TEXT,
    score INTEGER
);
 
-- 插入数据
INSERT INTO student_scores VALUES
    ('张三', '数学', 85), ('张三', '语文', 92),
    ('李四', '数学', 78), ('李四', '语文', 88),
    ('王五', '数学', 92), ('王五', '语文', 95);
 
-- ROW_NUMBER():连续排名(1, 2, 3, 4, ...)
-- RANK():跳跃排名(1, 2, 2, 4, ...)- 并列时占用后续名次
-- DENSE_RANK():密集排名(1, 2, 2, 3, ...)- 并列时不占用后续名次
SELECT
    student_name,
    subject,
    score,
    ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num,
    RANK() OVER (ORDER BY score DESC) AS rank,
    DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank
FROM student_scores
ORDER BY score DESC;
 
-- 按科目分组排名
SELECT
    subject,
    student_name,
    score,
    RANK() OVER (PARTITION BY subject ORDER BY score DESC) AS subject_rank
FROM student_scores
ORDER BY subject, rank;

聚合窗口函数

-- 计算移动平均
SELECT
    date,
    sales,
    AVG(sales) OVER (
        ORDER BY date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moving_avg
FROM sales_data;
 
-- 累积求和
SELECT
    date,
    sales,
    SUM(sales) OVER (
        ORDER BY date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_sales
FROM sales_data;

偏移量函数

-- LAG:访问前面行的值
-- LEAD:访问后面行的值
SELECT
    date,
    revenue,
    LAG(revenue, 1) OVER (ORDER BY date) AS prev_revenue,
    revenue - LAG(revenue, 1) OVER (ORDER BY date) AS revenue_change,
    LEAD(revenue, 1) OVER (ORDER BY date) AS next_revenue
FROM monthly_revenue;
 
-- FIRST_VALUE、LAST_VALUE:访问分区第一行/最后一行的值
SELECT
    department,
    employee_name,
    salary,
    FIRST_VALUE(employee_name) OVER (
        PARTITION BY department
        ORDER BY salary DESC
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS highest_paid_employee
FROM employees;

实际应用案例

flowchart TB
    subgraph WindowFunctions[窗口函数应用场景]
        direction TB

        subgraph Ranking[排名分析]
            direction TB
            R1[销售排名<br/>RANK DENSE_RANK]
            R2[成绩排名<br/>ROW_NUMBER]
            R3[前N名筛选<br/>过滤 rank <= N]
        end

        subgraph Aggregation[聚合分析]
            direction TB
            A1[移动平均<br/>AVG OVER ROWS]
            A2[累积求和<br/>SUM OVER ROWS]
            A3[同比增长<br/>同比率计算]
        end

        subgraph Offset[偏移分析]
            direction TB
            O1[环比增长<br/>当前-上一行]
            O2[年度比较<br/>同比当前-去年]
            O3[填补缺失值<br/>COALESCE 当前值 LAG]
        end
    end

    style Ranking fill:#c8e6c9
    style Aggregation fill:#fff9c4
    style Offset fill:#ffcdd2

图表讲解:这张图展示了窗口函数的主要应用场景——这些是实际业务中经常遇到的分析需求。

排名分析是窗口函数最常见的应用。例如,计算销售员的业绩排名(并列时是否占用名次)、学生的成绩排名、产品的销售额排名。使用不同的排名函数可以实现不同的排名语义:RANK用于”锦标赛排名”(并列时跳跃),DENSE_RANK用于”班级排名”(并列时不跳跃),ROW_NUMBER用于生成唯一序号。

聚合分析允许我们计算移动平均(平滑数据波动)、累积求和(计算总计)、同比增长(与去年同期比较)。这些计算使用普通聚合函数很难实现,因为聚合函数会将所有行压缩成一行。窗口函数的优势是保留原始行,同时添加聚合计算结果。

偏移分析用于比较当前行与前后行的差异,典型应用是计算环比增长率(与上个月比较)、同比(与去年同月比较)、填补缺失值(使用前一个非空值)。LAG和LEAD函数使得这些分析变得简单,不需要自连接。

窗口函数的真正威力在于组合使用。例如,计算每个部门的销售额排名、同比增长率、移动平均,可以在一个查询中完成,而不需要多个子查询或临时表。


五、集合操作:合并查询结果

集合操作符(UNION、INTERSECT、EXCEPT)用于合并多个查询的结果集。注意参与集合操作的查询必须返回相同数量的列,且对应列的数据类型兼容。

UNION:合并结果集

UNION合并两个查询的结果集,自动去除重复行。UNION ALL保留所有行(包括重复),性能更好。

-- UNION:合并并去重
SELECT username FROM admins
UNION
SELECT username FROM regular_users;
 
-- UNION ALL:合并不去重(更快)
SELECT username FROM admins
UNION ALL
SELECT username FROM regular_users;
 
-- 实际应用:合并不同时期的数据
SELECT user_id, order_date, total
FROM orders_2023
UNION ALL
SELECT user_id, order_date, total
FROM orders_2024;

INTERSECT:交集

INTERSECT返回两个查询结果集的交集(同时出现在两个结果集中的行)。

-- 查找既是VIP用户又是活跃用户的人
SELECT user_id FROM vip_users
INTERSECT
SELECT user_id FROM active_users;
 
-- 实际应用:查找两个表共有的用户
SELECT user_id FROM users_2023
INTERSECT
SELECT user_id FROM users_2024;

EXCEPT:差集

EXCEPT返回第一个查询中有但第二个查询中没有的行。

-- 查找有2023年订单但没有2024年订单的用户
SELECT user_id FROM orders_2023
EXCEPT
SELECT user_id FROM orders_2024;
 
-- 实际应用:找出丢失的客户(曾经购买但今年没有购买)
SELECT customer_id FROM customers
EXCEPT
SELECT DISTINCT customer_id FROM orders_this_year;

六、公用表表达式(CTE)

公用表表达式(Common Table Expression,CTE)是临时命名的结果集,在查询中可以像表一样引用。CTE使复杂查询更易读、更易维护。

CTE的基本语法

-- 基本语法
WITH cte_name AS (
    cte_query
)
main_query;
 
-- 单个CTE示例
WITH avg_prices AS (
    SELECT category, AVG(price) AS avg_price
    FROM products
    GROUP BY category
)
SELECT p.name, p.category, p.price, c.avg_price
FROM products p
JOIN avg_prices c ON p.category = c.category
WHERE p.price > c.avg_price;

多个CTE

-- 多个CTE用逗号分隔
WITH
user_order_counts AS (
    SELECT user_id, COUNT(*) AS order_count
    FROM orders
    GROUP BY user_id
),
high_value_users AS (
    SELECT user_id, SUM(total) AS total_spent
    FROM orders
    GROUP BY user_id
    HAVING SUM(total) > 10000
)
SELECT
    u.username,
    o.order_count,
    h.total_spent
FROM users u
JOIN user_order_counts o ON u.id = o.user_id
JOIN high_value_users h ON u.id = h.user_id;

递归CTE

递归CTE可以处理树形结构、层级数据等复杂查询。

-- 查询组织架构中的所有员工(包括下属)
WITH RECURSIVE employee_tree AS (
    -- 非递归部分:根节点
    SELECT id, name, manager_id, 1 AS level
    FROM employees
    WHERE manager_id IS NULL
 
    UNION ALL
 
    -- 递归部分:查找下级员工
    SELECT e.id, e.name, e.manager_id, et.level + 1
    FROM employees e
    JOIN employee_tree et ON e.manager_id = et.id
)
SELECT * FROM employee_tree ORDER BY level, name;

CTE vs 子查询

CTE相比子查询的优势:

  • 可读性更好:可以命名中间结果集,逻辑更清晰
  • 性能更优:查询优化器可以更好地优化
  • 代码复用:CTE可以在同一查询中多次引用
  • 支持递归:处理层级数据
-- 使用CTE(推荐)
WITH dept_stats AS (
    SELECT department, COUNT(*) AS emp_count, AVG(salary) AS avg_salary
    FROM employees
    GROUP BY department
)
SELECT
    department,
    emp_count,
    avg_salary,
    (SELECT AVG(avg_salary) FROM dept_stats) AS overall_avg
FROM dept_stats;
 
-- 使用子查询(不推荐)
SELECT
    department,
    COUNT(*) AS emp_count,
    AVG(salary) AS avg_salary,
    (SELECT AVG(avg_salary)
     FROM (SELECT department, AVG(salary) AS avg_salary
           FROM employees GROUP BY department) dept) AS overall_avg
FROM employees
GROUP BY department;

七、视图:虚拟表

视图(View)是虚拟表,不存储数据,只存储查询定义。视图简化复杂查询,提供数据的安全封装。

创建和使用视图

-- 创建简单视图
CREATE VIEW user_orders AS
SELECT
    u.id AS user_id,
    u.username,
    u.email,
    o.id AS order_id,
    o.order_date,
    o.total
FROM users u
JOIN orders o ON u.id = o.user_id;
 
-- 使用视图
SELECT * FROM user_orders WHERE user_id = 1;
 
-- 创建带聚合的视图(需要WITH CHECK OPTION)
CREATE VIEW department_stats AS
SELECT
    department,
    COUNT(*) AS employee_count,
    AVG(salary) AS avg_salary,
    SUM(salary) AS total_salary
FROM employees
GROUP BY department;

视图的优势

简化查询:将复杂查询封装为视图,用户只需查询视图。

-- 原始复杂查询
SELECT u.username, o.order_date, oi.quantity, p.name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.order_date >= '2024-01-01';
 
-- 使用视图(简化)
CREATE VIEW recent_order_details AS
SELECT u.username, o.order_date, oi.quantity, p.name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;
 
SELECT * FROM recent_order_details
WHERE order_date >= '2024-01-01';

数据安全:视图可以限制用户访问敏感数据。

-- 创建只包含必要字段的视图
CREATE VIEW public_user_info AS
SELECT id, username, email
FROM users;
 
-- 授予用户访问视图的权限,而不是基础表
GRANT SELECT ON public_user_info TO app_user;

逻辑独立性:基础表结构变化时,可以修改视图定义,不需要修改应用代码。

物化视图

PostgreSQL支持物化视图(Materialized View),它实际存储数据,可以刷新。

-- 创建物化视图
CREATE MATERIALIZED VIEW sales_summary AS
SELECT
    DATE_TRUNC('month', order_date) AS month,
    COUNT(*) AS order_count,
    SUM(total) AS total_sales
FROM orders
GROUP BY DATE_TRUNC('month', order_date);
 
-- 刷新物化视图
REFRESH MATERIALIZED VIEW sales_summary;
 
-- 查询物化视图(非常快,因为数据已预计算)
SELECT * FROM sales_summary ORDER BY month;

八、事务深入理解

事务是保证数据一致性的核心机制。在第1篇我们介绍了事务的基本概念,这里我们深入探讨事务的隔离级别和并发控制。

事务隔离级别

SQL标准定义了四个隔离级别,从低到高依次是:

隔离级别脏读不可重复读幻读说明
READ UNCOMMITTED可能可能可能最低隔离,几乎不用
READ COMMITTED不可能可能可能PostgreSQL默认
REPEATABLE READ不可能不可能可能
SERIALIZABLE不可能不可能不可能最高隔离,串行执行

脏读(Dirty Read):读取未提交的数据。

不可重复读(Non-repeatable Read):同一事务内两次读取同一数据,结果不同。

幻读(Phantom Read):同一事务内两次执行相同查询,返回的行数不同。

-- 设置事务隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
 
-- 查看当前隔离级别
SHOW transaction_isolation;
 
-- 在事务中设置隔离级别
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 执行查询
COMMIT;

锁机制

PostgreSQL使用多种锁机制保护并发访问:

表级锁

  • ACCESS SHARE锁:SELECT语句获取
  • ROW EXCLUSIVE锁:INSERT、UPDATE、DELETE获取
  • SHARE锁:CREATE INDEX CONCURRENTLY获取
  • EXCLUSIVE锁:LOCK TABLE语句获取

行级锁

  • FOR UPDATE:SELECT … FOR UPDATE获取行锁,用于更新
  • FOR SHARE:SELECT … FOR SHARE获取共享锁,用于读取
  • FOR KEY SHARE:用于外键检查
-- 显式锁定行
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 此时其他事务试图修改id=1的行将被阻塞
UPDATE users SET email = '[email protected]' WHERE id = 1;
COMMIT;
 
-- 检查锁状态
SELECT * FROM pg_locks WHERE NOT granted;

死锁与处理

死锁是两个或多个事务互相等待对方持有的锁,形成循环等待。

-- 死锁检测(PostgreSQL自动检测)
-- 死锁发生时,PostgreSQL会选择一个事务作为牺牲品,回滚该事务
ERROR: could not serialize access due to concurrent update
 
-- 处理死锁:应用程序应该捕获错误并重试

死锁的预防方法:

  • 让事务按相同顺序访问表和行
  • 尽量缩短事务持有锁的时间
  • 尽早提交事务
  • 使用合适的隔离级别
sequenceDiagram
    participant T1 as 事务A
    participant T2 as 事务B
    participant DB as 数据库

    Note over T1,DB: 死锁场景

    T1->>DB: 1. BEGIN
    T1->>DB: 2. UPDATE users SET ... WHERE id = 1<br/>获取行锁(id=1)
    DB->>DB: 锁定id=1

    T2->>DB: 3. BEGIN
    T2->>DB: 4. UPDATE users SET ... WHERE id = 2<br/>获取行锁(id=2)
    DB->>DB: 锁定id=2

    T1->>DB: 5. UPDATE users SET ... WHERE id = 2<br/>等待id=2的锁
    DB->>T1: 阻塞等待

    T2->>DB: 6. UPDATE users SET ... WHERE id = 1<br/>等待id=1的锁
    DB->>T2: 阻塞等待

    Note over T1,DB: 死锁形成!A等待B,B等待A

    DB->>T2: 检测到死锁
    DB->>T2: 回滚事务B(牺牲品)
    DB->>T1: 解除阻塞,继续执行

    T1->>DB: 7. 提交事务A
    DB->>DB: 释放所有锁

    T2->>T2: 8. 应用程序捕获错误<br/>重试事务B

图表讲解:这张时序图展示了死锁的形成和解决过程——这是理解并发控制的关键场景。

死锁发生在两个事务互相等待对方持有的锁时。事务A先锁定id=1的行,事务B随后锁定id=2的行。然后事务A试图锁定id=2的行,但该行已被事务B锁定,所以事务A等待。事务B也试图锁定id=1的行,但该行已被事务A锁定,所以事务B也等待。现在形成循环等待:A等待B,B等待A。

PostgreSQL的死锁检测机制定期检测这种循环等待。一旦检测到死锁,PostgreSQL会选择一个”牺牲品”(通常是修改较少数据的事务),回滚该事务并释放其持有的锁。被回滚的事务会收到错误信息,应用程序应该捕获这个错误并重试事务。

死锁的预防是更好的策略。通过让事务按固定顺序访问表和行,可以避免循环等待。例如,总是按id从小到大的顺序更新行。缩短事务持有锁的时间也能减少死锁概率,因为锁的持有时间越短,冲突的可能性越小。


九、核心概念总结

概念说明应用场景
INNER JOIN只返回匹配的行查询有关系的实体
LEFT JOIN返回左表所有行包含无关联的主记录
窗口函数不聚合行的跨行计算排名、移动平均、累积求和
CTE命名的临时结果集简化复杂查询
视图虚拟表,存储查询定义数据封装、简化访问
隔离级别事务之间的隔离程度平衡并发与一致性
死锁事务循环等待需要应用层处理
物化视图存储数据的视图预计算,提高查询速度

十、本篇总结

本文深入讲解了SQL的高级查询技术,包括多表连接、子查询、聚合函数、窗口函数、集合操作、CTE、视图和事务隔离级别。这些技术让你能够:

  • 使用各种连接类型处理关联数据
  • 编写复杂的嵌套查询和相关子查询
  • 执行数据统计和分析
  • 使用窗口函数解决排名和累积分析问题
  • 使用CTE简化复杂查询
  • 创建视图封装数据访问
  • 理解事务隔离级别和并发控制

下一篇将探讨数据库设计的深层次问题,包括数据规范化、约束机制、索引原理和性能优化基础。


下篇预告

第3篇将深入讲解数据库设计的艺术,带你学习数据规范化理论、约束的深入应用、表分区策略,以及如何设计高性能、可扩展的数据库结构。


常见问题解答

Q1:什么时候使用子查询,什么时候使用JOIN?

:一般情况下,JOIN的效率高于子查询,特别是相关子查询。

JOIN让数据库优化器选择执行计划,而子查询(特别是相关子查询)可能执行多次。但如果子查询结果集很小,或者只返回单个值(如聚合比较),子查询可能更清晰。

EXISTS子查询通常比IN子查询更高效,特别是当子查询结果集大时。实际编写查询时,可以先用子查询清晰地表达逻辑,然后考虑是否可以用JOIN或窗口函数改写优化。关键是理解查询的语义,选择最清晰、最高效的实现方式。


Q2:窗口函数和GROUP BY有什么区别,什么时候用哪个?

:窗口函数和GROUP BY都是对数据进行聚合,但关键区别是:GROUP BY将多行压缩成一行,窗口函数保留原始行。

例如,计算部门平均工资并保留员工详情,只能用窗口函数;如果只需要每个部门的平均工资,用GROUP BY更简单。另一个区别是窗口函数可以计算”移动平均”、“累积求和”等跨行计算,GROUP BY无法实现。

性能方面,窗口函数不一定比GROUP BY慢,有时甚至更快,因为不需要排序和哈希聚合。选择时考虑:是否需要保留明细行?是否需要跨行计算?回答这些可以决定使用哪种技术。


Q3:什么是”幻读”,为什么需要SERIALIZABLE隔离级别?

:幻读是指在同一事务中,两次执行相同的查询,返回的行数不同。

这通常由其他事务插入或删除满足条件的新行导致。例如,事务A查询所有年龄大于30的用户,得到10行;事务B插入5个新用户(年龄大于30);事务A再次查询,得到15行,这就是幻读。

幻读与不可重复读的区别是:不可重复读是已有行的值改变,幻读是新行的出现或消失。SERIALIZABLE是最高隔离级别,通过严格的锁定保证事务完全隔离,就像串行执行一样。但SERIALIZABLE的并发性最差,性能最低,只在有严格一致性要求的场景下使用。


Q4:视图和物化视图有什么区别,什么时候使用物化视图?

:视图是虚拟表,只存储查询定义,不存储数据。

每次查询视图时,数据库执行底层的查询。物化视图实际存储查询结果,占用存储空间,但查询非常快,因为数据已预计算。

视图适合简化复杂查询、提供数据安全封装,数据实时更新。物化视图适合预计算复杂聚合、提高查询性能、数据定期刷新(如每天一次)。物化视图的缺点是数据不是实时的,需要手动或自动刷新。物化视图特别适合报表、仪表板等查询频繁但数据变化不频繁的场景。PostgreSQL的REFRESH MATERIALIZED VIEW命令可以刷新物化视图。


Q5:为什么递归CTE可以处理树形结构,它如何工作?

:递归CTE是CTE的一种特殊形式,它引用自身,形成递归查询。

递归CTE由两部分组成:非递归部分(锚成员)和递归部分(递归成员)。非递归部分提供初始结果集,递归部分迭代地引用CTE自身,不断添加新行,直到没有新行产生。

这类似于编程中的递归函数:需要基准情况(非递归部分)和递归步骤(递归部分)。递归CTE可以遍历树形结构、图结构、层级数据。例如,查询组织架构中的所有员工(包括下属)、计算物料的BOM(物料清单)、查找路径关系。递归CTE的深度受postgresql.conf中的max_stack_depth参数限制。使用递归CTE时,应该有终止条件,避免无限递归。


更新时间:2026年3月2日 系列:PostgreSQL数据库技术 标签:#PostgreSQL 高级查询 窗口函数 CTE 多表连接 事务隔离 JOIN