来源: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 Serversp_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 VIEWCREATE VIEW 在视图上获取 AccessExclusiveLockALTER 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 COLUMNADD COLUMNALTER COLUMN TYPE 才能使视图能够安全地演化。目录和原地重写机制已经存在,正如 pg_dump 的占位符技巧所证明的那样;缺少的是面向用户的 DDL。

即使没有它,视图仍然值得使用。只是不要假装它们是表

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