Skip to content

哈希分区(HASH) ​

哈希分区(Hash Partitioning)用取模的方式把数据打散到各个分区:每个分区声明一个模数(modulus)和一个余数(remainder),行的分区键经过哈希函数计算后,若「哈希值 ÷ 模数」的余数等于该分区的余数,这一行就落入该分区。

它不表达任何业务语义——orders_p0 里装的是什么区间的订单?没有答案。哈希分区解决的不是「按维度归档」,而是「把一张巨型表均匀拆开」,让写入、索引维护、VACUUM 的压力分摊到多个物理文件上。

前置知识

阅读本节前,建议先了解:声明式分区概述、范围分区(RANGE)、列表分区(LIST)

基础概念 ​

哈希分区在解决什么问题 ​

范围分区与列表分区都要求分区键具有「可切分的业务维度」——时间、地区、租户。但现实中大量表并没有这样的维度:

  • 一张 events 表,只有自增 id 和一个 payload,没有任何自然的归档维度;
  • 一张超大的日志表,希望把写入压力摊到多块磁盘上;
  • 一张千万级用户表,查询几乎都是按主键点查,不需要区间裁剪,只是单表太大了。

这些场景的共同点是:目标是「分摊」,而不是「隔离」。用范围分区按 id 切会带来热点(所有新写入都落在最后一个分区),用列表分区又无处枚举。哈希分区正是为这类「无自然维度、只需均匀打散」的表准备的。

三种声明式分区方法的定位对比如下:

方法目标分区键能否裁剪能否按业务维度归档
RANGE按连续区间隔离多列能(范围条件)能(时间、ID)
LIST按离散取值隔离单列能(等值条件)能(地区、租户)
HASH均匀打散、分摊压力多列仅等值条件不能(无业务语义)

与范围分区、列表分区的本质差异 ​

最本质的差异在分区边界的语义:

text
RANGE:  分区 = [FROM 下界, TO 上界)           —— 一段连续区间
LIST :  分区 = IN (值1, 值2, ...)              —— 一组离散取值
HASH :  分区 = { row | hash(key) % M = R }     —— 一个等价类

前两者是「业务可读」的:拿到分区的边界定义,就能说出它装什么。哈希分区是「业务不可读」的:拿到 MODULUS 8, REMAINDER 3,只能说它装的是「哈希值模 8 余 3 的那 1/8 数据」,说不出具体是哪些行。

正因为如此,哈希分区必须依赖 PostgreSQL 的哈希函数与哈希操作符类,而不能用普通的 B-tree 操作符类。官方对 PARTITION BY 的原文是:

Range and list partitioning require a btree operator class, while hash partitioning requires a hash operator class. ... When hash partitioning is used, the operator class used must implement support function 2 (see Section 36.16.3 for details).

也就是说,如果分区键用了自定义类型,该类型必须提供实现了 support function 2(hash 扩展支持函数)的哈希操作符类,否则建表会直接失败。

取模的数学含义 ​

每个哈希分区由一对整数 (MODULUS, REMAINDER) 定义。官方定义:

The table is partitioned by specifying a modulus and a remainder for each partition. Each partition will hold the rows for which the hash value of the partition key divided by the specified modulus will produce the specified remainder.

用公式表达:

text
行落入分区 (M, R)  ⟺  hash(partition_key) mod M = R

举个具体的例子。假设分区键值 tenant_id = 42 的哈希值为 0x1A3F...,取模运算后得到某个余数 r。则这一行只会进入余数为 r 的那个分区。对同一行数据,无论插入多少次、由哪个会话插入、经过多少次 VACUUM,这个余数都是稳定不变的(哈希函数对同一输入确定),这保证了「同一分区键值的行始终落在同一分区」。

官方明确要求:

约束规则
MODULUS必须是正整数(numeric_literal)
REMAINDER必须是非负整数,且严格小于 MODULUS
同一父表的所有分区应当使用相同的 MODULUS,各用不同的 REMAINDER 覆盖 0 .. M-1
分区键列数支持多列(复合键),与范围分区相同

模数与余数写在建分区那一刻就固定了

MODULUS 与 REMAINDER 是不可变的分区边界。你无法通过 ALTER TABLE 修改已有分区的模数;想改变分区数量,只能新建分区再搬运数据(见下文「详细说明」中的扩容困境)。

完整覆盖 0 .. M-1 才不留缺口 ​

如果 MODULUS = 4,那么合法的余数是 0、1、2、3。只有当你把 REMAINDER 为 0/1/2/3 四个分区全部建齐,父表才覆盖了「所有可能的哈希值」。少建一个,某些行就会无处可去而报错:

text
ERROR:  no partition of relation "events" found for row

这与范围分区的「悬空区间」问题是同一类错误,只是哈希分区里表现为「某个余数没有对应分区」。实践中要么建齐所有余数,要么额外挂一个 DEFAULT 分区兜底。

