4. 列表分区与哈希分区实战
范围分区解决的是“连续区间”的切分问题,而现实中还有两类更常见的切分诉求:一类是取值有限且可枚举,比如订单状态、地区代码、业务线标识;另一类是取值高度离散、没有天然区间,比如用户 ID、设备号、会话 ID。前者适合列表分区,后者适合哈希分区。本节围绕这两类分区方式,讲清建表写法、分区键选择的权衡,以及如何验证数据是否真的被均匀打散。
列表分区:把枚举值显式映射到分区
列表分区的核心是 FOR VALUES IN (...),每个分区声明自己接收哪些离散值。以一张按地区拆分的订单表为例:
CREATE TABLE orders (
order_id bigint NOT NULL,
region text NOT NULL,
customer_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL
) PARTITION BY LIST (region);
CREATE TABLE orders_cn PARTITION OF orders FOR VALUES IN ('CN');
CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('US');
CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('DE','FR','IT','ES');
CREATE TABLE orders_other PARTITION OF orders DEFAULT;
这里有两个关键点。第一,orders_eu 一个分区承载了四个枚举值,说明列表分区的粒度由业务决定,不必“一值一分区”。第二,DEFAULT 分区是列表分区的安全网:当插入一个未在任何分区声明的 region 时,PostgreSQL 会把它放进默认分区,而不是直接报错。但默认分区是一把双刃剑——它会让“漏配分区”这种错误静默通过,长期积累后默认分区膨胀,反而成为新的热点。因此生产上更推荐的做法是:要么不建默认分区,让非法值立刻失败;要么建默认分区但配合监控,定期检查其中是否有数据。
列表分区还有一个容易踩的坑:分区键的取值必须能精确匹配。如果 region 存的是 'cn' 而分区声明的是 'CN',插入会落到默认分区或直接失败。所以列表分区对数据规范性要求很高,通常需要在应用层或 CHECK 约束上保证取值域收敛。
哈希分区:用取模把离散键打散
当分区键没有可枚举的少量取值,或者取值分布本身不均匀时,哈希分区更合适。它不关心值本身是什么,只关心 hash(值) % 分区数 落在哪个桶:
CREATE TABLE events (
event_id bigint NOT NULL,
user_id bigint NOT NULL,
event_type text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL
) PARTITION BY HASH (user_id);
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);
MODULUS 是分区总数,REMAINDER 是当前分区负责的余数,二者共同定义了一个分区。哈希分区的最大优势是写入天然分散:只要 user_id 本身没有极端倾斜,四个分区的行数会大致接近。它的代价是范围查询无法裁剪分区——查 user_id BETWEEN 1000 AND 2000 会扫描所有分区,因为哈希打散了顺序。所以哈希分区适合“按等值键访问”的场景,比如按 user_id 查某个用户的事件流。
MODULUS 变化会导致几乎所有数据需要重新分布,必须通过新建表 + 数据迁移完成,不能像范围分区那样简单 ATTACH 一个新分区。因此哈希分区数应在建表时一次性规划到位。分区键选择的权衡
选择分区键,本质是在三个目标之间取舍:查询裁剪率、写入均匀性、分区可维护性。
- 裁剪率优先:如果绝大多数查询都带某个等值或范围条件,就用它做分区键。例如日志表总按
created_at查最近一天,时间范围分区裁剪效果最好。 - 均匀性优先:如果查询条件分散、没有稳定前缀,但写入量很大,用哈希分区把压力摊平,避免单分区成为写入瓶颈。
- 可维护性优先:范围分区便于按时间滚动归档(
DETACH旧分区即可),列表分区便于按业务线独立管理,哈希分区则几乎无法做局部归档。
一个常见的误区是拿低基数列做哈希分区键。比如用 status(只有几个取值)做哈希,虽然哈希函数会把它们打散,但总行数仍然集中在少数几个值上,分区之间依然可能倾斜。哈希分区的前提是键本身具有足够高的基数且分布相对均匀。
数据分布均匀性验证
分区建好不等于分布合理。验证分两步:先看每个分区的行数,再看查询是否真的裁剪到了目标分区。
行数验证可以直接查系统视图。PostgreSQL 的 pg_class.reltuples 是统计信息估算值,速度快但不够精确;要精确值就对每个分区执行 COUNT(*):
SELECT 'orders_cn' AS part, count(*) FROM orders_cn
UNION ALL SELECT 'orders_us', count(*) FROM orders_us
UNION ALL SELECT 'orders_eu', count(*) FROM orders_eu
UNION ALL SELECT 'orders_other', count(*) FROM orders_other;
假设返回结果为 orders_cn=1200000、orders_us=800000、orders_eu=950000、orders_other=50000,总行数 300 万。四个分区占比分别约 40%、26.7%、31.7%、1.7%。orders_other 只有 1.7% 说明枚举值覆盖基本完整;但 orders_cn 达到 40%,明显高于均值 25%,说明地区业务量本身不均。这种不均不是分区方式的问题,而是数据分布的真实反映——列表分区只是如实映射了业务分布。如果希望写入更均衡,就需要改用哈希分区,或者对 orders_cn 再做二级拆分。
裁剪验证用 EXPLAIN 观察执行计划中出现的分区数量:
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE region = 'CN';
-- 期望:Append 下只出现 orders_cn,不出现其他分区
EXPLAIN (COSTS OFF) SELECT * FROM events WHERE user_id = 12345;
-- 期望:Append 下只出现 events_p? 中的一个分区
如果 EXPLAIN 显示扫描了全部分区,通常有三个原因:查询条件没有直接引用分区键、条件中使用了函数包裹分区键(如 WHERE upper(region)='CN')、或者分区键类型与常量类型不一致导致无法比较。这三种情况都会让分区裁剪失效,需要改写查询或调整分区键。
最后要强调的是,列表分区和哈希分区可以组合使用。例如先按 region 做列表分区,再在每个地区分区内部按 user_id 做哈希子分区,这样既保留了地区裁剪能力,又让每个地区内部写入分散。组合分区会显著增加分区总数和管理复杂度,只有在单层分区确实无法同时满足裁剪和均匀性时才值得引入。