DeepSeek总结的关于 PostgreSQL 视图的强硬观点(下)
摘要:PostgreSQL中视图与基表的强耦合关系可能导致修改表结构时产生连锁反应。使用CASCADE选项会直接删除依赖视图而非修改,带来权限丢失等风险。建议采用手动流程:保存视图定义→按依赖顺序删除→修改表→重建视图→恢复权限。SELECT *在视图中会被静态展开,无法自动获取新增列。这种设计选择确保了编译时安全,但增加了维护成本。变通方案包括避免视图、脚本化重建、版本化管理或查询系统目录。事务
来源:https://boringsql.com/posts/strong-views/
提示建议使用 CASCADE:
ALTER TABLE customers DROP COLUMN name CASCADE;
NOTICE: drop cascades to 3 other objects
DETAIL: drop cascades to view active_customers
drop cascades to view customer_orders
drop cascades to view customer_summary
所有三个视图都被删除,而非修改,连同它们的权限、RLS 策略以及任何其他依赖对象。在具有数十个相互连接的视图的模式上,CASCADE 是一场噩梦。
永远不要在没有准备好完整重建脚本的情况下,在生产环境的视图上使用 CASCADE。
CASCADE不会修改视图,它会删除它们。所有的GRANT、行级安全策略以及下游依赖都会随之消失。没有撤销操作。
因此,手动路径是:保存每个视图定义,按依赖关系的逆序删除它们,修改表,按依赖关系的顺序重新创建它们,重新应用所有权限。对于三个视图来说,这很繁琐。对于跨越多个模式的三十个视图来说,这是一个完整的迁移项目。
-- 1. 保存定义
SELECT pg_get_viewdef('customer_summary', true);
SELECT pg_get_viewdef('customer_orders', true);
SELECT pg_get_viewdef('active_customers', true);
-- 2. 按叶子优先的顺序删除
DROP VIEW customer_summary;
DROP VIEW customer_orders;
DROP VIEW active_customers;
-- 3. 现在你可以修改表了
ALTER TABLE customers DROP COLUMN name;
-- 4. 按正确顺序重新创建
CREATE VIEW active_customers AS
SELECT id, email, status, last_login_at, deleted_at
FROM customers
WHERE deleted_at IS NULL
AND status = 'active'
AND last_login_at > now() - interval '90 days';
CREATE VIEW customer_orders AS ...;
CREATE VIEW customer_summary AS ...;
-- 5. 重新应用权限等
-- ...
SELECT * 的陷阱
你可能会认为 SELECT * 可以让你免于列级依赖。但事实并非如此。它会让情况变得更糟。
CREATE VIEW all_customers AS
SELECT * FROM customers;
这看起来很灵活。PostgreSQL 在视图创建时展开 SELECT * 并冻结结果。检查数据库实际存储的内容:
SELECT pg_get_viewdef('all_customers'::regclass, true);
SELECT id,
email,
name,
status,
last_login_at,
deleted_at
FROM customers;
* 被展开为视图创建时存在的列。现在向基表添加一列:
ALTER TABLE customers ADD COLUMN phone TEXT;
SELECT * FROM all_customers;
phone 列不在那里。视图仍然返回原来的列。要获取新列,你必须执行 CREATE OR REPLACE VIEW all_customers AS SELECT * FROM customers,这将根据当前的表定义重新展开 *。
删除原始展开中的某一列,迁移会以与使用显式列列表相同的方式被阻止,只不过现在依赖关系隐藏在目录中,而不是在视图主体中可见。SELECT * 在制造相同刚性耦合的同时,却给人一种灵活的错觉。
这实际上是有文档记录的行为,并且遵循 SQL 标准。但几乎每个人第一次遇到时都会感到惊讶,并且它是许多"我的视图缺少列"的错误报告的根源。
始终在视图中使用显式的列列表。至少这样依赖关系是可见的,并且破坏是可预测的。
依赖关系的墙、类型 OID 的耦合、冻结的 SELECT * 展开:所有这些都是相同的权衡,并且一致地应用。刚性是特性,而非缺陷。
为什么这不是 PostgreSQL 的 bug
这种刚性是一种设计选择,而不是缺陷。其他数据库做出了不同的选择,但每一种都不是没有代价的。
- Oracle 将依赖的视图标记为
INVALID,而不是阻塞 DDL。视图会在下次访问时自动重新编译。如果还能工作,很好;如果不能,你会在查询时得到一个错误。这听起来更好,直到你意识到它在实践中意味着什么:你可以部署一个迁移,得到一个干净的退出码,直到周一早上用户访问时才发现一个关键的报表视图已经损坏。破坏从迁移时(你在关注)转移到了运行时(你可能没在关注)。对于重视部署信心的团队来说,这可以说更糟糕。 - SQL Server 有
sp_refreshview,它根据当前的表定义重新编译视图的元数据。你可以修改表,然后刷新依赖的视图以获取更改。但是sp_refreshview一次只处理一个视图。没有内置的方法可以按正确顺序刷新整个依赖链。而且,如果视图引用了一个已删除的列,sp_refreshview会失败;它不会帮你移除引用。你仍然需要手动编辑并重新创建视图。这是一个便利性功能,而非解决方案。
PostgreSQL 选择了编译时安全:生产环境中没有意外的 INVALID 视图,也没有懒惰的重新编译掩盖结构变化而导致的静默错误结果。其代价是每次模式更改都需要手动管理依赖关系,这个代价高到足以让有经验的团队完全放弃视图。
救命稻草:事务性 DDL
在讨论变通方法之前,有一个 PostgreSQL 特性实际上改变了风险评估:DDL 是事务性的。整个删除-修改-重建-重新授权的序列可以放在一个 BEGIN/COMMIT 内部:
BEGIN;
DROP VIEW customer_summary;
DROP VIEW customer_orders;
DROP VIEW active_customers;
ALTER TABLE customers DROP COLUMN name;
CREATE VIEW active_customers AS ...;
CREATE VIEW customer_orders AS ...;
CREATE VIEW customer_summary AS ...;
GRANT SELECT ON customer_summary TO reporting;
COMMIT;
如果任何语句出错(重新创建的视图中有错别字、缺少授权、意外的依赖关系),整个迁移就会回滚,数据库会恢复到原来的状态。Oracle 和 SQL Server 对大多数 DDL 都无法做到这一点;它们的 CREATE VIEW 会自动提交,因此部分失败会使你处于一个半迁移的模式,没有 ROLLBACK 可以依靠。PostgreSQL 的“你必须删除并重建所有东西”的痛苦是真实的,但其恢复机制比 Oracle 或 SQL Server 要好得多。
这并没有解决锁定问题。DROP VIEW 和 CREATE VIEW 在视图上获取 AccessExclusiveLock;ALTER TABLE 在表上获取一个。在事务期间,任何访问这些对象的操作都会被阻塞。在繁忙的系统上,一个长时间运行的视图重建事务就是一个停止世界的窗口。保持事务紧凑:在事务外部准备好新的 SQL,不要持有其他锁,并且不要在流量高峰期间运行。事务性 DDL 给你带来的是安全性,而不是并发性。
变通方法
这些方法都不能解决根本问题;它们只是使其可管理。
- 完全避免视图。 用应用程序级别的查询构建器或 ORM 作用域替换视图。你失去了共享抽象:当三个服务都需要“活跃客户”的相同定义时,每个服务都会独立实现它。定义会逐渐偏离。对于能够完全控制小型代码库的团队来说,这是一个合理的权衡。对于共享数据平台,则不然。
- 编写脚本进行重建。 使用
pg_get_viewdef()提取定义,然后编写迁移脚本,在模式更改时删除并重建视图。问题在于:没有影响分析(你必须自己弄清楚依赖顺序),没有权限保留(视图定义不包括权限),并且无法扩展到几个视图之外。你的迁移框架在这里也帮不上忙。它们按顺序运行你的 SQL 文件,但弄清楚要写什么 SQL 完全取决于你。 - 对视图进行版本管理。 不要就地修改视图,而是在
active_customers_v1旁边创建active_customers_v2。逐个迁移消费者。当没有东西使用时,删除旧版本。当视图被多个独立团队使用时,这很有效,因为你无法强制每个人都在同一个部署窗口内迁移。代价是命名规范和版本的激增:如果customer_orders_v1依赖于active_customers_v1,那么你现在也需要customer_orders_v2。对于面向公共的 API,基于模式的版本控制(CREATE SCHEMA api_v2; CREATE VIEW api_v2.active_customers AS ...)比使用后缀更清晰。它为你提供了一个自然的命名空间,并允许你通过SET search_path来切换版本。 - 自己查询
pg_depend。 PostgreSQL 在pg_depend系统目录中跟踪所有对象依赖关系。问题是:视图并不直接依赖于表。实现视图的重写规则才依赖,因此每次遍历都需要经过pg_depend -> pg_rewrite -> pg_class。直接依赖于customers的视图:
-- 一个查询直接依赖者的 SQL 示例
SELECT
depns.nspname AS dependent_schema,
depc.relname AS dependent_view,
COALESCE(
(SELECT a.attname FROM pg_attribute a
WHERE a.attrelid = d.refobjid AND a.attnum = d.refobjsubid),
'*'
) AS source_column
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class depc ON depc.oid = r.ev_class
JOIN pg_namespace depns ON depns.oid = depc.relnamespace
WHERE d.refobjid = 'customers'::regclass
AND d.classid = 'pg_rewrite'::regclass
AND d.refclassid = 'pg_class'::regclass
AND depc.relkind IN ('v', 'm')
AND depc.oid <> 'customers'::regclass
ORDER BY depns.nspname, depc.relname;
这很有用,但只能跳一步。customer_summary 通过两个中间视图间接依赖于 customers,这个查询会遗漏。一个递归 CTE 可以遍历整个链:
-- 一个用于递归查找所有依赖视图的 SQL 示例
WITH RECURSIVE view_deps AS (
-- direct dependents of the target table
SELECT DISTINCT
depc.oid AS view_oid,
depns.nspname || '.' || depc.relname AS view_name,
1 AS depth
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class depc ON depc.oid = r.ev_class
JOIN pg_namespace depns ON depns.oid = depc.relnamespace
WHERE d.refobjid = 'customers'::regclass
AND d.classid = 'pg_rewrite'::regclass
AND depc.relkind IN ('v', 'm')
UNION
-- views depending on views we already found
SELECT depc.oid,
depns.nspname || '.' || depc.relname,
vd.depth + 1
FROM view_deps vd
JOIN pg_depend d ON d.refobjid = vd.view_oid
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class depc ON depc.oid = r.ev_class
JOIN pg_namespace depns ON depns.oid = depc.relnamespace
WHERE d.classid = 'pg_rewrite'::regclass
AND depc.relkind IN ('v', 'm')
AND depc.oid <> vd.view_oid -- skip the view's own _RETURN rule
)
SELECT view_name, MIN(depth) AS depth
FROM view_deps
GROUP BY view_name
ORDER BY depth, view_name;
这能工作,但到了这一步,你实际上已经在编写一个依赖分析工具了。再加上拓扑排序、权限捕获、物化视图处理、RLS 策略保留,你就有了一个小产品。
- 物化视图(Materialized Views)的情况更糟。 以上所有都适用,此外,在
DROP时你还会丢失缓存的结果集。重建后,REFRESH会从头开始重建:对于大型数据集,这需要几分钟到几小时,并且在存在唯一索引之前没有CONCURRENTLY选项。对于支撑仪表板的物化视图,这意味着停机时间。
pg_dump 中隐藏的已有实现
手动变通方法一直在重新发明的机制——在保留视图身份、权限和依赖关系的同时重写其主体——其实已经存在于 PostgreSQL 内部。它恰好存在于 pg_dump 中,只在需要时使用。
大多数情况下,pg_dump 会做显而易见的事情:构建一个依赖图,对其进行拓扑排序,然后按基表优先的顺序使用普通的 CREATE VIEW 发出每个视图。转储我们的四个视图链,你得到的就是这个。
有趣的情况发生在排序失败时。循环不常见但确实存在:一个视图主体调用了一个函数,而该函数的主体又反向引用了该视图;基表上的一个触发器通过读取该基表的视图进行读取;交叉引用的物化视图与指向视图的 RLS 策略。当 pg_dump 遇到这种情况时,它会回退到占位符视图技巧:提前发出其中一个视图,作为一个具有正确列列表和类型但主体为空的存根,然后在循环的其余部分存在后,使用 CREATE OR REPLACE VIEW 回来安装真正的定义。
-- 提前发出,作为一个占位符
CREATE VIEW customer_summary AS
SELECT
NULL::integer AS id,
NULL::text AS email,
NULL::text AS name,
NULL::bigint AS orders_12mo,
NULL::bigint AS revenue_12mo_cents;
-- 稍后发出,一旦依赖存在
CREATE OR REPLACE VIEW customer_summary AS
SELECT co.id, co.email, co.name,
COUNT(co.order_id) AS orders_12mo,
COALESCE(SUM(co.total_cents), 0) AS revenue_12mo_cents
FROM customer_orders co
GROUP BY co.id, co.email, co.name;
权限、注释和策略会附加到存根上,并在重写后保留,因为 OID 从未改变。CREATE OR REPLACE VIEW 在原地修改同一个 pg_class 行。多年来,PostgreSQL 在每次 pg_dump --schema-only 时都在做这件事。这个机制是存在的;只是没有作为面向用户的 DDL 暴露出来。
在你使用视图之前
视图值得使用。抽象是真实的:共享的定义、清晰的分层、列级安全,并且在规划器可以内联它们时没有运行时成本。问题是,所有这些刚性从外部都是不可见的。视图在目录中、在 \d 中、在 ORM 中、在访问它的每个查询中,看起来都像表。其拆解成本只有在第一次有人试图删除一列、加宽一种类型,或者在周五下午试图用 CASCADE 摆脱迁移时才会显现出来。
因此,在你使用视图之前,请牢记这些权衡:
- 视图主体中的
SELECT *是一个陷阱。它在创建时冻结列列表,将依赖关系隐藏在目录中,并且仍然会像显式列列表一样阻塞相同的 DDL。始终把列写出来。 - 每一层都会使拆解成本倍增。一个三层的视图链意味着,对于底层列的任何结构性更改,都需要按正确顺序进行三次删除、三次重建、三组权限和策略的重新应用。保持依赖树浅层,并诚实地评估抽象是否物有所值。
CASCADE不是一个修复方案。它会删除依赖的视图以及它们的权限、RLS 策略和下游依赖,且没有撤销操作。在没有准备好重建脚本的情况下,永远不要在生产环境中运行它。- 当迁移最终必须发生时,有两件事可以让这个循环变得可承受。将整个删除-修改-重建-重新授权序列包装在一个事务中,这样拼写错误或遗漏的授权就能干净地回滚,并且保持该事务紧凑——
AccessExclusiveLock在其持续期间会阻塞其他一切。然后,在动手之前映射依赖图:通过pg_rewrite连接pg_depend,用递归 CTE 遍历,它会告诉你真正会破坏什么。在迁移之前运行这个查询,而不是在事故之后。
这种痛苦可以追溯到一个缺失的原语。今天的 ALTER VIEW 可以处理重命名、所有者更改、模式更改、列默认值和 security_barrier 等选项,但没有任何结构性操作。CREATE OR REPLACE VIEW 只能在末尾追加列,仅此而已。一个真正的 ALTER VIEW DROP COLUMN、ADD COLUMN、ALTER COLUMN TYPE 才能使视图能够安全地演化。目录和原地重写机制已经存在,正如 pg_dump 的占位符技巧所证明的那样;缺少的是面向用户的 DDL。
即使没有它,视图仍然值得使用。只是不要假装它们是表。
更多推荐

所有评论(0)