Skip to content

事件触发器应用场景 ​

前三节把事件触发器的机制讲完了:有哪五个事件、每个阶段能拿到什么信息、表重写怎么拦。但机制本身不是目的——事件触发器真正的价值在于,它把「DDL 治理」从「靠制度约束人」变成了「靠数据库约束代码」。

本页是这组内容的落地篇:把前四节的能力组合成六个可上生产的治理方案,每个方案都给出完整的表结构、函数、触发器与验证方式,并说清它的边界与误伤风险。所有方案都以 PostgreSQL 18 为准,且在同一个数据库内可叠加使用。

前置知识

阅读本节前,建议先了解:事件触发器概述、DDL 事件触发(CREATE / ALTER / DROP)、表重写事件

基础概念 ​

事件触发器能治理的四类问题 ​

把前几页的机制归纳一下,事件触发器实际能解决的问题只有四类,其余都是它们的组合:

类别手段用哪个事件典型需求
记录(审计)INSERT 到日志表 / RAISE WARNINGddl_command_end、sql_drop、login谁在什么时候改了什么结构
强制(规范)RAISE EXCEPTIONddl_command_start、ddl_command_end禁止无主键表、强制命名规范、禁止 DROP
同步(联动)调用外部接口 / 写队列表ddl_command_end结构变更同步到配置中心、血缘系统
限额(准入)RAISE EXCEPTION + 环境判断table_rewrite、ddl_command_start大表重写只许在维护窗口

要特别注意的是「强制」这一类:用 RAISE EXCEPTION 强制规范是把双刃剑。它能拦住不该发生的变更,也能拦住本该发生的变更——一旦策略写错,整个团队的发布都会失败。所以本页所有「强制」类方案都强调三件事:范围收窄、消息可操作、留逃生通道。

选事件的原则 ​

text
想在命令执行前就拦住        → ddl_command_start + RAISE EXCEPTION
想记录「改成了什么」        → ddl_command_end + pg_event_trigger_ddl_commands()
想知道「级联删了什么」      → sql_drop + pg_event_trigger_dropped_objects()
想拦住大表物理重写          → table_rewrite
想在连接时做会话初始化      → login

一个常见的选择错误:在 ddl_command_end 里做「拦截」。这虽然能通过回滚达到目的,但命令已经执行、可能已经付出重写代价,纯属浪费。要拦就用 ddl_command_start(或在重写场景用 table_rewrite),要审计才用 ddl_command_end。

部署形态:一个个库地装 ​

事件触发器是数据库级对象,不跨库、不被 pg_dump 导出。因此所有方案都需要:

text
① 一份幂等的安装脚本(存在版本库里)
② 在 template1 里预装(让新库自动带上)
③ 对存量库逐库执行
④ 把 pg_event_trigger 清单纳入巡检
sql
-- 幂等安装的通用骨架
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname = 'trg_xxx') THEN
        CREATE EVENT TRIGGER trg_xxx ON ddl_command_end EXECUTE FUNCTION f();
    END IF;
END $$;

注意 CREATE EVENT TRIGGER 没有 IF NOT EXISTS 语法,必须用上面的 DO 块或先查再建。

语法与用法 ​

通用:审计表 + 审计函数 + 触发器 ​

后面的方案会反复引用这套基础件,先建好:

sql
CREATE SCHEMA IF NOT EXISTS gov;          -- governance,治理对象统一放这里

CREATE TABLE gov.ddl_audit (
    id               bigserial PRIMARY KEY,
    logged_at        timestamptz NOT NULL DEFAULT clock_timestamp(),
    db_name          text NOT NULL DEFAULT current_database(),
    session_user_name text NOT NULL DEFAULT session_user,
    current_role_name text NOT NULL DEFAULT current_user,
    app_name         text,
    backend_pid      integer,
    event            text NOT NULL,
    tag              text NOT NULL,
    object_type      text,
    schema_name      text,
    object_identity  text,
    in_extension     boolean,
    classid          oid,
    objid            oid,
    objsubid         integer
);

CREATE INDEX ddl_audit_logged_at_idx ON gov.ddl_audit (logged_at);
CREATE INDEX ddl_audit_identity_idx  ON gov.ddl_audit (object_identity);

CREATE OR REPLACE FUNCTION gov.capture_ddl() RETURNS event_trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, gov AS $$
DECLARE r record;
BEGIN
    FOR r IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
        INSERT INTO gov.ddl_audit
               (app_name, backend_pid, event, tag, object_type,
                schema_name, object_identity, in_extension,
                classid, objid, objsubid)
        VALUES (current_setting('application_name', true), pg_backend_pid(),
                TG_EVENT, r.command_tag, r.object_type,
                r.schema_name, r.object_identity, r.in_extension,
                r.classid, r.objid, r.objsubid);
    END LOOP;
