解锁 ClickHouse 新技能: JSON 数据性能调优,实时获取业务洞察

图片

本文字数:19861;估计阅读时间:50 分钟

作者: Tom Schreiber

本文在公众号【ClickHouseInc】首发

图片

为什么实时仪表盘的速度至关重要

对于实时分析应用(如仪表盘),流畅的响应速度至关重要。Jakob Nielsen 的研究、心理计时学理论,以及 Steve Henty 的进一步分析总结了人类对系统响应时间的感知标准:

<100ms(瞬时)—— 近乎即时,理想用于筛选或快速更新。

100ms - 500ms(非常快)—— 运行流畅,适用于图表渲染、选项卡切换或摘要计算。

500ms - 1s(可察觉的延迟)—— 用户能明显感受到等待,但对于复杂查询仍可接受。

1s - 2s(缓慢但可容忍)—— 可能会让用户感觉卡顿,建议添加加载指示器。

>2s(过慢)—— 体验不佳,用户可能会失去耐心或注意力。

要在大规模数据集上实现 小于 500ms,甚至小于 100ms 的查询响应时间,尤其是在处理 数十亿个 JSON 文档 时,没有合适的数据库支撑将极具挑战性。随着数据量增长,大多数系统的查询性能都会显著下降,导致仪表盘响应变慢,用户体验受损。

在本文中,我们将通过 三个典型的实时仪表盘场景,展示经过验证的查询加速技术。在这些场景中,所有查询均基于 40 亿个 Bluesky JSON 文档(1.6 TiB 数据) 进行计算,并且 仅运行在一台普通的中等规格服务器上,但仍能实现:

① 确保 ClickHouse 查询始终低于 100ms,提供瞬时响应。  

② 无论数据规模增长多少,查询速度始终保持稳定。  

③ 始终运行在最新数据上,且延迟极低。  

④ CPU 和内存占用极低,仅消耗 KB 级到低 MB 级的 RAM。

下表展示了我们将要实现的优化效果,证明 ClickHouse 即使在 数十亿 JSON 文档规模下,依然能够保障实时仪表盘的高性能。

图片

在探讨优化方案之前,我们先介绍 Bluesky 数据集及其运行环境。然后,我们将通过三个典型的实时仪表盘场景,详细解析如何在 任何数据规模下 实现并保持 <100ms 的瞬时查询性能。

TL;DR?直接查看关键要点!  

如果你想跳过详细过程,直接查看最终结果和优化分析,请前往:  

目标达成:稳定实现 <100ms 查询。

Bluesky JSON 数据集

我们的示例数据集来源于 Bluesky,这是一组 实时 JSON 事件流,捕获自 知名社交媒体平台。正如我们在另一篇文章中介绍的,该数据集会 持续接收 新的事件,如 帖子、点赞、转发。

如何在 ClickHouse 中存储 Bluesky JSON 数据

下方是 ClickHouse 存储 Bluesky 数据集的表结构,该数据集会 不断增长,并可在 ClickHouse SQL playground 中访问:

CREATE TABLE bluesky.bluesky
(
  kind LowCardinality(String),
  data JSON,
  bluesky_ts DateTime64(6)
)
ENGINE = MergeTree
ORDER BY (kind, bluesky_ts);

data 列采用 新版 JSON 类型 存储 原始的 Bluesky JSON 文档。

过去,为了加速查询,我们曾将 事件类型 和 事件时间 提取为顶层列,并用于排序。不过,现在已经不需要这样做了,因为 新版 JSON 类型已支持直接使用 JSON 路径作为排序键和主键列。

数据集的规模有多大?

数据摄取始于 去年 12 月下旬。截至 2025 年 3 月,表中已存储 40 亿+ 条 Bluesky 事件 JSON 文档,未压缩数据总量超过 1.6 TiB。

SELECT
    formatReadableQuantity(sum(rows)) AS docs,
    formatReadableSize(sum(data_uncompressed_bytes)) AS data_size
FROM system.parts
WHERE active AND (database = 'bluesky') AND (table = 'bluesky');

上述查询结果基于 2025 年 3 月的数据。

┌─docs─────────┬─data_size─┐
│ 4.14 billion │ 1.61 TiB  │
└──────────────┴───────────┘

数据集增长有多快?

目前,该表 每月新增约 15 亿条数据:

SELECT
    toStartOfMonth(bluesky_ts) AS month,
    count() AS docs
FROM bluesky.bluesky
GROUP BY month
ORDER BY month DESC
LIMIT 10
SETTINGS enable_parallel_replicas=1;

由于 数据摄取始于去年 12 月下旬,该月份的数据量相对较少。  2025 年 2 月只有 28 天,数据量也比其他月份略少。

当前,该表每天新增约 5000 万条 Bluesky 事件:

SELECT
    toStartOfDay(bluesky_ts) AS day,
    count() AS docs
FROM bluesky.bluesky
WHERE day < toStartOfDay(now())
GROUP BY day
ORDER BY day DESC
LIMIT 10
SETTINGS enable_parallel_replicas=1;

如此快速的增长意味着,如果缺乏合理优化,查询速度必然会下降。

那么,如何在数据量持续增长的同时,依然保持实时查询性能? 继续阅读...

实时测试所有查询!  

或者,直接让博客为你执行查询。

本博客涉及的所有 Bluesky 表、查询加速技术和示例查询,均可在 ClickHouse SQL playground 中运行,您可以自行探索,亲自执行每个示例。

事实上,本博客中的 所有查询 都会 实时执行,让您边阅读边查看查询结果。

