当单张表涨到几千万甚至上亿行,连最简单的按时间范围查询都开始变慢,加索引也开始力不从心——这时候就该请出分区表了。PostgreSQL 的分区表能把一张逻辑大表按规则拆成多个物理子表,查询时只扫描命中的那几个分区,既能大幅裁剪数据量、又能让维护操作(如清理旧数据)变成毫秒级的 DETACH。本文从 Range/List/Hash 三种分区策略讲起,手把手演示建表、分区剪枝、索引设计与大表在线改分区,并点出主键约束、默认分区、跨分区更新三个高频雷区。
一、为什么需要分区表
没有分区时,一张 orders 表无论多大都是单一堆表(heap),所有索引、所有查询都在这整张表上做。问题有两层:一是扫描量无法裁剪,哪怕你只查上个月的数据,优化器也得从整张表里筛;二是维护代价指数级上升,DROP 旧数据本质是 DELETE 再 VACUUM,亿级行删除会让事务和膨胀失控。
分区表的思路是”分而治之”:逻辑上仍是一张表,应用层 SQL 完全不变;物理上按分区键切成若干子表(分区)。查询带了分区键,优化器就能做分区剪枝(Partition Pruning),只访问相关分区。清理历史数据则退化为对某个分区的 DETACH/DROP——这是文件级操作,毫秒完成,不再有长事务。若你的表已经到了千万行量级且带明显的时间或租户维度,就该认真考虑分区,具体思路也可对照PostgreSQL 慢查询优化里谈的执行计划分析。
二、三种分区策略:Range / List / Hash
PostgreSQL 内置三种分区方式,选哪个取决于你的”切分维度”:
| 策略 | 分区键语义 | 典型场景 | 是否易产生热点 |
|---|---|---|---|
| Range | 连续区间(时间、数值范围) | 订单、日志、流水等按时间 | 否,新数据集中但可控 |
| List | 离散枚举(地区、租户、渠道) | 多租户、按省/渠道分表 | 视枚举分布 |
| Hash | 哈希取模分散 | 无明显范围、追求均匀分布 | 否,天然打散 |
绝大多数业务表落在 Range(按时间)上,这也是本文重点。List 适合多租户 SaaS 按 tenant_id 切分;Hash 在 11 之后支持,适合想均匀分散但不关心”某段数据在一起”的场景。三者可组合,比如先 List 按地区、再 Range 按时间(复合分区)。
三、实战:按时间 Range 分区
先看父表声明与子分区挂载。父表只定义结构和分区方式,本身不存数据;每个子分区是一个独立表,通过 PARTITION OF 挂到父表:
-- 父表:按月 Range 分区,分区键为 created_at
CREATE TABLE orders (
id bigserial,
user_id bigint NOT NULL,
amount numeric(12,2),
status varchar(16),
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
-- 子分区:2026 年 1 月
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- 子分区:2026 年 2 月
CREATE TABLE orders_2026_02 PARTITION OF orders
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
插入数据时应用层完全无感——直接 INSERT 父表,PostgreSQL 按 created_at 自动路由到对应子分区。查询带上 created_at 条件,优化器只会扫命中分区。注意 Range 的边界是”左闭右开”,TO ('2026-02-01') 不包含 2 月 1 日当天,正好接上下个分区的起点。
四、分区剪枝与执行计划
分区的价值靠”剪枝”兑现。用 EXPLAIN 看一个带时间范围的查询:
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2026-01-15' AND created_at < '2026-02-01';
-- 计划片段(已剪枝,只扫一个分区)
-- Append (cost=...)
-- -> Seq Scan on orders_2026_01 (...) <-- 只出现一个分区
-- Filter: ((created_at >= '2026-01-15') AND (created_at < '2026-02-01'))
如果计划里出现了全部分区的扫描(每个分区一行),说明剪枝没生效,常见原因是分区键上套了函数(如 date(created_at)),优化器无法把范围条件下推到分区边界。务必让查询条件直接落在分区键原列上。想确认是否真正剪枝,对照PG 慢查询优化里的执行计划解读会更顺手。
五、分区表上的索引策略
PostgreSQL 的分区表没有全局索引,索引必须建在每个子分区上(称为局部索引)。可以在父表上建索引,系统会”级联”到所有现有及未来分区:
-- 在父表建索引,自动落到每个子分区(局部索引)
CREATE INDEX ON orders (user_id);
CREATE INDEX ON orders (created_at DESC, status);
-- 也可以只给某个分区单独建
CREATE INDEX ON orders_2026_01 (amount);
| 方案 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 父表建索引 | CREATE INDEX ON orders(...) | 新分区自动继承,省心 | 统一结构,无法分区差异 |
| 分区单独建 | CREATE INDEX ON orders_2026_01(...) | 可按冷热分区差异化 | 新增分区需手动补,易遗漏 |
实践建议:高频通用索引在父表建,保证新分区不漏;只有个别分区才需要的特殊索引,单独建。索引同样是每个子分区一份,所以分区数不是越多越好——几百个分区会让规划阶段变慢,一般按月分区、保留 12–24 个月是稳妥区间。
六、大表在线改造成分区表
历史表早已是单表、现在想分区,怎么平滑改造?两条路:
方案一:pg_partman 扩展。它专门做分区管理,能按配置自动建/删分区、做存量数据迁移:
-- 安装并创建一个按月、保留 24 个月的分区模板
SELECT partman.create_parent(
p_parent_table => 'public.orders',
p_control => 'created_at',
p_type => 'native',
p_interval => '1 month',
p_premake => 3
);
UPDATE partman.part_config
SET retention = '24 months', retention_keep_table = true
WHERE parent_table = 'public.orders';
方案二:原生零停机换表。先建好新的分区父表与子表,再把存量数据按批次搬过去,最后在一个短锁窗口内改名切换:
BEGIN;
-- 存量导入(分批,避免长事务)
INSERT INTO orders_parted SELECT * FROM orders_old
WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01';
-- ...逐月搬...
-- 短锁窗口切换:先挂默认分区接住新写入,再改名
ALTER TABLE orders RENAME TO orders_legacy;
ALTER TABLE orders_parted RENAME TO orders;
COMMIT;
无论哪种,都建议先在从库或影子库演练,并配合PG 备份与 PITR做好回滚保障。迁移完别忘用 ANALYZE 更新统计信息,否则优化器可能给出偏差计划。
七、三个高频雷区
1. 主键/唯一约束必须包含分区键
分区表不支持跨分区的全局唯一,所以主键或唯一索引必须包含分区键。想用自增 id 做唯一主键?要么把分区键并入主键(如 PRIMARY KEY (id, created_at)),要么改用业务唯一键(如 (tenant_id, biz_no))。否则建表直接报错。
-- 错误:缺少分区键
ALTER TABLE orders ADD PRIMARY KEY (id); -- ERROR
-- 正确:把分区键 created_at 纳入
ALTER TABLE orders ADD PRIMARY KEY (id, created_at);
2. 一定要留默认分区
如果查询或插入的分区键值找不到对应子分区,PostgreSQL 会报错(Range 没有覆盖区间时)或把数据塞进默认分区。强烈建议建一个 DEFAULT 分区兜住异常数据,避免一次脏写入把整条 INSERT 打挂;同时监控默认分区是否异常膨胀,它是”分区边界没及时新建”的报警器。
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
3. 跨分区 UPDATE 会触发行移动
如果 UPDATE 改变了分区键、让行应当”搬家”到另一个分区,PostgreSQL 需要先把旧行 DELETE 再插入新分区。这要求开启 ENABLE ROW LEVEL SECURITY 之外的行移动开关,且会带来额外开销。设计时应尽量避免把分区键做成可频繁变更的字段——分区键应当是稳定维度(如创建时间、租户 id)。
八、分区 vs 分库分表 vs MongoDB 分片
分区表不是”分布式”方案,它仍在一台实例内。如果你的瓶颈是单实例容量或写入带宽,就该上真正的水平拆分:
| 方案 | 边界 | 跨节点 | 运维复杂度 | 适用 |
|---|---|---|---|---|
| PG 分区表 | 单实例内子表 | 否 | 低 | 单实例大表提速、冷热分离 |
| 分库分表(ShardingSphere) | 跨库/跨实例 | 是 | 高 | 写入与容量超出单机 |
| MongoDB 分片集群 | 跨 shard | 是 | 中 | 文档模型 + 弹性扩容 |
三者是不同层级的能力,不是替代关系。分区表是成本最低的”单机大表治理”手段,先把它用透;真要突破单机,再考虑分库分表 ShardingSphere或MongoDB 分片集群。对于偏分析、列存友好的场景,也可以参考MySQL 深度调优与列式引擎的思路做选型。
九、小结
PostgreSQL 分区表是单机大表治理的第一把利器:用 Range 按时间切分最通用,靠分区剪枝把扫描量打下来,靠 DETACH 把历史清理变成瞬时操作。落地时记住四件事——分区键选稳定维度、索引在父表建以继承、务必留 DEFAULT 分区兜异常、主键必须含分区键。它是一种”水平拆分前的轻量解”,真遇到单机容量瓶颈,再向上演进到分库分表或分片集群。想从更底层的索引原理理解分区为什么能提速,可以延伸阅读MySQL 索引底层原理,把 PG 与 MySQL 的存储优化思路打通。