END;
$$;

CREATE EVENT TRIGGER trg_gov_capture_ddl
    ON ddl_command_end
    EXECUTE FUNCTION gov.capture_ddl();
text
postgres=# CREATE TABLE app.orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, amount numeric);
CREATE TABLE
postgres=# SELECT tag, object_type, object_identity FROM gov.ddl_audit;
    tag      | object_type |    object_identity
-------------+-------------+-----------------------
 CREATE TABLE | table       | app.orders
 CREATE TABLE | table       | pg_toast.pg_toast_16501
 CREATE TABLE | index       | app.orders_pkey
(3 rows)

一条 CREATE TABLE 产生了三行日志(表、TOAST 表、主键索引)。这是正常行为——pg_event_trigger_ddl_commands() 是按对象而不是按命令返回的。

详细说明 ​

场景一:DDL 审计(最基础、最该先做) ​

这是投入产出比最高的一个场景:先有可观测性,再谈治理。没有审计日志,后面所有「谁改了结构」的问题都无从回答。

sql
-- 只审计业务 schema,忽略系统与扩展对象
CREATE OR REPLACE FUNCTION gov.audit_business_ddl() RETURNS event_trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, gov AS $$
DECLARE r record;
BEGIN
    FOR r IN SELECT * FROM pg_event_trigger_ddl_commands()
             WHERE NOT in_extension
               AND schema_name IS NOT NULL
               AND schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
               AND schema_name NOT LIKE 'pg_temp%'
    LOOP
        INSERT INTO gov.ddl_audit
               (app_name, backend_pid, event, tag, object_type,
                schema_name, object_identity, in_extension,
                classid, objid, objsubid)
        VALUES (current_setting('application_name', true), pg_backend_pid(),
                TG_EVENT, r.command_tag, r.object_type,
                r.schema_name, r.object_identity, r.in_extension,
                r.classid, r.objid, r.objsubid);
    END LOOP;
END;
$$;

CREATE EVENT TRIGGER trg_gov_audit_ddl
    ON ddl_command_end
    EXECUTE FUNCTION gov.audit_business_ddl();

审计表设计上有几个关键点:

字段为什么需要
session_user_name谁连上来的(登录身份)
current_role_name以什么身份执行(SET ROLE 之后可能是别人)
app_name哪个应用/工具发起的(application_name)
backend_pid关联日志与 pg_stat_activity 的钥匙
classid/objid/objsubid精确定位对象;object_identity 是给人看的,这三个是给机器用的
in_extension事后过滤扩展噪音

场景二:强制命名规范 ​

命名规范这类「团队约定」,靠 code review 是拦不住的——总有绕过评审的路径。用事件触发器把它变成数据库的硬约束:

sql
CREATE OR REPLACE FUNCTION gov.enforce_naming() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    r     record;
    re    constant text := '^[a-z][a-z0-9_]*$';   -- 小写字母开头,只含小写/数字/下划线
    obj   text;
BEGIN
    FOR r IN SELECT * FROM pg_event_trigger_ddl_commands()
             WHERE NOT in_extension
               AND object_type IN ('table', 'index', 'view', 'sequence',
                                   'materialized view', 'foreign table')
               AND schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
    LOOP
        obj := r.object_identity;

        -- 只校验对象名本身(去掉 schema 前缀)
        IF split_part(reverse(split_part(reverse(obj), '.', 1)), '', 1) = '' THEN
            NULL;   -- 占位,实际用下面的写法
        END IF;

        IF NOT (regexp_replace(obj, '^.*\.', '') ~ re) THEN
            RAISE EXCEPTION '命名不符合规范:% (类型 %)', obj, r.object_type
                  USING HINT = '对象名必须匹配 ^[a-z][a-z0-9_]*$:小写字母开头,仅含小写字母、数字、下划线';
        END IF;
    END LOOP;
END;
$$;

上面那段代码里有个演示用的冗余判断,实际生产请写成干净版本:

sql
CREATE OR REPLACE FUNCTION gov.enforce_naming() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    r    record;
    name text;
BEGIN
    FOR r IN SELECT * FROM pg_event_trigger_ddl_commands()
             WHERE NOT in_extension
               AND object_type IN ('table', 'index', 'view', 'sequence',
                                   'materialized view', 'foreign table')
               AND schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
               AND schema_name NOT LIKE 'pg_temp%'
    LOOP
        name := regexp_replace(r.object_identity, '^.*\.', '');   -- 去掉 schema 前缀

        IF name !~ '^[a-z][a-z0-9_]*$' THEN
            RAISE EXCEPTION '命名不符合规范:%(类型 %)', r.object_identity, r.object_type
                  USING HINT = '对象名必须匹配 ^[a-z][a-z0-9_]*$';
        END IF;
    END LOOP;