我们的 公共 ClickHouse SQL playground 设有限制(如 配额、访问控制、查询复杂度),确保所有用户都能 公平使用资源,防止个别用户占用过多计算能力。因此,高开销查询可能会受限。

为了提供 完整且准确的查询执行统计信息,本博客中的部分查询 由 ClickHouse Cloud playground 上的管理员用户运行,并通过 clickhouse-client 连接到无约束权限环境 执行,以便展示完整结果。

ClickHouse 运行环境

我们的 ClickHouse SQL playground 运行在 ClickHouse Cloud 上,拥有至少 三个计算节点,每个节点配备 59 核 CPU 和 236 GiB 内存。虽然 ClickHouse Cloud 具备 高可用性和可扩展性,但本博客中的 所有仪表盘查询 均运行在 单个计算节点,因为我们 未启用 ClickHouse Cloud 的并行副本。除了 ClickHouse Cloud 采用共享对象存储 这一点外,本博客的查询性能 可与具备类似 CPU 和 RAM 的独立 ClickHouse 实例相媲美。

在接下来的内容中,我们将展示 如何在这种硬件环境下,实现并稳定保持 <100ms 的 ClickHouse 查询响应时间,无论 Bluesky 表的数据量增长到多少亿条 JSON 文档。  我们将通过 三个实时仪表盘场景,逐步解析 查询优化的核心技术。

仪表盘 1:监测 Bluesky 活跃趋势  

图片

本案例是一个 实时仪表盘,按 小时 统计 最受欢迎的 Bluesky 事件类型,直观展示 一天中用户的活跃时间。

基础查询:按小时统计事件(44 秒)

仪表盘查询逻辑基于 事件类型计数,计算 最受欢迎的 Bluesky 事件:

SELECT
    data.commit.collection AS event,
    toHour(bluesky_ts) AS hour_of_day,
    count() AS count
FROM bluesky.bluesky
WHERE kind = 'commit'
  AND event in [
    'app.bsky.feed.post',
    'app.bsky.feed.repost',
    'app.bsky.feed.like']
GROUP BY event, hour_of_day;

⚠️ 直接运行该查询可能无法顺利执行,因为它 很可能超出 我们 公共 playground 设定的查询时间限制和复杂度阈值。

无限制环境下的查询执行情况:

Elapsed: 44.901 sec. Processed 4.12 billion rows, 189.18 GB (91.84 million rows/s., 4.21 GB/s.)
Peak memory usage: 775.96 MiB.

在 ClickHouse Cloud playground 中,通过 clickhouse-client 运行该查询(无查询限制),结果如下:查询耗时:44 秒,内存占用:776 MiB,性能远不能满足仪表盘的实时交互需求。

 >2s(过慢)—— 体验不佳,用户容易失去专注。

尽管 ClickHouse 处理 Bluesky JSON 数据的速度 远超 其他主流 JSON 数据存储,但即便在 高性能硬件 上,其最大吞吐量也仅为 91.84M 文档/秒(4.21 GB/s)。

然而,面对 40 亿+ 文档且不断增长的数据量,仅靠全表扫描无法直接实现 <100ms 查询。如果我们能在 新事件到达时 预先 聚合数据,是否就能避免每次查询都扫描数十亿行数据呢?这正是 增量物化视图(incremental materialized views) 发挥作用的关键!

增量聚合如何提升查询速度

要实现 瞬时查询,我们需要 实时增量聚合数据,让 聚合结果随着新的 Bluesky 事件到达而持续更新:

图片

我们将 预聚合的数据 存储在 ① events_per_hour_of_day 表中,③ 该表用于支持仪表盘查询。同时,② 增量物化视图 负责 监测源表数据变更,并自动更新预聚合表,确保数据始终保持最新。

基于 物化视图的增量聚合 极大提升资源利用率,特别是在 源表包含数十亿甚至数万亿行数据 的情况下。与其在每次查询时 重新扫描整个数据集并计算聚合,ClickHouse 采用 增量计算 方法,仅对新插入的数据执行局部聚合,并在后台将这些聚合状态与已有数据增量合并,大幅减少计算开销。

以下是 events_per_hour_of_day 表的 DDL 语句,用于存储 预聚合数据:

CREATE TABLE bluesky.events_per_hour_of_day
(
    event LowCardinality(String),
    hour_of_day UInt8,
    count SimpleAggregateFunction(sum, UInt64)
)
ENGINE = AggregatingMergeTree
ORDER BY (event, hour_of_day);

同时,以下是 增量物化视图的 DDL 定义。它的核心逻辑是 每当 Bluesky 数据集有新数据插入时,自动触发转换查询,预聚合新增数据后,将结果写入目标表 events_per_hour_of_day,并让 ClickHouse 在后台持续执行增量合并,实现 高效、低延迟的聚合查询。

CREATE MATERIALIZED VIEW bluesky.events_per_hour_of_day_mv
TO bluesky.events_per_hour_of_day
AS SELECT
    data.commit.collection::String AS event,
    toHour(bluesky_ts) as hour_of_day,
    count() AS count
FROM bluesky.bluesky
WHERE (kind = 'commit')
GROUP BY event, hour_of_day;

预聚合对存储的影响

我们来检查 events_per_hour_of_day 目标表的存储占用,当其数据 与 40 亿+ 行 Bluesky 数据集完全同步时——无论是 实时更新,还是 在已有表上新增物化视图后进行回填:

SELECT
    formatReadableQuantity(sum(rows)) AS rows,
    formatReadableSize(sum(data_uncompressed_bytes)) AS data_size
