帮你快速理解、总结文档立即下载
文档中心>腾讯云数据仓库 TCHouse-C>开发指南>表引擎>自研实时更新表引擎 UniqueMergeTree

自研实时更新表引擎 UniqueMergeTree

最近更新时间:2026-08-12 23:43:05
我的收藏
腾讯云数据仓库 TCHouse-C 自研了基于 UniqueMergeTree 引擎的数据实时更新能力,提供完整的 UPSERT 语义。写入、更新和删除操作在写入节点上同步完成,执行完成后在当前节点查询结果立即可见,无需等待后台合并。
本文为您介绍 UniqueMergeTree 引擎的适用场景、建表方法、数据变更操作、监控运维手段以及使用过程中的最佳实践。
说明:
当前实时更新表引擎 UniqueMergeTree 处于白名单试用阶段,若您想要体验该功能,可 联系我们 为您开通白名单使用。

功能介绍

传统 MergeTree 系列引擎采用 Merge-On-Read(读时合并)模型,重复数据需要等待后台 Merge 或在查询时使用 FINAL 关键字才能消除。UniqueMergeTree 采用改进型 Merge-On-Write(写时合并)模型,在数据写入阶段即完成去重,保证任意时刻查询结果的唯一性和一致性。
当前支持的典型业务场景如下:
场景
说明
实时数据去重
写入时按唯一键在分区内自动去重,后写入的数据覆盖先前数据
行级字段更新
通过 WHERE 条件定位目标行,修改指定字段值
行级删除
通过 WHERE 条件删除指定行,且支持删除后重新写入相同唯一键
CDC 数据同步
配合 version column 实现幂等写入,适合 Flink、Kafka 等流式接入场景
ReplacingMergeTree 的能力差异如下:
特性
UniqueMergeTree(Merge-On-Write)
ReplacingMergeTree(Merge-On-Read)
去重时机
写入时立即去重
后台 Merge 或查询时 FINAL
查询一致性
任意时刻结果唯一
未 Merge 前可能返回重复行
写入开销
较高,需比对唯一索引
低,直接追加写入
查询开销
低,无需额外去重
高,FINAL 需要排序合并
适用场景
实时更新、强一致性要求
批量导入、可容忍短暂重复
说明:
UniqueMergeTreeReplicatedUniqueMergeTree 支持 UPSERT 语义,副本表与非副本表均可使用。若您的业务为批量离线导入且可容忍短暂重复,建议继续使用 ReplacingMergeTree 以获得更高的写入吞吐。

使用限制

在使用 UniqueMergeTree 之前,请您了解以下限制:
限制项
说明
去重范围为分区内
唯一键去重仅在同一分区内生效,不同分区中相同唯一键的数据互不影响
去重范围为单节点内
唯一性保证仅限于单个 shard 内,分布式表需通过 sharding key 保证路由一致
唯一键不支持 Nullable
唯一键列不能被 Nullable 修饰,NULL 值在序列化时无法正确区分
不支持普通 projection
仅支持 TYPE unique 投影索引,不支持 SELECT ... GROUP BY ... 形式的投影
DDL 操作受限
不能对唯一键列或 version 列执行 DROP COLUMNRENAME COLUMN 或修改数据类型
不可删除主投影
不能 DROP 引擎参数所指定的唯一投影
UPDATE 不可改键列
不能更新唯一键、排序键或分区键列
带 version 列不支持 DELETE FROM
指定 version column 的表必须通过 _delete_flag_ 方式删除
说明:
由于去重范围是分区内 + 单节点内,请您在建表时通过分区键设计确保相同唯一键的数据落入同一分区,并在分布式表场景下通过 sharding key 确保相同唯一键路由到同一 shard。

前提条件

已创建 TCHouse-C 集群,且集群内核版本支持 UniqueMergeTree 引擎。
已获取具备目标数据库建表和读写权限的数据库账号。
已通过客户端或控制台 SQL 工作区连接到集群。

建表

基本语法

通过 PROJECTION <name> INDEX <keys> TYPE unique 定义唯一索引,并在引擎参数中指定所使用的 Projection 名称。
CREATE TABLE realtime_upsert
(
id UInt32,
value1 UInt32,
value2 UInt32,
PROJECTION __unique_index INDEX id TYPE unique
)
ENGINE = UniqueMergeTree()
ORDER BY id;
若需要自定义 Projection 名称,请在引擎参数中传入该名称:
CREATE TABLE realtime_upsert_custom
(
id UInt32,
value1 UInt32,
value2 UInt32,
PROJECTION my_dedup_index INDEX id TYPE unique
)
ENGINE = UniqueMergeTree('my_dedup_index')
ORDER BY id;
创建副本表时使用 ReplicatedUniqueMergeTree 引擎:
CREATE TABLE realtime_upsert_replicated
(
id UInt32,
value1 UInt64,
value2 UInt32,
PROJECTION __unique_index INDEX id TYPE unique
)
ENGINE = ReplicatedUniqueMergeTree('/clickhouse/tables/{shard}/realtime_upsert', '{replica}')
ORDER BY id;

