暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

ClickHouse 25.6 盛夏福利版:CoalescingMergeTree 表引擎

ClickHouseInc 2025-07-22
163


本文字数:7892;估计阅读时间:20 分钟

作者:ClickHouse Team


我们在 ClickHouse 25.6 的版本发布文章中已经介绍了很多功能,但实际上我们还有一个非常酷的新功能,保证大家会被“冰冻”的。😎

所以这就是你的夏日加餐博客,正好适合你躺在沙滩上时读一读:这次我们要专门介绍 CoalescingMergeTree —— 一个全新的表引擎,专为整合稀疏更新设计,可以在不损失数据完整性的前提下,帮你有效减少行数。

如果你是那种涂防晒霜的间隙也不忘查看 ClickHouse 更新的人,这篇文章正合你胃口。🏝️

让我们一探究竟。


CoalescingMergeTree 表引擎

贡献者:Konstantin Vedernikov

MergeTree 表引擎家族难得迎来新成员,因此这次的全新引擎 CoalescingMergeTree,值得我们用一整篇文章来介绍。

CoalescingMergeTree 能够在合并过程中,逐步将稀疏的记录合并,非常适合以下场景:

  • 你希望高效地保留每个实体的最完整版本。

  • 你可以接受在数据合并时,最终在磁盘上实现整合。

  • 你希望避免像 ReplacingMergeTree 那样整行覆盖,只想填补缺失的字段。