FROM system.parts
WHERE active AND (database = 'bluesky') AND (table = 'events_per_hour_of_day');

上述查询结果基于 2025 年 3 月的数据。

┌─rows───┬─data_size─┐
│ 892.00 │ 11.24 KiB │
└────────┴───────────┘

可以看到,预聚合数据表的存储占用远小于完整的 Bluesky 数据表,无论是 行数 还是 未压缩总数据量。

预聚合数据的存储占用与源表规模无关:物化视图 在 新数据到达时会实时更新目标表,但在数据 完全合并后,表的 行数和总大小将保持恒定,不会随着 完整 Bluesky 数据集的增长 而增加。

这一特性至关重要,它使 ClickHouse 查询能够始终保持 <100ms 的瞬时响应。为什么?因为 物化视图目标表的最大合并后大小,仅取决于唯一的 Bluesky 事件类型数量(当前 109 种) × 每天 24 小时,而不会随着 完整数据集的增长 而增加。

由于 数据规模恒定,优化后的查询执行时间也能保持稳定!

*并非所有事件类型都会出现在一天中的每个小时。

实现 6 毫秒查询性能

现在,我们可以直接在 events_per_hour_of_day 预聚合表 上运行查询:

SELECT event, hour_of_day, sum(count) as count
FROM bluesky.events_per_hour_of_day
WHERE event in [
    'app.bsky.feed.post',
    'app.bsky.feed.repost',
    'app.bsky.feed.like']
GROUP BY event, hour_of_day
ORDER BY hour_of_day;

查询执行统计(无约束 clickhouse-client):

Elapsed: 0.006 sec.

查询时间 从 44 秒缩短至 6 毫秒,完全满足 <100ms“瞬时查询”阈值 的要求。由于 输入表由增量物化视图实时更新,查询始终基于 最新数据 运行,并且 查询时间不会随数据增长而变化。

<100ms(瞬时)—— 适用于筛选和快速更新,用户几乎感觉不到延迟。

内存占用情况(186 KiB vs 776 MiB)

为了更直观地了解 优化后的查询效率,我们可以拆解 从原始 JSON 数据摄取到实时仪表盘查询 的完整数据流。下方示意图展示了整个流程,并详细解析各阶段的 内存占用:

图片

图片

最终查询仅消耗 186 KiB 内存,相比基线查询的 775.96 MiB,减少了 99.98% 的内存占用。即使加上 增量物化视图在处理新数据时使用的 314 MiB,整体内存消耗仍然远低于基线查询,确保了 大规模数据场景下的实时查询能力。

这一结果证明,增量预聚合不仅能显著减少查询延迟和资源占用,还确保了仪表盘在数据规模持续增长时依然保持流畅响应。

仪表盘 2:最受欢迎的 Bluesky 事件排行榜  

图片

在第二个场景中,我们构建了实时仪表盘,按事件类型统计最常见的 Bluesky 事件,并计算 每种事件的唯一用户数。

基础查询:计算唯一用户数(56 秒)

我们在 完整的 Bluesky 数据集 上执行查询,在 仪表盘 1 的 count 聚合 逻辑基础上,新增 uniq 聚合,不仅统计 每个事件的总次数,还计算 涉及的唯一用户数。

SELECT
    data.commit.collection AS event,
    count() AS count,
    uniq(data.did) AS users
FROM bluesky.bluesky
WHERE kind = 'commit'
GROUP BY event
ORDER BY count DESC;

查询执行统计(无约束 clickhouse-client):

Elapsed: 55.966 sec. Processed 4.41 billion rows, 387.45 GB (78.80 million rows/s., 6.92 GB/s.)
Peak memory usage: 1000.24 MiB.

>2s(过慢)—— 体验不佳,用户容易失去专注。

56 秒的查询时间过长,远不能满足实时仪表盘的需求。 为了应对数据增长,同时保持低查询延迟,我们需要更高效的方案。 与其每次查询都扫描完整数据集,不如 通过预聚合构建优化表,提升 实时分析性能。

从慢查询优化到实时分析  

与 第一个仪表盘 类似,我们新增 ① top_event_types 预聚合表,  ③ 该表用于支持第二个示例仪表盘的查询。  同时,② 增量物化视图 会 监测源表数据变更,并持续更新该表,确保 查询始终基于最新数据 运行:

图片

以下是 top_event_types 预聚合表 的 DDL 语句,  用于存储 按事件类型聚合的分析数据:

