PostgreSQL 教程: 利用视图的内联

二月 14, 2025

摘要:在本教程中,您将学习如何利用视图的内联能力。

目录

介绍

数据库视图是功能强大的工具,在构建数据库驱动的应用程序时,它们通常并没有被恰当地使用到。它们可以通过多种方式,使我们的数据库工作得更轻松:

  • 它们允许我们重用通用的查询模式,而不是重复地编写它们
  • 它们为我们提供了一个定义业务规则并在任何地方使用它们的地方
  • 它们帮助我们编写更清晰、更有条理的查询

让我们通过一个实际的示例,来了解其工作原理。假设我们想要处理活跃的用户 - 在过去 7 天内使用过该应用程序的用户。我们可以定义一个视图,而不是在每个查询中都编写这个条件:

CREATE VIEW active_users AS
SELECT
	*
FROM users;
WHERE
	last_login > current_date - INTERVAL '7 days';

现在,每当我们需要活跃用户时,我们都可以轻松地使用该视图。例如,如果我们想要查找来自德国的活跃用户:

SELECT
	user_id
FROM active_users
WHERE
	country = 'Germany';

虽然这个简单的示例只是展示了,视图可以通过组织和重用通用的逻辑,来简化开发人员的工作,但故事远不止于此。在本文中,我们将探讨更有趣的事情:PostgreSQL 如何通过称为 “内联” 的过程来优化这些视图 - 使它们不仅方便,而且快速。

什么是视图内联

当您使用视图时,它就像 SQL 查询中的一块积木。以前面的示例为例,PostgreSQL 会将视图替换为其底层的子查询,来有效地转换查询。

SELECT
	user_id
FROM (
	SELECT
		*
	FROM users
	WHERE
		last_login > current_date - INTERVAL '7 days'
) active users
WHERE
	country = 'Germany';

当我们使用active_users视图编写查询时,PostgreSQL 查询规划器会将其视为进一步优化的机会。它不是将子查询视为单独的步骤(以检索大量的用户集合),而是有效地转换查询,并将视图内联到查询本身。在幕后,PostgreSQL 将会执行类似下面的查询:

SELECT
	user_id
FROM users
WHERE
	last_login > current_date - INTERVAL '7 days'
	AND country = 'Germany';

内联过程允许 PostgreSQL 查询规划器将整个查询作为一个整体进行优化。这使它能够选择最佳的索引、可能的连接策略和其他的方面,而不必单独地执行各个部分,再去过滤不会在其他地方用到的数据。这正是视图的用途 - 我们可以获得两全其美的优势 - 为开发人员提供干净、模块化的代码,并为数据库实现最佳的性能。

视图内联演示

要预览视图内联的最简单方法是,运行 EXPLAIN:

 Seq Scan on users  (cost=0.00..19.20 rows=1 width=4)
   Filter: ((country = 'Germany'::text) AND (last_login > (CURRENT_DATE - '7 days'::interval)))

这里的 Filter 部分显示了,查询规划器如何将来自查询和视图的筛选条件合并在一起。

这特别适用于涉及连接、聚合和筛选条件的复杂查询。PostgreSQL 可以将整个查询链作为一个整体进行优化,而不是单独地执行各个部分。

CREATE VIEW order_analytics AS
SELECT
    o.customer_id,
    c.country,
    DATE_TRUNC('month', o.created_at) as month,
    COUNT(*) as orders,
    SUM(o.total_amount) as revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY 1, 2, 3;

在查询中使用该视图时:

SELECT customer_id, revenue
FROM order_analytics
WHERE country = 'Germany'
AND month >= '2024-01-01'
AND revenue > 1000;

您将看到 PostgreSQL 如何有效地将筛选条件内联到它们所属的地方。

HashAggregate  (cost=205.97..207.97 rows=200 width=16)
   Group Key: o.customer_id, c.country, (date_trunc('month'::text, o.created_at))
   Filter: (sum(o.total_amount) > 1000)
   ->  Hash Join  (cost=16.12..185.25 rows=1235 width=20)
         Hash Cond: (o.customer_id = c.id)
         ->  Seq Scan on orders o
             Filter: (created_at >= '2024-01-01'::date)
         ->  Hash
               ->  Seq Scan on customers c
                     Filter: (country = 'Germany'::text)

这在查询的复杂性增加的情况下尤其有效。特别在涉及到连接和进一步的过滤时。PostgreSQL 可以将整个查询链作为一个整体进行优化,而不是单独地执行各个部分。

CREATE VIEW completed_orders AS
SELECT
   o.id,
   o.customer_id,
   o.total_amount,
   o.created_at,
   o.status,
   p.name as product_name,
   c.email,
   c.country
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE
   o.status = 'completed';

SELECT
	*
FROM completed_orders
WHERE
	country = 'Germany';

CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);

如查询计划所示:

 Nested Loop  (cost=0.30..19.70 rows=1 width=176)
   ->  Nested Loop  (cost=0.15..13.52 rows=1 width=148)
         Join Filter: (o.id = oi.order_id)
         ->  Nested Loop  (cost=0.15..12.43 rows=1 width=144)
               ->  Seq Scan on orders o  (cost=0.00..1.05 rows=1 width=80)
                     Filter: (status = 'completed'::text)
               ->  Index Scan using customers_pkey on customers c  (cost=0.15..8.17 rows=1 width=68)
                     Index Cond: (id = o.customer_id)
                     Filter: (country = 'Germany'::text)
         ->  Seq Scan on order_items oi  (cost=0.00..1.04 rows=4 width=8)
   ->  Index Scan using products_pkey on products p  (cost=0.15..6.17 rows=1 width=36)
         Index Cond: (id = oi.product_id)