END;
$$;

CREATE EVENT TRIGGER trg_gov_enforce_naming
    ON ddl_command_end
    EXECUTE FUNCTION gov.enforce_naming();
text
app=# CREATE TABLE app.BadName (id int);
ERROR:  命名不符合规范:app.BadName(类型 table)
HINT:  对象名必须匹配 ^[a-z][a-z0-9_]*$
app=# CREATE TABLE app.good_name (id int);
CREATE TABLE

注意这里用的是 ddl_command_end 而不是 ddl_command_start:命名规范校验必须拿到对象名,而 ddl_command_start 阶段拿不到(命令还没执行)。代价是对象已经创建,靠 RAISE EXCEPTION 回滚撤销。对「建表」这种轻量操作,这个代价可以接受;但对「大表重写」,就必须改用 table_rewrite(见场景五)。

场景三:禁止无主键表 ​

「所有业务表必须有主键」是一条长期有效的硬要求。用事件触发器强制它:

sql
CREATE OR REPLACE FUNCTION gov.require_primary_key() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    r        record;
    tbl_oid  oid;
    has_pk   boolean;
BEGIN
    FOR r IN SELECT * FROM pg_event_trigger_ddl_commands()
             WHERE command_tag = 'CREATE TABLE'
               AND object_type = 'table'
               AND NOT in_extension
               AND schema_name NOT IN ('pg_catalog', 'information_schema')
    LOOP
        tbl_oid := r.objid;

        -- 分区子表继承父表的主键,单独判断会误伤
        SELECT EXISTS (
            SELECT 1 FROM pg_index i
            WHERE i.indrelid = tbl_oid AND i.indisprimary
        ) INTO has_pk;

        IF NOT has_pk THEN
            RAISE EXCEPTION '表 % 没有主键,禁止创建', r.object_identity
                  USING HINT = '请添加 PRIMARY KEY,或改用 CREATE TABLE ... (LIKE ...) 等带主键的方式';
        END IF;
    END LOOP;
END;
$$;

CREATE EVENT TRIGGER trg_gov_require_pk
    ON ddl_command_end
    EXECUTE FUNCTION gov.require_primary_key();
text
app=# CREATE TABLE app.no_pk (a int, b int);
ERROR:  表 app.no_pk 没有主键,禁止创建
HINT:  请添加 PRIMARY KEY,或改用 CREATE TABLE ... (LIKE ...) 等带主键的方式
app=# CREATE TABLE app.with_pk (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY);
CREATE TABLE

这类策略有两个必须处理的误伤场景:

  1. 分区子表:CREATE TABLE ... PARTITION OF 不会重复声明主键(继承父表的),如果只看子表自身会误判。要在判断时排除 relispartition = true 的表。
  2. 临时表 / 中间表:数据导入常用的 staging 表往往故意不设主键。要么排除 pg_temp% schema,要么用 WHEN TAG 之外的注解机制给这些表开白名单。
sql
-- 排除分区子表
AND NOT EXISTS (
    SELECT 1 FROM pg_class c WHERE c.oid = r.objid AND c.relispartition
)

场景四:关键 schema 的 DROP 防护 ​

「生产库上的对象只能改不能删」是很多团队的底线。用 sql_drop 拿到级联删除的完整清单,再决定是否放行:

sql
CREATE TABLE gov.drop_audit (
    id              bigserial PRIMARY KEY,
    logged_at       timestamptz NOT NULL DEFAULT clock_timestamp(),
    session_user_name text NOT NULL DEFAULT session_user,
    app_name        text,
    tag             text NOT NULL,
    object_type     text,
    schema_name     text,
    object_name     text,
    object_identity text,
    is_original     boolean,
    is_temp         boolean
);

CREATE OR REPLACE FUNCTION gov.guard_critical_drop() RETURNS event_trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, gov AS $$
DECLARE o record;
BEGIN
    FOR o IN SELECT * FROM pg_event_trigger_dropped_objects()
             WHERE NOT is_temporary
    LOOP
        -- 先落审计(同事务,若下面抛错会一起回滚,这是预期行为)
        INSERT INTO gov.drop_audit
               (app_name, tag, object_type, schema_name, object_name,
                object_identity, is_original, is_temp)
        VALUES (current_setting('application_name', true), TG_TAG,
                o.object_type, o.schema_name, o.object_name,
                o.object_identity, o.original, o.is_temporary);

        -- 关键 schema 一律禁止删除
        IF o.schema_name IN ('core', 'billing', 'auth') THEN
            RAISE EXCEPTION '禁止删除关键 schema % 中的对象:%(%)',
                  o.schema_name, o.object_identity, o.object_type
                  USING HINT = '如需下线,请提交变更工单并由 DBA 在维护窗口执行';
        END IF;
    END LOOP;
