事件触发器应用场景
前三节把事件触发器的机制讲完了:有哪五个事件、每个阶段能拿到什么信息、表重写怎么拦。但机制本身不是目的——事件触发器真正的价值在于,它把「DDL 治理」从「靠制度约束人」变成了「靠数据库约束代码」。
本页是这组内容的落地篇:把前四节的能力组合成六个可上生产的治理方案,每个方案都给出完整的表结构、函数、触发器与验证方式,并说清它的边界与误伤风险。所有方案都以 PostgreSQL 18 为准,且在同一个数据库内可叠加使用。
前置知识
阅读本节前,建议先了解:事件触发器概述、DDL 事件触发(CREATE / ALTER / DROP)、表重写事件
基础概念
事件触发器能治理的四类问题
把前几页的机制归纳一下,事件触发器实际能解决的问题只有四类,其余都是它们的组合:
| 类别 | 手段 | 用哪个事件 | 典型需求 |
|---|---|---|---|
| 记录(审计) | INSERT 到日志表 / RAISE WARNING | ddl_command_end、sql_drop、login | 谁在什么时候改了什么结构 |
| 强制(规范) | RAISE EXCEPTION | ddl_command_start、ddl_command_end | 禁止无主键表、强制命名规范、禁止 DROP |
| 同步(联动) | 调用外部接口 / 写队列表 | ddl_command_end | 结构变更同步到配置中心、血缘系统 |
| 限额(准入) | RAISE EXCEPTION + 环境判断 | table_rewrite、ddl_command_start | 大表重写只许在维护窗口 |
要特别注意的是「强制」这一类:用 RAISE EXCEPTION 强制规范是把双刃剑。它能拦住不该发生的变更,也能拦住本该发生的变更——一旦策略写错,整个团队的发布都会失败。所以本页所有「强制」类方案都强调三件事:范围收窄、消息可操作、留逃生通道。
选事件的原则
想在命令执行前就拦住 → 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 导出。因此所有方案都需要:
① 一份幂等的安装脚本(存在版本库里)
② 在 template1 里预装(让新库自动带上)
③ 对存量库逐库执行
④ 把 pg_event_trigger 清单纳入巡检-- 幂等安装的通用骨架
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 块或先查再建。
语法与用法
通用:审计表 + 审计函数 + 触发器
后面的方案会反复引用这套基础件,先建好:
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();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 审计(最基础、最该先做)
这是投入产出比最高的一个场景:先有可观测性,再谈治理。没有审计日志,后面所有「谁改了结构」的问题都无从回答。
-- 只审计业务 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 是拦不住的——总有绕过评审的路径。用事件触发器把它变成数据库的硬约束:
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;
$$;上面那段代码里有个演示用的冗余判断,实际生产请写成干净版本:
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();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(见场景五)。
场景三:禁止无主键表
「所有业务表必须有主键」是一条长期有效的硬要求。用事件触发器强制它:
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();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这类策略有两个必须处理的误伤场景:
- 分区子表:
CREATE TABLE ... PARTITION OF不会重复声明主键(继承父表的),如果只看子表自身会误判。要在判断时排除relispartition = true的表。 - 临时表 / 中间表:数据导入常用的 staging 表往往故意不设主键。要么排除
pg_temp%schema,要么用WHEN TAG之外的注解机制给这些表开白名单。
-- 排除分区子表
AND NOT EXISTS (
SELECT 1 FROM pg_class c WHERE c.oid = r.objid AND c.relispartition
)场景四:关键 schema 的 DROP 防护
「生产库上的对象只能改不能删」是很多团队的底线。用 sql_drop 拿到级联删除的完整清单,再决定是否放行:
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();core=# DROP TABLE core.accounts;
ERROR: 禁止删除关键 schema core 中的对象:core.accounts(table)
HINT: 如需下线,请提交变更工单并由 DBA 在维护窗口执行这里有一个必须理解的行为:上面的审计 INSERT 与后面的 RAISE EXCEPTION 在同一个事务里,因此被拦下的删除不会在 gov.drop_audit 里留下记录。如果你希望「拦截动作本身也要留痕」,必须走事务外通道(外部日志系统、写入另一个库)。这是事件触发器的固有限制,不是 bug。
要在「删除前」拿到对象的更多信息(比如表有多少行、多大),需要组合 ddl_command_start 抓取快照:
-- 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」是不同层的防护。
第 1 层:命名规范 → 拦「写错的」
第 2 层:禁止无主键 → 拦「设计缺陷」
第 3 层:禁止 DROP → 拦「不可逆操作」
第 4 层:重写准入 → 拦「代价不可控的操作」第 4 层之所以必须单独用 table_rewrite 事件,是因为前 3 层使用的 ddl_command_end 无法在重写前介入——等它触发时,重写已经跑完了。
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 给出的完整范例:
-- 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;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)。
实战示例
示例一:把六个方案组装成一份可部署的安装脚本
-- ============================================================
-- 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 $$;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,让新库自动带上
\c template1
\i gov_event_triggers.sql-- 之后新建的库都会自带这套治理
CREATE DATABASE new_app_db;psql -d new_app_db -c "\dy" 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 里的历史对象而报错。
示例三:变更前的临时豁免
迁移工具、扩展安装、批量建表这些场景会大量触发治理规则。标准做法是用事务包住「禁用 → 执行 → 启用」:
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;-- 迁移成功,且治理规则在事务结束后自动恢复为什么必须用显式事务?因为若中途失败而没有 ROLLBACK,ALTER EVENT TRIGGER ... DISABLE 会持久生效——治理规则从此静默失效,而监控里什么异常都看不到。
-- 验证:确认没有触发器被遗留在 DISABLE 状态
SELECT evtname, evtevent, evtenabled
FROM pg_event_trigger
WHERE evtenabled = 'D'; -- D = Disabled evtname | evtevent | evtenabled
---------+----------+------------
(0 rows)这条检查建议做成定时巡检。零行是健康的,出现任何一行都意味着治理机制被无意中关掉了。
示例四:结构变更同步到外部系统
把 DDL 事件推给外部系统(配置中心、血缘平台、变更审批流)时,有两个约束必须遵守:
- 不能在触发器里同步调用外部系统的 HTTP 接口——它会成为 DDL 路径上的一个故障点,外部系统一挂,全库 DDL 全部超时。
- 正确做法是只写本地的「待发送队列」,由异步进程消费。
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();-- 外部消费者(独立进程)按批拉取并标记
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:
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;
$$;app=# ALTER TABLE app.orders ADD COLUMN note text;
WARNING: 检测到表结构变更:app.orders.note(tag=ALTER TABLE),请确认已通知应用方
HINT: 新增列若无非空默认值,可能导致依赖 INSERT * 的应用报错
ALTER TABLERAISE WARNING 不阻断命令,只把文本写进服务端日志与客户端——日志收集系统(如 pgBadger、Loki)可以据此做告警。
示例六:完整的治理巡检视图
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;SELECT * FROM gov.event_trigger_inventory; 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。不要在报错信息里只写「禁止」。 必须给出对象、原因、以及正确做法。一线工程师拿到的唯一线索就是这句话。
sqlRAISE 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 周:上重写准入一事一触发器,命名体现用途与所属层。
sqlCREATE 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 都付出一次函数调用开销。sqlCREATE 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();治理函数统一放在
govschema,审计表只给只读权限。 用SECURITY DEFINER+ 固定search_path。sqlCREATE FUNCTION gov.guard_critical_drop() RETURNS event_trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, gov AS $$ ... $$;报错信息三要素:对象、原因、正确做法。
sqlRAISE EXCEPTION '表 % 没有主键,禁止创建', r.object_identity USING HINT = '请添加 PRIMARY KEY';治理阈值可配置,不要硬编码。 用
current_setting()或治理参数表,方便按库调整。sqlmax_pages := COALESCE(current_setting('gov.rewrite_max_pages', true)::integer, 20000);用 outbox 模式做外部同步,绝不在 DDL 路径上做网络调用。
sqlINSERT INTO gov.ddl_outbox (payload) VALUES (payload);把治理对象纳入版本管理,用幂等脚本部署。
sqlIF 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的增删走审批。sqlSELECT trigger_name, event, state, can_block FROM gov.event_trigger_inventory;审计表按时间分区并归档。 DDL 日志量小但只增不减。
大表改类型优先「新增列 + 双写 + 切换 + 删旧列」,而不是原地改类型。 把不可控的重写拆成可控的小步。
保留逃生通道并写进运维手册。 被治理规则锁死时,用
ALTER EVENT TRIGGER ... DISABLE或event_triggers = false/ 单用户模式恢复。sqlBEGIN; 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,以及共享状态的用法。
参考链接
- PostgreSQL 18 官方文档:Event Triggers(Chapter 38,含全部示例)
- PostgreSQL 18 官方文档:A Complete Event Trigger Example(审计范例)
- PostgreSQL 18 官方文档:A Table Rewrite Event Trigger Example(重写准入范例)
- PostgreSQL 18 官方文档:A Database Login Event Trigger Example(连接治理范例)
- PostgreSQL 18 官方文档:Event Trigger Functions(信息函数完整字段)
- PostgreSQL 18 官方文档:CREATE EVENT TRIGGER
- PostgreSQL 18 官方文档:ALTER EVENT TRIGGER(禁用与恢复)
- PostgreSQL 18 官方文档:pg_event_trigger(系统目录)
- PostgreSQL 18 官方文档:Server Configuration(
event_triggers参数)