#DevOps

PostgreSQL 分区策略:Range、List 与 Hash

大表慢?分区表把一张物理表拆成多张子表,查询只扫相关分区。本文讲清 PG 三种分区策略(Range 按范围/List 按枚举/Hash 按哈希)、分区裁剪(partition pruning)、何时该分区(千万级+)、分区键怎么选、以及分区与索引的关系。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月31日·更新于 2026年7月23日

我接过一张日志表,3 亿行,每天长 200 万行。业务方投诉"查最近一周的数据越来越慢",DBA 说"索引都建了,VACUUM 也跑了,没用"。我看了一眼:查询确实走了索引,但单次也要两三秒;更糟的是每次 VACUUM 要跑四十多分钟,autovacuum 跟不上,膨胀越来越严重。

根因不是索引没建对,也不是统计过期——是这张表本就不该是一张表。按月拆成分区表后,查"最近一周"只扫一两个分区(两千万行变两千万行里的一小块),VACUUM 也只跑热分区,老的冷分区直接 DETACH 归档走。整套行为没变,但快了一个数量级。

很多人把分区当成"高级技巧,用不上"。恰恰相反:数据量到了千万级,分区是比加索引更根本的优化手段——索引优化的是"在一堆数据里找",分区优化的是"压根不去碰那一堆数据"。这篇文章把 PG 声明式分区的三种策略、裁剪原理、选键原则和维护套路一次讲透,并标出我实战中反复见到的坑。

一、为什么要分区:把一张大表拆成多张小表

理解分区,先要理解大表的痛从哪来。一张几亿行的表,问题不止"查询慢"那么单一:

  1. 查询慢——即便有索引,B-tree 的深度、回表的随机 I/O、加上热数据被冷数据稀释(缓存命中率下降),单次查询从毫秒涨到秒级。
  2. 维护慢——VACUUM、ANALYZE、REINDEX 都是全表量级的操作。3 亿行的 VACUUM 跑几十分钟,期间还占 I/O,autovacuum 经常追不上写入速度,死堆积压、膨胀加剧,进入恶性循环。
  3. 批量操作慢——DELETE 历史数据要逐行删、逐行写 WAL,删 5000 万行可能锁住几个小时;DROP 一张分区表则是秒级元数据操作。
  4. 可用性风险集中——整张表共享一份数据文件和索引,磁盘损坏、损坏修复(pg_repack、重建)影响全部数据。

分区的核心思路:把一张逻辑大表,在物理上拆成多张子表(分区)。每张子表是一个独立的物理表,有自己的数据文件、自己的索引。上层对应用透明——SELECT/INSERT 还是写那张逻辑表名,PG 内部自动路由到正确的分区。

带来的收益是结构性的:

  • 查询只扫相关分区(分区裁剪),扫描范围从"全表"缩到"一个分区";
  • 维护操作粒度变小,VACUUM 只跑热分区;
  • 历史归档变成秒级操作——老分区直接 DETACH 再 DROP,绕开逐行删除;
  • 单分区损坏不影响其它分区。

代价是:DDL 变复杂、跨分区查询和聚合有额外开销、主键和外键有约束。所以分区不是"越多越好",而是对症下药。

二、声明式分区:三种策略怎么选

PG 10 引入声明式分区(declarative partitioning),语法干净,应用层无感。三种策略对应三种数据分布:

策略划分依据典型场景
RANGE值的连续区间时间序列(日志、订单、埋点)
LIST离散枚举值按地区、租户、类型分桶
HASH哈希取模均匀打散没有明显维度、单分区过大

1. RANGE:按范围,时间序列的标配

按时间分区是 90% 的实际用例。语法是"父表声明分区方式,子表声明区间":