END;
$$;

CREATE EVENT TRIGGER trg_gov_guard_drop
    ON sql_drop
    EXECUTE FUNCTION gov.guard_critical_drop();
text
core=# DROP TABLE core.accounts;
ERROR:  禁止删除关键 schema core 中的对象:core.accounts(table)
HINT:  如需下线,请提交变更工单并由 DBA 在维护窗口执行

这里有一个必须理解的行为:上面的审计 INSERT 与后面的 RAISE EXCEPTION 在同一个事务里,因此被拦下的删除不会在 gov.drop_audit 里留下记录。如果你希望「拦截动作本身也要留痕」,必须走事务外通道(外部日志系统、写入另一个库)。这是事件触发器的固有限制,不是 bug。

要在「删除前」拿到对象的更多信息(比如表有多少行、多大),需要组合 ddl_command_start 抓取快照:

sql
-- ddl_command_start 里把「即将被操作的对象的当前状态」存进事务级 GUC 或临时表
CREATE OR REPLACE FUNCTION gov.snapshot_before_ddl() RETURNS event_trigger
LANGUAGE plpgsql AS $$
BEGIN
    IF TG_TAG LIKE 'DROP%' THEN
        PERFORM set_config('gov.last_ddl_tag', TG_TAG, true);
        -- 复杂场景可在此把候选对象快照写入 pg_temp 表
    END IF;
END;
$$;

场景五:大表重写准入(结合 table_rewrite) ​

这个方案在上一节已展开,这里只补充它在整套治理体系中的位置:它和「命名规范」「禁止 DROP」是不同层的防护。

text
第 1 层:命名规范     → 拦「写错的」
第 2 层:禁止无主键   → 拦「设计缺陷」
第 3 层:禁止 DROP    → 拦「不可逆操作」
第 4 层:重写准入     → 拦「代价不可控的操作」

第 4 层之所以必须单独用 table_rewrite 事件,是因为前 3 层使用的 ddl_command_end 无法在重写前介入——等它触发时,重写已经跑完了。

sql
CREATE OR REPLACE FUNCTION gov.rewrite_window_policy() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    t_oid     oid     := pg_event_trigger_table_rewrite_oid();
    t_pages   integer;
    reason    integer := pg_event_trigger_table_rewrite_reason();
    hour_now  integer := extract('hour' from current_time);
    threshold constant integer := 20000;
BEGIN
    SELECT relpages INTO t_pages FROM pg_class WHERE oid = t_oid;

    IF COALESCE(t_pages, 0) <= threshold THEN
        RETURN;                                  -- 小表放行
    END IF;

    IF hour_now BETWEEN 2 AND 5 THEN
        RAISE WARNING '维护窗口内放行大表重写:%', t_oid::regclass;
        RETURN;
    END IF;

    RAISE EXCEPTION '表 % 需重写(% 页,reason=%),当前非维护窗口',
          t_oid::regclass, t_pages, reason
          USING HINT = '请在 02:00-05:00 执行,或使用 pg_repack / 在线变更方案';
END;
$$;

CREATE EVENT TRIGGER trg_gov_rewrite_window
    ON table_rewrite
    EXECUTE FUNCTION gov.rewrite_window_policy();

场景六:连接治理与会话初始化(login) ​

login 是五个事件里唯一不属于 DDL 生命周期的。它在新会话建立时触发,用途只有三类:连接审计、连接准入、会话环境初始化。

官方 §38.5 给出的完整范例:

sql
-- create test tables and roles
CREATE TABLE user_login_log (
  "user" text,
  "session_start" timestamp with time zone
);
CREATE ROLE day_worker;
CREATE ROLE night_worker;

-- the example trigger function
CREATE OR REPLACE FUNCTION init_session()
 RETURNS event_trigger SECURITY DEFINER
 LANGUAGE plpgsql AS
$$
DECLARE
    hour integer = EXTRACT('hour' FROM current_time at time zone 'utc');
    rec boolean;
