Skip to content

表重写事件 ​

有些 ALTER TABLE 看着只是改个属性,实际却会把整张表连同它的所有索引重新写一遍。在一个几亿行的表上,这不是「改个元数据」,而是一次持续数十分钟、占满 I/O、锁住 ACCESS EXCLUSIVE 的重量级操作。更糟的是,同一句 ALTER TABLE 在小表上是秒级、在大表上是灾难级,语法上完全看不出区别。

table_rewrite 事件触发器就是为这件事准备的:它在表即将被物理重写之前触发,让你有机会记录、告警,或者直接把它拦下来。本页讲清三件事:哪些操作真的会重写表、pg_event_trigger_table_rewrite_reason() 的原因码怎么解读、以及如何用它在维护窗口之外禁止重写。

基础概念 ​

什么是「表重写」 ​

PostgreSQL 的表数据存在一堆堆文件块(heap page)里,每行按固定布局存放。当某次 ALTER TABLE 改变了行的物理布局(列的数量与类型、列的默认值是否需要回填、表的持久性、访问方法)时,旧的行格式不再适用,数据库只能:

text
新建一张「空的新表」
   │
   ├─► 逐行从旧表读出 → 按新格式转换 → 写入新表
   ├─► 索引全部重建(索引里存的是行指针 TID)
   ├─► TOAST 数据一并处理
   └─► 写 WAL(生成量与表大小同量级)

完成后:把新表文件改名成旧表的 relfilenode,删掉旧文件

这个过程就叫 表重写(table rewrite)。它的三个致命特征:

特征后果
持有 ACCESS EXCLUSIVE 锁重写期间读写全部阻塞,不是「慢」,是「不可用」
生成与表大小同量级的 WAL主从延迟、WAL 归档压力、磁盘空间被吃掉一倍
不可中断(除非取消整个命令)中途 Ctrl-C 会回滚整个重写,代价白付

官方文档对此有一句非常直接的说明:Adding a column with a volatile DEFAULT or changing the type of an existing column will require the entire table and its indexes to be rewritten.

table_rewrite 事件的精确定义 ​

官方原文:The table_rewrite event occurs just before a table is rewritten by some actions of the commands ALTER TABLE and ALTER TYPE. While other control statements are available to rewrite a table, like CLUSTER and VACUUM, the table_rewrite event is not triggered by them.

拆开来读,有三条关键信息:

  1. 触发时机是重写之前(just before),所以还有机会阻止。
  2. 触发来源是 ALTER TABLE 与 ALTER TYPE 的某些子命令——不是全部。
  3. CLUSTER、VACUUM FULL 这类「也会重写表」的命令不会触发 table_rewrite。这是一个明确的盲区,后面会专门讲。
text
ALTER TABLE ...(会重写的子命令)  ─► table_rewrite 触发 ✔
ALTER TYPE  ...(类型变更连带重写) ─► table_rewrite 触发 ✔
CLUSTER / VACUUM FULL             ─► 不触发 ✘
REINDEX                           ─► 不触发 ✘

事件在时间轴上的位置 ​

text
客户端:  ALTER TABLE big_table ALTER COLUMN c TYPE bigint;
   │
   ├─► ① ddl_command_start
   │
   ├─► ② table_rewrite          ← 表还没开始重写
   │      pg_event_trigger_table_rewrite_oid()    → 即将重写的表
   │      pg_event_trigger_table_rewrite_reason() → 重写原因码
   │      此处 RAISE EXCEPTION 可以阻止重写
   │
   ├─► ③ 内核执行重写(耗时最长的一步)
   │
   ├─► ④ ddl_command_end
   │
   └─► 事务提交 / 回滚

注意 table_rewrite 在 ddl_command_end 之前,也就是说它触发时 pg_event_trigger_ddl_commands() 还拿不到东西(那个函数只允许在 ddl_command_end 里调用)。

语法与用法 ​

两个专用信息函数 ​

官方定义(§9.30.3):

Table 9.111. Table Rewrite Information Functions

函数返回类型说明
pg_event_trigger_table_rewrite_oid()oidReturns the OID of the table about to be rewritten.
pg_event_trigger_table_rewrite_reason()integerReturns a code explaining the reason(s) for rewriting.

两者都只能在 table_rewrite 事件触发器的函数里调用,其他上下文调用会报错。