使用默认唯一索引

为简化建表,当您未显式定义 PROJECTION ... TYPE unique 时,引擎会自动按表的 ORDER BY 键创建一个默认的唯一投影索引,默认名称为 __unique_index
以下两种建表方式完全等价:
-- 写法一:省略 projection,自动按 ORDER BY id 创建唯一索引
CREATE TABLE realtime_upsert_auto
(
id UInt32,
value1 UInt32,
value2 UInt32
)
ENGINE = UniqueMergeTree()
ORDER BY id;
-- 写法二:等价的显式写法
CREATE TABLE realtime_upsert_explicit
(
id UInt32,
value1 UInt32,
value2 UInt32,
PROJECTION __unique_index INDEX id TYPE unique
)
ENGINE = UniqueMergeTree()
ORDER BY id;
说明:
自动创建的唯一索引仅包含 ORDER BY键。若您需要以非排序键或表达式作为唯一键,仍需显式定义 PROJECTION ... TYPE unique

使用多列唯一键

唯一键支持指定多个列,多列之间以逗号分隔:
CREATE TABLE realtime_upsert_multi
(
id UInt32,
region UInt32,
value UInt32,
PROJECTION __unique_index INDEX id, region TYPE unique
)
ENGINE = UniqueMergeTree()
ORDER BY id;

使用表达式唯一键

唯一键支持任意确定性表达式:
CREATE TABLE realtime_upsert_expr
(
a UInt32,
b UInt32,
value UInt32,
PROJECTION __unique_index INDEX a * 100 + b TYPE unique
)
ENGINE = UniqueMergeTree()
ORDER BY a;

唯一键类型选择建议

所有非 Nullable 的数据类型均可作为唯一键,但不同类型的序列化开销存在差异。以下类型支持定长内存序列化,写入去重性能最优:
类型分类
具体类型
整数
Int8/16/32/64/128/256UInt8/16/32/64/128/256
浮点
Float32Float64BFloat16
定点数
Decimal32/64/128/256
日期时间
DateDate32DateTimeDateTime64
网络/标识
IPv4IPv6UUID
定长字符串
FixedString(N)
变长字符串
String
其中整数、UUIDIPv4IPv6StringFixedString 还支持字节序可比较序列化,在多列组合唯一键场景下性能最佳。

说明:
ArrayTupleMapLowCardinality 等复合类型虽然也能作为唯一键使用,但序列化开销较大,不推荐在高频写入场景中使用。

指定 Version Column

在多源写入、CDC 同步等场景下,数据到达顺序无法保证。您可以通过指定 Version column,使系统在去重时按版本值决定数据新旧,从而实现幂等写入。

语法

TYPE unique 后通过字符串参数指定 version 列名:

CREATE TABLE realtime_upsert_version
(
id UInt32,
version UInt64,
value UInt32,
PROJECTION __unique_index INDEX id TYPE unique('version')
)
ENGINE = UniqueMergeTree()
ORDER BY id;
Version 列支持以下数据类型:UInt8UInt16UInt32UInt64DateDateTime

说明:
Date32DateTime64 暂不支持作为 version 列。

版本比较规则

当两行数据具有相同唯一键时,系统按以下规则决定保留哪一行:
场景
比较规则
已指定 version column
比较 version 值,大者胜出并保留
version 值相等
以 part 的 max_block_number(写入顺序)作为 tiebreaker,后写入者胜出
未指定 version column
仅以 max_block_number 决定新旧,后写入的数据整体覆盖
唯一索引中每个 key 存储 (version, part_offset) 二元组,占用 16 字节;未指定 version column 时仅存储 part_offset,占用 8 字节。

示例

INSERT INTO realtime_upsert_version VALUES (1, 100, 10);
INSERT INTO realtime_upsert_version VALUES (1, 50, 20); -- version=50 < 100,本次写入被忽略

SELECT * FROM realtime_upsert_version;
-- 返回结果:(1, 100, 10)
说明:
建议 version 值随时间单调递增,例如使用时间戳或全局递增序列号,以确保新数据的版本号始终高于旧数据。

