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 查某个用户的事件流。

哈希分区不支持新增分区来扩容。如果从 4 个分区改成 8 个,MODULUS 变化会导致几乎所有数据需要重新分布,必须通过新建表 + 数据迁移完成,不能像范围分区那样简单 ATTACH 一个新分区。因此哈希分区数应在建表时一次性规划到位。

分区键选择的权衡

选择分区键,本质是在三个目标之间取舍:查询裁剪率、写入均匀性、分区可维护性。

  • 裁剪率优先:如果绝大多数查询都带某个等值或范围条件,就用它做分区键。例如日志表总按 created_at 查最近一天,时间范围分区裁剪效果最好。
  • 均匀性优先:如果查询条件分散、没有稳定前缀,但写入量很大,用哈希分区把压力摊平,避免单分区成为写入瓶颈。
  • 可维护性优先:范围分区便于按时间滚动归档(DETACH 旧分区即可),列表分区便于按业务线独立管理,哈希分区则几乎无法做局部归档。

一个常见的误区是拿低基数列做哈希分区键。比如用 status(只有几个取值)做哈希,虽然哈希函数会把它们打散,但总行数仍然集中在少数几个值上,分区之间依然可能倾斜。哈希分区的前提是键本身具有足够高的基数且分布相对均匀。

列表分区 vs 哈希分区:数据流向与分布验证 列表分区(PARTITION BY LIST) 插入行 region = 'CN' / 'US' / 'DE' / 'FR' … 按枚举值精确匹配,无匹配则进 DEFAULT orders_cn IN ('CN') orders_us IN ('US') orders_eu IN ('DE','FR','IT','ES') DEFAULT 分区:接收未声明的 region 值 风险:漏配分区时静默落入,需监控其行数 分布特征 分布由业务枚举值决定,可能天然不均 裁剪率高:WHERE region='CN' 只扫 orders_cn 维护要点 新增枚举值需 CREATE 新分区或改 DEFAULT 适合业务线、地区、状态等有限取值 哈希分区(PARTITION BY HASH) 插入行 user_id = 任意 bigint 计算 hash(user_id) % 4,按余数路由 p0 余 0 p1 余 1 p2 余 2 p3 余 3 写入天然分散:高基数键下各分区行数接近 低基数列做哈希键仍会倾斜,需先验证基数 分布特征 等值查询裁剪好:WHERE user_id=123 只扫 1 个分区 范围查询无法裁剪:BETWEEN 会扫全部分区 维护要点 分区数需一次规划,扩容要重建并迁移数据 适合用户 ID、设备号、会话 ID 等等值访问 验证手段:查询 pg_class.reltuples 或对每个分区执行 COUNT(*),比较各分区行数偏差

数据分布均匀性验证

分区建好不等于分布合理。验证分两步:先看每个分区的行数,再看查询是否真的裁剪到了目标分区。

行数验证可以直接查系统视图。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')、或者分区键类型与常量类型不一致导致无法比较。这三种情况都会让分区裁剪失效,需要改写查询或调整分区键。

一个实用的经验判断:如果某张表 80% 以上的查询都带同一个等值条件,且该列基数较高、分布均匀,优先哈希分区;如果该列取值有限且查询模式稳定,优先列表分区;如果查询总带时间范围,优先范围分区。分区键不是越细越好,分区数量控制在几十个以内通常更利于规划和维护。

最后要强调的是,列表分区和哈希分区可以组合使用。例如先按 region 做列表分区,再在每个地区分区内部按 user_id 做哈希子分区,这样既保留了地区裁剪能力,又让每个地区内部写入分散。组合分区会显著增加分区总数和管理复杂度,只有在单层分区确实无法同时满足裁剪和均匀性时才值得引入。