原因码是一个 bitmap,不是一个枚举 ​

这是最容易写错的地方。官方原文:

Returns a code explaining the reason(s) for rewriting. The value is a bitmap built from the following values: 1 (the table has changed its persistence), 2 (default value of a column has changed), 4 (a column has a new data type) and 8 (the table access method has changed).

翻译过来:

位值含义
1表的持久性发生变化(LOGGED ↔ UNLOGGED)
2某列的默认值发生变化
4某列有了新的数据类型
8表的访问方法发生变化

关键点:它返回的是按位组合(bitmap)的整数,而不是 1/2/4/8 这四个互斥的枚举值。因此:

  • 只改类型 → 返回 4
  • 同时改类型和持久性 → 返回 4 | 1 = 5
  • 只有一步操作触发重写时通常是单值,但不要假设它一定是单值

官方只定义了数值,没有公开 AT_REWRITE_* 宏名

在某些资料里你会看到 AT_REWRITE_DEFAULT_VAL、AT_REWRITE_COLUMN_TYPE 之类的符号名,它们是 PostgreSQL 源码里的内部宏,官方 SQL 文档并没有把这些名字作为稳定接口公开。写文档、写脚本、做判断时,请以官方文档给出的数值 1 / 2 / 4 / 8 为准。跨版本升级时这些数值的语义以当版官方文档为准。

由于是 bitmap,解码必须用按位运算,不能用等值比较:

sql
-- 错误!这是等值比较,reason = 5 时判断不出「类型变了」
IF pg_event_trigger_table_rewrite_reason() = 4 THEN ...

-- 正确:按位与
IF pg_event_trigger_table_rewrite_reason() & 4 <> 0 THEN ...  -- 涉及类型变更

最小可运行示例 ​

官方文档给出的范例:

sql
CREATE FUNCTION test_event_trigger_table_rewrite_oid()
 RETURNS event_trigger
 LANGUAGE plpgsql AS
$$
BEGIN
    RAISE NOTICE 'rewriting table % for reason %',
        pg_event_trigger_table_rewrite_oid()::regclass,
        pg_event_trigger_table_rewrite_reason();
END;
$$;

CREATE EVENT TRIGGER test_table_rewrite_oid
 ON table_rewrite
 EXECUTE FUNCTION test_event_trigger_table_rewrite_oid();
text
postgres=# ALTER TABLE demo ALTER COLUMN code TYPE varchar(64);
NOTICE:  rewriting table demo for reason 4
ALTER TABLE

pg_event_trigger_table_rewrite_oid() 返回 oid,用 ::regclass 转成可读的表名是标准写法。

原因码解码函数 ​

sql
CREATE OR REPLACE FUNCTION rewrite_reason_text(reason integer)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
    SELECT string_agg(part, ' + ' ORDER BY bit)
    FROM (VALUES
        (1, 'persistence changed'),
        (2, 'column default changed'),
        (4, 'column data type changed'),
        (8, 'access method changed')
    ) AS t(bit, part)
    WHERE reason & bit <> 0;
$$;
sql
SELECT rewrite_reason_text(4)  AS r4,    -- column data type changed
       rewrite_reason_text(5)  AS r5,    -- persistence changed + column data type changed
       rewrite_reason_text(15) AS r15;   -- 全部四项
text
      r4       |                 r5                  |                                  r15
---------------+-------------------------------------+------------------------------------------------------------------------
 column data type changed | persistence changed + column data type changed | persistence changed + column default changed + column data type changed + access method changed

详细说明 ​

哪些 ALTER TABLE 操作真的会重写表 ​

不是所有 ALTER TABLE 都会重写。改「元数据」的操作(比如 SET STATISTICS、RENAME、SET DEFAULT)几乎都是瞬时的;只有改变行物理布局或需要回填数据的操作才会重写。

