ClickHouseNotes

第 08 章:ReplacingMergeTree

zjc 于 2026-01-08 发布

这是《ClickHouse 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 ReplacingMergeTree 用来处理“同一业务键可能被多次写入”的数据。它在后台合并时按排序键去重,并可结合版本列保留最新记录。它不是实时唯一索引,查询侧仍要正确处理版本。

8.1 适用场景

适合:

  1. MySQL 表同步;
  2. CDC 变更流;
  3. 订单、用户、商品等状态快照;
  4. 任务失败重试造成的数据重放;
  5. 上游补数和修正。

不适合:

  1. 需要强一致事务;
  2. 高频单键实时更新;
  3. 每次查询都必须物理去重;
  4. 业务键本身无法稳定表达唯一性。

8.2 建表

CREATE TABLE analytics.orders_local
(
    order_date Date,
    order_id String,
    user_id UInt64,
    order_status LowCardinality(String),
    amount Decimal64(2),
    updated_at DateTime,
    version UInt64
)
ENGINE = ReplacingMergeTree(updated_at)
PARTITION BY toYYYYMM(order_date)
ORDER BY (order_date, order_id)
SETTINGS index_granularity = 8192;

版本列可以是 DateTime 或 UInt64。建议版本来源稳定、单调递增,并能覆盖同秒内的多次修改。

8.3 去重键

去重范围由 ORDER BY 决定,不是某个显式 PRIMARY KEY 猜测值。

ORDER BY (order_date, order_id)

表示同一个分区中,order_date + order_id 相同的行会在合并时被替换。

如果 order_date 会随状态更新而改变,同一订单可能出现在两个日期下,去重就不符合预期。因此同步链路必须固定业务日期字段或把 order_date 排除在更新字段外。

8.4 版本语义

写入示例:

INSERT INTO analytics.orders_local VALUES
('2026-08-25', 'O001', 1001, 'CREATED', 100.00, '2026-08-25 10:00:00', 1),
('2026-08-25', 'O001', 1001, 'PAID', 100.00, '2026-08-25 10:05:00', 2),
('2026-08-25', 'O001', 1001, 'CANCELLED', 100.00, '2026-08-25 10:03:00', 3);

如果版本列是 updated_at,第三行版本不是最大值,最终保留第二行。因此不能只看插入顺序,必须确认版本定义。

推荐:

版本来源 说明
upstream version 业务库版本号,优先
binlog 时间戳 注意乱序和精度
事件生成时间 可能与入库时间不同
ETL 批次号 同批内多次更新要处理

8.5 查询最新状态

合并前可能仍有重复,稳妥查询:

SELECT
    order_date,
    order_id,
    argMax(user_id, version) AS user_id,
    argMax(order_status, version) AS order_status,
    argMax(amount, version) AS amount,
    max(updated_at) AS updated_at
FROM analytics.orders_local
WHERE order_date = '2026-08-25'
GROUP BY order_date, order_id;

argMax(value, version) 返回版本最大行的 value。不要使用 max(order_status),状态字符串大小与业务状态无关。

8.6 FINAL

SELECT *
FROM analytics.orders_local
FINAL
WHERE order_date = '2026-08-25';

FINAL 会让查询在读取阶段应用合并语义,可能增加 CPU、内存和执行时间。使用原则:

  1. 先过滤分区和键范围;
  2. 小范围查询可以使用;
  3. 大报表优先物化最新状态;
  4. 定位慢查询时检查是否 FINAL 导致。

8.7 手动合并

OPTIMIZE TABLE analytics.orders_local PARTITION 202608;

更彻底:

OPTIMIZE TABLE analytics.orders_local PARTITION 202608 FINAL;

手动 FINAL 合并会强制处理 part,成本高。适合维护窗口或小分区修复,不适合业务高峰循环执行。

8.8 与副本和分布式表组合

本地表:

CREATE TABLE analytics.orders_shard
(
    order_date Date,
    order_id String,
    order_status LowCardinality(String),
    updated_at DateTime,
    version UInt64
)
ENGINE = ReplicatedReplacingMergeTree(
    '/clickhouse/tables/{shard}/orders',
    '{replica}',
    version
)
PARTITION BY toYYYYMM(order_date)
ORDER BY (order_date, order_id);

分布式表:

CREATE TABLE analytics.orders_all
AS analytics.orders_shard
ENGINE = Distributed(
    analytics_cluster,
    analytics,
    orders_shard,
    cityHash64(order_id)
);

查询分布式表时 FINAL 的下发行为与版本和查询方式有关。核心是保证同一个 order_id 落到同一分片,否则不同分片各有一份记录,无法全局替换。

8.9 同步链路设计

MySQL binlog / CDC
        |
        v
Kafka
        |
        v
ETL 解析、补版本、映射日期
        |
        v
ReplacingMergeTree
        |
        v
最新状态视图 / 汇总表

必须明确:

  1. 主键字段;
  2. 不可变业务日期;
  3. 版本字段;
  4. 删除事件表达;
  5. 延迟消息处理;
  6. 重放窗口;
  7. 对账规则。

8.10 对账与排查

检查重复:

SELECT order_id, count() AS c
FROM analytics.orders_local
WHERE order_date = '2026-08-25'
GROUP BY order_id
HAVING c > 1
ORDER BY c DESC;

检查最新状态:

SELECT order_id, max(version), argMax(order_status, version)
FROM analytics.orders_local
GROUP BY order_id
ORDER BY order_id;

常见问题:

现象 原因
重复一直存在 尚未合并,属正常
旧状态胜出 版本列错误或乱序
同键跨分区 排序键含可变日期
分布式仍重复 分片路由改变或同键跨分片
查询变慢 大范围 FINAL

本章小结

ReplacingMergeTree 用后台合并降低物理重复,用版本列表达最新状态。正确的排序键和稳定版本是设计核心;查询侧使用 argMax 或受限 FINAL 是一致性保障。它解决的是幂等分析存储,不是实时事务更新。

思考题

  1. ReplacingMergeTree 的去重键由什么决定?
  2. 版本列为什么必须单调可信?
  3. FINAL 有什么代价?
  4. 分片路由变化如何影响去重?
  5. 如何为 CDC 链路设计版本和对账?