BEGIN
    -- 1. 禁止 2:00 - 4:00 登录
    IF hour BETWEEN 2 AND 4 THEN
        RAISE EXCEPTION 'Login forbidden';
    END IF;

    -- 备库上不能做写操作,先判断是否处于恢复状态
    SELECT pg_is_in_recovery() INTO rec;
    IF rec THEN
        RETURN;
    END IF;

    -- 2. 按时段授予不同角色
    IF hour BETWEEN 8 AND 20 THEN
        EXECUTE 'REVOKE night_worker FROM ' || quote_ident(session_user);
        EXECUTE 'GRANT day_worker TO ' || quote_ident(session_user);
    ELSE
        EXECUTE 'REVOKE day_worker FROM ' || quote_ident(session_user);
        EXECUTE 'GRANT night_worker TO ' || quote_ident(session_user);
    END IF;

    -- 3. 初始化会话数据
    CREATE TEMP TABLE session_storage (x float, y integer);
    ALTER TABLE session_storage OWNER TO session_user;

    -- 4. 记录连接时间
    INSERT INTO public.user_login_log VALUES (session_user, current_timestamp);
END;
$$;

CREATE EVENT TRIGGER init_session
 ON login
 EXECUTE FUNCTION init_session();
ALTER EVENT TRIGGER init_session ENABLE ALWAYS;
text
postgres=# SELECT "user", session_start FROM user_login_log;
   user    |         session_start
-----------+-------------------------------
 postgres  | 2026-09-28 10:12:33.418+08
(1 row)

这份官方示例里有三个必须照抄的细节:

细节原因
先 pg_is_in_recovery() 再写在备库上写会让备库不可访问,官方原文明确警告:Writing to a standby server will make it inaccessible.
ENABLE ALWAYS让登录审计在任何 session_replication_role 下都生效
用 quote_ident(session_user) 拼接动态 SQL防注入;session_user 虽由数据库提供,动态 SQL 拼接仍应规范

login 触发器的风险远高于其他四个

login 在连接建立的极早期执行,任何错误都直接放大为「所有人连不上数据库」。因此:

  • 函数里绝不能做重查询、绝不能依赖可能不可用的外部系统;
  • 一旦函数报错,连接直接失败,运维自己也进不去;
  • 必须在测试库验证过再上线,且上线时保留一个已知可用的超级用户连接通道;
  • 逃生方法是 event_triggers = false 启动参数或单用户模式(postgres --single)。

实战示例 ​

示例一:把六个方案组装成一份可部署的安装脚本 ​

sql
-- ============================================================
-- gov_event_triggers.sql  —— 幂等安装脚本
-- ============================================================
CREATE SCHEMA IF NOT EXISTS gov;

-- ① 基础审计表与审计函数(见上文,此处省略建表语句)
-- ② 各治理函数(见上文)
-- ③ 幂等注册事件触发器

DO $$
DECLARE
    et record;
BEGIN
    FOR et IN
        SELECT * FROM (VALUES
            ('trg_gov_audit_ddl',      'ddl_command_end', 'gov.audit_business_ddl'),
            ('trg_gov_enforce_naming', 'ddl_command_end', 'gov.enforce_naming'),
            ('trg_gov_require_pk',     'ddl_command_end', 'gov.require_primary_key'),
            ('trg_gov_guard_drop',     'sql_drop',        'gov.guard_critical_drop'),
            ('trg_gov_rewrite_window', 'table_rewrite',   'gov.rewrite_window_policy')
        ) AS v(name, event, func)
    LOOP
        IF NOT EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname = et.name) THEN
            EXECUTE format('CREATE EVENT TRIGGER %I ON %s EXECUTE FUNCTION %s()',
                           et.name, et.event, et.func);
            RAISE NOTICE '已创建事件触发器 %', et.name;
        ELSE
            RAISE NOTICE '事件触发器 % 已存在,跳过', et.name;
        END IF;
    END LOOP;
END $$;
text
NOTICE:  已创建事件触发器 trg_gov_audit_ddl
NOTICE:  已创建事件触发器 trg_gov_enforce_naming
NOTICE:  已创建事件触发器 trg_gov_require_pk
NOTICE:  已创建事件触发器 trg_gov_guard_drop
NOTICE:  已创建事件触发器 trg_gov_rewrite_window
DO

重跑一遍会显示全部「已存在,跳过」,脚本因此可以安全地纳入 CI/CD。

示例二:把事件触发器装进 template1,让新库自动带上 ​

sql
\c template1
\i gov_event_triggers.sql
sql
-- 之后新建的库都会自带这套治理
CREATE DATABASE new_app_db;
bash
psql -d new_app_db -c "\dy"
text
                 List of event triggers
        Name         |       Event       |  Owner   | Enabled