ALTER TABLE 子命令是否重写说明
ADD COLUMN ... DEFAULT <volatile>是需要为已有行回填一个不稳定值
ADD COLUMN ... DEFAULT <非 volatile>否只记录默认值,已有行按需填充(元数据变更)
ADD COLUMN(无默认值)否纯元数据操作
ALTER COLUMN ... SET DATA TYPE通常是无隐含/赋值转换时、或需要转换数据时重写
ALTER COLUMN ... SET/DROP DEFAULT否只影响后续 INSERT/UPDATE,官方明确:it does not cause rows already in the table to change
ALTER COLUMN ... SET/DROP NOT NULL否需要扫描表校验,但不是重写
ALTER COLUMN ... SET EXPRESSION AS (...)是(存储生成列)官方:Existing data in a stored generated column is rewritten
ALTER COLUMN ... SET STORAGE否官方:doesn't itself change anything in the table; it just sets the strategy
ALTER COLUMN ... SET COMPRESSION否官方:This does not cause the table to be rewritten
ALTER COLUMN ... SET STATISTICS否只设置统计目标,取 SHARE UPDATE EXCLUSIVE 锁
SET LOGGED / SET UNLOGGED是持久性变化(原因码含 1)
SET ACCESS METHOD是访问方法变化(原因码含 8)
SET TABLESPACE是迁移到另一个表空间需要复制全部数据
SET ( storage_parameter )否(多数)例如调 fillfactor 不重写,只影响后续写入
ADD CONSTRAINT / VALIDATE CONSTRAINT否扫描校验,但不重写
RENAME / SET SCHEMA / OWNER TO否纯元数据

关于 SET DATA TYPE 需要补充一句:官方说索引和「简单表约束」会被自动重新解析转换,USING 子句可自定义转换。当新旧类型之间没有隐含或赋值转换时,必须显式提供 USING,否则命令直接报错——这种情况一定伴随重写。

关于 ADD COLUMN ... DEFAULT 的 volatile 判定,有一个极具实操价值的对比:

sql
-- 不重写:now() 是 stable,PostgreSQL 只存默认值表达式
ALTER TABLE big ADD COLUMN created_at timestamptz DEFAULT now();

-- 重写!random() 是 volatile,必须为每一行算出不同的值
ALTER TABLE big ADD COLUMN token uuid DEFAULT gen_random_uuid();

官方原文即:Adding a column with a volatile DEFAULT or changing the type of an existing column will require the entire table and its indexes to be rewritten. 判断依据是该函数的 volatility 属性:volatile 需要逐行求值 → 重写;stable / immutable 可以只存表达式 → 不重写。

ALTER TYPE 也会触发 ​

除了 ALTER TABLE,对类型本身的修改也可能连带重写使用该类型的表。官方定义里明确写了触发来源包含 ALTER TYPE。因此给一个自定义类型加属性、改变类型的存储表示等操作,都可能让所有使用该类型的表被重写——这往往是「我只改了个类型,怎么全库都在锁」的根因。

text
ALTER TYPE composite_t ADD ATTRIBUTE note text;   -- 使用该类型的表可能被重写

排查这类问题时,table_rewrite 触发器是唯一能在重写发生前给出告警的机制。

官方范例:只在维护窗口允许重写 ​

这是 table_rewrite 最经典的用法,官方 §38.4 给出了完整实现:

sql
CREATE OR REPLACE FUNCTION no_rewrite()
 RETURNS event_trigger
 LANGUAGE plpgsql AS
$$
---
--- Implement local Table Rewriting policy:
---   public.foo is not allowed rewriting, ever
---   other tables are only allowed rewriting between 1am and 6am
---   unless they have more than 100 blocks
---
DECLARE
    table_oid oid := pg_event_trigger_table_rewrite_oid();
    current_hour integer := extract('hour' from current_time);
    pages integer;
    max_pages integer := 100;
BEGIN
    IF pg_event_trigger_table_rewrite_oid() = 'public.foo'::regclass
    THEN
        RAISE EXCEPTION 'you''re not allowed to rewrite the table %',
            table_oid::regclass;
    END IF;

    SELECT INTO pages relpages FROM pg_class WHERE oid = table_oid;
    IF pages > max_pages
    THEN
        RAISE EXCEPTION 'rewrites only allowed for table with less than % pages',
            max_pages;
    END IF;

    IF current_hour NOT BETWEEN 1 AND 6
    THEN
        RAISE EXCEPTION 'rewrites only allowed between 1am and 6am';
    END IF;
END;
$$;

CREATE EVENT TRIGGER no_rewrite_allowed
 ON table_rewrite
 EXECUTE FUNCTION no_rewrite();

这份官方示例直接用 RAISE EXCEPTION 阻止重写,这回答了「能不能阻断」的问题:

结论:table_rewrite 中可以阻断重写

