ClickHouse ReplacingMergeTree 去重陷阱:为什么你的 FINAL 查询无效?

问题背景

在使用 ClickHouse 的 ReplacingMergeTree 引擎时,很多开发者会遇到一个困惑:明明使用了 FINAL 关键字,查询结果却仍然包含重复数据。比如这样的情况:

数据库表
 err := db.Table(model.BlockTaskTableName).
		Set("gorm:table_options", "ENGINE = ReplacingMergeTree(created_at) PARTITION BY intDiv(start_block, 500000) "+
			"PRIMARY KEY(start_block, created_at, end_block) "+
			"ORDER BY (start_block, created_at, end_block, status, owner) "+
			"SETTINGS enable_block_number_column = 1,enable_block_offset_column = 1").
		AutoMigrate(&model.BlockTask{})
	if err != nil {
		panic(fmt.Sprintf("Failed to create block_tasks table in %s: %v", chain, err))
	}
 
FInal查询语句
 SELECT * FROM test_ethereum.block_tasks FINAL WHERE status = 'init' AND (owner = '' OR owner IS NULL) ORDER BY start_block DESC, created_at DESC LIMIT 100

 

final查询结果
 created_at                   |updated_at                   |deleted_at|start_block|end_block|status|owner|assigned_at        |completed_at       |data_hash|reset_times|blocks_finished|transactions_finished|logs_finished|acc
-----------------------------+-----------------------------+----------+-----------+---------+------+-----+-------------------+-------------------+---------+-----------+---------------+---------------------+-------------+---
2025-09-30 14:55:47.849000000|2025-09-30 14:55:47.849000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:46.718000000|2025-09-30 14:55:46.718000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:45.544000000|2025-09-30 14:55:45.544000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:44.394000000|2025-09-30 14:55:44.394000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:43.182000000|2025-09-30 14:55:43.182000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:42.029000000|2025-09-30 14:55:42.029000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:40.887000000|2025-09-30 14:55:40.887000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:39.710000000|2025-09-30 14:55:39.710000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0|   
2025-09-30 14:55:38.516000000|2025-09-30 14:55:38.516000000|          |   23379678| 23379687|init  |     |1970-01-01 08:00:00|1970-01-01 08:00:00|         |          0|              0|                    0|            0| 

 

返回的结果中,相同的 start_block 和 end_block 组合出现了多次,完全没有去重效果。

问题根源:ORDER BY 的错误理解

ReplacingMergeTree 的工作原理

ReplacingMergeTree 的去重机制基于两个关键部分:

  1. 版本字段:在引擎声明中指定,如 ReplacingMergeTree(created_at)

  2. 去重依据:由 ORDER BY 子句定义的字段组合

核心规则:当 ORDER BY 的所有字段都相同时,ClickHouse 才认为这些记录是"重复数据",然后根据版本字段保留最新版本。

错误的表结构设计

sql
-- 错误的设计!
ENGINE = ReplacingMergeTree(created_at)
ORDER BY (start_block, created_at, end_block, status, owner)

这里的问题在于:版本字段 created_at 出现在了 ORDER BY 子句中

为什么这样设计是错误的?

假设有以下数据:

 
 
start_block created_at end_block status owner
23379678 2025-09-30 14:55:47 23379687 init  
23379678 2025-09-30 14:55:46 23379687 init  
23379678 2025-09-30 14:55:45 23379687 init  

在 ClickHouse 看来,这些是完全不同的记录,因为:

  • (23379678, 2025-09-30 14:55:47, 23379687, init, )

  • (23379678, 2025-09-30 14:55:46, 23379687, init, )

  • (23379678, 2025-09-30 14:55:45, 23379687, init, )

由于 created_at 不同,每条记录的 ORDER BY 键都不同,因此永远不会触发去重机制。

正确的解决方案

1. 修正表结构

sql
-- 正确的设计
ENGINE = ReplacingMergeTree(created_at)
ORDER BY (start_block, end_block, status, owner)

现在,相同的 (start_block, end_block, status, owner) 组合被认为是重复数据,系统会保留其中 created_at 最大的版本。

2. Go 代码示例

go
err := db.Table(model.BlockTaskTableName).
    Set("gorm:table_options", "ENGINE = ReplacingMergeTree(created_at) "+
        "PARTITION BY intDiv(start_block, 500000) "+
        "PRIMARY KEY(start_block, end_block) "+
        "ORDER BY (start_block, end_block, status, owner) "+  // 关键:移除 created_at
        "SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1").
    AutoMigrate(&model.BlockTask{})

3. 验证修正效果

修改后,相同的测试数据:

 
 
start_block created_at end_block status owner
23379678 2025-09-30 14:55:47 23379687 init  
23379678 2025-09-30 14:55:46 23379687 init  
23379678 2025-09-30 14:55:45 23379687 init  

现在 ClickHouse 认为这些是相同的记录(因为 ORDER BY 键相同),最终只保留 created_at = 2025-09-30 14:55:47 的那条记录。

重要注意事项

1. 后台合并的异步性

即使表结构正确,ReplacingMergeTree 的去重也是后台异步进行的:

sql
-- 手动触发合并(生产环境慎用)
OPTIMIZE TABLE block_tasks FINAL;

2. 实时精确查询的替代方案

如果业务要求100%实时精确,建议使用聚合查询替代 FINAL

sql
-- 方法1:使用子查询
SELECT * 
FROM block_tasks 
WHERE (start_block, end_block, created_at) IN (
    SELECT start_block, end_block, MAX(created_at)
    FROM block_tasks 
    WHERE status = 'init'
    GROUP BY start_block, end_block
);

-- 方法2:使用窗口函数
SELECT * FROM (
    SELECT *,
        ROW_NUMBER() OVER (PARTITION BY start_block, end_block ORDER BY created_at DESC) as rn
    FROM block_tasks 
    WHERE status = 'init'
) WHERE rn = 1;

最佳实践总结

  1. 版本字段不入 ORDER BYReplacingMergeTree(version_field) 中的版本字段不应出现在 ORDER BY 子句中

  2. ORDER BY 定义去重粒度ORDER BY 字段的组合决定了什么算是"重复数据"

  3. 高基数字段在前:在 ORDER BY 中将高基数字段(如ID、时间戳)放在前面

  4. 接受最终一致性ReplacingMergeTree 适合能接受短暂数据延迟的场景

  5. 实时需求用聚合:需要实时精确去重时,使用聚合查询而非 FINAL

结论

ReplacingMergeTree 的去重失效通常不是因为 FINAL 关键字的问题,而是由于表结构设计不当。记住这个简单的原则:版本字段决定保留谁,ORDER BY 字段决定谁是重复。正确区分这两者的角色,就能避免这个常见的陷阱。

通过本文的解决方案,你的 FINAL 查询将能够正确工作,返回真正去重后的数据结果。

posted @ 2025-09-30 15:44  若-飞  阅读(15)  评论(0)    收藏  举报