DeepSeek总结的关于 PostgreSQL 视图的强硬观点(上)
来源:https://boringsql.com/posts/strong-views/
关于 PostgreSQL 视图的强硬观点
作者:Radim Marek
日期:2026-05-10
阅读时间:19 分钟
目录
视图(VIEW)应该是 SQL,也因此是 Postgres,所能提供的最干净的抽象。我热爱这个概念。将逻辑意图与物理存储解耦的承诺在纸面上是完美的。但在实践中,数据库领域很少有东西能引发如此激烈的争论或承载如此多的历史包袱。视图混合了巨大的承诺和虚假的希望,而这些承诺很少能在生产环境中幸存下来。
视图的诱惑
其吸引力是直接的。将"活跃客户"抽象一次,然后在任何地方重用它。每个查询、报表和仪表板都使用相同的定义。"活跃客户"随后成为"客户订单"视图的基础,而后者又为一个操作型的"客户摘要"视图提供动力。
-- 第 1 层:谁算作活跃客户?CREATEVIEWactive_customersASSELECTc.*FROMcustomers cWHEREc.deleted_atISNULLANDc.status='active'ANDc.last_login_at>now()-interval'90 days';-- 第 2 层:活跃客户及其近期订单CREATEVIEWcustomer_ordersASSELECTac.*,o.idASorder_id,o.total_cents,o.created_atASordered_at,o.statusASorder_statusFROMactive_customers acLEFTJOINorders oONo.customer_id=ac.idWHEREo.created_at>now()-interval'12 months'ORo.created_atISNULL;-- 第 3 层:每个客户一行,为仪表板准备CREATEVIEWcustomer_summaryASSELECTco.id,co.email,co.name,COUNT(co.order_id)ASorders_12mo,COALESCE(SUM(co.total_cents),0)ASrevenue_12mo_cents,MAX(co.ordered_at)ASlast_order_at,COUNT(*)FILTER(WHEREco.order_status='refunded')ASrefunds_12moFROMcustomer_orders coGROUPBYco.id,co.email,co.name;每一层都有一个职责。"活跃客户"只定义一次——如果市场营销部门明天改变九十天的规则,只需在一个地方修改一行,而仪表板查询就简化为SELECT * FROM customer_summary WHERE id = $1。
视图也有可能成为一个真正的安全边界。三个手写的查询意味着三个可能忘记谓词的地方,以及一个季度后三个略有不同的结果集。而使用一个定义,你只有一个结果集。
对于简单的视图,也没有性能损失。PostgreSQL 会将它们直接内联到调用查询中,因此规划器可以看穿视图,就像你手写了底层 SQL 一样进行规划。该机制及其失效的情况,本身值得深入探讨。本文将其作为背景知识,重点关注另一方面:视图是由什么构成的,以及当它们底层的模式发生变化时会发生什么。
视图就像表一样
问题从语法就开始了。
SELECT * FROM customer_summary读起来和SELECT * FROM customers完全一样。目录强化了这一点:pg_class.relkind = 'v'而不是'r',但其他所有东西(列列表、权限、注释,甚至\d的输出)看起来都一样。因此,开发人员以同样的方式使用它们:连接它们、嵌套它们、将其包装在其他视图中而不加思考。
表是一个带有索引的堆。视图是一个存储的解析树,在规划时被展开到你的查询中。大多数情况下,规划器足够聪明,以至于差异无关紧要:简单的视图会被内联,谓词会被下推,最终的执行计划就是你手写出来的样子。
嵌套视图的螺旋
在实践中,你得到的是引言中的依赖树(customer_summary依赖于customer_orders,customer_orders依赖于active_customers)和一个新工程师,他无法知道SELECT * FROM customer_summary WHERE id = $1会扩展成一个三层重写、一个对十二个月订单的左连接、以及一个规划器无法将id谓词推入其中的GROUP BY。
查询、模式浏览器或 ORM 中没有任何东西能提示这一点。你只有通过自上而下阅读每个定义才能了解树的形状,而这恰恰是抽象本应为你节省的工作。
你无法在不阅读视图、它所依赖的视图以及底层表的情况下,推理出针对视图的查询的性能。
视图是重写规则
视图是一个宏。当你引用一个视图时,Postgres 在规划器运行之前将其主体粘贴到你的查询中。没有存储的结果,也没有执行器需要查阅的视图"对象"。你在查询目录时看到的pg_class行是一个空壳:它保存了名称、列列表和权限,但没有定义。
定义本身,即你编写的SELECT语句,单独存储在pg_rewrite中:
SELECTev_class::regclass,ev_type,is_insteadFROMpg_rewriteWHEREev_class='active_customers'::regclass;ev_class|ev_type|is_instead------------------+---------+------------active_customers|1|t那一行就是视图。ev_type = 1表示SELECT,is_instead = t表示"替换,不增强"(pg_class外壳没有行可读,因此替换是唯一有意义的选项)。在解析器和规划器之间,Postgres 运行一个称为重写器(rewriter)的阶段,重写器的工作是遍历解析树,找到引用带有规则的 relation 的地方,并用规则体替换它们。这样一来,像SELECT * FROM active_customers WHERE id = $1这样的查询到达规划器时已经被重写了:
-- 规划器实际看到的内容SELECT*FROM(SELECTc.id,c.email,c.name,c.status,c.last_login_at,c.deleted_atFROMcustomers cWHEREc.deleted_atISNULLANDc.status='active'ANDc.last_login_at>now()-interval'90 days')active_customersWHEREid=$1;注意,c.*已经被展开成一个显式的列列表。这发生在CREATE VIEW时,并被冻结在存储的解析树中;重写器不会重新展开它。从这里开始,规划器通常可以通过一个称为子查询上拉(subquery pull-up)的过程将子查询展平到外层查询中,使得id = $1与其他谓词放在一起,从而可以访问customers.id上的索引。
这种展平就是使简单视图零成本的原因。当它失败时(因为视图主体包含LIMIT、DISTINCT、聚合、集合操作或一些其他形式),子查询会保留,外层谓词无法越过它。规划器障碍的完整列表以及如何在EXPLAIN中发现它们,请参阅关于内联的文章。
历史上,Postgres 将相同的机制作为面向用户的CREATE RULE公开,其理论是任意查询重写是一个通用特性。但实际情况并非如此。规则实际上已被弃用(视图之外);触发器可以完成相同的工作而没有这些意外。规则基础设施之所以存在,是因为视图需要它。
一旦你掌握了这个模型,关于视图的所有尴尬之处就都显现出来了。其中两点驱动了本文的其余部分。
- 列通过属性编号(attribute number)引用,而不是名称。存储的解析树不记得它读取的是
customers.email;它记得读取的是关系 16385 的属性 2。重命名列,视图仍然有效,因为 OID 和位置没有改变。删除中间的某一列,Postgres 会直接拒绝,因为存储的属性编号将不再对齐。本文后面出现的每个依赖错误都可以追溯到此。 - 主体每次被引用都会展开一次。查询中对视图的每次提及都会在重写的计划中产生其解析树的独立副本。对于稳定表达式来说,这不可见。对于像
random()或clock_timestamp()这样的 volatile 表达式,每个副本会分别计算:
CREATEVIEWv_randASSELECTid,random()ASrFROMcustomers;SELECTa.id,a.rASr_a,b.rASr_bFROMv_rand aJOINv_rand bONa.id=b.idLIMIT3;id|r_a|r_b----+----------------------+---------------------1|0.84043639656488|0.08334587699020892|0.009846241116064247|0.65740004695862283|0.14667469313524628|0.21862693208148087同一个视图,同一行,两个不同的r值。重写器展开了v_rand两次,因此重写后的查询包含两个独立的random()调用,规划器没有理由共享它们。你可能会期望a.r = b.r而遇到此问题,最终不得不重写视图以将 volatile 调用推出去。
这也是为什么CREATE OR REPLACE VIEW只能在列列表末尾追加列。现有的属性编号必须对每个依赖对象保持稳定,因此任何会导致它们偏移的操作都是被禁止的。
同一个机制的一个有用的副作用:视图不受搜索路径(search-path)攻击的影响,而这种攻击会困扰SECURITY DEFINER函数。标识符在CREATE VIEW时被解析为 OID,因此后续的SET search_path无法将视图重定向到不同的表。除非你固定它,否则函数每次调用时都会根据调用者的search_path解析标识符;视图则永远不会有这种风险。
可更新视图,一个半途而废的承诺
有趣的是,直到今年早些时候,我才知道存在可更新的视图。这是在 2026 年赫尔辛基的 PgDay Nordic 会议上,与 Ervin Weber 和 Jesper St John 在走廊交谈时偶然提到的。写了十二年的 PostgreSQL,自动更新规则竟然从未出现在我的工作中。
最初的承诺是对称性:如果一个视图在读取时看起来像表,那么在写入时也应该像表。Postgres 对简单情况(基于单个基表的视图,没有连接、没有聚合、没有DISTINCT)提供了这种能力,并称之为自动更新(auto-updatable)。你可以通过它们执行INSERT、UPDATE和DELETE,重写规则会将对视图的操作转换为对底层表的操作。
INSERTINTOactive_customers(email,name)VALUES('alice@example.com','Alice');-- 变为: INSERT INTO customers (email, name, status, last_login_at, deleted_at)-- VALUES ('alice@example.com', 'Alice', DEFAULT, DEFAULT, DEFAULT);添加一个连接、一个聚合、一个GROUP BY或任何超出自动更新规则范围的东西,写入路径就会失效。SELECT仍然有效,但INSERT会报错,你必须手动设置INSTEAD OF触发器。因此,customer_orders和customer_summary是只读的,这不是设计使然,而是偶然,并且哪些情况算作自动更新的规则在视图定义本身中并不可见。你会在写入时,在生产环境中才发现。
WITH CHECK OPTION会拒绝任何会产生视图无法看到的行的写入操作,其中LOCAL只检查本视图的谓词,而CASCADED(默认值)会检查链中每个底层视图的谓词。通过它,你可以向应用程序提供一个可写的、有作用域限制的表切片,而无需信任应用程序自己去强制实施这个作用域。
架构师的污名
当我初次接触数据库时,视图是资深人士告诫你要远离的东西。“不要在生产中使用视图”、“视图很慢”、“视图隐藏了真正的查询”。你学会了内联编写 SQL,即使相同的谓词出现在二十个地方,因为否则你在下次代码审查时就会受到反对。
在 Oracle 环境中,这种警告有特定的根源。那个时代的成本优化器难以将谓词通过带有复杂投影、连接或集合操作的视图进行合并,因此一个干净的三层视图可能会扩展成一个扫描所有数据并在最后进行过滤的计划。整个公司的解决方案几乎总是物化视图或封装了所有访问路径的 PL/SQL 包;简单的视图尴尬地介于两者之间,不像物化视图那样有缓存,也不像包那样有封装,而且还有优化器带来的意外。
Postgres 因为关联而背负了同样的包袱。嵌套视图的螺旋是真实存在的,规则系统的意外也是真实存在的,而且十五年前的优化器不如今天强大。这些经验法则固化成了教条,并超越了其适用条件。
改进是真实的;但声誉没有跟上。今天在代码审查中尝试使用视图,仍然会有人出于原则而反对。在你的 PR 进入审查之前,值得了解这一点。
当你更改表时会发生什么
到目前为止,所涵盖的一切在底层模式保持不变时都是不可见的。视图主体存在于pg_rewrite中,列被固定到属性编号,类型在CREATE VIEW时被冻结,规划器只是展开并运行。
当有人试图更改视图树引用的列时,这些机制中的每一个都会立刻显现出来。假设有人需要从customers表中删除name列:
ALTERTABLEcustomersDROPCOLUMNname;ERROR: cannotdropcolumnnameoftablecustomers because other objects dependonit DETAIL:viewactive_customers dependsoncolumnnameoftablecustomers HINT:UseDROP...CASCADEtodropthe dependent objects too.active_customers引用了name(c.*在视图创建时已被展开),customer_orders通过ac.*继承了依赖关系,而customer_summary是更下面的一层。整个树都冻结在这一列上。
不仅仅是删除。尝试加宽类型:
ALTERTABLEcustomersALTERCOLUMNemailTYPETEXT;ERROR: cannotaltertypeofacolumnusedbyavieworruleDETAIL:rule_RETURNonviewactive_customers dependsoncolumn"email"同样的墙会阻止对视图引用的列进行任何结构性更改:删除、重命名、类型更改。即使是将VARCHAR(255)加宽为TEXT,这种不会丢失数据也不会增加约束的更改,也是被禁止的,因为视图存储的定义引用了旧的类型 OID。
