本文将为您详细介绍在 ByteHouse 中进行分区与分桶设计的原则、方法与最佳实践。
随着表数据规模持续增长,系统通常会同时面对三类压力:
在这样的背景下,ByteHouse 中常见的建模原则是:能分区尽量先分区,分桶作为性能优化手段按需引入。
对于典型分析型业务,这一实践尤其适用于以下场景:
create_time、event_time、request_time 等时间字段。JOIN、GROUP BY、IN、等值过滤与分桶键一致时,可进一步获得查询优化收益。ByteHouse 中分区与分桶的职责可以概括为“一个负责组织与裁剪,一个负责分布与并行”。在实际链路中,常见的逻辑关系如下:
PARTITION BY 规则进入对应分区,再按 CLUSTER BY 规则分散到多个桶。在开始设计分区与分桶方案前,建议先确认以下前提:
分区与分桶设计本身存在一些天然限制,实践中需要提前接受这些约束:
user_id、order_id、device_id、trace_id 等。分区裁剪能否生效,关键取决于查询条件是否能与表定义中的 PARTITION BY 表达式建立有效映射。简单理解:
JOIN、GROUP BY、IN、等值过滤字段一致。优先从以下三类字段中选择:
常见粒度包括小时、天、月、年,选择时重点看两个问题:
在超高流量场景下,按小时分区可以有效降低单分区压力;而在归档、月度运维场景下,按月分区往往更符合操作习惯。
以下两类场景通常值得进一步考虑分桶:
user_id、order_id。表业务类型 | 分区? | 常见分区表达式 |
|---|---|---|
事实表 | 是 | 按天分区: |
大型表 | 有时 | 选择按照时间字段或者业务特征字段进行分区 |
小维表/查询 | 否 | 数据量比较小(百万行以下): 推荐不进行分区 |
方面 | 分区 | 分桶 |
|---|---|---|
设计目标 | 粗粒度管理数据的生命周期(运维&TTL) | 细粒度的控制并行度以及每个分区内的数据分布 |
执行计划 | 分区是元数据信息,查询时能通过实际的明文筛选条件直接跳过特定的分区数据而不去真正扫描数据 | 只能通过等值类筛选条件来跳过特定的分桶内的数据 |
运维层面 | 删除分区(DROP PARTITION), 分离分区(DETACH PARTITION) 都是元数据操作,高效而且方便。 | 业务中极少执行分桶删除操作,数据表数据量通常持续上涨。为防止单个分桶存储数据量过大,可随数据规模成倍扩容分桶数量,支持通过 ALTER TABLE 语句动态调整表分桶数。详细可以参考:Bucket Table 动态扩容最佳实践 |
单表推荐个数 | 每个表1-10000个(小时,天,月,年,租户等)。 | 分桶个数依据数据量决定,尽量控制单个分桶内的数据量在千万级别最佳,同时考虑到负载均衡的问题,简单的做法是从这些数字中选择一个作为桶个数4,8,16,32,64,128,这样能保证是worker节点个数的整倍数,降低负载倾斜的可能性 |
数据出现倾斜时 | 通过修改分区键以获得更均匀合理的数据分割范围,极端场景(分桶键本身比较离散,数据分布得很均匀)也可以去掉分区完全依靠分桶来处理数据分布问题 | 合理选择更离散的复合的字段组合来作为分桶键 |
尽量避免 | 过多的分区意味着过多的part文件,在发生查询时可能导致更重的IO操作反而拉低查询性能,另外后台的Merge操作也需要消耗更多的资源 | 单桶存储记录数不宜超过 8000 万,将单桶记录规模维持在 4000 万区间可保障最佳读写性能。单桶数据体量过高将显著损耗读写效率,针对该场景,可结合数据增长情况按倍数扩容表分桶数量,均衡各桶数据负载 |
分区粒度 | 应用场景 | 优劣 | 常用的表达式 |
|---|---|---|---|
按天 | 大多数报表和日志表等 | 简单,能较好的控制分区的个数,方便和明确的分区运维管理 | toYYYYMMDD(create_time) |
按小时 | 表写入量巨大的场景,比如每小时可能写入超过4亿行 | 将一天分成24个分区,能在按小时查询时做到精确筛选数据块 | toStartOfHour(create_time) |
按月 | 按天分区的单分区数据量较少(<100万) | 更粗粒度的管理数据,对时间细粒度的查询上没有大的优化效果,主要方便管理运维历史数据 | toYYYYMM(create_time) |
按年 | 主要是归档或者按年分区后但分区数据量也只在千万级的表 | 很大限度的控制了表的总分区个数,分区本身对查询性能没有特别大帮助,需要依赖索引或者排序键协助改进查询性能 | toYear(create_time) |
完成设计后,建议至少从以下三个维度验证方案是否合理:
如果验证结果显示:
背景:
event_log 每天写入量约 2000 万行。event_time,同时存在按天聚合分析需求。结论:
event_time 作为分区键,并按天分区。CREATE TABLE event_log ( event_id String, user_id UInt64, device_id String, event_name String, event_time DateTime, platform LowCardinality(String), properties String, ingest_time DateTime DEFAULT now() ) ENGINE = CnchMergeTree PARTITION BY toYYYYMMDD(event_time) ORDER BY (event_time, user_id, event_id) TTL toYYYYMMDD(event_time) + INTERVAL 180 DAY;
背景:
order_history 每月平均约 3 亿行。order_id 修改记录。order_id 与其他维表做联合分析。设计结论:
create_time 按月分区,满足月度删除与重导诉求。order_id 作为唯一键。order_id,以降低单分区内写入与并行处理压力。CREATE TABLE order_history ( order_id String, shop_id UInt64, region LowCardinality(String), order_amount Decimal(18, 2), pay_order_amount Decimal(18, 2), refund_amount Decimal(18, 2), create_time DateTime64(3) DEFAULT now(), update_time DateTime64(3) DEFAULT now() ) ENGINE = CnchMergeTree PARTITION BY toYYYYMM(create_time) ORDER BY (region, shop_id, order_id) UNIQUE KEY(order_id) CLUSTER BY EXPRESSION cityHash64V2(order_id) % 8 INTO 8 BUCKETS TTL toYYYYMM(create_time) + INTERVAL 48 MONTH;
背景:
request_time 过滤特定小时数据。设计结论:
request_time 作为分区键,并按小时分区。CREATE TABLE access_log ( request_id String, service_name LowCardinality(String), api_path String, client_ip IPv4, status_code UInt16, latency_ms UInt32, request_time DateTime, ingest_time DateTime DEFAULT now() ) ENGINE = CnchMergeTree PARTITION BY toStartOfHour(request_time) ORDER BY (request_time, service_name, api_path, request_id) TTL toStartOfHour(request_time) + INTERVAL 7 DAY;
Q:分区和分桶应该先做哪个?
A: 通常先设计分区。因为分区直接决定查询裁剪、数据生命周期管理和运维方式;只有当分区设计完成后,单分区内仍存在明显性能瓶颈,才再评估是否需要分桶。
Q:时间字段是不是总是最好的分区键?
A: 在大多数分析型场景下,时间字段通常是最稳妥的选择,但前提是查询确实会大量使用该字段过滤,且字段本身不会频繁更新。如果业务隔离维度更关键,也可以考虑与时间字段组合设计。
Q:引入分桶后,是不是一定能解决数据倾斜?
A: 不一定。分桶能提升并行处理能力,但如果分桶键本身分布不均匀,倾斜仍然可能存在。分桶前应优先判断字段基数与分布是否足够均衡。