CREATE TABLE bluesky.top_event_types
(
	event LowCardinality(String),
	count SimpleAggregateFunction(sum, UInt64),
	users AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree
ORDER BY event;

以下是增量物化视图(incremental materialized view) 的 DDL 语句,该视图用于将预聚合数据写入 top_event_types 表:

CREATE MATERIALIZED VIEW bluesky.top_event_types_mv 
TO bluesky.top_event_types
AS
SELECT
  data.commit.collection::String AS event,
  count() AS count,
  uniqState(data.did::String) AS users
FROM bluesky.bluesky
WHERE kind = 'commit'
GROUP BY event;

预聚合数据占用多少空间?

我们检查 top_event_types 目标表的大小,当其数据 与 40 亿+ 行的完整 Bluesky 数据集完全同步时:

SELECT
    formatReadableQuantity(sum(rows)) AS rows,
    formatReadableSize(sum(data_uncompressed_bytes)) AS data_size
FROM system.parts
WHERE active AND (database = 'bluesky') AND (table = 'top_event_types');

上述查询的静态结果基于 2025 年 3 月的数据。

┌─rows───┬─data_size─┐
│ 109.00 │ 2.27 MiB  │
└────────┴───────────┘

预聚合数据的存储占用不会随源表规模增长而变化 : 同样,目标表在 完全合并后,其 大小和行数 都将 保持恒定,  不受 Bluesky 数据集增长 的影响,它的大小 仅受唯一 Bluesky 事件类型数(当前 109 种)影响。

这一特性保证 无论 Bluesky 数据集如何增长,  ClickHouse 查询始终能稳定保持 <100ms 响应时间。

最终优化后查询:7 毫秒响应时间  

我们在 top_event_types 预聚合表上执行优化后的查询:

SELECT
  event,
  sum(count) AS count,
  uniqMerge(users) AS users
FROM bluesky.top_event_types
GROUP BY event
ORDER BY count DESC;

查询执行统计(无约束 clickhouse-client):

Elapsed: 0.007 sec.

查询时间从 56 秒缩短至 7 毫秒,大幅优化性能。

<100ms(瞬时)—— 适用于筛选或快速更新,用户几乎感觉不到延迟。

优化后查询的内存占用对比(16 MiB vs 1 GiB)

为了直观展示 优化效果,下表对比了 各阶段的内存占用情况,证明增量物化视图如何显著减少查询开销。

图片

图片

优化后查询仅消耗 16 MiB 内存,相比基线查询的 1 GiB,减少 99% 以上。 即使考虑 增量物化视图处理新数据的 276 MiB,整体内存占用仍显著降低,确保 高效、快速的实时分析能力。

再次证明,增量预聚合不仅能极大减少内存占用,同时保持低查询延迟,让大规模 JSON 数据分析更加高效。

仪表盘 3:发现最热门的 Bluesky 转发帖  

图片

在 第三个场景 中,我们构建了一个 实时仪表盘,用于展示被转发次数最多的 Bluesky 帖子。

识别转发事件的技术挑战

乍一看,找出被转发最多的帖子似乎是个简单问题。  但在实现过程中,我们需要解决几个关键难题:

1. 转发事件不包含原始帖子的内容:转发事件仅存储原帖的 CID(内容标识符),但不会携带任何文本信息:

图片

2. 计算转发次数的开销较大:必须对高基数 JSON 字段 `cid` 进行聚合,这会显著拖慢查询速度。

3. 帖子不包含用户的用户名或句柄:Bluesky 事件 JSON 仅记录 DID(去中心化标识符),而不会存储用户的实际名称:

图片

在优化之前,我们先来看一个 基础查询,用于查找最热门的转发帖子。

为什么转发查询如此缓慢?(基线查询:37 秒)

在优化之前,我们先执行一个基础查询,获取 转发次数最多的前 10 篇帖子,之后再解决补充帖子内容和将 DID 映射到用户名 这两个问题。

SELECT
    data.commit.record.subject.cid AS cid,
    count() AS reposts
FROM bluesky.bluesky
WHERE data.commit.collection = 'app.bsky.feed.repost'
GROUP BY cid
ORDER BY cid DESC
LIMIT 10;

查询执行统计(无约束 clickhouse-client):

Elapsed: 37.234 sec. Processed 4.14 billion rows, 376.91 GB (111.26 million rows/s., 10.12 GB/s.)
Peak memory usage: 45.43 GiB.
    >2s(过慢)—— 体验不佳,用户容易失去专注。

    很明显,这种查询方式 过于缓慢。由于 `cid` 是高基数 JSON 字段,查询耗时 37 秒,内存占用 45 GiB,远远达不到实时仪表盘的性能要求。要提升转发查询的效率,我们需要更优的解决方案。  

    加速转发查询,提升实时分析能力

    与之前的优化方案一致,我们在 ① reposts_per_post 表中存储和更新 预聚合数据,③ 该表作为仪表盘查询的输入。同时,② 增量物化视图会实时更新该表,确保数据始终与源表同步:

    图片

    以下是 reposts_per_post 目标表及其增量物化视图 的 DDL 定义:

    CREATE TABLE bluesky.reposts_per_post
    (
        cid String,
        reposts SimpleAggregateFunction(sum, UInt64)
    )
    ENGINE = AggregatingMergeTree
    ORDER BY (cid);
    CREATE MATERIALIZED VIEW bluesky.reposts_per_post_mv TO bluesky.reposts_per_post
    AS 
    SELECT
        data.commit.record.subject.cid::String AS cid,
        count() AS reposts
    FROM bluesky.bluesky
    WHERE data.commit.collection = 'app.bsky.feed.repost'
    GROUP BY cid;

    预聚合数据的存储占用分析

    我们检查 reposts_per_post 目标表的大小,当其数据 与 40 亿+ 行的完整 Bluesky 数据集完全同步时:

    SELECT
        formatReadableQuantity(sum(rows)) AS rows,
        formatReadableSize(sum(data_uncompressed_bytes)) AS data_size
    FROM system.parts
    WHERE active AND (database = 'bluesky') AND (table = 'reposts_per_post');

    上述查询的静态结果基于 2025 年 3 月的数据。

    ┌─rows──────────┬─data_size─┐
    │ 50.62 million │ 3.58 GiB  │
    └───────────────┴───────────┘

    注意:增量物化视图的目标表 比完整的 Bluesky 数据表小,但仍然占据大量存储空间,且行数较多。

    在本场景中,预聚合数据的大小仍然取决于源表规模:尽管增量物化视图可预计算转发次数,但目标表的大小仍随源数据集增长,其行数和存储占用会随着帖子和转发数量的增加而扩展。

    这一增长会影响查询性能,但稍后我们将介绍 一种优化方案,来降低存储增长对查询性能的影响。

    优化后查询:仍未达标(1.7 秒)

    我们在 reposts_per_post 预聚合表 上执行优化后的查询:

    SELECT
      cid,
      sum(reposts) AS reposts
    FROM bluesky.reposts_per_post
    GROUP BY cid
    ORDER BY reposts DESC
    LIMIT 10;

    查询执行统计(无约束 clickhouse-client):

    Elapsed: 1.732 sec. Processed 50.62 million rows, 3.85 GB (29.23 million rows/s., 2.22 GB/s.)
    Peak memory usage: 9.66 GiB.

    即使采用预聚合数据,查询仍未达到瞬时响应。虽然相比 37 秒和 45 GiB 内存占用 已有大幅优化,但仍 未能达到 <100ms ‘瞬时查询’ 的标准。

    1s - 2s(缓慢但可接受)—— 体验略显迟滞,建议使用加载指示器。

    正如前文所述,物化视图的目标表会随基础数据集一起扩展。

    因此,即便查询的是预聚合数据,随着 Bluesky 数据集增长,查询速度仍将不断变慢。

    更智能的优化方案。我们真的需要 统计每篇帖子的转发数吗?不,我们只关心最热门的 N 篇帖子。然而,增量物化视图并不适用于维护 top-N 结果,但我们可以利用 可刷新(refreshable)物化视图 高效管理最热门的帖子,确保仅保留最热门的转发帖子,以进一步优化查询性能。

    优化性能瓶颈:更高效的方法  

    我们保留了 reposts_per_post 表及其增量物化视图,并新增 ① 一个定期刷新的物化视图,用于维护 ② 精简的 reposts_per_post_top10 表,该表 ③ 作为仪表盘查询的数据源。此表会定期原子更新,不影响正在运行的查询,仅包含当前转发次数最多的前 10 篇帖子:

    图片

    reposts_per_post_top10 的 DDL 语句与 reposts_per_post 相同:

    CREATE TABLE bluesky.reposts_per_post_top10
    (
        cid String,
        reposts UInt64
    )
    ENGINE = MergeTree
    ORDER BY ();

    以下是该可刷新的物化视图的定义:

    CREATE MATERIALIZED VIEW bluesky.reposts_per_post_top10_mv
    REFRESH EVERY 10 MINUTE TO bluesky.reposts_per_post_top10
    AS
    SELECT
        cid,
        sum(reposts) AS reposts
    FROM bluesky.reposts_per_post
    GROUP BY cid
    ORDER BY reposts DESC
    LIMIT 10;

    我们将其配置为每 10 分钟刷新一次。  

    增量物化视图可实时更新目标表,使其与源数据保持同步,而可刷新的物化视图则按固定间隔更新,最短间隔由查询执行时间决定。 

     如果直接在完整数据集上执行此查询,需要 37 秒,并占用 45 GiB 内存,开销过大,不适合频繁执行。  

    然而,查询 reposts_per_post 这一预聚合表,仅需 1.7 秒即可完成相同的计算,并仅占用 10 GiB 内存,大幅降低了资源消耗。  

    通过结合使用可刷新的物化视图和增量物化视图,我们有效提升了资源利用效率。

    精简数据,实时查询  

    现在,核心优化来了——正如预期的那样,reposts_per_post_top10 表即使与包含 40 亿+ 行的 Bluesky 数据集保持完全同步,其数据量始终固定在 10 行,总计仅 680 字节:  

    SELECT
        formatReadableQuantity(sum(rows)) AS rows,
        formatReadableSize(sum(data_uncompressed_bytes)) AS data_size
    FROM system.parts
    WHERE active AND (database = 'bluesky') AND (table = 'reposts_per_post_top10');

    上述查询的静态结果(截至 2025 年 3 月):  

    ┌─rows──┬─data_size─┐
    │ 10.00 │ 680.00 B  │
    └───────┴───────────┘

    预聚合数据的大小不会受源表增长影响:与前两个仪表盘示例类似,目标表的大小和行数始终固定为 10 行,总计 680 字节,无论原始 Bluesky 数据集如何扩展,都不会影响它的存储大小。 

    极限优化:小于 100 毫秒的查询  

    最终的优化成果——在 reposts_per_post_top10 这个紧凑表(始终仅 10 行)上运行查询,可确保始终毫秒级(<100ms)返回结果:  

    SELECT *
    FROM bluesky.reposts_per_post_top10
    ORDER BY reposts DESC;

    通过 clickhouse-client 查询的执行统计信息(无查询限制):  

    Elapsed: 0.002 sec.
      <100ms(毫秒级响应)——查询速度极快,非常适合过滤或快速更新。  

      内存占用对比:28 KiB vs 45 GiB  

      为了直观展示优化方案如何大幅降低内存占用,下面的表格对比了三个关键组件的内存消耗情况。  

      图片

      图片

      即使在峰值负载下,所有组件的总内存消耗仍然远低于原始的 45 GiB 基线,充分证明了该方法的高效性。  

      为转发数据补充帖子内容  

      正如前面提到的,这一场景带来了额外挑战:仪表盘需要展示 Bluesky 中被转发最多的帖子,但转发事件仅包含帖子标识符(CID),而不包含实际的帖子内容。  

      图片

      为了解决这一问题,我们采用了以下优化方案:  

      1. 创建 ① 一个增量物化视图,用于预填充 ② cid_to_text 表,该表存储每条新帖子的内容,并针对 CID 快速查找进行了优化。  

      2. 扩展可刷新的物化视图,使其利用该表,从而高效地 ③ 进行 join 操作,检索并存储前 10 篇转发最多的帖子的文本内容,并写入 reposts_per_post_top10 这一精简表,④ 作为仪表盘查询的数据源。

      图片

      完整的 Bluesky 数据集表采用 (kind, bluesky_ts) 作为主键,这种设计并不适用于基于 CID 的快速帖子内容查找,尤其是在数据量达数十亿行的情况下。为此,我们创建了专门的 cid_to_text 表,并优化了其主键,以提升基于 CID 的文本检索效率。  

      CREATE TABLE bluesky.cid_to_text
      (
          cid String,
          did String,
          text String
      )
      ENGINE = MergeTree
      ORDER BY (cid);

      以下是用于同步 cid_to_text 表的增量物化视图 DDL 语句,该视图会在有新帖子发布时自动更新表中的内容:  

      CREATE MATERIALIZED VIEW bluesky.cid_to_text_mv 
      TO bluesky.cid_to_text
      AS 
      SELECT
          data.commit.cid AS cid,
          data.did AS did,
          data.commit.record.text AS text
      FROM bluesky.bluesky
      WHERE (kind = 'commit') AND (data.commit.collection = 'app.bsky.feed.post');

      需要注意的是,cid_to_text 表的大小会随着完整的 Bluesky 数据集增长。然而,由于它仅供可刷新的物化视图定期查询,且不要求低延迟响应,因此这种依赖关系是可接受的,不会影响实时仪表盘的性能。  

      通过优化帖子内容查找提升 `join` 效率  

      在介绍最终的可刷新的物化视图 DDL 之前,先来看它所用到的 join 查询。该查询通过连接两个优化后的表来识别转发次数最多的前 10 篇帖子,并补充其文本内容:reposts_per_post 预聚合表(作为 join 左侧)  ,cid_to_text 专用表(作为 join 右侧,用于根据左侧的 CID 获取文本内容)  

      WITH top_reposted_cids AS
      (
        SELECT
          cid,
          sum(reposts) AS reposts
        FROM bluesky.reposts_per_post
        GROUP BY cid
        ORDER BY reposts DESC
        LIMIT 10
      )
      SELECT
          t2.did AS did,
          t1.reposts AS reposts,
          t2.text AS text
      FROM top_reposted_cids AS t1
      LEFT JOIN bluesky.cid_to_text AS t2
      ON t1.cid = t2.cid;

      查询执行统计(通过 clickhouse-client 无查询限制获取):  

      Elapsed: 295.427 sec. Processed 379.42 million rows, 85.14 GB (1.28 million rows/s., 288.18 MB/s.)
      Peak memory usage: 126.77 GiB.

      显然,这样的性能表现远远不理想。  

      问题在于,当前的 join 查询优化器尚未能自动将左侧表的过滤条件下推至右侧表。但我们可以手动优化 join,并利用右侧表的主键提升查询性能,使查询执行更加高效。  

      WITH top_reposted_cids AS
      (
        SELECT
          cid,
          sum(reposts) AS reposts
        FROM bluesky.reposts_per_post
        GROUP BY cid
        ORDER BY reposts DESC
        LIMIT 10
      )
      SELECT
          t2.did AS did,
          t1.reposts AS reposts,
          t2.text AS text
      FROM top_reposted_cids AS t1
      LEFT JOIN
      (
          SELECT *
          FROM bluesky.cid_to_text
          WHERE cid IN (SELECT cid FROM top_reposted_cids)
      ) AS t2 ON t1.cid = t2.cid;

      通过未受限制的 clickhouse-client 查询执行统计信息:

      Elapsed: 3.681 sec. Processed 102.37 million rows, 7.78 GB (27.81 million rows/s., 2.11 GB/s.)
      Peak memory usage: 9.91 GiB.

      在连接(join)查询中,先使用前 10 个被转发最多的帖子对应的 CID 预过滤大型 cid_to_text 表(作为连接的右表)。这一优化充分利用了表的主键,使查询时间从 300 秒缩短至 3.6 秒,内存消耗从 127 GiB 降至 9.91 GiB。

      高效维护前 10 个被转发最多的帖子

      我们基于上述连接查询,构建了一个可刷新的物化视图(refreshable materialized view)。该视图会定期更新目标表,存储前 10 个被转发最多的帖子及其文本,并包含原作者的 DID。

      CREATE TABLE bluesky.reposts_per_post_top10_v2
      (
        did String,
        reposts UInt64,
        text String
      )
      ENGINE = MergeTree
      ORDER BY ();

      以下是该可刷新的物化视图的 DDL 语句:

      CREATE MATERIALIZED VIEW bluesky.reposts_per_post_top10_mv_v2
      REFRESH EVERY 10 MINUTE TO bluesky.reposts_per_post_top10_v2
      AS
      WITH top_reposted_cids AS
      (
        SELECT
          cid,
          sum(reposts) AS reposts
        FROM bluesky.reposts_per_post
        GROUP BY cid
        ORDER BY reposts DESC
        LIMIT 10
      )
      SELECT
          t2.did AS did,
          t1.reposts AS reposts,
          t2.text AS text
      FROM top_reposted_cids AS t1
      LEFT JOIN
      (
          SELECT *
          FROM bluesky.cid_to_text
          WHERE cid IN (SELECT cid FROM top_reposted_cids)
      ) AS t2 ON t1.cid = t2.cid;

      最终聚合数据的规模

      如预期所示,当 reposts_per_post_top10_v2 表与包含 40 亿行完整 Bluesky 数据集的表保持同步时,它始终只包含 10 行数据,总大小仅为 2.35 KiB。

      SELECT
          formatReadableQuantity(sum(rows)) AS rows,
          formatReadableSize(sum(data_uncompressed_bytes)) AS data_size
      FROM system.parts
      WHERE active AND (database = 'bluesky') AND (table = 'reposts_per_post_top10_v2');

      上述查询在 2025 年 3 月的静态查询结果:

      ┌─rows──┬─data_size─┐
      │ 10.00 │ 2.35 KiB  │
      └───────┴───────────┘

      最终优化查询:3 毫秒响应时间,即时返回结果

      在紧凑型 reposts_per_post_top10_v2 表(仅 10 行数据)上运行查询,可确保始终获得瞬时响应时间:

      SELECT *
      FROM bluesky.reposts_per_post_top10_v2
      ORDER BY reposts DESC;

      通过未受限制的 clickhouse-client 查询执行统计信息:

      Elapsed: 0.003 sec.
        <100ms(近乎瞬时)— 适用于筛选或快速更新。

        优化后的查询内存使用情况(45 KiB vs 45 GiB)

        我们的优化方案包含四个关键组件,每个组件都在显著降低内存消耗的同时,保持实时性能。

        图片

        图片

        这四个组件的总内存占用分别为 37.62 MiB、327.70 MiB、10 GiB 和 45.71 KiB,远低于基线查询的 45 GiB。

        实时映射 DID 到用户句柄

        目前的第三个实时仪表盘场景涉及两个关键挑战:

        1. 高效的 top-N 检索:单独使用增量物化视图(incremental materialized views)仍然不足,因此需要可刷新的物化视图(refreshable materialized view)来维护转发最多的 N 篇帖子。

        2. 丰富转发数据:由于转发事件仅包含帖子标识符(CID),而不包括文本,因此需要通过表连接来检索完整的帖子内容。

        但仍有一个缺失环节——当前仪表盘按 DID 显示转发数据,这样的展示方式不够直观。接下来,我们将在实时环境下将 DID 映射到实际的用户句柄。

        在 Bluesky 生态中,帖子、转发、点赞等事件的 JSON 数据中,仅包含用户的 DID,而不包含用户名或句柄:

        图片

        与 DID 不同,用户句柄和名称可能会因身份变更事件而随时发生变化:

        图片

        幸运的是,ClickHouse 提供了一个完美的解决方案:可更新的内存字典(updatable in-memory dictionaries),能够高效地进行实时查找,并支持无缝更新。

        字典(Dictionaries)是 ClickHouse 的核心功能之一,它可以将不同数据源的信息存储为内存中的键值对,并针对超低延迟查询进行了优化。

        下图展示了在仪表盘场景下,如何创建并加载内存字典(in-memory dictionary),以支持动态元数据(如用户句柄)的高效实时查找,使仪表盘能够在查询时动态更新数据,保持最新状态:  

        图片

        我们创建了 ① handle_per_user_dict 字典,用于将持久的 Bluesky 用户标识符(DID)映射到其最新的用户句柄。当用户更改其句柄时,Bluesky API 会流式传输一个身份 JSON 文档。通过按 DID 归类,我们在 ClickHouse 中使用 argMax 聚合函数,从最新的身份文档中提取用户句柄,并构建 ② 字典加载查询(dictionary load query)。  

        与可刷新的物化视图类似,字典可以通过定期执行加载查询进行原子化更新,确保查询性能不受影响。  

        然而,为了避免在持续扩大的 40 亿行 Bluesky 数据集上重复运行 argMax 聚合,我们引入了一项优化方案,如下图所示:  

        图片

        我们新增了 ① 一个增量物化视图(incremental materialized view),该视图仅对新插入的数据块执行 argMax 聚合,并将预聚合的数据存储在 ② ReplacingMergeTree 表 中。该表的后台合并机制确保每个唯一 DID 仅保留最新的句柄。字典的 ③ 加载查询(load query) 直接作用于这个较小的表,并使用 FINAL 修饰符在查询时合并未完成的数据部分,确保查询结果始终最新。  

        接下来,我们将提供 DDL 语句来配置该方案。  

        增量物化视图目标表的 DDL 语句:  

        CREATE TABLE bluesky.handle_per_user
        (
            did String,
            handle String
        )
        ENGINE = ReplacingMergeTree
        ORDER BY (did);

        增量物化视图的 DDL 语句:  

        CREATE MATERIALIZED VIEW bluesky.handle_per_user_mv 
        TO bluesky.handle_per_user
        AS 
        SELECT
            data.identity.did::String AS did,
            argMax(data.identity.handle, bluesky_ts) AS handle
        FROM bluesky.bluesky
        WHERE (kind = 'identity')
        GROUP BY did;

        最后,我们通过查询物化视图的目标表来创建 内存字典(in-memory dictionary),并设定自动更新的时间间隔(秒级)。ClickHouse 在此时间范围内随机分配更新时间,以平衡大规模集群的更新负载:  

        CREATE DICTIONARY bluesky.handle_per_user_dict
        (
            did String,
            handle String
        )
        PRIMARY KEY (did)
        SOURCE(CLICKHOUSE(QUERY $query$
            SELECT did, handle
            FROM bluesky.handle_per_user FINAL
        $query$))
        LIFETIME(MIN 300 MAX 360)
        LAYOUT(complex_key_hashed());

        需要注意的是,字典的加载策略取决于 dictionaries_lazy_load 设置,它可以在服务器启动时加载,也可以在首次查询时动态加载。  

        此外,用户可以手动运行 SYSTEM 命令触发字典加载。当与 ON CLUSTER 语句结合使用时,可确保云服务中的所有计算节点同步加载字典到内存中:  

        SYSTEM RELOAD DICTIONARY bluesky.handle_per_user_dict ON cluster default;

        现在,该字典已准备就绪,可在仪表盘查询中即时用于 DID 到用户句柄的映射,例如:  

        SELECT dictGet('bluesky.handle_per_user_dict', 'handle', 'did:plc:emtmklr75yrbuuth4avvutos') AS handle;

        上述查询的静态结果(2025 年 3 月):  

        ┌─handle──────────────┐
        │ agbogho.bsky.social │
        └─────────────────────┘
        
        1 row in set. Elapsed: 0.001 sec.

        最终查询:实时用户句柄的转发排行榜(3 毫秒响应)  

        所有组件就位后,我们可以运行最终的仪表盘查询 3,使用字典来获取前 10 个被转发最多的帖子对应的 DID 的最新用户句柄。  

        SELECT
          dictGetOrDefault(
            'bluesky.handle_per_user_dict',
            'handle', did, did) as user,
          reposts,
          text
        FROM bluesky.reposts_per_post_top10_v2
        ORDER BY reposts DESC;

        查询执行统计信息(通过未受限制的 clickhouse-client 获取):  

        Elapsed: 0.003 sec.

        请注意,我们使用 dictGetOrDefault 函数,如果字典中未找到对应映射,则返回原始 DID。  

        理论上,该字典可以包含所有 Bluesky 用户的映射记录,目前约有 3000 万 用户。然而,由于我们从 2024 年 12 月 开始实时摄取 Bluesky 事件数据,因此字典仅包含自该日期以来记录的 句柄或名称变更 事件。  

        截至 2025 年 3 月,字典已包含 约 800 万条 记录,占用 1.12 GiB 内存(通过 clickhouse-client 查询)。  

        SELECT
            status,
            element_count AS entries,
            formatReadableSize(bytes_allocated) AS memory_allocated,
            formatReadableTimeDelta(loading_duration) AS loading_duration
        FROM system.dictionaries
        WHERE database = 'bluesky' AND name = 'handle_per_user_dict';
        ┌─status─┬─entries─┬─memory_allocated─┬─loading_duration─┐
        │ LOADED │ 7840778 │ 1.12 GiB         │ 4 seconds        │
        └────────┴─────────┴──────────────────┴──────────────────┘

        如果用户数量增长至 3000 万,我们预计字典的大小将达到 约 4.5 GiB。  

        如果 Bluesky 用户数量持续增长,我们可能需要重新考虑是否继续使用内存字典。  

        字典使用的内存开销  

        为了高效维护最新字典,同时避免扫描完整的 40 亿行数据集,我们引入了 增量物化视图(incremental materialized view) 来预聚合句柄变更数据。字典本身通过 加载查询(load query) 进行定期刷新,以减少计算开销。

        图片

        图片

        即使在计算内存开销时包含字典及其更新,再加上之前优化的仪表盘查询,总体内存使用量仍然远低于 45 GiB 的基线查询。  

        通过合理设计 增量更新机制 和 周期性字典刷新,我们成功实现了 高效、低延迟的查找,避免了 完整数据扫描 带来的内存负担。  

        现在,让我们回顾这些优化在 三个仪表盘场景 中的整体影响。  

        优化成功:查询性能持续保持 <100ms  

        经过优化,我们的仪表盘查询现在始终能够在 100 毫秒以内 完成,无论数据量如何增长,即便在 中等配置的硬件 上依然高效。秘诀是什么?确保查询始终运行在小型、稳定且预聚合的数据上。  

        尽管我们处理着 40 亿+ 条 JSON 数据,并且每月新增 15 亿 条,我们的输入表依然保持紧凑:  

        图片

        如何加速大规模查询?  

        关键优化思路:避免扫描完整数据集,同时确保数据始终最新。  

        1. 仪表盘 1 & 2:增量物化视图(incremental materialized views)实时更新 预聚合表,以保持高效查询。  

        2. 仪表盘 3:可刷新的物化视图(refreshable materialized view)仅维护 top-N 结果,确保查询高效执行。  

        这种优化方式使得 无论数据规模如何增长,仪表盘查询始终在小型、稳定、最新的数据表上运行。  

        从低效到高效:优化前后对比  

        图片

        ClickHouse 用户的关键优化策略  

        ✅ 增量物化视图:适用于 独立于源表增长 的预聚合数据,确保低延迟查询。  

        ✅ 可刷新的物化视图:与增量物化视图结合使用,可维护 小型且稳定的 top-N 查询输入,在数据新鲜度与查询性能之间取得平衡。  

        ✅ 内存字典:提供 实时元数据查找,进一步优化仪表盘查询体验。  

        通过 确保输入表始终小型且不受数据集规模影响,ClickHouse 能够在 任何数据规模下都保持实时 JSON 分析的高性能。

        征稿启示

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

        评论
        添加红包

        请填写红包祝福语或标题

        红包个数最小为10个

        红包金额最低5元

        当前余额3.43前往充值 >
        需支付:10.00
        成就一亿技术人!
        领取后你会自动成为博主和红包主的粉丝 规则
        hope_wisdom
        发出的红包
        实付
        使用余额支付
        点击重新获取
        扫码支付
        钱包余额 0

        抵扣说明:

        1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
        2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

        余额充值