在 table_rewrite 事件中 RAISE EXCEPTION 可以阻止表被重写——因为事件在重写之前触发,此时命令还没执行,抛错会让整个 ALTER TABLE 失败回滚。官方文档的示例正是用这个手段实现「只在维护窗口允许重写」的策略。因此 table_rewrite 不仅能「告警」,也能「管控」。

这份示例还顺手演示了三种实用判据:

判据用到的信息
表是否在禁止清单里pg_event_trigger_table_rewrite_oid() = 'public.foo'::regclass
表是否「太大」查 pg_class.relpages 与阈值比较
是否在允许的时间窗内extract('hour' from current_time) BETWEEN 1 AND 6

顺带说说 relpages ​

上面示例里的 pages 来自 pg_class.relpages(单位是 8KB 块)。注意它是 ANALYZE / VACUUM 时更新的估计值,不是实时精确值,用做「量级判断」足够,用做「精确额度」不可靠。

sql
SELECT relpages,
       pg_size_pretty(relpages::bigint * 8192) AS approx_size
FROM pg_class WHERE oid = 'big_table'::regclass;

要做更稳的容量判断,可以结合 pg_total_relation_size():

sql
SELECT pg_size_pretty(pg_total_relation_size('big_table')) AS total;

盲区:CLUSTER、VACUUM FULL、REINDEX 不触发 ​

这一点必须反复强调。table_rewrite 只能看到 ALTER TABLE / ALTER TYPE 引起的重写,而下面这些命令同样会把表整体重写一遍,却完全不触发事件:

命令是否重写表是否触发 table_rewrite
CLUSTER / CLUSTER ... USING idx是否
VACUUM FULL是否
REINDEX / REINDEX TABLE重建索引(非表)否
ALTER TABLE ... SET TABLESPACE是是

官方原文已明确:While other control statements are available to rewrite a table, like CLUSTER and VACUUM, the table_rewrite event is not triggered by them.

因此不能把 table_rewrite 当作「全量重写监控」。完整的重写治理必须同时覆盖:

text
① ALTER TABLE / ALTER TYPE 引起的重写   → table_rewrite 事件触发器
② CLUSTER / VACUUM FULL               → 靠流程管控(禁止在生产直接执行)+ 监控长事务/锁等待
③ SET TABLESPACE 迁移                  → table_rewrite 事件触发器(能覆盖)

原因码为 0 的情况 ​

如果触发器被触发但原因码为 0,说明触发了重写路径但四个已定义的位都没置位——这通常意味着成因在官方文档列出的四项之外(例如某些类型的连带重写),或者是版本差异。不要把 0 当成「没有原因」而忽略,在做管控策略时应把它视为「未知原因,按最严格处理」或至少记录告警。

sql
DECLARE reason integer := pg_event_trigger_table_rewrite_reason();
BEGIN
    IF reason = 0 THEN
        RAISE WARNING 'table_rewrite 触发但原因码为 0,table=%,请人工确认',
              pg_event_trigger_table_rewrite_oid()::regclass;
    END IF;
END;

拿到表名之外还能拿到什么 ​

table_rewrite 事件没有类似 pg_event_trigger_ddl_commands() 的对象清单函数。你能拿到的只有:

  • 即将重写的表的 OID(进而可查到表名、schema、大小、页数、所有者)
  • 重写原因码

TG_TAG 变量在 plpgsql 里仍然可用,可以告诉你触发命令的命令标签:

sql
RAISE NOTICE 'tag=% table=% reason=%',
    TG_TAG,
    pg_event_trigger_table_rewrite_oid()::regclass,
    pg_event_trigger_table_rewrite_reason();
text
NOTICE:  tag=ALTER TABLE table=demo reason=4

实战示例 ​

示例一:重写审计表(记录 before/after 两阶段) ​

sql
CREATE SCHEMA IF NOT EXISTS audit;

CREATE TABLE audit.rewrite_log (
    id          bigserial PRIMARY KEY,
    logged_at   timestamptz NOT NULL DEFAULT clock_timestamp(),
    who         text NOT NULL DEFAULT session_user,
    db_name     text NOT NULL DEFAULT current_database(),
    tag         text,
    table_name  text,
    reason      integer,
    reason_text text,
    relpages    integer,
    total_size  bigint,
    blocked     boolean NOT NULL DEFAULT false
);