规划器屏障

虽然 PostgreSQL 可以很好地内联视图,但某些操作会产生阻止优化的 “规划器屏障”。对于视图,会阻止其内联的一些条件有:

  • DISTINCT ON 操作
  • 窗口函数(OVER 子句)
  • 集合操作(UNION/INTERSECT/EXCEPT)
  • 更复杂的聚合
  • 物化的公共表表达式(带有 MATERIALIZED 提示,或者由查询规划器决定的物化)
  • 使用 VOLATILE 函数
  • 在某些情况下的复杂子查询

当规划器无法内联视图时,可能会导致不必要的性能和资源影响。即,每个子查询可能会单独地物化结果,会出现已经检索到的行被查询的另一部分丢弃,这是最常见的情况之一。

使用 EXPLAIN 时,以下选项之一可以证明该情况:

  • Subquery scan on...节点
  • 物化节点
  • 在主流程之前分离聚合和排序的步骤

如上所述,窗口函数正是这样一个规划器屏障。

CREATE VIEW order_insights AS
SELECT
   o.customer_id,
   o.total_amount,
   o.created_at,
   ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.created_at) as order_sequence,
   SUM(o.total_amount) OVER (PARTITION BY o.customer_id ORDER BY o.created_at) as running_total
FROM orders o;

EXPLAIN
SELECT * FROM order_insights
WHERE customer_id = 1
AND total_amount > 500;

这让我们很容易发现Subquery scan节点。

 Subquery Scan on order_insights  (cost=1.06..1.10 rows=1 width=84)
   Filter: (order_insights.total_amount > '500'::numeric)
   ->  WindowAgg  (cost=1.06..1.08 rows=1 width=84)
         ->  Sort  (cost=1.06..1.06 rows=1 width=44)
               Sort Key: o.created_at
               ->  Seq Scan on orders o  (cost=0.00..1.05 rows=1 width=44)
                     Filter: (customer_id = 1)

运行时物化

让我们澄清一个可能会混淆的点 - 我们不是在这里讨论CREATE MATERIALIZED VIEW。这是关于 PostgreSQL 在查询执行期间的运行时决策:将视图结果缓存到内存中。当查询需要多次引用视图结果或防止重复进行昂贵的计算时,规划器可能会使用显式的物化。查询规划器会通过 Materialize 节点显示出这一点:

CREATE VIEW customer_summary AS
SELECT
    customer_id,
    COUNT(*) as orders,
    SUM(total_amount) as total_spent
FROM orders
GROUP BY 1;

EXPLAIN SELECT
    a.customer_id,
    a.total_spent,
    b.total_spent as other_customer_spent
FROM customer_summary a
JOIN customer_summary b ON b.total_spent > a.total_spent;

这样就给出了一个完美的物化示例。

 Nested Loop  (cost=46.00..200.75 rows=3333 width=68)
   Join Filter: (b.total_spent > (sum(orders.total_amount)))
   ->  HashAggregate  (cost=23.00..24.25 rows=100 width=44)
         Group Key: orders.customer_id
         ->  Seq Scan on orders  (cost=0.00..18.00 rows=1000 width=15)
   ->  Materialize  (cost=23.00..25.75 rows=100 width=32)
         ->  Subquery Scan on b  (cost=23.00..25.25 rows=100 width=32)
               ->  HashAggregate  (cost=23.00..24.25 rows=100 width=44)
                     Group Key: orders_1.customer_id
                     ->  Seq Scan on orders orders_1  (cost=0.00..18.00 rows=1000 width=15)

除了物化 - 物化视图(CREATE MATERIALIZED VIEW)出于实际的目的 “只是另一种表”,它们提出了一个最终的规划器屏障。PostgreSQL 必须直接扫描物化的数据,以牺牲查询的灵活性来换取性能。

编写内联友好视图的提示

没有一种唯一 “正确” 的方法来编写视图。再者,视图应该使您的数据库更易于使用,而不是更难使用。在设计视图时,请关注单一的职责 - 每个视图都应该处理业务逻辑的一个方面,而不是试图一次优化所有方面。简单的视图更有可能被 PostgreSQL 的查询规划器内联。

有几种常见的模式,会阻止内联:

  1. 完全避免窗口函数、DISTINCT 和集合操作
  2. 如果可能,将复杂逻辑拆分为多个视图
  3. 最小化子查询的使用
  4. 考虑用物化视图进行聚合

视图依赖关系是一个关键的考虑因素。即使是微小的结构变更,也会级联地触发整个数据库的视图更改。不要创建深层的视图层次结构,而是将复杂的逻辑拆分为更小的可组合视图。对于许多应用程序所依赖的关键视图,请考虑版本控制(v1、v2),而不是修改现有的视图。

如有疑问,请先编写纯 SQL,然后将常见模式提取到视图中。这有助于避免过度设计,并保持数据库架构的可维护性。

了解更多

PostgreSQL 优化