适用范围与建模目标
本文讨论自建 ClickHouse 中 MergeTree 家族的分析表设计,覆盖排序键、分区、数据 part、更新去重与生命周期。示例使用通用 MergeTree SQL,并以在线官方文档核对语义;执行前仍需记录 SELECT version() 和现有表结构。ClickHouse Cloud 的 SharedMergeTree、自动扩缩容和存储管理需要采用相应产品文档。
建模从三份材料开始:主要查询的过滤条件与聚合维度、写入批次与峰值、保留及更新规则。下面 DDL 只供隔离沙箱评审,元数据查询限定为一张表,未运行实际建表或压测,验证等级为 PENDING。
排序键不是唯一约束
MergeTree 将写入的数据组织为多个不可变 part,后台逐步合并。part 内按 ORDER BY 排序,主键建立稀疏索引以帮助跳过无需读取的粒度;如果省略 PRIMARY KEY,排序键同时作为主键。它不提供关系数据库主键那样的唯一性约束,重复业务 ID 可以同时存在。
选择排序键时,优先考虑实际高频过滤路径,再考虑列的基数与压缩效果。对「租户加时间范围」查询,以租户开头通常值得评估;若大多数查询跨租户按时间检索,同一设计可能缺乏优势。主键越长也不一定越好,索引大小、写入成本与扫描缩减应一起比较。MergeTree 原理
分区控制数据组织和维护范围
分区把 part 归入不同集合,后台合并不会跨分区进行。分区键首先应服务于保留、装载和分区管理,不应为了每个查询条件建出大量细分区。按租户 ID 或精确时间戳分区容易产生大量小 part,并增加文件与合并管理成本。
月分区适合部分长期时间序列,短保留或日级替换场景可以评估日分区,但没有全业务通用的粒度。分区裁剪能够减少相关范围,分区键却不能替代排序键带来的数据跳过能力。对一个月份内仍要扫描大量数据的查询,应继续检查排序和过滤路径。
一个追加事件表的设计示例
以下演示表用于不可变事件明细,假设 sandbox 是已经建立的隔离数据库;同名表不存在时才纳入演练。它没有自动去重、删除或复制配置。
CREATE TABLE sandbox.events_design_demo
(
tenant_id UInt32,
event_id UInt64,
event_time DateTime('UTC'),
event_type LowCardinality(String),
amount Decimal(18, 2)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, event_id);金额使用定点小数表达明确精度,事件类型在低基数前提下考虑 LowCardinality。event_id 放在末尾不意味着全局唯一;如果查询经常只按它定位,需评估额外访问路径。生产容量、金额范围和时间精度都应根据业务修订,不能用演示类型截断真实数据。
从 Part 数量解释写入成本
每次小批次写入都可能带来新的 part,写入持续超过后台合并能力时,part 会积累。大批次也有延迟和客户端内存代价,应在有限样本中选择合适批次,并单独核对异步插入的确认与重试语义。
SELECT partition, count() AS active_parts,
sum(rows) AS physical_rows,
sum(bytes_on_disk) AS bytes_on_disk
FROM system.parts
WHERE database = 'REPLACE_WITH_DATABASE'
AND table = 'REPLACE_WITH_TABLE' AND active
GROUP BY partition
ORDER BY active_parts DESC
LIMIT 12
SETTINGS max_execution_time = 5, max_threads = 2;这是单节点元数据汇总,不是全分片逻辑行数。active 避免把已被替代的 part 混入当前数据量;物理行数对 ReplacingMergeTree 仍可能包含多个版本。LIMIT 只限制结果数量,应配合表过滤和执行预算,不能宣称只处理了 12 条元数据。system.parts
ReplacingMergeTree 的更新与去重边界
ReplacingMergeTree 在合并时按完整 ORDER BY 值识别相同记录,有版本列时按版本规则保留记录。它提供最终合并行为,不保证写入后立刻唯一;后台合并时间不可作为查询正确性的承诺。需要当前版本视图的查询,应评估 FINAL 或明确的版本聚合逻辑。
CDC 模型必须让同一业务实体的排序键保持稳定。把会改变的状态、更新时间放入排序键,会使新旧版本成为不同实体;把可变时间用作分区,也可能让版本分散到永不后台合并的不同分区。版本号应有可比较的顺序和冲突处理,不把不稳定到达次序当成业务最新状态。ReplacingMergeTree 语义
插入重试去重不是业务去重
写入去重主要用于一定窗口内识别重试的数据块,受引擎、设置、去重窗口和批次构造影响。将同一批数据换一种切分、顺序或生成方式重新发送,不能默认仍被识别;窗口过期也不能提供永久去重保证。
应保存生产端批次 ID、业务事件 ID 和每次请求结果,对超时但提交状态不明的批次单独核对。物化视图及异步插入还存在额外设置与版本边界,不能因为源表看起来不重复,就推断下游汇总也没有重复。跨系统管道的幂等设计需要与 Flink Sink 的实际交付语义一起验收。插入重试去重
TTL 与合并的运行代价
TTL 可以删除行、处理列或迁移数据,通常在后台合并过程中执行,并非到期瞬间的可见性保证。如果查询要求严格排除过期数据,应在业务查询层明确时间条件。缩短 TTL 会影响历史可恢复数据,改回旧规则无法找回已经删除的记录。
不要把 OPTIMIZE TABLE ... FINAL 当作日常小 part、去重或 TTL 的通用补救。它会强制重写大量数据,可能绕过后台合并的保护性选择并形成很大的 part;资源不足时进一步放大压力。查询中的 FINAL 与强制物理合并不是同一种操作。TTL 机制、避免频繁 OPTIMIZE FINAL
设计验收、误区与回退
在固定且有限的数据集上比较代表性查询的读取行数、字节、耗时与内存,同时记录写入延迟、part 增长和压缩效果。更新模型增加乱序版本、重复重试与跨月变更样本,验收逻辑最新值而非只看物理行数。排序键或分区调整应先使用独立候选表验证并保留旧表及源数据。
常见误区是把分区数量当作并行能力、把主键当作唯一约束,以及认为后台最终会合并就无需查询去重。若候选表无法正确表达更新、part 增长失控或原始重放来源不完整,停止迁移。回退可以恢复读取旧表,但新写入和已删除历史需要明确同步或重放方案,不能仅改表名宣称数据已经回到原点。