分区策略设计:时间分区、桶分区与混合分区

分区这件事,说白了就是「把大表切成小份」。我在项目中见过太多人一上来就建个单表,数据量一上来查询直接崩。你想想看,几 TB 的数据全扫一遍,再好的硬件也扛不住。

分区策略选对了,查询效率能提升几十倍。选错了,嗯… 我踩过的坑后面会讲。

为什么分区这么重要?

数据湖仓里存的是历史数据,动辄几个月甚至几年。每次查询都要全量扫描?不现实。分区的核心目的就两个:

  • 减少扫描数据量 —— 只读需要的分区
  • 提升写入性能 —— 并行写入不同分区

我个人习惯把分区策略分成三类:时间分区、桶分区、混合分区。下面一个个说。

时间分区:最常用的方案

时间分区是历史数据查询的标配。为什么?因为大部分查询都带时间范围。比如「查上个月的订单」、「查某天的日志」。

常见的分区粒度:

粒度 适用场景 分区数(一年) 我踩过的坑
年 归档数据,极少查询 1~3 分区太少,扫描量还是大
月 常规业务报表 12 比较均衡,推荐
天 实时日志、高频查询 365 小文件太多,元数据压力大
小时 流式数据,近实时分析 8760 慎用!元数据可能撑爆
注意: 我曾经在一个项目里用了小时级分区,结果一年产生了 8000 多个分区。查询时元数据服务直接 OOM。后来改成天级分区,问题解决。

时间分区的写法(以 Hive 风格为例):

CREATE TABLE orders (
    order_id STRING,
    amount DOUBLE,
    user_id STRING
)
PARTITIONED BY (dt STRING)
STORED AS PARQUET;

查询时带上分区过滤:

SELECT * FROM orders
WHERE dt >= '2024-01-01' AND dt < '2024-02-01';

这样只会扫描 1 月份的分区,而不是全表。

桶分区:解决数据倾斜

时间分区有个问题:同一个分区内的数据可能分布不均。比如某天的订单量是平时的 10 倍,那这个分区的查询就会很慢。

桶分区(Bucket)就是用来解决这个问题的。它按某个字段的哈希值把数据分成固定数量的桶。

举个例子:

CREATE TABLE orders (
    order_id STRING,
    amount DOUBLE,
    user_id STRING
)
PARTITIONED BY (dt STRING)
CLUSTERED BY (user_id) INTO 16 BUCKETS
STORED AS PARQUET;

这样每个时间分区内,数据按 user_id 的哈希值分散到 16 个桶里。查询时如果带上 user_id 过滤,就能直接定位到某个桶。

我的经验: 桶数一般设成 2 的幂次(8、16、32、64)。这样哈希分布更均匀。我曾经设成 15 个桶,结果数据严重倾斜,后来改成 16 就好了。

混合分区:时间 + 桶的组合拳

实际项目中,我很少只用一种分区方式。混合分区才是常态。

常见的组合:

  • 时间分区 + 桶分区:先按时间切大块,再按业务键打散
  • 时间分区 + 二级分区:比如先按年,再按地区
  • 时间分区 + 动态分区:写入时自动创建分区

下面这张图展示了混合分区的整体流程:

混合分区策略流程图 原始数据 第一步:时间分区(按 dt) dt=2024-01-01 dt=2024-01-02 dt=2024-01-03 桶 0~15 桶 0~15 桶 0~15 每个时间分区内再按 user_id 哈希分桶,查询时先定位时间分区,再定位桶

分区裁剪优化

分区裁剪(Partition Pruning)是查询引擎自动做的优化。说白了就是:只读需要的分区,跳过无关的。

但前提是——你的查询条件要能命中分区键。我见过太多人写了这样的 SQL:

-- ❌ 错误写法:分区键被函数包裹,无法裁剪
SELECT * FROM orders
WHERE year(dt) = 2024 AND month(dt) = 1;

-- ✅ 正确写法:直接比较分区键
SELECT * FROM orders
WHERE dt >= '2024-01-01' AND dt < '2024-02-01';
核心原则: 分区键在 WHERE 条件中必须是裸字段,不能套函数、不能做运算。否则分区裁剪失效,全表扫描。

另外,动态分区裁剪是 Spark 3.0+ 引入的优化。它能在 Join 时自动推导出分区条件。比如:

-- 动态分区裁剪会自动加上 dt 过滤
SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.region = '华东';

Spark 会先扫描 users 表,拿到符合条件的 user_id,再反推到 orders 表的分区上。这个优化在星型模型中特别有用。

动态分区写入

动态分区写入,就是写入时自动创建分区,不用手动指定。这在数据湖仓里很常见,尤其是流式写入场景。

配置方式(Spark SQL):

-- 开启动态分区
SET hive.exec.dynamic.partition = true;
SET hive.exec.dynamic.partition.mode = nonstrict;

-- 写入时自动按 dt 创建分区
INSERT OVERWRITE TABLE orders PARTITION (dt)
SELECT order_id, amount, user_id, dt
FROM source_table;
避坑指南: 我曾经在生产环境开了动态分区,结果源数据里有个异常日期 '9999-12-31',直接创建了一个超大分区,把集群搞挂了。后来我加了数据校验,过滤掉异常值。

动态分区写入的注意事项:

  • 控制分区数:单次写入不要产生太多分区,建议不超过 1000 个
  • 小文件合并:动态分区容易产生大量小文件,写入后记得做 compaction
  • 数据倾斜:某些分区数据量特别大时,考虑加一层桶分区

总结一下我的建议

分区策略没有银弹。我一般这样选:

  • 查询总是带时间范围 → 时间分区(天或月)
  • 查询经常按某个字段过滤 → 加桶分区(如 user_id、city)
  • 数据量大且查询模式复杂 → 混合分区(时间 + 桶)
  • 流式写入 → 动态分区 + 事后 compaction