CoalescingMergeTree:列级合并,一行成景

CoalescingMergeTree 引擎通过追加写的方式,为 ClickHouse 带来了真正的列级更新能力。作为 MergeTree 家族的新成员,它能够在后台合并时,把稀疏的局部记录逐步聚合成完整行,非常适合以下场景:

  • 需要高效保留每个实体的最完整版本
  • 可接受后台合并时才落盘整合的延迟
  • 只想填补缺失字段,而非像 ReplacingMergeTree 那样整行覆盖

CoalescingMergeTree 首发于 25.6 版本,25.7.2.54 已具备完整更新能力。详情查看issue #84116

一、实现原理

1.1 八股文版

与其它 MergeTree 引擎一样,需要先通过 ORDER BY 声明排序键(主键)。CoalescingMergeTree 会把相同排序键的多条记录做合并,规则:每列保留最新的非 NULL 值,最终在磁盘上得到一行“完整”数据。
merge
其中 1- 4 为数据 insert 过程,对于同一个 vin 每次插入局部数据

  1. 初始化 vin 的电池电量和防火墙信息
  2. 更新 GPS 数据(本质上是插入追加操作)
  3. 更新速度和温度数据
  4. 更新电池电量
    其中的 5 - 6 为 MergeTree 的局部合并, 7 则将若干个 part 合并成一个 active part 并进行落盘,其中的合并逻辑为:取每列最新非 null 数据。

    tip: 这里的最新指的是行插入的顺序,并非依据排序列或者人为指定的列。

1.2 解剖版:用 SQL 讲清楚

如果上面仍显抽象,下面用最简 SQL 演示其本质。
首先是对“最新”的解释:可以理解成 clickhouse 为每一行都分配一个默认值为 now() 的虚拟插入时间。
建表(演示用,非 CoalescingMergeTree)

create table demo  
(
id UInt8 comment '主键 ID',
name Nullable(String) comment '姓名',
age Nullable(UInt8) comment '年龄',
_insert_time DateTime64 materialized now() comment '插入时间'
) engine MergeTree
order by id;

追加式更新

insert into table demo(id, name, age) values (1, '张三', 20); -- 初始化 id: 1 数据  
insert into table demo(id, age) values (1, 21); -- 更新年龄
insert into table demo(id, name) values (1, '张一三'); -- 更新姓名

明细数据
query_result
CoalescingMergeTree 的合并逻辑等价于

select  
id,
argMax(name, _insert_time) as name,
argMax(age, _insert_time) as age
from demo
group by id;

若把该语句封装成物化视图,即可模拟 CoalescingMergeTree;而 CoalescingMergeTree 只是把这个过程内嵌到了 MergeTree 的合并阶段。

二、实战

CoalescingMergeTree 特别适合局部更新场景,例如官网给出的 Tesla 在车联网上的应用。我们知道 clickhouse 在超大宽表的查询上性能极高,但超大宽表中每个数据域的就绪时间往往是不同的,企业中为了让 clickhouse 中宽表的可用时间尽可能长采用局部分批更新策略,使用 CoalescingMergeTree 将会变得更加方便

2.1 构建环境

  • 宽表: user_profile_wide(CoalescingMergeTree)
  • 分域表: user_profile_basic / behavior / business(MergeTree)
create table user_profile_wide  
(
-- 主键 & 分区键
user_id UInt64,

-- 1️⃣ 基础属性域(低频更新,每日)
gender Nullable(Enum8('M' = 1, 'F' = 2)),
age Nullable(UInt8),
city_level Nullable(UInt8),
register_channel Nullable(String),

-- 2️⃣ 行为偏好域(中频更新,小时级)
last30d_pv Nullable(UInt32),
last30d_uv Nullable(UInt32),
fav_category Nullable(String),
last_login_time Nullable(DateTime64),

-- 3️⃣ 业务价值域(高频更新,分钟级)
ltv_7d Nullable(Float64),
ltv_30d Nullable(Float64),
churn_prob Nullable(Float32),
vip_level Nullable(UInt8)
)
engine = CoalescingMergeTree
order by (user_id);