CREATE OR REPLACE FUNCTION audit.log_table_rewrite() RETURNS event_trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, audit AS $$
DECLARE
    r_oid    oid     := pg_event_trigger_table_rewrite_oid();
    r_reason integer := pg_event_trigger_table_rewrite_reason();
    r_pages  integer;
    r_size   bigint;
BEGIN
    SELECT relpages INTO r_pages FROM pg_class WHERE oid = r_oid;
    r_size := pg_total_relation_size(r_oid);

    INSERT INTO audit.rewrite_log
           (tag, table_name, reason, reason_text, relpages, total_size)
    VALUES (TG_TAG, r_oid::regclass::text, r_reason,
            rewrite_reason_text(r_reason), r_pages, r_size);

    RAISE WARNING '表 % 即将被重写:reason=% (%),约 % 页',
          r_oid::regclass, r_reason, rewrite_reason_text(r_reason), r_pages;
END;
$$;

CREATE EVENT TRIGGER trg_log_table_rewrite
    ON table_rewrite
    EXECUTE FUNCTION audit.log_table_rewrite();
text
postgres=# ALTER TABLE demo ALTER COLUMN code TYPE varchar(128);
WARNING:  表 demo 即将被重写:reason=4 (column data type changed),约 12 页
ALTER TABLE
postgres=# SELECT table_name, reason, reason_text, relpages FROM audit.rewrite_log;
 table_name | reason |      reason_text       | relpages
------------+--------+------------------------+----------
 demo       |      4 | column data type changed |       12
(1 row)

示例二:生产环境「大表重写拦截器」 ​

小表随便重写,大表必须走维护窗口——这是最实用的策略。

sql
CREATE OR REPLACE FUNCTION guard.table_rewrite_policy() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    t_oid    oid     := pg_event_trigger_table_rewrite_oid();
    t_name   text    := t_oid::regclass::text;
    t_pages  integer;
    t_size   bigint;
    reason   integer := pg_event_trigger_table_rewrite_reason();
    hour_now integer := extract('hour' from current_time);
    max_pages integer := 20000;   -- 约 156MB,按你的环境调整
BEGIN
    SELECT relpages INTO t_pages FROM pg_class WHERE oid = t_oid;
    t_size := pg_total_relation_size(t_oid);

    IF t_pages IS NULL OR t_pages <= max_pages THEN
        RETURN;     -- 小表放行
    END IF;

    -- 大表:仅允许维护窗口 02:00 - 05:00
    IF hour_now BETWEEN 2 AND 5 THEN
        RAISE WARNING '维护窗口内允许重写大表 %(% 页,reason=%)',
              t_name, t_pages, reason;
        RETURN;
    END IF;

    RAISE EXCEPTION '大表 % 禁止在非维护窗口重写(% 页,约 %,reason=%)',
          t_name, t_pages, pg_size_pretty(t_size), rewrite_reason_text(reason)
          USING HINT = '请在维护窗口(02:00-05:00)执行,或改用在线变更工具(如 pg_repack)';
END;
$$;

CREATE EVENT TRIGGER trg_table_rewrite_policy
    ON table_rewrite
    EXECUTE FUNCTION guard.table_rewrite_policy();
text
postgres=# ALTER TABLE orders ALTER COLUMN order_no TYPE varchar(64);
ERROR:  大表 orders 禁止在非维护窗口重写(184320 页,约 1440 MB,reason=column data type changed)
HINT:  请在维护窗口(02:00-05:00)执行,或改用在线变更工具(如 pg_repack)

注意这里用了 pg_size_pretty() 让报错信息带上人类可读的大小——一线工程师看到 1440 MB 会立刻明白为什么被拦,比只报页数有效得多。

示例三:纯告警模式(记录上下文,不阻断) ​

有些团队不希望事件触发器阻断变更(一旦策略写错,会连带阻塞所有发布),只想要「可观测性」。

sql
CREATE OR REPLACE FUNCTION observe.rewrite_warning() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    t_oid   oid     := pg_event_trigger_table_rewrite_oid();
    reason  integer := pg_event_trigger_table_rewrite_reason();
BEGIN
    RAISE WARNING '[table_rewrite] table=% reason=% session_user=% application_name=%',
          t_oid::regclass, reason, session_user, current_setting('application_name')
          USING HINT = '若为大表请确认已评估锁与 WAL 影响';
END;
$$;