数据写入与更新

UniqueMergeTree 支持三种数据变更方式,均为同步执行,语句执行完成后在当前节点查询结果立即可见,副本表中的数据会异步同步到其他副本。
操作
语义
说明
INSERT INTO
UPSERT
同一分区内相同唯一键时覆盖旧行
UPDATE ... SET ... WHERE
部分列更新
读取匹配行后以新值重新写入
DELETE FROM ... WHERE
行级删除
标记匹配行为已删除

INSERT INTO(UPSERT)

写入时自动按唯一键去重,后写入的数据覆盖先前数据:
INSERT INTO realtime_upsert VALUES (1, 10, 10);
INSERT INTO realtime_upsert VALUES (1, 20, 20);

SELECT * FROM realtime_upsert WHERE id = 1;
-- 返回结果:(1, 20, 20)
当单个 INSERT 块内存在重复唯一键时,仅保留最后一行:
INSERT INTO realtime_upsert VALUES (5, 100, 100), (5, 200, 200);

SELECT * FROM realtime_upsert WHERE id = 5;
-- 返回结果:(5, 200, 200)

UPDATE

语法如下:
UPDATE [db.]table SET column1 = expr1 [, ...] WHERE filter_expr;
示例:
INSERT INTO realtime_upsert VALUES (1, 10, 10);
UPDATE realtime_upsert SET value1 = 99 WHERE id = 1;

SELECT * FROM realtime_upsert WHERE id = 1;
-- 返回结果:(1, 99, 10)
说明:
UPDATE 不支持更新唯一键、排序键或分区键列。若需要变更这些列的值,请先删除原行再写入新行。

DELETE FROM

基本用法

INSERT INTO realtime_upsert VALUES (1, 10, 10), (2, 20, 20), (3, 30, 30);
DELETE FROM realtime_upsert WHERE id = 2;

SELECT * FROM realtime_upsert ORDER BY id;
-- 返回结果:(1, 10, 10), (3, 30, 30)
删除后可通过 INSERT 重新写入相同唯一键:
INSERT INTO realtime_upsert VALUES (2, 50, 50);

SELECT * FROM realtime_upsert ORDER BY id;
-- 返回结果:(1, 10, 10), (2, 50, 50), (3, 30, 30)

通过 INSERT 指定 _delete_flag_ 删除

DELETE FROM 语法外,您还可以通过 INSERT 指定隐藏列 _delete_flag_ = 1 标记删除:
INSERT INTO realtime_upsert (id, value1, value2, _delete_flag_) VALUES (1, 0, 0, 1);
-- id=1 的行被标记为已删除

删除带 version column 的表数据

对于指定了 version column 的表,不支持 DELETE FROM 语法,必须通过 INSERT 指定 _delete_flag_ 的方式删除,且同样遵循版本比较规则:
INSERT INTO realtime_upsert_version VALUES (1, 100, 10);

-- 使用更高版本删除,删除生效
INSERT INTO realtime_upsert_version (id, version, value, _delete_flag_) VALUES (1, 200, 0, 1);

-- 使用更低版本删除,删除被忽略
INSERT INTO realtime_upsert_version (id, version, value, _delete_flag_) VALUES (1, 50, 0, 1);

实现说明

DELETE FROM 在内部被转换为 INSERT INTO ... SELECT:从原表中读取满足 WHERE 条件的行,仅保留唯一键、分区键、排序键等关键列,其余数据列使用默认值填充,同时设置 _delete_flag_ = 1。这条 tombstone 行通过正常的 UPSERT 去重流程覆盖原行。
关键特性如下:
_delete_flag_ 为系统隐藏列(MATERIALIZED 0),不可自定义、不可 ALTER,SELECT * 不会显示该列。
查询时自动过滤 _delete_flag_ = 1 的行,对您完全透明,无需额外操作。
tombstone 行仅保留关键列,以较小的空间代价完成删除标记。
说明:
当前 tombstone 行仅做逻辑删除标记,不会立即物理回收空间。后续版本将提供针对历史分区的 clean GC 机制来物理回收这部分空间。

实现原理

了解底层实现有助于您合理设计表结构并优化写入性能。

写入流程