---------------------+-------------------+----------+---------
 trg_gov_audit_ddl   | ddl_command_end   | postgres | enabled
 trg_gov_guard_drop  | sql_drop          | postgres | enabled
 trg_gov_require_pk  | ddl_command_end   | postgres | enabled
(3 rows)

注意:template1 里也会执行这些触发器本身,因此脚本必须幂等、且不能因为 template1 里的历史对象而报错。

示例三:变更前的临时豁免 ​

迁移工具、扩展安装、批量建表这些场景会大量触发治理规则。标准做法是用事务包住「禁用 → 执行 → 启用」:

sql
BEGIN;
ALTER EVENT TRIGGER trg_gov_require_pk DISABLE;
ALTER EVENT TRIGGER trg_gov_enforce_naming DISABLE;

\i migration/V2026_09__add_tables.sql

ALTER EVENT TRIGGER trg_gov_require_pk ENABLE;
ALTER EVENT TRIGGER trg_gov_enforce_naming ENABLE;
COMMIT;
text
-- 迁移成功,且治理规则在事务结束后自动恢复

为什么必须用显式事务?因为若中途失败而没有 ROLLBACK,ALTER EVENT TRIGGER ... DISABLE 会持久生效——治理规则从此静默失效,而监控里什么异常都看不到。

sql
-- 验证:确认没有触发器被遗留在 DISABLE 状态
SELECT evtname, evtevent, evtenabled
FROM pg_event_trigger
WHERE evtenabled = 'D';      -- D = Disabled
text
 evtname | evtevent | evtenabled
---------+----------+------------
(0 rows)

这条检查建议做成定时巡检。零行是健康的,出现任何一行都意味着治理机制被无意中关掉了。

示例四:结构变更同步到外部系统 ​

把 DDL 事件推给外部系统(配置中心、血缘平台、变更审批流)时,有两个约束必须遵守:

  1. 不能在触发器里同步调用外部系统的 HTTP 接口——它会成为 DDL 路径上的一个故障点,外部系统一挂,全库 DDL 全部超时。
  2. 正确做法是只写本地的「待发送队列」,由异步进程消费。
sql
CREATE TABLE gov.ddl_outbox (
    id          bigserial PRIMARY KEY,
    created_at  timestamptz NOT NULL DEFAULT clock_timestamp(),
    payload     jsonb NOT NULL,
    sent_at     timestamptz NULL,
    attempts    integer NOT NULL DEFAULT 0
);

CREATE OR REPLACE FUNCTION gov.enqueue_ddl_event() RETURNS event_trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, gov AS $$
DECLARE r record;
BEGIN
    FOR r IN SELECT * FROM pg_event_trigger_ddl_commands()
             WHERE NOT in_extension
               AND schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
    LOOP
        INSERT INTO gov.ddl_outbox (payload)
        VALUES (jsonb_build_object(
            'db',       current_database(),
            'who',      session_user,
            'app',      current_setting('application_name', true),
            'event',    TG_EVENT,
            'tag',      r.command_tag,
            'type',     r.object_type,
            'schema',   r.schema_name,
            'identity', r.object_identity,
            'at',       clock_timestamp()
        ));
    END LOOP;
END;
$$;

CREATE EVENT TRIGGER trg_gov_enqueue_ddl
    ON ddl_command_end
    EXECUTE FUNCTION gov.enqueue_ddl_event();
sql
-- 外部消费者(独立进程)按批拉取并标记
WITH batch AS (
    SELECT id FROM gov.ddl_outbox
    WHERE sent_at IS NULL
    ORDER BY id
    LIMIT 100
    FOR UPDATE SKIP LOCKED          -- 支持多个消费者并发
)
UPDATE gov.ddl_outbox o
SET sent_at = clock_timestamp(),
    attempts = o.attempts + 1
FROM batch b
WHERE o.id = b.id
RETURNING o.id, o.payload;

FOR UPDATE SKIP LOCKED 让队列天然支持多消费者,是这套「outbox 模式」的关键。

示例五:告警型触发器(只发通知,不阻断) ​

不是所有治理都要用 RAISE EXCEPTION。对于那些「需要知道,但不该阻断」的场景,用 RAISE WARNING:

sql
CREATE OR REPLACE FUNCTION gov.warn_risky_ddl() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE r record;
BEGIN
    FOR r IN SELECT * FROM pg_event_trigger_ddl_commands()
             WHERE NOT in_extension AND object_type = 'table'
    LOOP
        -- 新增列但没有默认值 —— 可能让应用写入报错
        IF r.command_tag = 'ALTER TABLE' THEN
            RAISE WARNING '检测到表结构变更:%(tag=%),请确认已通知应用方',
                  r.object_identity, r.command_tag
                  USING HINT = '新增列若无非空默认值,可能导致依赖 INSERT * 的应用报错';
        END IF;
    END LOOP;