均匀性来自哈希,不来自数据分布 ​

哈希分区的一个诱人之处是:不需要数据本身均匀,分区就能大致均匀。假设 tenant_id 的取值极度倾斜——某个大租户占了 60% 的数据量——只要不是所有行的分区键值都相同,哈希就会把这 60% 也打散到各个分区里(前提是分区键的哈希值分散)。

但有两个前提必须成立:

  1. 分区键的哈希值要足够分散。 如果分区键只有两个取值(例如布尔列),哈希后仍只有两个不同的余数,分区再多也只会用上两个。这就是为什么不要把低基数列单独作为哈希分区键。
  2. 数据量要足够大。 数据量小的时候,哈希的统计波动会让各分区大小差异明显。分区均匀是有「统计学意义」的,几万行的表哈希分区基本靠运气。

声明式哈希分区的版本门槛 ​

哈希分区不是 PostgreSQL 最早的声明式分区能力,理解版本演进有助于排查老系统的行为:

版本与哈希分区相关的能力
PostgreSQL 10声明式分区初版,只支持 RANGE 与 LIST,无 HASH
PostgreSQL 11引入 HASH 分区;同时支持分区表上的主键、外键、索引、DEFAULT 分区、ALTER INDEX ATTACH PARTITION、更新分区键时的跨分区搬行
PostgreSQL 12分区内省函数 pg_partition_tree() 等;ATTACH PARTITION 降锁
PostgreSQL 14DETACH PARTITION CONCURRENTLY
PostgreSQL 18DETACH PARTITION ... FINALIZE

所以如果看到「哈希分区」出现在 PostgreSQL 10 的部署里,那一定是表继承(inheritance)手工拼出来的,不是声明式分区。必须在 PostgreSQL 11 及以上才能写出 PARTITION BY HASH。

语法与用法 ​

创建父表 ​

哈希分区的父表 DDL 与范围/列表分区一致,只是把 PARTITION BY 的策略换掉:

text
CREATE TABLE [ IF NOT EXISTS ] table_name (
    column_name data_type [ column_constraint [ ... ] ]
    [, ... ]
)
PARTITION BY HASH ( { column_name | ( expression ) } [ COLLATE collation ] [ opclass ] [, ... ] )

PARTITION BY HASH 后面可以跟多个列或表达式(最多 32 列),构成复合分区键。

创建哈希分区 ​

子分区的边界用 WITH (MODULUS ..., REMAINDER ...):

text
CREATE TABLE [ IF NOT EXISTS ] table_name
    PARTITION OF parent_table
    FOR VALUES WITH ( MODULUS numeric_literal, REMAINDER numeric_literal )
    [ PARTITION BY ... ]
    [ TABLESPACE tablespace_name ]

也可以在 CREATE TABLE ... PARTITION OF 之外,先建普通表再用 ATTACH PARTITION 挂上去(见「创建与附加分区」一节)。

完整示例 ​

下面把一个 events 表按 tenant_id 哈希切成 4 个分区:

sql
-- 1. 创建父表,声明按 tenant_id 哈希分区
CREATE TABLE events (
    event_id   bigint GENERATED ALWAYS AS IDENTITY,
    tenant_id  int         NOT NULL,
    event_type text        NOT NULL,
    payload    jsonb       NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, event_id)
) PARTITION BY HASH (tenant_id);

-- 2. 创建 4 个分区,模数统一为 4,余数 0~3 各一个
CREATE TABLE events_p0 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE events_p1 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE events_p2 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE events_p3 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 3);

注意主键写成了 (tenant_id, event_id) 而不是 (event_id)。原因见下文「注意事项」:分区表上的唯一约束必须包含全部分区键列。

查看父表结构,可见 Partition key: HASH (tenant_id):

text
postgres=# \d+ events
                                              Partitioned table "public.events"
   Column   |           Type           | Collation | Nullable |           Default            | Storage  |
------------+--------------------------+-----------+----------+------------------------------+----------+
 event_id   | bigint                   |           | not null | generated always as identity| plain    |
 tenant_id  | integer                  |           | not null |                              | plain    |
 event_type | text                     |           | not null |                              | extended |
 payload    | jsonb                    |           | not null |                              | extended |
 created_at | timestamp with time zone |           | not null | now()                        | plain    |
Partition key: HASH (tenant_id)
Indexes:
    "events_pkey" PRIMARY KEY, btree (tenant_id, event_id)
Number of partitions: 4 (Use \d+ to list them.)

单独看某个分区:

text
postgres=# \d+ events_p2
                                    Table "public.events_p2"
   Column   |           Type           | Collation | Nullable |           Default            |
------------+--------------------------+-----------+----------+------------------------------+-----------
 event_id   | bigint                   |           | not null | generated always as identity|
 tenant_id  | integer                  |           | not null |                              |
 ...