CREATE EVENT TRIGGER trg_observe_rewrite
    ON table_rewrite
    EXECUTE FUNCTION observe.rewrite_warning();

application_name 在这里是关键信息:它能告诉你这次重写是谁发起的(哪个应用、哪个迁移工具),比只知道「表被重写了」有用得多。可以配合 lsn / backend 信息落到日志系统。

示例四:把重写从「隐式」变成「显式决策」 ​

下面这个函数把原因码翻译成人话,并针对最常见的两类场景给出不同建议:

sql
CREATE OR REPLACE FUNCTION advise_rewrite() RETURNS event_trigger
LANGUAGE plpgsql AS $$
DECLARE
    reason integer := pg_event_trigger_table_rewrite_reason();
    t_name text    := pg_event_trigger_table_rewrite_oid()::regclass::text;
BEGIN
    IF reason & 2 <> 0 THEN
        RAISE WARNING '%:列默认值变化触发重写(volatile 默认值)', t_name
              USING HINT = '考虑先 ADD COLUMN 不带默认值,再分批 UPDATE,最后 SET DEFAULT';
    END IF;

    IF reason & 4 <> 0 THEN
        RAISE WARNING '%:列类型变化触发重写', t_name
              USING HINT = '评估是否可用「新增列 + 双写 + 切换」替代原地改类型';
    END IF;

    IF reason & 1 <> 0 THEN
        RAISE WARNING '%:持久性变化触发重写(LOGGED/UNLOGGED)', t_name;
    END IF;

    IF reason & 8 <> 0 THEN
        RAISE WARNING '%:访问方法变化触发重写', t_name;
    END IF;
END;
$$;

按位判断的价值在这里体现得最清楚:reason & 2 <> 0 能在 reason = 3(默认值 + 持久性同时变)时依然正确命中。

示例五:验证「阻断」确实生效,且事务被完整回滚 ​

sql
BEGIN;
ALTER TABLE demo ALTER COLUMN n TYPE bigint;
ROLLBACK;
text
ERROR:  大表 demo 禁止在非维护窗口重写(...)

被拦下后要确认:表结构没有被改动(因为报错来自重写之前,命令未执行)。

sql
SELECT attname, atttypid::regtype
FROM pg_attribute
WHERE attrelid = 'demo'::regclass AND attnum > 0 AND NOT attisdropped;
text
 attname | atttypid
---------+----------
 n       | integer     ← 仍是 integer,未被改成 bigint
(1 row)

这是 table_rewrite 强于 ddl_command_end 的地方:它在命令执行前就拦住了,而不是执行完再回滚整张表的重写。

示例六:巡检当前的表重写策略 ​

sql
SELECT e.evtname,
       e.evtevent,
       e.evtenabled,
       n.nspname || '.' || p.proname AS func,
       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
WHERE e.evtevent = 'table_rewrite';
text
        evtname        |   evtevent    | evtenabled |          func           | can_block
-----------------------+---------------+------------+-------------------------+-----------
 trg_log_table_rewrite | table_rewrite | O          | audit.log_table_rewrite | f
 trg_table_rewrite_policy | table_rewrite | O          | guard.table_rewrite_policy | t
(2 rows)

can_block 这一列虽然只是粗略的文本匹配,但足以在巡检时提醒你「这个触发器是会阻断变更的」——这在生产环境是一等重要信息。