END;
$$;
text
app=# ALTER TABLE app.orders ADD COLUMN note text;
WARNING:  检测到表结构变更:app.orders.note(tag=ALTER TABLE),请确认已通知应用方
HINT:  新增列若无非空默认值,可能导致依赖 INSERT * 的应用报错
ALTER TABLE

RAISE WARNING 不阻断命令,只把文本写进服务端日志与客户端——日志收集系统(如 pgBadger、Loki)可以据此做告警。

示例六:完整的治理巡检视图 ​

sql
CREATE OR REPLACE VIEW gov.event_trigger_inventory AS
SELECT e.evtname                                  AS trigger_name,
       e.evtevent                                 AS event,
       CASE e.evtenabled
            WHEN 'O' THEN 'enabled'
            WHEN 'D' THEN 'DISABLED'
            WHEN 'R' THEN 'replica'
            WHEN 'A' THEN 'always'
       END                                        AS state,
       COALESCE(array_to_string(e.evttags, ', '), '(all tags)') AS tags,
       n.nspname || '.' || p.proname               AS function_name,
       p.prosecdef                                 AS security_definer,
       pg_get_functiondef(p.oid) LIKE '%RAISE EXCEPTION%' AS can_block
FROM pg_event_trigger e
JOIN pg_proc p       ON p.oid = e.evtfoid
JOIN pg_namespace n  ON n.oid = p.pronamespace
ORDER BY e.evtevent, e.evtname;
sql
SELECT * FROM gov.event_trigger_inventory;
text
       trigger_name       |      event      |  state  |    tags     |           function_name           | security_definer | can_block
--------------------------+-----------------+---------+-------------+-----------------------------------+------------------+-----------
 trg_gov_audit_ddl        | ddl_command_end | enabled | (all tags)  | gov.audit_business_ddl            | t                | f
 trg_gov_enforce_naming   | ddl_command_end | enabled | (all tags)  | gov.enforce_naming                | f                | t
 trg_gov_require_pk       | ddl_command_end | enabled | (all tags)  | gov.require_primary_key           | f                | t
 trg_gov_guard_drop       | sql_drop        | enabled | (all tags)  | gov.guard_critical_drop           | t                | t
 trg_gov_rewrite_window   | table_rewrite   | enabled | (all tags)  | gov.rewrite_window_policy         | f                | t
(5 rows)

这个视图建议直接给监控用:state = 'DISABLED' 要告警,can_block = true 的触发器增删要审批。

注意事项 ​

  • 不要把所有治理塞进一个触发器函数。 一个函数既审计又拦截,出问题时无法分辨是哪条规则导致失败。一事一触发器,名字可追溯。

  • 不要在 ddl_command_end 里做本该在 ddl_command_start 做的事(拦截)。 命令已经执行了才拦,代价已经付出。

  • 不要忘记 login 触发器写操作前先判断 pg_is_in_recovery()。 官方明确警告:在备库上写会让备库不可访问。

  • 不要在 login 触发器里做重活。 它拖慢每一次连接,且报错会导致连接直接失败。

  • 不要假设事件触发器会被 pg_dump 带出去。 它不在备份范围内,必须用独立脚本管理。

  • 不要忘记事件触发器是「每库一份」。 新库不会继承(除非装在 template1 里)。

  • 不要在触发器里同步调用外部 HTTP 服务。 外部系统一挂,全库 DDL 全部阻塞。用 outbox 表 + 异步消费者。

    sql
    -- 不推荐
    PERFORM http_post('https://config-center/api/ddl', payload);
    -- 推荐
    INSERT INTO gov.ddl_outbox (payload) VALUES (payload);
  • 不要让治理触发器误伤分区子表、临时表、扩展安装。 判断条件里必须排除 relispartition、pg_temp%、in_extension。

  • 不要在报错信息里只写「禁止」。 必须给出对象、原因、以及正确做法。一线工程师拿到的唯一线索就是这句话。

    sql
    RAISE EXCEPTION '表 % 没有主键,禁止创建', r.object_identity
          USING HINT = '请添加 PRIMARY KEY';
  • 不要在临时禁用治理后忘记恢复。 用显式事务包住,并把「是否有 evtenabled = ''D''」纳入巡检。

  • 不要把审计与管控混成 ALWAYS。 审计希望宽(ALWAYS),管控希望窄(只对正常路径生效,避免 pg_restore 被拦),混用会让恢复流程不可预测。

  • 不要忘记 sql_drop 里对象已经不存在。 需要删除前的属性必须用 ddl_command_start 提前抓取。

  • 不要依赖同事务的审计记录来证明「拦截发生过」。 被 RAISE EXCEPTION 拦下的操作会连同审计写入一起回滚。

  • 不要在治理触发器里执行慢查询。 它是 DDL 路径上的一环,每次执行都会延长锁持有时间。

  • 不要一次性上全部规则。 先上审计(只记录不阻断),观察一段时间日志,确认规则不会误伤,再逐步把要强制的规则加上 RAISE EXCEPTION。

  • 不要在模板脚本里使用 CREATE EVENT TRIGGER IF NOT EXISTS。 该语法不存在,必须用 DO 块先查再建。

  • 不要忽略 application_name。 治理日志里没有它,事后就分不清「是哪个工具干的」。