Partition of: events FOR VALUES WITH (MODULUS 4, REMAINDER 2)

查询分区边界 ​

分区边界保存在 pg_class.relpartbound 里,用 pg_get_expr() 展开成可读文本:

sql
SELECT c.relname,
       pg_get_expr(c.relpartbound, c.oid) AS bound
FROM pg_class c
JOIN pg_inherits i ON i.inhrelid = c.oid
JOIN pg_class p    ON p.oid = i.inhparent
WHERE p.relname = 'events'
ORDER BY c.relname;
text
 relname  |                     bound
----------+------------------------------------------------
 events_p0 | FOR VALUES WITH (MODULUS 4, REMAINDER 0)
 events_p1 | FOR VALUES WITH (MODULUS 4, REMAINDER 1)
 events_p2 | FOR VALUES WITH (MODULUS 4, REMAINDER 2)
 events_p3 | FOR VALUES WITH (MODULUS 4, REMAINDER 3)

插入并观察路由 ​

插入几行数据,然后逐分区统计行数,就能直观看到哈希打散的效果:

sql
INSERT INTO events (tenant_id, event_type, payload)
SELECT (random() * 1000)::int, 'click', '{}'::jsonb
FROM generate_series(1, 100000);
sql
SELECT tableoid::regclass AS partition, count(*)
FROM events
GROUP BY tableoid
ORDER BY 1;
text
 partition | count
-----------+-------
 events_p0 | 25031
 events_p1 | 24948
 events_p2 | 24986
 events_p3 | 25035

四个分区各约 2.5 万行,偏差在千分之一量级,说明 tenant_id 这个键的哈希分布足够分散。

带分区键的等值查询可裁剪 ​

哈希分区只在等值条件下能裁剪,而且要求条件是对分区键的等值比较:

sql
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE tenant_id = 42;
text
             QUERY PLAN
-------------------------------------
 Seq Scan on events_p2
   Filter: (tenant_id = 42)

计划只出现了一个分区 events_p2,其余三个分区的子计划被移除了。这与范围分区的裁剪原理相同,只不过哈希分区的「分区边界」是一个取模条件,规划器需要能静态算出 hash(42) mod 4 的值才能裁剪。

范围条件无法裁剪 ​

同样是 tenant_id 上的查询,换成范围条件就完全失效:

sql
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE tenant_id >= 100;
text
                  QUERY PLAN
----------------------------------------------
 Append
   ->  Seq Scan on events_p0
         Filter: (tenant_id >= 100)
   ->  Seq Scan on events_p1
         Filter: (tenant_id >= 100)
   ->  Seq Scan on events_p2
         Filter: (tenant_id >= 100)
   ->  Seq Scan on events_p3
         Filter: (tenant_id >= 100)

四个分区全部扫描。这是哈希分区最重要的性能特性,也是它最大的局限:哈希取模对区间条件是「信息不可用」的——tenant_id >= 100 无法推导出哈希余数落在哪个范围,所以任何区间、前缀、排序条件都必须扫全部子分区。

详细说明 ​

模数与分区数量的选择 ​

一个最常见的误区是「模数不等于分区数」。规则非常简单:

分区数量 = 模数,只要你把 REMAINDER 从 0 到 M-1 全部建齐。

sql
-- 4 个分区,模数 4
CREATE TABLE t_p0 PARTITION OF t FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE t_p1 PARTITION OF t FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE t_p2 PARTITION OF t FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE t_p3 PARTITION OF t FOR VALUES WITH (MODULUS 4, REMAINDER 3);

但也可以「模数远大于分区数,只建部分余数」——例如模数取 16,只建 4 个分区分别取余数 0/4/8/12:

sql
CREATE TABLE t_p0  PARTITION OF t FOR VALUES WITH (MODULUS 16, REMAINDER 0);
CREATE TABLE t_p4  PARTITION OF t FOR VALUES WITH (MODULUS 16, REMAINDER 4);
CREATE TABLE t_p8  PARTITION OF t FOR VALUES WITH (MODULUS 16, REMAINDER 8);
CREATE TABLE t_p12 PARTITION OF t FOR VALUES WITH (MODULUS 16, REMAINDER 12);

这种「模数留大、只占部分余数」的技巧,是为了给未来的扩容留余地:以后想把分区数翻倍,只需新增余数为 2/6/10/14 等分区,再把旧分区里的部分数据搬过去即可(需要 ATTACH/DETACH 配合)。但代价是当前覆盖率不完整:余数 1/2/3/... 的行没有归宿,必须靠 DEFAULT 分区兜底,否则插入报错。两种方案的选择:

方案覆盖率扩容难度适用
模数 = 分区数,余数全覆盖完备,无需 DEFAULT难(改模数要重建所有分区)分区数已定型
模数 > 分区数,只占部分余数不完整,需 DEFAULT 兜底较易(新增余数即可)预计会扩容