以一次 INSERT 为例,完整流程如下:
1. 生成新 Part:数据按分区键分组,每组生成一个 data part,同时构建该 part 的唯一索引(以 SST 文件格式存储)。
2. 块内去重:同一个 INSERT 块内如果存在重复唯一键,仅保留最后一行;已指定 version column 时保留版本最大的行。
3. 获取去重锁:获取 UniqueProcessLock,进入串行化提交阶段。
4. 跨 Part 去重:打开新 part 和同分区所有已有 part 的 SST Reader,通过归并扫描找出重复键。
5. 生成 Delete Bitmap:对于每个重复键,比较版本号或写入顺序,将失败方对应的行标记到 Delete Bitmap 中。
6. 原子提交:将新 part 加入活跃 part 集合,同时将 Delete Bitmap 持久化到 RocksDB。
7. 释放去重锁:提交完成后释放锁,允许下一个写入进入提交阶段。
查询时,内核根据 Delete Bitmap 自动跳过被标记删除的行,该过程对您完全透明。

去重并行度配置

跨 Part 去重是写入路径中开销最大的环节,需要将新 part 的每个唯一键与同分区内所有已有 part 的唯一索引逐一比对。UniqueMergeTree 在索引维度进行了分桶,支持按分桶并行化去重。
您可以通过 merge_tree_settings 配置表维度的去重并行度:
<unique_key_dedup_max_parallel_threads>16</unique_key_dedup_max_parallel_threads>
该参数默认值为 16。

监控与运维

查看 Part 的标记删除信息

通过 system.parts 系统表可以查看各 part 的有效行数,评估存储空间占用情况:
SELECT
name,
rows,
delete_marks,
rows - delete_marks AS effective_rows
FROM system.parts
WHERE table = '{table_name}'
AND database = currentDatabase()
AND active;
关键字段含义如下:
字段
含义
rows
part 的原始总行数,包含已删除行
delete_marks
被标记删除的行数
effective_rows
有效行数,等于 rows - delete_marks
返回结果示例:
┌─name──────┬─rows─┬─delete_marks─┬─effective_rows─┐
│ all_0_5_1 │ 12002001000
└───────────┴──────┴──────────────┴────────────────┘
说明:
delete_marksrows 的比例持续偏高时,说明该分区存在大量被覆盖或删除的历史数据,建议您评估分区设计是否合理。

最佳实践

避免并发更新相同唯一键

UPDATEDELETE FROM 内部包含读取和写入两个步骤,并发操作相同唯一键会导致最终结果不确定。建议您为表指定 version column,利用版本比较机制保证操作的幂等性。

合理设计分区与排序键

分区粒度不宜过细,避免产生大量小 part。
WHERE 条件尽量包含排序键字段,以加速 UPDATEDELETE 的数据定位。
单个分区内 part 数量越多或总行数越大,去重性能越低,建议定期关注分区内 part 数量。

保证唯一键路由一致

分区维度:唯一键去重仅在同一分区内生效,请通过分区键设计确保相同唯一键的数据落入同一分区。
节点维度:唯一性保证仅限于单个 shard 内,分布式表场景下请通过 sharding key 确保相同唯一键路由到同一 shard。

合理选择 Version Column

业务场景
建议
多源写入、CDC 同步、需要幂等保证
指定 version column
单源顺序写入
无需指定,以写入顺序 max_block_number 决定新旧
Version 值建议使用单调递增的时间戳或序列号。

优先选择高性能唯一键类型

优先使用整数、UUIDStringFixedString 等支持字节序可比较序列化的类型。
避免在高频写入场景中使用 ArrayTupleMapLowCardinality 等复合类型作为唯一键。

常见问题

应该选择 UniqueMergeTree 还是 ReplacingMergeTree?

若您的业务要求任意时刻查询结果唯一、需要强一致性保证,请选择 UniqueMergeTree。若为批量离线导入、可容忍短暂重复且对写入吞吐要求极高,建议使用 ReplacingMergeTree

为什么 DELETE 操作没有生效?

请确认表是否指定了 version column。指定 version column 的表不支持 DELETE FROM 语法,需改用 INSERT INTO ... _delete_flag_ = 1 方式删除,且删除时使用的 version 值必须高于现有数据的 version 值。

实时更新会影响查询性能吗?

不会。UniqueMergeTree 采用 Merge-On-Write 模型,去重工作已在写入阶段完成,查询时仅需根据 Delete Bitmap 跳过已删除行,额外开销很低。相比 ReplacingMergeTree 需要 FINAL 排序合并的方案,查询性能更优。

删除的数据何时物理回收?

当前 tombstone 行仅做逻辑删除标记,不会立即物理回收空间。后续版本将提供针对历史分区的 clean GC 机制来回收这部分空间。

为什么不同分区存在相同唯一键的数据?

这是预期行为。唯一键的去重范围是分区内而非全表,不同分区中可以存在相同唯一键的数据且互不影响。请在业务层面通过分区键设计确保相同唯一键的数据写入同一分区。