为了方便进行局部数据更新,以及对不同主题域 ETL 任务的拆分,每个域的数据在 clickhouse 中都有一张表

create table user_profile_basic  
(
-- 主键 & 分区键
user_id UInt64,

-- 1️⃣ 基础属性域
gender Nullable(Enum8('M' = 1, 'F' = 2)),
age Nullable(UInt8),
city_level Nullable(UInt8),
register_channel Nullable(String)
) engine = MergeTree
order by (user_id);

create table user_profile_behavior
(
-- 主键 & 分区键
user_id UInt64,

-- 2️⃣ 行为偏好域
last30d_pv Nullable(UInt32),
last30d_uv Nullable(UInt32),
fav_category Nullable(String),
last_login_time Nullable(DateTime64)
) engine = MergeTree
order by (user_id);

create table user_profile_business
(
-- 主键 & 分区键
user_id UInt64,

-- 3️⃣ 业务价值域
ltv_7d Nullable(Float64),
ltv_30d Nullable(Float64),
churn_prob Nullable(Float32),
vip_level Nullable(UInt8)
) engine = MergeTree
order by (user_id);

2.2 构建数据管道

每个分域表只负责追加写入,宽表通过物化视图自动合并

create materialized view mv_user_profile_basic to user_profile_wide as  
select
user_id,
gender,
age,
city_level,
register_channel
from user_profile_basic;


create materialized view mv_user_profile_behavior to user_profile_wide as
select
user_id,
last30d_pv,
last30d_uv,
fav_category,
last_login_time
from user_profile_behavior;


create materialized view mv_user_profile_business to user_profile_wide as
select
user_id,
ltv_7d,
ltv_30d,
churn_prob,
vip_level
from user_profile_business;

至此!一个借助 CoalescingMergeTree 实现的大宽表局部更新数据管道就构建好了

2.3 演示更新

分三批将三个主题域的数据写入主题表中,每一批写入之后都可以查询一下宽表

insert into user_profile_basic  
values (1001, 1, 23, 1, 'ios');

insert into user_profile_behavior
values (1001, 123003, 21, '橘子', now64());

insert into user_profile_business
values (1001, 10, 20, 1.2, '1');

因为更新操作是发生在 MergeTree 的合并过程中,通常在查询时需要加上 final 关键字保证数据的一致性

select * from user_profile_wide final;

后续如果想要更新某个字段的值只需要向对应字段插入数据即可

insert into tblxxx(id, colxxx) values(1001, valuexxx);

三、注意事项

因为 CoalescingMergeTree 判断字段不更新的逻辑是 null,因此 DDL 中除了排序键外所有的字段必须要是 Nullable 修饰的数据类型,如果所有字段类型都不使用 Nullable 修饰则与 ReplacingMergeTree 功能一致,即:整行替换
那么与 ReplacingMergeTree 的区别如下:

维度CoalescingMergeTreeReplacingMergeTree
设计目的逐列合并,减少行数,节省存储多记录去重,保留最新一行
合并粒度列级行级
null 值处理表示未更新,合并时被非 null 覆盖与普通值一样不做特殊处理
是否丢列不会丢失任何已写入的列值未出现在新行里的列值会丢失

其次,一定要注意数据乱序对 CoalescingMergeTree 的影响特别是在高频数据场景中,因为 CoalescingMergeTree 的局部更新逻辑依赖物理插入顺序,建议:

  • 同一实体短时间内避免多批次并行写入
  • 统一 ETL 作业,显式控制写入顺序

同时 clickhouse 的局部更新方案远不止如此,点击卡片了解更多

文末留个小问题:为什么 CoalescingMergeTree 要求使用 Nullable 数据类型?