哈希稳定性:为什么同一行总是进同一分区 ​

PostgreSQL 的哈希函数是确定性的:对同一输入类型、同一输入值,不同会话、不同时间、不同数据库版本(同大版本内)算出的哈希值一致。这一点至关重要,因为:

  • 如果哈希不稳定,INSERT 路由到的分区和 WHERE 查询裁剪出的分区就可能不一致,导致查不到数据;
  • 分区裁剪依赖规划器在计划期算出哈希值,而插入依赖执行期算出的哈希值,二者必须用同一套算法。

要理解某个具体值落在哪个分区,可以观察规划器的裁剪结果:

sql
-- 把三个不同的 tenant_id 放进同一个查询,看它们分别裁到哪个分区
EXPLAIN (COSTS OFF)
SELECT tenant_id, tableoid::regclass
FROM events
WHERE tenant_id IN (1, 2, 3);
text
                  QUERY PLAN
----------------------------------------------
 Append
   ->  Seq Scan on events_p1
         Filter: (tenant_id = ANY ('{1,2,3}'::integer[]))
   ->  Seq Scan on events_p3
         Filter: (tenant_id = ANY ('{1,2,3}'::integer[]))

可以看到 1/2/3 这三个值恰好落在 p1 与 p3,另外两个分区被裁掉。哈希值与值本身毫无直观关系——1 不因为「小」就落在 p0。

不要依赖哈希值的具体分布做业务假设

哈希值是内部实现细节,不同 PostgreSQL 主版本间可能因哈希算法改进而变化(因此跨主版本逻辑复制/物理迁移时会重新计算)。千万不要写出依赖「tenant_id = 42 一定落在 p2」这类假设的 SQL。

哈希分区几乎没有裁剪收益 ​

这是选型时最需要牢记的一点。范围/列表分区的核心价值是「裁剪」,而哈希分区的裁剪面非常窄:

条件形式范围分区列表分区哈希分区
key = 常量裁到 1 个裁到 1 个裁到 1 个
key IN (v1, v2)少裁少裁少裁
key > 常量 / key < 常量裁到 1~2 个全扫全扫
key BETWEEN a AND b裁到少数全扫全扫
key LIKE 'abc%'裁到少数全扫全扫
key IS NULL全扫裁到 NULL 分区裁到 1 个(若 NULL 哈希确定)
无 key 条件全扫全扫全扫

也就是说,哈希分区只有在等值点查时才有裁剪红利。如果业务查询是区间、排序、范围聚合,用哈希分区不但没有裁剪收益,还会因为 Append 多个子计划而增加规划开销。

sql
-- 排序 + 分页这类查询,哈希分区毫无帮助
EXPLAIN (COSTS OFF)
SELECT * FROM events ORDER BY created_at DESC LIMIT 10;
text
                             QUERY PLAN
------------------------------------------------------------------
 Limit
   ->  Merge Append
         Sort Key: created_at DESC
         ->  Index Scan Backward using events_p0_created_at_idx ...
         ->  Index Scan Backward using events_p1_created_at_idx ...
         ->  Index Scan Backward using events_p2_created_at_idx ...
         ->  Index Scan Backward using events_p3_created_at_idx ...

哈希分区的主要收益:写入与维护并发 ​

既然裁剪收益有限,哈希分区到底值不值得用?答案是:当目标是分摊单表压力时值得。

考虑一张 10 亿行的单表:

  • 单表只有一个 B-tree 主键索引,所有 INSERT 都要争抢同一棵索引的元组页与同一批 WAL 缓冲;
  • VACUUM 必须扫描整张表,无法拆分;
  • REINDEX 要重建整棵索引,无法并行到多张表;
  • 单表文件达到 TB 级,备份/恢复、pg_dump 粒度难以拆分。

哈希切成 8 个分区后:

  • 每个分区有自己的主键索引,INSERT 落在哪个分区由哈希决定,天然分散;
  • VACUUM events_p0 可以逐个分区做,负载可控;
  • REINDEX TABLE events_p0 只重建 1/8 的索引;
  • 备份/迁移可以逐分区操作。
sql
-- 逐分区维护,压力可控
VACUUM (ANALYZE, VERBOSE) events_p0;
REINDEX TABLE events_p1;
CLUSTER events_p2 USING events_p2_created_at_idx;

这种「可拆分的维护单元」是哈希分区最实在的价值,即便它不带裁剪红利。

无裁剪收益下的索引策略 ​

由于哈希分区无法按范围裁剪,分区内索引就变得更加重要。查询 WHERE tenant_id = 42 AND created_at > '2026-01-01' 会裁剪到单个分区,然后在该分区内用 created_at 索引过滤——这是哈希分区最常见的组合拳。

sql
-- 每个分区上都会自动创建同名索引(PostgreSQL 11+)
CREATE INDEX events_created_at_idx ON events (created_at);
text
postgres=# \d events_p2
              Table "public.events_p2"
   Column   | ...
