腾讯云数据仓库 TCHouse-C 自研了基于
UniqueMergeTree 引擎的数据实时更新能力,提供完整的 UPSERT 语义。写入、更新和删除操作在写入节点上同步完成,执行完成后在当前节点查询结果立即可见,无需等待后台合并。本文为您介绍
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 需要排序合并 |
适用场景 | 实时更新、强一致性要求 | 批量导入、可容忍短暂重复 |
说明:仅UniqueMergeTree与ReplicatedUniqueMergeTree支持 UPSERT 语义,副本表与非副本表均可使用。若您的业务为批量离线导入且可容忍短暂重复,建议继续使用ReplacingMergeTree以获得更高的写入吞吐。
使用限制
在使用
UniqueMergeTree 之前,请您了解以下限制:限制项 | 说明 |
去重范围为分区内 | 唯一键去重仅在同一分区内生效,不同分区中相同唯一键的数据互不影响 |
去重范围为单节点内 | 唯一性保证仅限于单个 shard 内,分布式表需通过 sharding key 保证路由一致 |
唯一键不支持 Nullable | 唯一键列不能被 Nullable 修饰,NULL 值在序列化时无法正确区分 |
不支持普通 projection | 仅支持 TYPE unique 投影索引,不支持 SELECT ... GROUP BY ... 形式的投影 |
DDL 操作受限 | 不能对唯一键列或 version 列执行 DROP COLUMN、RENAME 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/256、UInt8/16/32/64/128/256 |
浮点 | Float32、Float64、BFloat16 |
定点数 | Decimal32/64/128/256 |
日期时间 | Date、Date32、DateTime、DateTime64 |
网络/标识 | IPv4、IPv6、UUID |
定长字符串 | FixedString(N) |
变长字符串 | String |
其中整数、
UUID、IPv4、IPv6、String、FixedString 还支持字节序可比较序列化,在多列组合唯一键场景下性能最佳。
说明:Array、Tuple、Map、LowCardinality等复合类型虽然也能作为唯一键使用,但序列化开销较大,不推荐在高频写入场景中使用。
指定 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 列支持以下数据类型:
UInt8、UInt16、UInt32、UInt64、Date、DateTime。
说明:Date32与DateTime64暂不支持作为 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 的有效行数,评估存储空间占用情况:SELECTname,rows,delete_marks,rows - delete_marks AS effective_rowsFROM system.partsWHERE 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 │ 1200 │ 200 │ 1000 │└───────────┴──────┴──────────────┴────────────────┘
说明:当delete_marks占rows的比例持续偏高时,说明该分区存在大量被覆盖或删除的历史数据,建议您评估分区设计是否合理。
最佳实践
避免并发更新相同唯一键
UPDATE 和 DELETE FROM 内部包含读取和写入两个步骤,并发操作相同唯一键会导致最终结果不确定。建议您为表指定 version column,利用版本比较机制保证操作的幂等性。合理设计分区与排序键
分区粒度不宜过细,避免产生大量小 part。
WHERE 条件尽量包含排序键字段,以加速
UPDATE、DELETE 的数据定位。单个分区内 part 数量越多或总行数越大,去重性能越低,建议定期关注分区内 part 数量。
保证唯一键路由一致
分区维度:唯一键去重仅在同一分区内生效,请通过分区键设计确保相同唯一键的数据落入同一分区。
节点维度:唯一性保证仅限于单个 shard 内,分布式表场景下请通过 sharding key 确保相同唯一键路由到同一 shard。
合理选择 Version Column
业务场景 | 建议 |
多源写入、CDC 同步、需要幂等保证 | 指定 version column |
单源顺序写入 | 无需指定,以写入顺序 max_block_number 决定新旧 |
Version 值建议使用单调递增的时间戳或序列号。
优先选择高性能唯一键类型
优先使用整数、
UUID、String、FixedString 等支持字节序可比较序列化的类型。避免在高频写入场景中使用
Array、Tuple、Map、LowCardinality 等复合类型作为唯一键。常见问题
应该选择 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 机制来回收这部分空间。
为什么不同分区存在相同唯一键的数据?
这是预期行为。唯一键的去重范围是分区内而非全表,不同分区中可以存在相同唯一键的数据且互不影响。请在业务层面通过分区键设计确保相同唯一键的数据写入同一分区。