PostgreSQL 分区表实战:设计与性能优化

当单张表涨到几千万甚至上亿行,连最简单的按时间范围查询都开始变慢,加索引也开始力不从心——这时候就该请出分区表了。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文档模型 + 弹性扩容

三者是不同层级的能力,不是替代关系。分区表是成本最低的”单机大表治理”手段,先把它用透;真要突破单机,再考虑分库分表 ShardingSphereMongoDB 分片集群。对于偏分析、列存友好的场景,也可以参考MySQL 深度调优与列式引擎的思路做选型。

九、小结

PostgreSQL 分区表是单机大表治理的第一把利器:用 Range 按时间切分最通用,靠分区剪枝把扫描量打下来,靠 DETACH 把历史清理变成瞬时操作。落地时记住四件事——分区键选稳定维度、索引在父表建以继承、务必留 DEFAULT 分区兜异常、主键必须含分区键。它是一种”水平拆分前的轻量解”,真遇到单机容量瓶颈,再向上演进到分库分表或分片集群。想从更底层的索引原理理解分区为什么能提速,可以延伸阅读MySQL 索引底层原理,把 PG 与 MySQL 的存储优化思路打通。

上一篇 Spring Boot 缓存 @Cacheable 实战
下一篇 Helm Chart 实战:K8s 应用打包与版本管理