Indexes:
    "events_p2_pkey" PRIMARY KEY, btree (tenant_id, event_id)
    "events_p2_created_at_idx" btree (created_at)

注意:在父表上直接 CREATE INDEX 会自动在每个分区上建对应索引(PostgreSQL 11 起),无需手工逐个分区建。这比范围分区时代的手工维护方便得多。

扩容困境:为什么哈希分区数量不宜定太少 ​

哈希分区最尴尬的一点是扩容极难。假设现在 MODULUS = 4,数据已经存了 5 亿行。业务量涨了 4 倍,想把分区数从 4 提到 16:

做法问题
直接改模数为 16不可能——分区边界不可变,ALTER TABLE 没有改模数的语法
新建 16 个分区的子表、全量搬数据需要 DETACH 全部旧分区、搬运全部数据、再 ATTACH,期间停机
保留旧分区、新增余数分区无法做到——旧分区覆盖了全部余数,新分区会「边界重叠」而失败

所以扩容几乎是「重建整张分区表」级别的大工程。结论是:一开始就要把模数留足,宁可分区数偏多(比如按未来 2~3 年的数据量规划),也不要定太小。

sql
-- 规划时把模数定得比当前需要的大,例如当前 4 个分区够用,先按 16 规划
-- (覆盖完整时 16 个分区全建齐,若只想建 4 个就配合 DEFAULT)
CREATE TABLE events_p00 PARTITION OF events FOR VALUES WITH (MODULUS 16, REMAINDER 0);
-- ... 依此类推直到 REMAINDER 15

代价是分区数量多会带来规划开销(见下文「注意事项」),需要在「扩容余量」与「分区数量」之间权衡。

复合分区键:多列哈希 ​

与范围分区一样,哈希分区键可以是多列,写成一个元组:

sql
CREATE TABLE sessions (
    tenant_id  int  NOT NULL,
    user_id    bigint NOT NULL,
    ...
) PARTITION BY HASH (tenant_id, user_id);

多列哈希的语义是「对元组 (tenant_id, user_id) 整体求哈希」,而不是分别取模。它适合两个维度都参与打散、且希望同一 (tenant, user) 组合稳定落在同一分区的场景。但要注意,只有对完整元组做等值比较时才能裁剪:

sql
-- 可裁剪:等值比较完整元组
SELECT * FROM sessions WHERE tenant_id = 5 AND user_id = 100;
-- 不可裁剪:元组的子集,无法确定整体哈希
SELECT * FROM sessions WHERE tenant_id = 5;

与范围/列表组合成多级分区 ​

哈希分区可以作为一级分区,再由其他方法做二级分区,反之亦然。最常见的组合是「先按时间 RANGE,再按 tenant_id HASH」:一级负责按时间归档,二级负责均匀打散。

sql
-- 一级:按 created_at 月度范围分区
CREATE TABLE events (
    event_id   bigint      NOT NULL,
    tenant_id  int         NOT NULL,
    created_at timestamptz NOT NULL
) PARTITION BY RANGE (created_at);

-- 二级:每个月一个分区,再按 tenant_id 哈希切成 4 份
CREATE TABLE events_2026_01 PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
    PARTITION BY HASH (tenant_id);

CREATE TABLE events_2026_01_p0 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE events_2026_01_p1 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE events_2026_01_p2 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE events_2026_01_p3 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 3);

这样的两级结构,按时间的查询能裁到一个月(一级),该月内的按 tenant_id 等值查询能裁到一个二级分区。更多细节见多级分区(子分区)。

DISTINCT、GROUP BY 与并行聚合 ​

哈希分区不会自动带来并行查询能力——并行度由 max_parallel_workers_per_gather 等参数控制,与分区无关。但分区确实给并行聚合提供了天然的「可切分单元」:

sql
EXPLAIN (COSTS OFF)
SELECT tenant_id, count(*) FROM events GROUP BY tenant_id;
text
                  QUERY PLAN
----------------------------------------------
 HashAggregate
   Group Key: tenant_id
   ->  Append
         ->  Seq Scan on events_p0
         ->  Seq Scan on events_p1
         ->  Seq Scan on events_p2
         ->  Seq Scan on events_p3

GROUP BY tenant_id 无法裁剪(每个分区都可能包含任意 tenant_id),只能全扫。如果这张表的聚合还很频繁,就说明哈希分区不是最优解——也许该考虑在 tenant_id 上直接建索引,而不是分区。

DEFAULT 分区在哈希分区中的角色 ​

前面提到,若模数大于已建余数的覆盖范围,就需要 DEFAULT 分区兜底:

sql
CREATE TABLE events_default PARTITION OF events DEFAULT;