sql
CREATE TABLE events (
  id          BIGINT GENERATED ALWAYS AS IDENTITY,
  created_at  TIMESTAMPTZ NOT NULL,
  user_id     BIGINT NOT NULL,
  payload     JSONB,
  PRIMARY KEY (id, created_at)          -- 主键必须包含分区键
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2025_01 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE events_2025_02 PARTITION OF events
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

CREATE TABLE events_default PARTITION OF events DEFAULT;  -- 兜底,兜住漏网数据

两个关键细节:区间是左闭右开 [FROM, TO),所以相邻分区边界对接不会重叠也不会漏;主键/唯一约束必须包含分区键——因为 PG 只维护"局部索引"(见第五节),没法跨分区保证全局唯一,只能把分区键也纳入约束,让 PG 知道"同一个键的行只会落在一个分区"。

2. LIST:按枚举值,按地区/租户分桶

数据有天然的离散维度(地区、租户 id、状态码)时,LIST 比 RANGE 更直观:

sql
CREATE TABLE users (
  id       BIGINT GENERATED ALWAYS AS IDENTITY,
  region   TEXT NOT NULL,
  name     TEXT,
  PRIMARY KEY (id, region)
) PARTITION BY LIST (region);

CREATE TABLE users_apac PARTITION OF users
  FOR VALUES IN ('CN', 'JP', 'KR', 'SG');

CREATE TABLE users_emea PARTITION OF users
  FOR VALUES IN ('DE', 'FR', 'GB', 'RU');

CREATE TABLE users_default PARTITION OF users DEFAULT;

查"中国用户"只扫 users_apac 一个分区。DEFAULT 分区强烈建议加上——否则插入一个没覆盖到的 region 值会直接报错,而补建分区又要先把数据迁出来,麻烦。

3. HASH:均匀打散,没有维度时的兜底

当数据没有明显的查询维度、单纯是单表过大,HASH 分区按哈希值均匀分布,写多读少的场景常用:

sql
CREATE TABLE sessions (
  id          BIGINT GENERATED ALWAYS AS IDENTITY,
  user_id     BIGINT NOT NULL,
  token       TEXT NOT NULL,
  PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

-- 建几个?通常 8/16/32,2 的幂方便扩容
CREATE TABLE sessions_p0 PARTITION OF sessions
  FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
  FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ...直到 p7

HASH 分区的特点是写入天然均匀(只要 hash key 基数够大),但裁剪价值不如 RANGE/LIST——只有当查询条件里恰好带 user_id = ? 时才能裁到单个分区;范围查询、聚合则要扫所有分区。所以 HASH 是"为了分散写入和单点大小",不是为了查询加速。另外 HASH 分区扩容痛苦:从 8 分区扩到 16,hash 取模全变,数据要重新分布。生产里要么一开始就多建点(16 或 32),要么靠 pg_partman 之类的工具在线重分布。

三、分区裁剪:分区的命门,裁不掉就白分

分区之所以能加速,九成功劳归于分区裁剪(partition pruning):当查询的 WHERE 条件里包含分区键时,优化器能直接判断哪些分区"绝不可能命中",跳过它们,只扫剩下的分区。

sql
EXPLAIN SELECT * FROM events
 WHERE created_at >= '2025-01-15' AND created_at < '2025-02-15';

执行计划里你只会看到 events_2025_01 和 events_2025_02 两个分区,其它分区根本不出场。3 亿行变 2000 万行,快是必然的。

裁剪分两种时机,理解这层差异很关键:

  • 计划期裁剪(plan-time):WHERE 条件是常量(如上面的字面量),优化器在生成计划时就直接把无关分区剔掉。计划里这些分区根本不存在。
  • 运行期裁剪(runtime):WHERE 条件是参数(PREPARE / EXECUTE、prepared statement、PL/pgSQL 变量),优化器在计划阶段不知道具体值,会生成一个 Append 节点包含所有可能分区,执行时根据实际参数跳过不需要的子计划(计划里会标注 Subplans Removed)。

运行期裁剪虽然能裁,但计划本身还是要枚举所有分区,分区数极多时计划生成有开销(数百个分区开始明显)。所以**"查询条件带不带分区键"是分区设计成败的判据**——不带,裁剪失效,扫描所有分区,比分区前还慢(多了 Append 开销)。这是下一节要讲的核心。

验证裁剪是否生效,永远用 EXPLAIN 看一眼,别凭感觉。如果你看到计划里出现了你以为该被裁掉的分区名,那就是裁剪没生效,十有八九是 WHERE 条件没触达分区键。

四、分区键怎么选:跟着查询走

分区键的选择只有一条原则,但它决定一切:分区键必须是绝大多数查询的过滤条件。

为什么这么强调?因为裁剪只在 WHERE 含分区键时生效。你按 created_at 分区,但业务查询 80% 是"查某个用户的所有订单"(按 user_id),那分区键完全帮不上忙,每次都要扫所有分区。这是最常见的"分了区反而更慢"的原因。

实操上,按优先级选:

  1. 找到最高频的查询模板,看它们过滤什么。日志表大多按时间查,订单表可能按时间也可能按用户,要统计清楚。
  2. 如果高频查询有一个共同的维度(典型是时间),用它做 RANGE 分区键。这是最理想的情况。
  3. 如果有两个高频维度(比如既要按时间又要按用户),用最高频的那个做分区键,另一个走索引;或者上多级分区(subpartitioning)——先按 RANGE 分时间,子分区再按 HASH 分 user_id:
sql
CREATE TABLE events (...) PARTITION BY RANGE (created_at);

CREATE TABLE events_2025_01 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01')
  PARTITION BY HASH (user_id);

CREATE TABLE events_2025_01_p0 PARTITION OF events_2025_01
  FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- ...

多级分区威力大但 DDL 复杂度指数上升,分区总数要控制(几百个以内,多了计划开销和元数据压力都上来)。不到必要别上多级,一级分区通常是性价比最高的方案。

一个反面教训:有人按 status 字段做 LIST 分区("处理中/已完成/已取消"),但 status 只有 3 个值,而且查询大多是按时间或按用户,完全没带上 status。结果三个分区每次都被全扫,分区表成了摆设。分区键的基数要够、查询要常用,两条都满足才有意义。

五、分区与索引:局部索引,不是全局索引

这是新手最容易误解的一层:分区表上没有真正的"全局索引"。

你在父表上 CREATE INDEX,PG 实际是在每个子分区上各建一个同名的局部索引:

sql
-- 父表上建索引,自动级联到所有现有分区
CREATE INDEX idx_events_user ON events (user_id);
-- 实际效果:events_2025_01、events_2025_02 各有一个 idx_events_user

新增分区时,PG 会自动给新分区建同名索引,无需手动维护。这点设计很省心。

但"局部索引"意味着几件事:

  • 跨分区查询时,每个分区用自己的局部索引,没有跨分区的 B-tree。所以带分区键的查询(裁到单分区)能完美用上局部索引;不带分区键的查询要扫所有分区,每个分区的局部索引都用上,但合并开销在应用侧。
  • 唯一约束必须包含分区键(前面说过),因为没法跨分区保证唯一性。这是 PG 的硬性限制,不是设计缺陷——全局唯一索引会和分区裁剪、DETACH 归档等特性冲突。
  • 如果想跨分区查得快,要么靠裁剪把范围缩到少数分区,要么评估是否该调整分区键。

这也回答了一个常见疑问:"分区了还要建索引吗?"——要,而且每个分区都要建。分区解决"扫哪一堆",索引解决"在一堆里怎么快速定位",两者是协同的,不是二选一。理想态是:分区裁剪把数据缩到单个分区,该分区的局部索引再在分区内快速定位。

六、分区维护:自动建新区、归档老区

分区表上线后,运维问题立即浮上来:下个月的分区还没建,写入会失败(除非有 DEFAULT 兜底,但 DEFAULT 会拖累裁剪性能);三年前的冷数据占着空间和备份成本。这两件事必须自动化。

1. 自动创建新分区

最通用的方案是 pg_partman 扩展。配置好后它定期跑,提前建好未来 N 个分区,并按策略处理老分区(归档/DETACH/压缩):

sql
CREATE EXTENSION pg_partman;

-- 按月自动维护 events 表,提前建 4 个,保留 12 个
SELECT partman.create_parent(
  'public.events', 'created_at', 'native', 'monthly',
  p_premake := 4
);

不想装扩展,就用定时任务(cron / k8s CronJob)跑一个脚本,提前 CREATE TABLE ... PARTITION OF ... 未来几个月的分区。逻辑简单,但别忘了做——这是分区表运维事故最高频的来源:某天 DEFAULT 分区意外接住了新数据,再想把它拆回正常分区要折腾半天。

2. 归档老分区(DETACH)

历史数据归档是分区表最爽的能力。一张存了 5 年的分区表,要清理 2 年前的数据,逐行 DELETE 是灾难;分区表直接 DETACH:

sql
-- PG 14+:CONCURRENTLY 不阻塞读写
ALTER TABLE events DETACH PARTITION events_2023_01 CONCURRENTLY;

-- 现在 events_2023_01 是一张独立表,可以随意处理
-- 导出到对象存储、压缩后塞回冷库、或直接 DROP
DROP TABLE events_2023_01;

DETACH 是元数据操作,秒级完成。CONCURRENTLY 关键字(PG 14+)避免阻塞读写,代价是耗时略长(要等事务边界)。生产归档基本都该带 CONCURRENTLY。

归档策略常见的有两种:冷库归档(DETACH 后导出到 S3 / 对象存储 / 列式数据库如 ClickHouse)和就地压缩(DETACH 后用 pg_compress 或导出为列式格式再挂回来)。前者适合"历史基本不查",后者适合"偶尔要回溯"。

七、陷阱:跨分区 JOIN/聚合、主键、子分区上限

分区不是银弹,坑很具体,列几个我反复见到的。

陷阱 1:跨分区 JOIN 和聚合慢(各分区算再合并)

SELECT COUNT(*) FROM events 这条看似无害的聚合,分区表下会变成"每个分区各自 COUNT,再 Append 合并"。分区少时几乎无感,但带 JOIN 的聚合(比如 events JOIN users GROUP BY ...)跨分区执行,计划复杂度上升明显。

更要命的是 JOIN 两张大分区表:如果两张表没按同一个键、同样的边界分区,PG 没法做"分区对齐 JOIN"(partitionwise join),只能把一张表的所有分区和另一张的所有分区两两 JOIN,开销爆炸。打开 SET enable_partitionwise_join = on; 可以让 PG 尝试对齐 JOIN,但要求两边分区方式完全一致——规划阶段就要对齐好,事后改不动。

陷阱 2:主键/唯一约束必须含分区键

前面强调过,再列一次因为踩坑率太高。下面这个会失败:

sql
CREATE TABLE events (
  id BIGINT GENERATED ALWAYS AS IDENTITY,
  created_at TIMESTAMPTZ NOT NULL,
  payload JSONB,
  PRIMARY KEY (id)            -- ❌ 失败:主键缺分区键
) PARTITION BY RANGE (created_at);
-- ERROR: unique constraint on partitioned table must include all partitioning columns

正确写法是 PRIMARY KEY (id, created_at)。这意味着用应用层 id 查单行时,必须同时带上 created_at 才能裁剪到单分区。如果业务只有 id、查单行不带时间,要么接受扫所有分区(id 列在每个分区各有局部索引,扫 N 个局部索引),要么改用 HASH 分区把 id 做成分区键。

陷阱 3:DEFAULT 分区的双刃剑

DEFAULT 兜底防止插入失败,但它的数据没法参与高效裁剪(PG 不知道里面有什么值,只能全扫),而且想把 DEFAULT 里的数据迁到正式分区,需要先把 DEFAULT 数据迁出再建分区(PG 会校验 DEFAULT 里没有符合新区间的数据)。生产策略:DEFAULT 用来保险,但配监控告警,一旦 DEFAULT 分区有数据进,立刻排查原因并迁移。

陷阱 4:分区数过多,计划开销陡增

每个分区在元数据里都是一张表,分区上百后,SELECT 的计划生成要枚举所有分区做裁剪判断,DDL 也变慢。经验值:单表分区数控制在数百以内,超过就该考虑分层(按年 RANGE + 子分区)或者干脆换架构(分库 / 冷热分离到不同实例)。

陷阱 5:以为分区能解决一切

分区解决的是"大",不是"乱"。如果查询本身没索引、SQL 写得烂(SELECT *、N+1、缺必要列),分区表救不了你——裁到单个分区后,该建索引还得建,该优化还得优化。把分区当万灵药,是新手最常见的误判。

小结

  • 分区的价值是结构性的:查询只扫相关分区、维护操作粒度变小、历史归档秒级 DETACH——大表千万级以上,分区比加索引更根本。
  • 三种策略对应三种分布:RANGE 适合时间序列(90% 场景),LIST 适合地区/租户这类离散维度,HASH 适合没有明显维度、只为均匀打散写入。
  • 分区裁剪是分区的命门:查询条件必须包含分区键才裁剪,分区键跟着最高频查询的过滤条件走——选错键,分区表比单表还慢。
  • 分区表是局部索引,没有全局索引:每个分区有自己的索引,唯一约束必须含分区键;分区解决"扫哪一堆",索引解决"在堆里怎么定位",二者协同。
  • 运维必须自动化:pg_partman 或定时任务提前建新区,老分区 DETACH ... CONCURRENTLY 秒级归档——分区表的事故八成来自"忘了建下个月的分区"。

金句:索引优化的是"在一堆数据里找得更快",分区优化的是"压根不去碰那一堆数据"——前者是提速,后者是降维。 大表的瓶颈到了"索引也救不了"那一步,就该想分区了。

Related

相关推荐