- 有时候图形客户端弹出一句“分区表正被其它程序独占访问”,并不是文件被占住,而是数据库内部的锁在排队。
这句话是我接手过好几个PostgreSQL生产环境之后最深的感触。很多同事第一次看到这个提示,第一反应是去检查文件权限、杀掉进程,甚至重启数据库,但真正的问题往往藏在锁等待里。PostgreSQL本身是支持分区表的,尤其PG 10之后引入了声明式分区,运维上一个分区就跟管理一张独立表一样方便。可一旦涉及到“删分区”“搬数据”“跨库迁移”,各种坑就开始往外冒。
这篇内容我不打算写成一版官方文档的复述,而是围绕分区表日常维护和迁移这两个核心场景,把我在真实环境里碰到过的问题、排查思路、最终落地的方案全部摊开讲。如果你正在用PostgreSQL管着一张不断增长的大表,或者正准备把一套业务从旧库迁到新库,这篇文章应该能帮你少走不少弯路。
1. 分区表维护的日常:加分区、归档删除与自动化的基础操作
1.1 为什么“分区表”是对运维最友好的数据组织方式之一
很多人第一次接触分区表会觉得麻烦,毕竟多了一层父表和子表的关系,写SQL的时候还得注意命名。但真正扛过千万级甚至亿级数据之后,你会感谢分区表带来的运维便利。
核心逻辑很简单:一张大表被拆成若干独立存储的分区,每个分区拥有自己的存储、索引和统计信息。查询的时候,优化器根据分区键直接裁剪掉无关分区,只扫必要的数据文件。维护的时候,你也不再需要面对“整表DELETE再VACUUM FULL”这种耗时间的操作,删掉一个分区就是一张表的DROP,秒级完成。
拿一个典型的订单表举例:
sql复制CREATE TABLE orders (
order_id bigint NOT NULL,
order_time timestamptz NOT NULL,
amount numeric(10,2)
) PARTITION BY RANGE (order_time);
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
PG 10之后的声明式分区会把父子关系、约束、默认分区都交给内核管理,不再需要手动写check约束和trigger。对比老式的“继承+触发器”方案,声明式分区在查询计划、锁处理和并发控制上都要干净得多,这也是我至今推荐团队首选声明式分区的原因。
1.2 日常加分区:手动SQL与自动扩展
分区表的维护频率通常是按月来算的。每个月月底加下一个月的分区,这种活看起来简单,但漏掉一次就会出问题:新数据没有对应分区,INSERT直接报错“no partition of relation found for row”。
手动加一个分区的SQL不复杂:
sql复制CREATE TABLE orders_2024_03 PARTITION OF orders
FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
CREATE INDEX ON orders_2024_03 (order_time);
问题在于,生产环境不会给你太多手动操作空间。最好是在应用发布流程里带上每月自动加分区的脚本,或者使用pg_partman这套扩展,它专门做分区生命周期管理。
pg_partman的基本思路是:给你一个定时任务,到点自动创建新分区、清理过期分区。安装后创建一套按月分区的配置大概是这样:
sql复制SELECT partman.create_parent(
p_parent_table => 'public.orders',
p_control => 'order_time',
p_interval => '1 month',
p_premake => 2
);
配合PgAgent还是外部cron,定时调用partman.run_maintenance()就能自动把下个月、下下个月的分区提前建好。用上这套之后,我基本没有再遇到过“今天1号发现0点数据写不进去”的尴尬。除非上游维护窗口临时取消,一般来说提前预建两个分区足够覆盖绝大多数情况。
另一个容易被忽略的点是默认分区。我强烈建议分区表保留一个default分区,用于路由边界外的数据。比如SQL里的时间异常,或者某个历史数据时间戳超出了预定范围,至少数据不会直接失败,而是落到default分区里暂存,后续再人工修正。代价是default分区扫起来会比较慢,但作为一个兜底机制,它远比整条链路报错强。
1.3 老分区归档:TRUNCATE与DROP的正确姿势
订单表越来越大之后,就得考虑历史分区怎么处理。最常见的需求是“只保留最近24个月数据”,那么超过24个月的分区需要删除。
这里的删除有两种方式:
DROP TABLE orders_2022_01;,直接连表结构带数据一起干掉,最彻底。TRUNCATE orders_2022_01;,只清数据,保留结构和索引。
大多数场景我会优先选DROP。原因在于TRUNCATE之后,分区依然存在,未来的INSERT如果有数据落入这个区间,还是会写进去,维护上反而容易产生歧义。而且DROP表在PostgreSQL里是纯粹的元数据操作,速度极快。
需要注意一点:如果分区表有外键、视图或依赖对象,DROP之前先检查依赖关系:
sql复制SELECT * FROM pg_depend WHERE refobjid = 'orders_2022_01'::regclass;
另一个更稳妥的归档思路是“先拆下来再处理”。把要归档的分区先从父表上拆掉,变成一张独立表,再决定是导出备份还是物理拷贝:
sql复制ALTER TABLE orders DETACH PARTITION orders_2022_01;
之后可以继续用这张表查询数据,也可以在确认不需要后DROP。这样做的最大优势是:解耦归档动作和业务表结构,避免在业务高峰期持有一把长事务DDL锁。这也是后面数据迁移里最常用的一招,先在这里埋个伏笔。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁等待排查:为什么“分区表正被其它程序独占访问”总是出现
2.1 锁的等级与分区表特有的锁冲突
回到文章开头提到的报错。“分区表正被其它程序独占访问,本程序修改它失败,请先关闭其它的所有程序”,这通常是数据库图形管理工具对锁等待的一种表层通告。真正的原因是:你的DDL语句要求获得一个很强的表级锁,但当前表上有其他事务正在访问,导致这个锁一直等不到。
PostgreSQL的锁分为很多级别,从弱到强分别是:
| 锁模式 | 冲突范围 | 典型场景 |
|---|---|---|
| ACCESS SHARE | 与ACCESS EXCLUSIVE冲突 | SELECT查询 |
| ROW SHARE | 与EXCLUSIVE冲突 | SELECT FOR UPDATE |
| ROW EXCLUSIVE | 与SHARE等冲突 | INSERT / UPDATE / DELETE |
| SHARE UPDATE EXCLUSIVE | 与自身、SHARE等冲突 | VACUUM / ANALYZE |
| SHARE | 与ROW EXCLUSIVE冲突 | CREATE INDEX非并发 |
| SHARE ROW EXCLUSIVE | 与绝大多数据写冲突 | 部分DDL |
| EXCLUSIVE | 与SHARE等冲突 | 刷新视图 |
| ACCESS EXCLUSIVE | 与所有锁冲突 | DROP / TRUNCATE / ALTER TABLE |
对分区表执行DROP PARTITION、ALTER TABLE DETACH PARTITION这类操作时,获取的是ACCESS EXCLUSIVE锁。这意味着它和任何正在进行的SELECT、INSERT、UPDATE、DELETE都互斥。
你可以把这种场景想象成办公室里的一张共享会议桌。大家坐着各自看资料、写文档,这叫并发读取,很安全。但如果有人要搬走这张桌子(相当于DDL),就必须等所有还在用桌子的人先离开。只要有一个人的文档没合上,搬桌子就只能等着。
对于分区表来说,问题更容易放大。因为父表上往往有大量并发业务读写,只要其中任何一个事务还没结束,你执行的分区管理操作就只能在锁队列里排队。而且很多情况下,排队的不只是你一个操作,后面还可能堵着一堆其他业务查询。
2.2 一次线上锁等待的完整排查链路
我之前处理过一次线上故障,现象就是分区表删不掉,图形客户端一直弹“正被其它程序独占访问”。排查过程大致是这样:
第一步,查看当前活跃会话中有没有长时间等待锁的进程。
sql复制SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state = 'active' AND wait_event_type IS NOT NULL;
发现有一个DDL语句的wait_event_type是Lock,对应的wait_event是relation,说明它正在等待某个表上的锁。
第二步,查出这个DDL到底被谁阻塞。
sql复制SELECT blocked.pid AS blocked_pid,
blocking.pid AS blocking_pid,
blocked.query AS blocked_query,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.state = 'active';
结果很典型:阻塞它的并不是一条慢SQL,而是一个空闲事务。某个应用连接开启了事务,执行了几条INSERT之后既不提交也不回滚,一直挂在那里。这种“空闲在事务中”的会话不会主动释放已经持有的ROW EXCLUSIVE锁,于是后面想获取ACCESS EXCLUSIVE锁的DDL就只能无限等待。
第三步,处理阻塞源。由于那个空闲事务属于某个批量处理任务,确认业务方已经不需要之后,我直接用管理员账号终止了它:
sql复制SELECT pg_terminate_backend(12345);
然后再执行分区删除,很快就成功了。
这个案例最有价值的地方在于:如果你不查pg_blocking_pids,只是盲目重启数据库或者停应用,损失会非常大。而只要找到阻塞源头,问题往往几秒钟就解决。
2.3 让DDL不再撞锁:lock_timeout与语句级别的超时策略
被锁等待一次之后,我给自己定了一个规矩:所有分区维护类DDL,必须显式设置锁等待超时。直接裸执行DROP TABLE最怕的就是无限期卡在锁队列里,既不给业务反馈,又占着一个后端连接。
推荐的写法是放在事务里,配合lock_timeout让DDL快速失败:
sql复制BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders DETACH PARTITION orders_2022_01;
COMMIT;
这样如果3秒内拿不到锁,语句会直接报错退出,而不是把会话挂在那里没完没了。生产环境里,这个策略的价值在于可预期性。你可以把失败的任务交给重试系统,在下一个低峰期再跑,而不是让一个“僵尸DDL”占着连接资源。
如果确实有一些大查询会长时间跑,等它自然结束又影响归档节奏,可以考虑维护窗口执行。在低峰期,比如凌晨2点到4点,手动触发分区管理任务。配合cron和自动化平台,把时间窗口卡死,几乎不会再出现“删分区把业务全拖死”的惨案。
3. 数据迁移的分层设计:从逻辑备份到物理迁移的路线图
3.1 先想清楚:分区表迁移和普通表迁移的差别
分区表迁移不是“把一张表从A库搬到B库”那么单纯。它实际上是一组表的联合迁移,父表、分区、索引、约束、默认分区,缺一不可。如果你只复制了父表结构和数据,漏掉了分区的边界定义,后续的新数据路由就会出问题。
所以做迁移规划之前,先回答这几个问题:
- 数据量是几百GB还是几十TB?
- 源库和目标库的PG版本是相同还是跨大版本?
- 业务窗口能停多久?
- 需要保留分区结构,还是可以拍平成一个普通大表?
这些问题直接决定方案选择。我通常把迁移方案分成三条路线:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 数据量小,停机窗口充足 | pg_dump + pg_restore | 简单、可靠、兼容性好 |
| 数据量大,分散在线 | 逻辑复制(publication/subscription) | 不锁表,增量持续同步 |
| 同版本、数据量极大且内网带宽充足 | 物理流复制后切换,或数据文件级拷贝 | 整体搬迁速度快 |
3.2 pg_dump/pg_restore组合:最稳妥的默认选择
只要数据量在百GB级别以内,我一般默认用pg_dump和pg_restore迁移分区表。这是最通用也最不容易出幺蛾子的组合。
导出命令:
bash复制pg_dump -h 旧库地址 -U 用户 -d 数据库名 -F c -f backup.dump
使用自定义格式(-F c)的好处是pg_restore可以按需恢复单个对象,而且支持并行恢复。不过要注意,分区表在dump文件里是一整套对象,直接恢复时会把父表、所有分区、索引按顺序重建出来。
恢复命令:
bash复制pg_restore -h 新库地址 -U 用户 -d 数据库名 -j 4 backup.dump
-j 4是并行度,这里面的坑在于:并行恢复会同时创建表和索引,如果表之间存在依赖关系,可能出现等待。不过PostgreSQL的pg_restore在并行模式下已经做了依赖排序,对于分区表这种父子结构基本能正确处理。
一个容易踩的坑是只恢复单张分区表。比如你只关心orders这一张分区表,就写-t public.orders,结果发现只恢复出一张空壳父表,分区一个都没过来。不同版本的pg_dump对-t参数处理分区表的行为不完全一致,新版本会自动连带分区,但为了保险起见,恢复之前先看一下dump文件清单:
bash复制pg_restore -l backup.dump | grep orders
确认所有分区都在清单里,再执行恢复。
3.3 分区表跨版本迁移的特殊姿势:逻辑复制与高可用切换
如果目标库版本比源库高,比如从PG 12迁到PG 16,就不能直接拿数据文件拷贝,逻辑复制是首选。PostgreSQL的内置逻辑复制基于发布和订阅模型,可以在运行中完成迁移,不锁业务表。
在源库创建发布:
sql复制CREATE PUBLICATION pub_orders FOR TABLE orders;
在目标库创建订阅:
sql复制CREATE SUBSCRIPTION sub_orders
CONNECTION 'host=源库地址 dbname=数据库名 user=复制用户 password=xxxx'
PUBLICATION pub_orders;
逻辑复制会自动把源库的分区表结构同步过去,包括之后新加的分区。这一点在PG 12及以后版本里表现得很成熟。
需要注意的是序列。如果分区表里的主键依赖序列自增,而序列本身不在发布内容里,目标库的序列不会自动同步。PG 16之前需要手工把序列值调成大于源库当前值,否则随后插入新数据可能主键冲突。我的常规做法是,在切换之前查看源库序列的当前值:
sql复制SELECT last_value FROM orders_order_id_seq;
然后在目标库手动设置:
sql复制SELECT setval('orders_order_id_seq', 1000000, true);
切换的时候也要讲究顺序。先让目标库追平源库,等订阅的wal receiver lag降到0附近,再把应用连接切到目标库,最后在源库上停掉写入。这样能最大程度压缩停机时间。逻辑复制加应用切换这套组合,我实测在几十GB的分区表迁移场景下,停机窗口可以控制在分钟级。
4. 分区级数据搬移:DETACH/ATTACH在迁移实战中的黄金组合
4.1 为什么不直接INSERT SELECT:绕过全表扫描与写放大
经常有人问,迁移某个历史分区,为什么不直接在目标库执行INSERT INTO ... SELECT ... FROM 源库?
不是说不行,而是代价太大。从源库读取数据是整分区扫描,网络传输是逐行序列化,目标库写入时还要写WAL日志、更新索引。如果分区有二级索引,写入成本会成倍上涨。整个过程源库压力大,目标库压力也不小,遇到几亿行的老分区,跑几个小时都算正常。
更聪明的方式是“整体搬移”,类似搬家的时候直接抬柜子,而不是把所有东西掏出来再重新摆进去。PostgreSQL天然支持这种操作,关键就是DETACH和ATTACH。
先说DETACH。它可以把一个分区从父表上拆下来,变成一个独立的普通表。数据还在原存储文件里,不需要逐行复制。
sql复制ALTER TABLE orders DETACH PARTITION orders_2019_12;
执行之后,orders表里不再有orders_2019_12这个分区,但orders_2019_12这张表本身依然存在,可以单独查询、导出或者拷贝。
再说ATTACH。它可以把一张独立表重新挂到一个分区父表上,前提是表的行数据满足分区边界约束。
sql复制ALTER TABLE orders_archive ATTACH PARTITION orders_2019_12
FOR VALUES FROM ('2019-12-01') TO ('2020-01-01');
整个过程不需要逐行搬运,只是元数据层面的挂接,速度非常快。这就是分区表迁移最优雅的地方。
4.2 实战:把2019年分区从A库搬到B库
我之前做过一个历史数据归档项目,要把2019年的12个月分区从在线订单库搬到历史查询库。当时在线库的数据量已经非常大,如果直接用INSERT SELECT,业务高峰一整天都可能受影响。最终采用的就是DETACH加ATTACH的组合。
第一步,在源库验证分区边界。确认orders_2019_12这个分区确实只是2019年12月的数据,没有混入其他月份。如果有default分区里散落的数据,先修正,否则搬过去之后边界对不上,ATTACH会失败。
第二步,在源库执行DETACH:
sql复制BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE orders DETACH PARTITION orders_2019_12;
COMMIT;
拆下来之后,这张表变成了完全独立的存在,源库在线业务的写入不再影响它,后续导出操作也只针对这一张表,不再干扰主表。
第三步,单独导出这张表。用小而全的方式:
bash复制pg_dump -h 源库 -U 用户 -d 数据库 -t public.orders_2019_12 -F c -f orders_2019_12.dump
第四步,在目标库预处理好父表和空分区结构。这一步很关键,目标库的orders_history分区表必须先存在,而且必须有对应的分区定义。导入时只需要把数据导入到一张独立表,然后ATTACH上去。
恢复这张表:
bash复制pg_restore -h 目标库 -U 用户 -d 数据库 -j 2 orders_2019_12.dump
第五步,执行ATTACH挂接:
sql复制ALTER TABLE orders_history ATTACH PARTITION orders_2019_12
FOR VALUES FROM ('2019-12-01') TO ('2020-01-01');
ATTACH执行时,PostgreSQL会校验表里的数据是否完全满足分区边界。如果整张表之前是源库分区表的一部分,边界约束是现成的,校验很快。但如果表是自己造出来的、没有边界约束,PG会做一次全表扫描来验证范围,几亿行数据的场景这个校验也够喝一壶。所以我的习惯是:让数据一直保持“从分区里出来再进分区里去”的状态,途中不要做任何可能破坏边界约束的操作。
4.3 搬移后的校验与索引重建:不能省的最后一步
搬移完成不是终点,校验才是。
我通常做四件事:
- 行数比对:源库导出的表与目标库ATTACH后的表,行数必须一致。
- 边界抽查:取几个边界值和随机值,确认数据落在了正确分区。
- 索引检查:分区挂上去之后,索引可能不是完整的,需要确认原有索引是否都建好了。
- 查询计划测试:在目标库跑几个典型查询,确认真实执行计划使用了分区裁剪,而不是全表扫描。
行数比对可以这样快速完成:
sql复制-- 源库
SELECT count(*) FROM orders_2019_12;
-- 目标库
SELECT count(*) FROM orders_history_2019_12;
如果数据量大,还可以用checksum方式进一步校验:
sql复制SELECT md5(string_agg(t::text, ',')) FROM orders_history_2019_12;
索引这块尤其容易被忽略。分区从源库DETACH出来的时候,索引随着分区表保留;但如果你在目标库通过pg_restore恢复,恢复出来的索引和原表索引可能不是一一对应的。我的做法是在ATTACH之后,检查目标库分区上的索引:
sql复制SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'orders_history_2019_12';
确认主键、业务索引、外键索引都在。如果缺了,手工补齐。
5. 迁移后的运维调优与扩展生态:从基础维护到pgvector集成
5.1 迁移后分区表统计信息与查询计划回归
一次分区搬移完成之后,数据库的统计信息很可能是空的,或者还停留在源库的旧版本上。PostgreSQL优化器在生成执行计划时会依赖pg_statistic里的统计信息,没有统计信息时它倾向于做保守的扫描策略,很可能导致分区表查询退化到全分区扫描。
所以迁移后的第一个动作就是ANALYZE。大分区表建议分表执行:
sql复制ANALYZE orders;
ANALYZE orders_history_2019_12;
对于分区表,ANALYZE父表不会自动更新所有子分区的统计信息,需要逐一执行。也可以使用ANALYZE VERBOSE观察每个表的统计信息更新情况。
统计信息到位之后,用EXPLAIN验证查询计划是否走分区裁剪:
sql复制EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders_history
WHERE order_time >= '2019-12-01' AND order_time < '2019-12-15';
理想情况下,执行计划里的Seq Scan只出现在orders_history_2019_12分区上,而不是在父表上出现Append且扫了所有分区。如果发现裁剪失效,大概率是统计信息没刷新,或者类型转换导致无法匹配分区键。
5.2 大分区与扩展生态:pg_partman、pgvector这类扩展的配合
分区表在PostgreSQL生态里不是孤立存在的,很多扩展可以和它配合得很好。
pg_partman前面提过,是自动加分区、删分区的好帮手。迁移后如果目标库也规划了同样的分区生命周期,可以直接在目标库同样配置pg_partman,让它接管后续分区维护。
pgvector现在也被很多团队用在AI应用里,用来存向量数据做相似度检索。分区表同样可以承载向量数据,比如按业务时间分区的embedding表。不过有个细节:pgvector的向量列也支持创建索引,但索引类型是ivfflat或者hnsw,这些索引在分区表上的行为和普通btree索引不太一样,迁移时如果原有分区有向量索引,需要确认恢复工具是否完整还原了索引定义。建议迁移后用\d+ 表名检查索引明细。
还有TimescaleDB这类时序数据库扩展,和原生分区的思路是重叠的。如果你的业务以时间序列为主,而且需要自动压缩、连续聚合等能力,TimescaleDB可能是更好的选择。但如果你只是想把一张大订单表按时间分区,原生分区表加pg_partman已经足够,不需要额外引入一套扩展。
5.3 长期运维的几个习惯:命名规范、保留策略、监控项
踩过的坑多了之后,我慢慢总结出一套长期运维分区表的习惯,这里直接列出来供参考:
- 分区命名统一格式。我一般使用
表名_YYYY_MM,按字母排序天然等于时间排序,查看表清单时一目了然。 - 永远保留一个default分区。虽然会有管理成本,但比起数据写入失败,这个成本完全值得。
- 每个月固定时间检查未来两个月的分区是否存在。不管用pg_partman还是自研脚本,都要有前置检查。
- 监控锁等待。重点看pg_stat_activity里是否出现长时间处于Lock状态的DDL,有就及时处理。
- 定期清理过期分区。保留策略写入自动化任务,不要等到磁盘快满才想起来。
监控锁等待的SQL我经常会跑:
sql复制SELECT pid, state, wait_event_type, wait_event,
now() - state_change AS wait_duration,
query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY wait_duration DESC;
如果这个查询长期返回有结果,说明业务中频繁出现锁竞争,需要排查一下是不是应用层事务过长,或者分区维护频率太高。
分区表的备份策略也值得单独说。用pg_dump做全量备份时,分区表会作为一组对象导出,数据量巨大时备份时间也会变长。如果数据量真的到了TB级,建议考虑基于PgBackRest这类工具的物理备份方案,按时间线做增量备份,恢复起来也比逻辑备份快很多。
最后再分享一个小技巧:做分区维护脚本的时候,尽量把所有DDL包在带lock_timeout的事务里,并且加上重试机制。比如每个月的自动归档任务,如果第一次因为业务高峰期锁竞争失败,脚本应该主动退避,等半小时后再试,而不是无限重试或者直接放弃。这样既不会打断业务,又能保证数据归档不会越积越多。分区表这东西,建好只是开始,真正决定长期体验的,永远是日常维护和迁移时的那套方法是否够稳。