但 DEFAULT 分区在哈希场景下有两处需要注意:

  1. 它会被放入所有裁剪不了的查询里。规划器无法证明某行的哈希余数不等于 DEFAULT 分区(因为 DEFAULT 覆盖一切剩余),所以带 DEFAULT 分区的哈希表,任何查询的 Append 都可能包含 DEFAULT 子计划。
  2. 一旦 DEFAULT 里有了数据,新增分区就要搬数据。若想把 REMAINDER 5 从 DEFAULT 中独立出来,需要先 DETACH 再搬行再 ATTACH(详见创建与附加分区)。
sql
-- 有数据时新增分区的通用流程
BEGIN;
ALTER TABLE events DETACH PARTITION events_default;
CREATE TABLE events_p5 PARTITION OF events FOR VALUES WITH (MODULUS 8, REMAINDER 5);
INSERT INTO events_p5 SELECT * FROM events_default WHERE ...;  -- 需要按哈希条件筛选,实操中常全量重分配
DELETE FROM events_default WHERE ...;
ALTER TABLE events ATTACH PARTITION events_default DEFAULT;
COMMIT;

哈希分区下 DEFAULT 分区的数据无法用简单条件筛出

范围/列表分区里,想要把某些行从 DEFAULT 搬到新分区,条件很直白(WHERE created_at >= ... 或 WHERE region = 'x')。哈希分区里,「哪些行的哈希余数是 5」这个条件在 SQL 层面无法表达——没有 hash_of(tenant_id) % 8 = 5 这样的表达式。所以哈希分区配合 DEFAULT 时,新增分区的数据搬运往往只能「全量重分配」或「接受 DEFAULT 长期存有数据」,这也是哈希分区应尽量「一次把余数建齐」的另一个原因。

分区裁剪失效的检查清单 ​

写完查询后如果发现四个分区全被扫描,按以下顺序排查:

排查项说明修复
条件是否等值?只有 key = 常量 / key IN (...) 能裁改查询或换分区方法
分区键是否被函数包裹?WHERE hash_part(key) = ... 破坏裁剪保持键列裸用
是否用了隐式类型转换?WHERE int_key = '42'::text 可能丢裁剪显式类型匹配
enable_partition_pruning 是否为 on?默认 on,被关掉就全扫SET enable_partition_pruning = on;
是否是参数化查询?PREPARE 的值在计划期未知依赖运行时裁剪(PG 11+)
sql
SHOW enable_partition_pruning;
text
 enable_partition_pruning
--------------------------
 on
sql
-- 反例:用函数包裹分区键,裁剪失效
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE abs(tenant_id) = 42;
text
                  QUERY PLAN
----------------------------------------------
 Append
   ->  Seq Scan on events_p0
         Filter: (abs(tenant_id) = 42)
   ->  Seq Scan on events_p1
         Filter: (abs(tenant_id) = 42)
   ->  Seq Scan on events_p2
         Filter: (abs(tenant_id) = 42)
   ->  Seq Scan on events_p3
         Filter: (abs(tenant_id) = 42)

运行时裁剪与参数化查询 ​

PostgreSQL 11 起支持运行时裁剪(runtime pruning):分区键的值在计划期未知(例如来自 PREPARE 参数、子查询、嵌套循环的当前行)时,规划器先生成一个「带裁剪逻辑的 Append」,执行时再把无关子计划停掉。

sql
PREPARE q(int) AS SELECT * FROM events WHERE tenant_id = $1;
EXPLAIN (ANALYZE, COSTS OFF) EXECUTE q(42);
text
                       QUERY PLAN
--------------------------------------------------------
 Seq Scan on events_p2 (actual time=0.011..0.014 rows=1 loops=1)
   Filter: (tenant_id = 42)
 Planning Time: 0.230 ms
 Execution Time: 0.038 ms

注意 Subplans Removed 相关信息在这里体现为执行期只保留了 events_p2。运行时裁剪让哈希分区的等值点查在参数化场景下依然能享受裁剪红利,这也是为什么哈希分区在「点查密集、需要 PREPARE」的应用里仍然实用。

实战示例 ​

下面完整走一遍「用哈希分区摊薄一张高写入量表」的流程。

第 1 步:明确目标与分区键 ​

假设有一张 metrics 表,预计一年 20 亿行,写入 QPS 很高,查询以「按 device_id 点查最近数据」为主,没有时间归档需求(数据保留 90 天,靠定时 DELETE 清理)。目标是分摊写入与索引维护压力。选择 device_id 作为哈希分区键,因为它是高基数列且点查频繁。

第 2 步:建父表(主键包含分区键) ​

sql
CREATE TABLE metrics (
    device_id   bigint      NOT NULL,
    ts          timestamptz NOT NULL,
    metric      text        NOT NULL,
    value       double precision NOT NULL,
    PRIMARY KEY (device_id, ts)
) PARTITION BY HASH (device_id);

第 3 步:一次性建齐 16 个分区 ​

