一次看起来万无一失的 PostgreSQL 迁移,在午夜把人吓醒——源端表安安静静几十 GB,目标端同样几张表却膨胀到 400 GB 以上,还在继续涨。排查到最后,罪魁祸首是一个再正常不过的配置:statement_timeout。两个各自合理的机制撞在一起,就制造了一场无声的灾难。
事情是怎么发生的
客户要把一个 PostgreSQL 数据库通过逻辑复制迁移到新服务器。逻辑复制的第一步是初始表拷贝——PostgreSQL 把源端已有行全量复制到目标端,之后才开始流式同步增量变更。
大多数表拷贝很快,没什么波澜。但第二天早上一看:源端几张表 50–90 GB,目标端对应表已经超过 400 GB,而且还在涨。
死循环的机制
根源在发布端服务器上 statement_timeout 被设成了 1 分钟。这个参数的本意很合理:任何查询或操作超过指定时间就强制终止,防止失控查询吃掉生产资源。
但逻辑复制的初始拷贝本质上就是一个长时间运行的大操作。1 分钟一到,statement_timeout 直接把它杀掉。
关键在于:PostgreSQL 逻辑复制的拷贝进程被中断后不会停下来等你手动重启——它自动重试。每次重试都会往目标表插入行,每次被杀掉后,这些已经写入但事务未完成的行就变成了 dead tuples。死元组占空间,不会立刻消失,需要 autovacuum 来清理。但 autovacuum 根本追不上拷贝全速重启产生死元组的速度。
于是整晚都在跑这个循环:
- 复制开始往目标表写行
- 发布端
statement_timeout在 1 分钟后杀掉 COPY - 已写入的行变成 dead tuples,表膨胀
- 复制自动重启,再次全量写行
- 重复,永不停歇
死垃圾堆积速度远超清理速度,表体积只涨不缩。
一行 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 的表吓醒要划算得多。