最佳实践 ​

  • 先审计后管控:第一阶段只记录不阻断,第二阶段再收紧。

    text
    第 1 周:上 trg_gov_audit_ddl,只 INSERT → 观察真实 DDL 分布
    第 2 周:上命名规范(RAISE EXCEPTION)→ 修正存量违规
    第 3 周:上禁止无主键、禁止 DROP
    第 4 周:上重写准入
  • 一事一触发器,命名体现用途与所属层。

    sql
    CREATE EVENT TRIGGER trg_gov_audit_ddl      ON ddl_command_end EXECUTE FUNCTION gov.audit_business_ddl();
    CREATE EVENT TRIGGER trg_gov_enforce_naming ON ddl_command_end EXECUTE FUNCTION gov.enforce_naming();
    CREATE EVENT TRIGGER trg_gov_guard_drop     ON sql_drop        EXECUTE FUNCTION gov.guard_critical_drop();
  • 用 WHEN TAG IN (...) 收窄监听范围。 全量监听会给每条 DDL 都付出一次函数调用开销。

    sql
    CREATE EVENT TRIGGER trg_gov_require_pk ON ddl_command_end
        WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
        EXECUTE FUNCTION gov.require_primary_key();
  • 治理函数统一放在 gov schema,审计表只给只读权限。 用 SECURITY DEFINER + 固定 search_path。

    sql
    CREATE FUNCTION gov.guard_critical_drop() RETURNS event_trigger
    LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, gov AS $$ ... $$;
  • 报错信息三要素:对象、原因、正确做法。

    sql
    RAISE EXCEPTION '表 % 没有主键,禁止创建', r.object_identity
          USING HINT = '请添加 PRIMARY KEY';
  • 治理阈值可配置,不要硬编码。 用 current_setting() 或治理参数表,方便按库调整。

    sql
    max_pages := COALESCE(current_setting('gov.rewrite_max_pages', true)::integer, 20000);
  • 用 outbox 模式做外部同步,绝不在 DDL 路径上做网络调用。

    sql
    INSERT INTO gov.ddl_outbox (payload) VALUES (payload);
  • 把治理对象纳入版本管理,用幂等脚本部署。

    sql
    IF NOT EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname = 'trg_x') THEN
        EXECUTE format('CREATE EVENT TRIGGER %I ON %s EXECUTE FUNCTION %s()', ...);
    END IF;
  • 新库自动治理:把安装脚本跑进 template1。

  • 治理规则变更走审批流程。 RAISE EXCEPTION 的能力等同于「全库 DDL 开关」,必须像改防火墙规则一样谨慎。

  • 日常巡检看 gov.event_trigger_inventory。 state = 'DISABLED' 告警,can_block = true 的增删走审批。

    sql
    SELECT trigger_name, event, state, can_block FROM gov.event_trigger_inventory;
  • 审计表按时间分区并归档。 DDL 日志量小但只增不减。

  • 大表改类型优先「新增列 + 双写 + 切换 + 删旧列」,而不是原地改类型。 把不可控的重写拆成可控的小步。

  • 保留逃生通道并写进运维手册。 被治理规则锁死时,用 ALTER EVENT TRIGGER ... DISABLE 或 event_triggers = false / 单用户模式恢复。

    sql
    BEGIN;
    ALTER EVENT TRIGGER trg_gov_require_pk DISABLE;
    -- 执行必要的紧急变更
    ALTER EVENT TRIGGER trg_gov_require_pk ENABLE;
    COMMIT;
  • 在影子库用真实数据演练全量迁移,确认治理规则不会让发布失败。

下一节

继续学习:PL/Python 基础——走出 PL/pgSQL,用 Python 写数据库函数与触发器:plpython3u 的安装、类型映射、plpy 执行 SQL,以及共享状态的用法。

参考链接 ​