一个典型的应用场景:IoT 设备的状态或配置快照。比如,车队的可观测性,就像 Tesla 那样[https://clickhouse.com/blog/how-tesla-built-quadrillion-scale-observability-platform-on-clickhouse]

在现代的联网车辆,尤其是电动车中,遥测数据的更新通常来自不同的子系统:

  • 电池模块汇报电量

  • GPS 模块发送位置信息

  • 软件更新模块上报固件版本

  • 传感器定期更新温度和速度

我们希望通过 CoalescingMergeTree,将这些增量的、稀疏的更新整合成每辆车的完整视图。


表定义

    CREATE TABLE electric_vehicle_state
    (
        vin String, -- vehicle identification number
        last_update DateTime64 Materialized now64(), -- optional (used with argMax)
        battery_level Nullable(UInt8), -- in %
        lat Nullable(Float64), -- latitude (°)
        lon Nullable(Float64), -- longitude (°)
        firmware_version Nullable(String),
        cabin_temperature Nullable(Float32), -- in °C
        speed_kmh Nullable(Float32) -- from sensor
    )
    ENGINE = CoalescingMergeTree
    ORDER BY vin;



    示例数据插入

      -- ① Initial battery and firmware readings
      INSERT INTO electric_vehicle_state VALUES
      ('5YJ3E1EA7KF000001'82NULLNULL'2024.14.5'NULLNULL);


      -- ② GPS reports in later
      INSERT INTO electric_vehicle_state VALUES
      ('5YJ3E1EA7KF000001'NULL37.7749-122.4194NULLNULLNULL);


      -- ③ Sensor update: temperature + speed
      INSERT INTO electric_vehicle_state VALUES
      ('5YJ3E1EA7KF000001'NULLNULLNULLNULL22.567.3);


      -- ④ Battery drops to 78%
      INSERT INTO electric_vehicle_state VALUES
      ('5YJ3E1EA7KF000001'78NULLNULLNULLNULLNULL);


      -- ⑤ Another car, initial firmware and temp readings
      INSERT INTO electric_vehicle_state VALUES
      ('5YJ3E1EA7KF000099'NULLNULLNULL'2024.14.5'19.2NULL);



      为什么不直接用 UPDATE?

      在许多数据库中,处理类似的状态变更通常会通过更新已有的行来实现,比如使用 UPDATE 指令来刷新最新的温度或固件版本。ClickHouse 当然也支持快速、频繁的单行 UPDATE。你可以在我们最近的 Open House 2025 用户大会上看到相关的功能演示[https://clickhouse.com/openhouse]。对于某些工作负载,这种方法确实很适用。

      但在高吞吐量的 IoT 场景,这种做法就显得低效了:UPDATE 需要先找到已有的那一行,然后重写,过程中往往还会锁定或改写比必要更多的数据。

      ClickHouse 更提倡一种简单的追加式模型:只要有新字段到达,直接插入即可。CoalescingMergeTree 则在此基础上再进一步,通过后台合并机制,自动把这些稀疏的插入记录整合成完整、紧凑的记录(后文我们会详细介绍)。这种方式尤其适合高写入量、高基数,且更新频繁但每次只改部分字段的场景。

      ClickHouse 对插入型工作负载做了深度优化:插入操作是完全隔离的,支持并行写入,彼此不会产生干扰,且可以全速写入磁盘,因为 ClickHouse 不存在需要锁定或更新的全局结构(比如全局 B++ 索引)。而记录合并等额外的计算工作,都是通过后台合并任务来异步完成,从而保证插入操作始终轻量高效。

      正是这种架构,使得 ClickHouse 在生产环境中能够实现超高的写入吞吐量。在某些部署场景下,ClickHouse 的稳定写入速度可超过每秒 10 亿行,同时保持内存和 CPU 使用的稳定。



      要点:万亿级行数的挑战

      在高吞吐量的 IoT 系统中,成千上万甚至上百万台设备持续不断地发送数据。每次更新往往只涉及一两个字段,结果就是表中充斥着大量稀疏且冗余的行。

      为了还原每个设备的最新完整状态,你需要从每列中提取最新的非 null 值。ClickHouse 即使使用普通的 MergeTree 引擎,也可以通过 argMax() 聚合函数来实现这一点。

      下面是 electric_vehicle_state 表在经过几次稀疏更新后的样子(假设还没有发生任何数据部分的合并,稍后会解释原因)。为了简洁,last_update 的时间戳被简化了:

        SELECT
            vin,
            right(toString(last_update), 12AS last_update,
            battery_level AS batt,
            lat,
            lon,
            firmware_version AS fw,
            cabin_temperature AS temp,
            speed_kmh AS speed
        FROM electric_vehicle_state
        ORDER BY vin ASC;
          ┌─vin───────────────┬─last_update──┬─batt─┬─────lat─┬───────lon─┬─fw────────┬─temp─┬─speed─┐
          │ 5YJ3E1EA7KF000001 │ 10:41:37.731 │   82 │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ 2024.14.5 │ ᴺᵁᴸᴸ │  ᴺᵁᴸᴸ │
          │ 5YJ3E1EA7KF000001 │ 10:41:37.734 │ ᴺᵁᴸᴸ │ 37.7749 │ -122.4194 │ ᴺᵁᴸᴸ      │ ᴺᵁᴸᴸ │  ᴺᵁᴸᴸ │
          │ 5YJ3E1EA7KF000001 │ 10:41:37.737 │ ᴺᵁᴸᴸ │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ ᴺᵁᴸᴸ      │ 22.5 │  67.3 │
          │ 5YJ3E1EA7KF000001 │ 10:41:37.739 │   78 │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ ᴺᵁᴸᴸ      │ ᴺᵁᴸᴸ │  ᴺᵁᴸᴸ │
          │ 5YJ3E1EA7KF000099 │ 10:41:37.742 │ ᴺᵁᴸᴸ │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ 2024.14.5 │ 19.2 │  ᴺᵁᴸᴸ │
          └───────────────────┴──────────────┴──────┴─────────┴───────────┴───────────┴──────┴───────┘

          接着,这是使用 argMax() 查询每个设备最新状态的方法:

            SELECT
                vin,
                argMax(battery_level, last_update) AS batt,
                argMax(lat, last_update) AS lat,
                argMax(lon, last_update) AS lon,
                argMax(firmware_version, last_update) AS fw,
                argMax(cabin_temperature, last_update) AS temp,
                argMax(speed_kmh, last_update) AS speed
            FROM electric_vehicle_state
            GROUP BY vin
            ORDER BY vin;
              ┌─vin───────────────┬─batt─┬─────lat─┬───────lon─┬─fw────────┬─temp─┬─speed─┐
              │ 5YJ3E1EA7KF000001 │   78 │ 37.7749 │ -122.4194 │ 2024.14.5 │ 22.5 │  67.3 │
              │ 5YJ3E1EA7KF000099 │ ᴺᵁᴸᴸ │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ 2024.14.5 │ 19.2 │  ᴺᵁᴸᴸ │
              └───────────────────┴──────┴─────────┴───────────┴───────────┴──────┴───────┘

              argMax 在这里非常适用,因为它可以保留每列的最新非 null 值。

              具体来说,argMax(x, t) 会返回时间戳 t 最大且 x 不为 null 的那一行的 x 值。如果存在更晚的时间戳,但对应的 x 是 null,这些行会被跳过。

              但如果完全依赖常规的 MergeTree 和 argMax(),就意味着:

              • 每次查询都要全表扫描

              • 每次都需要实时计算聚合

              当表的规模达到数十亿、甚至万亿行时,这种方式的效率就会变得非常低下。


              CoalescingMergeTree 如何解决这个问题

              使用 CoalescingMergeTree,ClickHouse 可以在物理存储层,将具有相同排序键(如 VIN)的一组行合并。在后台的部分合并过程中,ClickHouse 会对每个排序键,仅保留每列最新的非 null 值,实现了磁盘上的预聚合:

              上图展示了不同子系统(如电池、GPS、传感器)产生的稀疏更新,如何通过 CoalescingMergeTree 逐步合并为一个完整的设备状态。每次更新只改动部分字段,ClickHouse 会在后台合并时,自动将排序键相同(例如 vin)的行合并,形成最新的完整设备视图。

              图中展示了 7 个数据部分[https://clickhouse.com/docs/parts]:最初的插入数据(①–④)、中间合并形成的部分(⑤、⑥),以及最终的活跃部分 ⑦。合并完成后,①–⑥ 会变为非活跃数据并被自动清理。此后,查询只需在这个单一、紧凑的数据部分 ⑦ 上执行,效率大大提升。

              需要注意的是,这里的“最新”并非依据时间戳列判断,而是基于行的物理插入顺序。合并时,CoalescingMergeTree 会使用各数据部分在磁盘上的写入顺序。对于每列,ClickHouse 保留的是合并过程中,排序键下,最近写入的非 null 值。

              比如图中,数据部分 ① 到 ⑦ 按写入顺序排列,① 最早,⑦ 最新。

              这种方式极大地降低了:

              • 每个设备对应的行数

              • 查询时需要扫描的数据量


              读取最新状态:argMax 与 FINAL

              在 IoT 这样的高写入场景中,后台合并通常跟不上插入的速度(合并在后台持续运行,但写入总是更快,就像有个合并滞后的窗口)。

              即便如此,为了保证查询准确性,我们仍会使用 GROUP BY 和 argMax(),但由于后台合并已经完成了大部分整合,实际需要处理的行数大大减少。

              (前文的图示中为了简洁省略了 last_update 列。它并不影响 CoalescingMergeTree 的合并逻辑,后台合并会自动保留每列的最新非 null 值。但如果你在查询时使用 argMax(),就需要依靠 last_update 这样的时间戳来判断“最新”是哪一行。)


                SELECT
                    vin,
                    argMax(battery_level, last_update) AS batt,
                    argMax(lat, last_update) AS lat,
                    argMax(lon, last_update) AS lon,
                    argMax(firmware_version, last_update) AS fw,
                    argMax(cabin_temperature, last_update) AS temp,
                    argMax(speed_kmh, last_update) AS speed
                FROM electric_vehicle_state
                GROUP BY vin
                ORDER BY vin;
                  ┌─vin───────────────┬─batt─┬─────lat─┬───────lon─┬─fw────────┬─temp─┬─speed─┐
                  │ 5YJ3E1EA7KF000001 │   78 │ 37.7749 │ -122.4194 │ 2024.14.5 │ 22.5 │  67.3 │
                  │ 5YJ3E1EA7KF000099 │ ᴺᵁᴸᴸ │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ 2024.14.5 │ 19.2 │  ᴺᵁᴸᴸ │
                  └───────────────────┴──────┴─────────┴───────────┴───────────┴──────┴───────┘

                  如果所有数据部分已经合并完成且没有新数据插入,直接 SELECT * 查询就可以,不需要再加 GROUP BY

                    OPTIMIZE TABLE electric_vehicle_state FINAL-- force merge all parts into a single part


                    SELECT -- select from the fully consolidated, merged part
                        vin,
                        battery_level AS batt,
                        lat AS lat,
                        lon AS lon,
                        firmware_version AS fw,
                        cabin_temperature AS temp,
                        speed_kmh AS speed
                    FROM electric_vehicle_state
                    ORDER BY vin;
                      ┌─vin───────────────┬─batt─┬─────lat─┬───────lon─┬─fw────────┬─temp─┬─speed─┐
                      │ 5YJ3E1EA7KF000001 │   78 │ 37.7749 │ -122.4194 │ 2024.14.5 │ 22.5 │  67.3 │
                      │ 5YJ3E1EA7KF000099 │ ᴺᵁᴸᴸ │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ 2024.14.5 │ 19.2 │  ᴺᵁᴸᴸ │
                      └───────────────────┴──────┴─────────┴───────────┴───────────┴──────┴───────┘

                      在持续更新的场景下,也可以通过查询时加上 FINAL 修饰符,临时应用 CoalescingMergeTree 的合并逻辑(上文图示已讲解),这样就不用在查询时再做聚合。FINAL 不会将合并结果写回磁盘,而是针对当前查询,在内存中对相关数据做一次合并,生成完整的记录,避免了使用 GROUP BY 或 argMax()

                        SELECT
                            vin,
                            battery_level AS batt,
                            lat AS lat,
                            lon AS lon,
                            firmware_version AS fw,
                            cabin_temperature AS temp,
                            speed_kmh AS speed
                        FROM electric_vehicle_state FINAL
                        ORDER BY vin;
                          ┌─vin───────────────┬─batt─┬─────lat─┬───────lon─┬─fw────────┬─temp─┬─speed─┐
                          │ 5YJ3E1EA7KF000001 │   78 │ 37.7749 │ -122.4194 │ 2024.14.5 │ 22.5 │  67.3 │
                          │ 5YJ3E1EA7KF000099 │ ᴺᵁᴸᴸ │    ᴺᵁᴸᴸ │      ᴺᵁᴸᴸ │ 2024.14.5 │ 19.2 │  ᴺᵁᴸᴸ │
                          └───────────────────┴──────┴─────────┴───────────┴───────────┴──────┴───────┘

                          注意,使用 FINAL 后,last_update 字段就不再是必须的,因为 FINAL 在查询时依据插入顺序来判断最新值,不依赖时间戳。

                          但如果查询不用 FINAL,而是通过 argMax() 来聚合,就仍然需要时间戳列如 last_update。因为当数据部分尚未合并时,查询引擎无法直接知道写入的时间或行的物理顺序,必须借助时间戳来判断。



                          推荐的存储模式:原始表 + 整合表

                          我们推荐搭配使用 CoalescingMergeTree 和常规的 MergeTree 表,通常可以通过物化视图来实现[https://clickhouse.com/docs/materialized-views]

                          • MergeTree 表用来存储原始事件流,完整保留每辆车或设备的所有更新记录。
                          • CoalescingMergeTree 表则用于存储整合后的视图,例如每辆车的最新状态,适合用于仪表盘、定期报表或分析型查询。

                          这种双表模式,既保证了所有原始数据的留存,又能通过物理行整合,提升查询和存储的效率。更重要的是,如果排序键不能唯一标识每个事件,原始表仍然保留了完整数据,方便日后恢复或重算。


                          更多应用场景

                          除了 IoT 快照,任何存在稀疏、追加式更新,逐渐丰富记录的场景,CoalescingMergeTree 都很合适,比如:

                          • 用户画像补全:邮箱、电话、位置等信息随着用户交互逐渐补全。
                          • 安全审计日志:事件逐步添加更多上下文,如操作者身份、受影响系统。
                          • ETL 流程的迟到维度:后续补充步骤为缺失字段赋值。
                          • 患者健康档案:实验室结果、医生笔记和生命体征等来自不同系统,逐步补充。
                          • 广告或营销活动跟踪:展示和点击等信息从不同系统在不同时间汇集。
                          • 客户支持工单:随着收集更多信息(如严重程度、解决方案),工单持续完善。

                          这些场景下,CoalescingMergeTree 能在保证数据完整性的同时,降低存储成本,提升查询性能。


                          就这样,去享受你的夏日时光吧

                          你的 ClickHouse 知识已经更新,是时候给自己充充电了。

                          等你回来时,我们还在这儿。🏖️🍹

                          夏天快乐,查询顺利!


                          /END/

                          试用阿里云 ClickHouse企业版


                          轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G



                          征稿启示

                          面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

                          文章转载自ClickHouseInc,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                          评论