这里说明 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 不支持更新唯一键、排序键或分区键列。若需要变更这些列的值,请先删除原行再写入新行。
十八、基本用法
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 的行被标记为已删除
20、删除带 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);
21、实现说明
DELETE FROM 在内部被转换为 INSERT INTO … SELECT:从原表中读取满足 WHERE 条件的行,仅保留唯一键、分区键、排序键等关键列,其余数据列使用默认值填充,同时设置 _delete_flag_ = 1。这条 tombstone 行通过正常的 UPSERT 去重流程覆盖原行。
关键特性如下:
·_delete_flag_ 为系统隐藏列(MATERIALIZED 0),不可自定义、不可 ALTER,SELECT * 不会显示该列。
·查询时自动过滤 _delete_flag_ = 1 的行,对你完全透明,无需额外操作。
·tombstone 行仅保留关键列,以较小的空间代价完成删除标记。
注意:说明:
注意:当前 tombstone 行仅做逻辑删除标记,不会立即物理回收空间。后续版本将提供针对历史分区的 clean GC 机制来物理回收这部分空间。
22、实现原理
了解底层实现有助于你合理设计表结构并优化写入性能。
23、写入流程
以一次 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 自动跳过被标记删除的行,该过程对你完全透明。
24、去重并行度配置
跨 Part 去重是写入路径中开销最大的环节,需要将新 part 的每个唯一键与同分区内所有已有 part 的唯一索引逐一比对。UniqueMergeTree 在索引维度进行了分桶,支持按分桶并行化去重。
你可以通过 merge_tree_settings 配置表维度的去重并行度:
<unique_key_dedup_max_parallel_threads>16</unique_key_dedup_max_parallel_threads>
该参数默认值为 16。
26、查看 Part 的标记删除信息
通过 system.parts 系统表可以查看各 part 的有效行数,评估存储空间占用情况:
SELECT name, 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 的比例持续偏高时,说明该分区存在大量被覆盖或删除的历史数据,建议你评估分区设计是否合理。
28、避免并发更新相同唯一键
UPDATE 和 DELETE FROM 内部包含读取和写入两个步骤,并发操作相同唯一键会导致最终结果不确定。建议你为表指定 version column,利用版本比较机制保证操作的幂等性。
29、合理设计分区与排序键
·分区粒度不宜过细,避免产生大量小 part。
·WHERE 条件尽量包含排序键字段,以加速 UPDATE、DELETE 的数据定位。
·单个分区内 part 数量越多或总行数越大,去重性能越低,建议定期关注分区内 part 数量。
30、保证唯一键路由一致
·分区维度:唯一键去重仅在同一分区内生效,请通过分区键设计确保相同唯一键的数据落入同一分区。
·节点维度:唯一性保证仅限于单个 shard 内,分布式表场景下请通过 sharding key 确保相同唯一键路由到同一 shard。
31、合理选择 Version Column
| 业务场景 | 建议 |
| 多源写入、CDC 同步、需要幂等保证 | 指定 version column |
| 单源顺序写入 | 无需指定,以写入顺序 max_block_number 决定新旧 |
Version 值建议使用单调递增的时间戳或序列号。
32、优先选择高性能唯一键类型
·优先使用整数、UUID、String、FixedString 等支持字节序可比较序列化的类型。
·避免在高频写入场景中使用 Array、Tuple、Map、LowCardinality 等复合类型作为唯一键。
34、应该选择 UniqueMergeTree 还是 ReplacingMergeTree?
若你的业务要求任意时刻查询结果唯一、需要强一致性保证,请选择 UniqueMergeTree。若为批量离线导入、可容忍短暂重复且对写入吞吐要求极高,建议使用 ReplacingMergeTree。
35、为什么 DELETE 操作没有生效?
请确认表是否指定了 version column。指定 version column 的表不支持 DELETE FROM 语法,需改用 INSERT INTO … _delete_flag_ = 1 方式删除,且删除时使用的 version 值必须高于现有数据的 version 值。
36、实时更新会影响查询性能吗?
不会。UniqueMergeTree 采用 Merge-On-Write 模型,去重工作已在写入阶段完成,查询时仅需根据 Delete Bitmap 跳过已删除行,额外开销很低。相比 ReplacingMergeTree 需要 FINAL 排序合并的方案,查询性能更优。
37、删除的数据何时物理回收?
当前 tombstone 行仅做逻辑删除标记,不会立即物理回收空间。后续版本将提供针对历史分区的 clean GC 机制来回收这部分空间。
38、为什么不同分区存在相同唯一键的数据?
这是预期行为。唯一键的去重范围是分区内而非全表,不同分区中可以存在相同唯一键的数据且互不影响。请在业务层面通过分区键设计确保相同唯一键的数据写入同一分区。