用 DO 块避免手写 16 条 DDL:

sql
DO $$
BEGIN
  FOR i IN 0..15 LOOP
    EXECUTE format(
      'CREATE TABLE metrics_p%s PARTITION OF metrics FOR VALUES WITH (MODULUS 16, REMAINDER %s)',
      lpad(i::text, 2, '0'), i);
  END LOOP;
END
$$;
text
DO

第 4 步:建立分区内索引 ​

父表上一个 CREATE INDEX 会自动下发到所有分区:

sql
CREATE INDEX metrics_ts_idx ON metrics (ts);
text
CREATE INDEX

验证 metrics_p07 上确实有了对应索引:

text
postgres=# \d metrics_p07
              Table "public.metrics_p07"
  Column   |     Type     | Collation | Nullable | Default
-----------+--------------+-----------+----------+---------
 device_id | bigint       |           | not null |
 ts        | timestamp w. |           | not null |
 ...
Indexes:
    "metrics_p07_pkey" PRIMARY KEY, btree (device_id, ts)
    "metrics_p07_ts_idx" btree (ts)

第 5 步:验证写入分摊 ​

sql
INSERT INTO metrics (device_id, ts, metric, value)
SELECT (random() * 100000)::bigint,
       now() - (random() * interval '90 days'),
       'cpu',
       random() * 100
FROM generate_series(1, 500000);

ANALYZE metrics;
sql
SELECT tableoid::regclass AS part, pg_size_pretty(pg_relation_size(tableoid)) AS sz, count(*)
FROM metrics
GROUP BY tableoid
ORDER BY 1;
text
    part     |    sz     | count
-------------+-----------+-------
 metrics_p00 | 3648 kB   | 31092
 metrics_p01 | 3648 kB   | 31328
 metrics_p02 | 3624 kB   | 31077
 ...
 metrics_p15 | 3640 kB   | 31350

各分区行数与体积接近,说明 device_id 哈希分布均匀。

第 6 步:确认点查被裁剪 ​

sql
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM metrics WHERE device_id = 12345 AND ts > now() - interval '1 day';
text
                          QUERY PLAN
---------------------------------------------------------------
 Index Scan using metrics_p09_ts_idx on metrics_p09
   Index Cond: (ts > (now() - '1 day'::interval))
   Filter: (device_id = 12345)
 Planning Time: 0.412 ms
 Execution Time: 0.061 ms

只命中一个分区 metrics_p09,且在该分区内走了 ts 索引。

第 7 步:确认区间查询无法裁剪 ​

sql
EXPLAIN (COSTS OFF) SELECT count(*) FROM metrics WHERE ts > now() - interval '1 day';
text
                     QUERY PLAN
-----------------------------------------------------
 Aggregate
   ->  Append
         ->  Index Only Scan using metrics_p00_ts_idx on metrics_p00 ...
         ->  Index Only Scan using metrics_p01_ts_idx on metrics_p01 ...
         ...(16 个分区全部出现)

这印证了哈希分区「时间条件不裁剪」的特点,也说明 ts 上的索引必须存在,否则按时间查询会退化为全表顺序扫描。

第 8 步:逐分区清理数据 ​

数据保留 90 天,用逐分区 DELETE 分摊清理压力(哈希分区无法用 DROP/TRUNCATE 做时间归档,因为每个分区里都混着各时间段的数据):

sql
DO $$
DECLARE r record;
BEGIN
  FOR r IN SELECT c.relname FROM pg_class c
           JOIN pg_inherits i ON i.inhrelid = c.oid
           JOIN pg_class p ON p.oid = i.inhparent
           WHERE p.relname = 'metrics' LOOP
    EXECUTE format('DELETE FROM %I WHERE ts < now() - interval ''90 days''', r.relname);
  END LOOP;
END
$$;