注意事项 ​

  • 不要用等值比较判断原因码。 它是 bitmap,必须用按位与。

    sql
    -- 错误!
    IF pg_event_trigger_table_rewrite_reason() = 4 THEN ...
    -- 正确
    IF pg_event_trigger_table_rewrite_reason() & 4 <> 0 THEN ...
  • 不要引用官方未公开的 AT_REWRITE_* 宏名。 官方 SQL 文档只给出数值 1/2/4/8;源码宏名不是稳定接口。

  • 不要在 table_rewrite 里调用 pg_event_trigger_ddl_commands() 或 pg_event_trigger_dropped_objects()。 它们分别只允许在 ddl_command_end 与 sql_drop 中调用。

  • 不要以为 CLUSTER / VACUUM FULL 会触发。 官方明确说明它们不触发 table_rewrite。重写治理必须额外覆盖这两条路径。

  • 不要假设所有 ALTER TABLE 都会重写,也不要假设都不会。 SET DEFAULT 免重写,但 ADD COLUMN ... DEFAULT random() 会重写——差别只在默认值表达式的 volatility。

    sql
    -- 不重写
    ALTER TABLE t ADD COLUMN a timestamptz DEFAULT now();
    -- 重写!volatile 默认值必须逐行求值
    ALTER TABLE t ADD COLUMN b uuid DEFAULT gen_random_uuid();
  • 不要忽略 ALTER TYPE 这条触发路径。 它不在 ALTER TABLE 的语感里,但官方定义明确包含它。

  • 不要把 relpages 当精确大小。 它是 ANALYZE / VACUUM 时的估计值,做阈值判断足够,做精确计量请用 pg_total_relation_size()。

  • 不要在管控触发器的报错信息里只写「禁止」。 要把表名、大小、原因、正确做法(窗口时间、替代工具)写进 HINT。

  • 不要让管控触发器「误伤」小表。 小表重写是毫秒级的,如果一刀切全部禁止,会逼得开发绕过机制。按 relpages 或 pg_total_relation_size() 设阈值。

  • 不要忘记管控触发器一旦出错会阻塞所有发布。 RAISE EXCEPTION 逻辑必须简单、无副作用、不依赖外部系统。建议先在影子库跑一轮全量迁移验证。

  • 不要在触发器中做重查询。 它会延长 ACCESS EXCLUSIVE 锁的持有时间(重写前的等待也算进锁窗口)。

  • 不要忘记给管控留逃生通道。 被自己的策略锁死时,可以用 ALTER EVENT TRIGGER ... DISABLE(对事件触发器自身的命令不触发事件触发器)或 event_triggers = false 绕过。

    sql
    ALTER EVENT TRIGGER trg_table_rewrite_policy DISABLE;
    -- 执行必要变更
    ALTER EVENT TRIGGER trg_table_rewrite_policy ENABLE;
  • 不要在审计表里只记表名不记操作者。 记 session_user、current_user、application_name,否则事后无法追责。

最佳实践 ​

  • 小表放行、大表管控,用阈值而不是一刀切。

    sql
    IF t_pages <= max_pages THEN RETURN; END IF;
  • 审计与管控拆成两个触发器。 审计永不阻断(只 RAISE WARNING + 落库),管控负责拦截。这样某个策略写错也不至于让审计断档。

    sql
    CREATE EVENT TRIGGER trg_log_rewrite    ON table_rewrite EXECUTE FUNCTION audit.log_table_rewrite();
    CREATE EVENT TRIGGER trg_guard_rewrite  ON table_rewrite EXECUTE FUNCTION guard.table_rewrite_policy();
  • 用 application_name 记录「谁发起的重写」。 这是事后定位「哪个迁移工具干的」最直接的线索。

    sql
    current_setting('application_name')
  • 把原因码解码成可读文本再落库与报错。 存数值方便统计,存文本方便人读,两个字段都留。

    sql
    reason, rewrite_reason_text(reason)
  • 对 reason = 0 单独告警。 官方定义的四个位都没置位时不要静默放过。

  • 报错信息必须可操作。 带上表名、实际大小、重写原因、允许的时间窗、替代方案。

    sql
    RAISE EXCEPTION '大表 % 禁止在非维护窗口重写(约 %)', t_name, pg_size_pretty(t_size)
          USING HINT = '请在 02:00-05:00 执行,或改用 pg_repack';
  • 大表改类型优先考虑「新增列 + 双写 + 切换 + 删旧列」,把一次重写拆成多次轻量操作。table_rewrite 触发器的作用正是把这条路径「逼」出来。

  • SET TABLESPACE 也走同一套策略。 它是重写,会产生等量 WAL,务必安排在低峰期并预估磁盘余量。

  • 把重写的完整覆盖面写进运维手册。 table_rewrite 事件 + CLUSTER/VACUUM FULL 流程管控 + 长事务与锁等待监控,三者缺一不可。

  • 管控触发器逻辑保持极简、无外部依赖。 它是变更路径上的一环,任何额外故障都会放大成「发布全部失败」。

  • 部署前在影子库用真实大小的数据演练。 确认阈值合理、报错可读、不会误伤常规迁移。

下一节

继续学习:事件触发器应用场景——把前面三节的能力组合成可落地的治理方案:DDL 审计、命名规范强制、权限管控、结构同步与变更告警。

参考链接 ​