半夜表膨胀四倍:PostgreSQL 逻辑复制遇上 statement_timeout 的灾难现场

2026-06-02 15 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:7 分钟

一次看起来万无一失的 PostgreSQL 迁移,在午夜把人吓醒——源端表安安静静几十 GB,目标端同样几张表却膨胀到 400 GB 以上,还在继续涨。排查到最后,罪魁祸首是一个再正常不过的配置:statement_timeout。两个各自合理的机制撞在一起,就制造了一场无声的灾难。

事情是怎么发生的

客户要把一个 PostgreSQL 数据库通过逻辑复制迁移到新服务器。逻辑复制的第一步是初始表拷贝——PostgreSQL 把源端已有行全量复制到目标端,之后才开始流式同步增量变更。

大多数表拷贝很快,没什么波澜。但第二天早上一看:源端几张表 50–90 GB,目标端对应表已经超过 400 GB,而且还在涨。

死循环的机制

根源在发布端服务器上 statement_timeout 被设成了 1 分钟。这个参数的本意很合理:任何查询或操作超过指定时间就强制终止,防止失控查询吃掉生产资源。

但逻辑复制的初始拷贝本质上就是一个长时间运行的大操作。1 分钟一到,statement_timeout 直接把它杀掉。

关键在于:PostgreSQL 逻辑复制的拷贝进程被中断后不会停下来等你手动重启——它自动重试。每次重试都会往目标表插入行,每次被杀掉后,这些已经写入但事务未完成的行就变成了 dead tuples。死元组占空间,不会立刻消失,需要 autovacuum 来清理。但 autovacuum 根本追不上拷贝全速重启产生死元组的速度。

于是整晚都在跑这个循环:

  1. 复制开始往目标表写行
  2. 发布端 statement_timeout 在 1 分钟后杀掉 COPY
  3. 已写入的行变成 dead tuples,表膨胀
  4. 复制自动重启,再次全量写行
  5. 重复,永不停歇

死垃圾堆积速度远超清理速度,表体积只涨不缩。

一行 SQL 的修复

理解了机制之后,修复很简单——只对复制角色放开超时限制,其他用户不受影响:

-- 只对复制用户取消 statement_timeout,其他用户保持原有保护
ALTER ROLE replication_user SET statement_timeout = 0;

statement_timeout = 0 表示无超时限制,但仅作用于 replication_user 这个角色。生产查询的安全网完好无损,只有复制拷贝进程拿到了跑完所需的喘息空间。

改完之后,初始表拷贝一次跑完,目标表停止膨胀,复制干净地追上了进度。

迁移前该检查什么

这次事故的教训不是"别设 statement_timeout"——它该设。教训是:做逻辑复制之前,必须检查发布端所有可能打断长操作的配置。下面是一个实操检查清单,可以直接跑:

#!/bin/bash
# logical_replication_precheck.sh
# 在发布端执行,检查可能干扰初始拷贝的配置

PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-postgres}"

echo "=== 可能打断逻辑复制初始拷贝的配置 ==="

# 1. statement_timeout — 最关键的杀手
echo -e "\n[statement_timeout] 全局与角色级别设置:"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -c "
SELECT rolname, rolstatement_timeout
FROM pg_roles
WHERE rolstatement_timeout IS NOT NULL
ORDER BY rolname;
"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -c "
SHOW statement_timeout;
"

# 2. idle_in_transaction_session_timeout — 也会杀长事务
echo -e "\n[idle_in_transaction_session_timeout]:"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -c "SHOW idle_in_transaction_session_timeout;"

# 3. lock_wait_timeout 相关
echo -e "\n[deadlock_timeout / lock_timeout]:"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -c "SHOW lock_timeout;"

# 4. 检查复制用户当前的超时设置
echo -e "\n[复制用户超时设置](替换为你的实际复制用户名):"
REPL_USER="replication_user"
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -c "
SELECT rolname, rolstatement_timeout, rolidle_in_transaction_session_timeout
FROM pg_roles
WHERE rolname = '${REPL_USER}';
"

echo -e "\n=== 建议 ==="
echo "如果复制用户的 statement_timeout 不为 0,迁移前执行:"
echo "ALTER ROLE ${REPL_USER} SET statement_timeout = 0;"
echo "迁移完成后可恢复原值。"

运行前把 replication_user 替换成你实际的复制角色名,PGHOST/PGPORT/PGUSER 按环境调整。

除了 statement_timeout,还有几个配置值得留意:

  • idle_in_transaction_session_timeout:如果复制连接在事务中有空闲等待,也可能被杀。
  • lock_timeout:初始拷贝需要拿锁,超时放弃同样会中断进程。
  • 网络防火墙 idle timeout:中间网络设备可能杀掉"看起来不活跃"的长连接,症状类似但日志里看不到 PostgreSQL 自身的终止记录。

迁移完成后的回退

临时放开超时只是为了初始拷贝阶段。拷贝完成、复制进入正常流式同步后,长操作不再存在,可以把超时恢复:

-- 迁移完成后,恢复复制用户的 statement_timeout
-- 方式一:恢复为全局默认值
ALTER ROLE replication_user RESET statement_timeout;

-- 方式二:恢复为指定值(比如原来的 60000ms)
ALTER ROLE replication_user SET statement_timeout = '1min';

核心教训

statement_timeout 和逻辑复制初始拷贝各自都没问题,但碰在一起就形成了一个自动重启→死元组堆积→表膨胀的死循环。修复只需一行 SQL,但你得先知道去哪里找。

做逻辑复制迁移前,在发布端跑一遍上述检查脚本,确认复制角色不会被超时配置打断。这比半夜被 400 GB 的表吓醒要划算得多。


相关推荐