注意事项 ​

  • 唯一约束必须包含全部分区键列。 哈希分区键若不在唯一约束里,建表直接失败,因为没有它就无法保证全局唯一性。

    text
    ERROR:  unique constraint on partitioned table must include all partitioning columns
    DETAIL:  PRIMARY KEY constraint on table "metrics" lacks column "device_id" which is part of the partition key.
  • MODULUS 与 REMAINDER 规则不可违反。 模数必须为正整数,余数必须为非负整数且小于模数。

    sql
    -- 错误!余数不能等于或大于模数
    CREATE TABLE t_bad PARTITION OF t FOR VALUES WITH (MODULUS 4, REMAINDER 4);
    text
    ERROR:  remainder for hash partition must be less than modulus
  • 不要指望哈希分区做范围/排序优化。 区间条件(BETWEEN、>、<)、ORDER BY、GROUP BY、LIKE 前缀匹配都会扫描所有分区,换分区方法或另建索引才是正解。

  • 分区数不要定太少。 模数一旦确定就几乎无法修改,扩容等于重建整表。起步建议按未来 2~3 年数据量规划分区数。

  • 不要用低基数列作哈希分区键。 只有两个取值的列哈希后最多用上两个分区,其余分区永远空置。

    sql
    -- 不推荐:is_active 只有 true/false 两个值,哈希分区形同虚设
    CREATE TABLE users (...) PARTITION BY HASH (is_active);
  • 哈希分区不适合做时间归档。 想按月 DROP 旧数据,必须用范围分区;哈希分区里每个分区都混着各时间段的数据。

  • 不要依赖具体的哈希值分布。 哈希算法属于实现细节,不同版本可能变化,不能写死「某值一定在某个分区」。

  • DEFAULT 分区的数据在哈希场景下难以精准搬迁。 因为无法用 SQL 表达「哈希余数 = N」这个条件,新增余数分区时往往要全量重分配。

  • 分区过多会推高规划时间。 16~32 个分区通常无碍,上百个分区时每次查询的规划开销明显上升。

    sql
    -- 查看分区数量,避免膨胀
    SELECT count(*) FROM pg_inherits i
    JOIN pg_class p ON p.oid = i.inhparent
    WHERE p.relname = 'metrics';
  • 哈希分区表上不能直接 CREATE INDEX CONCURRENTLY。 只能先 CREATE INDEX ON ONLY 父表,再逐分区并发建索引,最后 ALTER INDEX ... ATTACH PARTITION(见分区索引策略)。

最佳实践 ​

  • 先判断是否真的需要哈希分区。 只有当单表规模与写入/维护压力确实构成瓶颈,且没有自然的时间/业务维度可切分时,才考虑哈希分区。

    sql
    -- 先看表有多大、写入压力如何
    SELECT pg_size_pretty(pg_total_relation_size('metrics')) AS total_size,
           (SELECT count(*) FROM metrics) AS rows;
  • 分区键选「高基数、点查频繁」的列。 device_id、user_id、tenant_id 这类列既保证哈希分散,又能在点查时裁剪。

  • 模数一次性留足余量。 按未来 2~3 年数据量规划,例如现在是 5000 万行但会涨到 5 亿行,直接按 16 或 32 个分区规划。

  • 余数从 0 到 M-1 一次建齐,慎用 DEFAULT。 建齐后无需兜底,且保证了写入覆盖完备。

    sql
    DO $$
    BEGIN
      FOR i IN 0..15 LOOP
        EXECUTE format('CREATE TABLE metrics_p%s PARTITION OF metrics FOR VALUES WITH (MODULUS 16, REMAINDER %s)',
                       lpad(i::text,2,'0'), i);
      END LOOP;
    END $$;
  • 唯一约束/主键一律写成「分区键 + 业务唯一列」。 这是分区表的硬性要求,也决定了业务主键设计。

    sql
    PRIMARY KEY (device_id, ts)   -- device_id 是分区键,必须在内
  • 每个分区都建好查询所需索引。 在父表上 CREATE INDEX 会自动下发,但务必确认索引确实建到了每个分区。

    sql
    CREATE INDEX metrics_ts_idx ON metrics (ts);
    -- 验证
    SELECT count(*) FROM pg_indexes WHERE tablename LIKE 'metrics_p%' AND indexdef LIKE '%ts%';
  • 维护操作逐分区进行。 VACUUM、REINDEX、ANALYZE 逐个分区做,负载更平滑。

    bash
    for p in $(psql -Atc "SELECT c.relname FROM pg_class c JOIN pg_inherits i ON i.inhrelid=c.oid JOIN pg_class p ON p.oid=i.inhparent WHERE p.relname='metrics'"); do
      psql -c "VACUUM (ANALYZE) $p"
    done
  • 用规划参数分别控制各阶段开销。 分区多时,确保 enable_partition_pruning 开启,必要时调 constraint_exclusion 观察裁剪行为。

    sql
    SET enable_partition_pruning = on;
    SET constraint_exclusion = partition;
  • 监控各分区大小与倾斜。 如果某个分区明显大于其他分区,说明分区键哈希不够分散,需要重新评估分区键。

    sql
    SELECT c.relname AS part, pg_size_pretty(pg_relation_size(c.oid)) AS size
    FROM pg_class c
    JOIN pg_inherits i ON i.inhrelid = c.oid
    JOIN pg_class p ON p.oid = i.inhparent
    WHERE p.relname = 'metrics'
    ORDER BY pg_relation_size(c.oid) DESC;
  • 把哈希分区与范围分区组合使用。 若既需要时间归档、又需要打散写入,用「RANGE(时间) → HASH(高基数列)」两级分区,兼顾归档与并发。

    sql
    CREATE TABLE events_2026_01 PARTITION OF events
        FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
        PARTITION BY HASH (device_id);

下一节

下一节:多级分区(子分区)——学习如何把不同分区策略逐级组合,处理多维度的大表。

参考链接 ​