上个月公司要做一个数据归档项目,核心是一张日均写入 200 万行的订单表,三个月下来涨到了 3 亿行。业务方要求按月份拆分、超过 90 天的数据能一键转走,运维侧最省事的做法就是上 PostgreSQL 的声明式分区。
当时用的还是 PG 13,pgAdmin 里点几下就能建好分区表,看起来两小时能搞完。结果真做起来,前前后后折腾了整整一个周末,中间还锁表锁了 40 分钟,业务侧投诉了一轮。这里把踩到的坑按顺序摊开讲,尤其是第二个坑,我在锁表之后才反应过来,早知道就不该用那个迁移姿势。
如果你也在做数据库的分区改造和历史数据归档,机器的磁盘容量和 IOPS 先得匹配上:QQG.NET(美国独立服务器,3.84T NVMe SSD 存储、大内存机型)的独立服务器有大容量 NVMe 盘和高内存配置,做分区迁移时的临时表空间和索引重建都比较从容;Ciallo Networks(香港 EPYC 系列 NVMe 云主机)的 EPYC + NVMe 组合适合做读多写少的归档查询节点。

这个坑最常见,也最容易被搜索结果里的“建表语句”骗过去。PG 的声明式分区有个硬规则:分区表上的主键和唯一约束,必须包含全部分区键。也就是说,如果你按 created_at 月份分区,主键就不能再是单纯的 id,必须写成 (id, created_at) 这种联合形式。我第一次建表时直接照抄原表的 `PRIMARY KEY (id)`,语句直接被拒:
ERROR: unique constraint on partitioned table must include all partitioning columns
更麻烦的是,这不只是改个建表语句的事。原表上那些引用了 id 的外键、那些基于 id 建的各种唯一索引,业务代码里还有“按 id 查订单”的逻辑,全都建在“单列 id 唯一”这个假设上。要么把所有唯一约束改成联合形式(应用层也得跟着改),要么退回到继承式分区(老派做法,PG 里 INHERITS 那套),维护成本一下就上来了。最后我们的做法是保留 (id, created_at) 联合主键,另外在 id 上建一个非唯一索引,配合业务侧加了一层缓存来扛查询压力。
这是那次事故的主角。我们的迁移姿势是:先建好新的分区表结构,然后按月份 ATTACH 一个个把老数据“挂”上去。文档上 ATTACH PARTITION 需要 ACCESS EXCLUSIVE 锁,而且这个锁会一直持有到整个事务提交为止。我当时把 12 个月的数据放在一个事务里批量 ATTACH,结果锁表从 21:40 一路持续到 22:20,整整 40 分钟,业务的写入全部排队。

后来查了 pg_locks 和 pg_stat_activity 才发现,ATTACH 之所以慢,是因为它要在分区表上建索引并更新所有分区根目录,3 亿行的元数据一更新就是几十分钟级。正确姿势是:每个分区单独开一个事务 ATTACH,提交一次,并且尽量在业务低峰期做。还有一点,PostgreSQL 12 以后 ATTACH 支持先 CREATE TABLE 空的分区结构,再用 ATTACH ... DEFAULT 或者提前把数据 COPY 进去,这样持锁时间能压到秒级。我们换了这个方式之后,再没出现过长时间锁表。
另一个容易翻车的点是迁移过程中序列(sequence)的状态。老表的 id 是靠序列取的,用 pg_dump --data-only 导出数据、再导入新表,导入完之后序列还停在原来的位置。新表接着插入数据,第一条就报 duplicate key。更绝的是有的表上有触发器,pg_restore 带 --section=data-only 时触发器定义不会跟着走,导入完发现新表的审计字段全是空的。
我们的处理办法是:迁移完成后逐个表执行 SELECT setval('表名_id_seq', (SELECT MAX(id) FROM 表名)); 把序列推上去,触发器则在切流前统一用 pg_dump --schema-only 单独导一份结构再补回去。整个过程手工活不少,200 多张关联表跑了两遍脚本才敢切流量。
数据都搬完、老表准备下线时,别急着 DROP。同样是 ACCESS EXCLUSIVE 锁,但这次是 3 亿行的一整张表,DROP 本身不慢,慢的是删除文件时文件系统要同步元数据。那次我们先用 ALTER TABLE ... DETACH PARTITION 把老数据拆出来,再把老表 RENAME 成 _bak 保留一周,确认新表数据没问题后才在周末低峰期删掉。留一个可回滚窗口,比省几个小时磁盘空间重要得多。

分区表建好后还有几件事必须做,不然等于白干。一是给每个分区建好索引,PG 声明式分区支持在父表上建索引再自动下发到分区,但已存在的分区需要手动补:CREATE INDEX ON parent ... 之后再逐个分区 attach。二是把 autovacuum 的参数按分区表的实际行为调一下,我们这批分区表写入频繁,默认的 autovacuum_naptime 已经不够用。三是监控要把分区维度的膨胀率加进去,PG 14 以后还可以直接看 pg_stat_partition 的统计,某个分区突然涨了几倍往往就是某个业务方改了写入逻辑。
还有一点分区表的老问题:如果查询条件里没带上分区键,规划器就会退化成全分区扫描,数据量小的时候看不出来,几千万行以后就是灾难。生产上务必开 enable_partition_pruning 的日志排查,或者用 EXPLAIN 确认每个查询的分区裁剪情况。

整个周末下来,PG 分区表最大的价值不是性能提升,而是把“归档”这件事变成了可执行的 SQL:ATTACH、DETACH、导出,粒度从整张表降到了一个月。真要动手,建议先用一张测试表把 ATTACH 的锁时间、索引下发的耗时、序列的恢复这三件事验证一遍,再上生产。另外,动手前把主键约束的问题想清楚,这块是唯一需要业务代码配合改的地方,也是最容易被拖到上线前一天才发现的。