跳到主要内容
跳到主要内容

为可观测性设计 schema

我们建议用户始终为日志和追踪数据创建自己的 schema,原因如下:

  • 选择主键 - 默认 schema 使用的 ORDER BY 已针对特定访问模式进行了优化。你的访问模式很可能与其并不一致。
  • 提取结构 - 用户可能希望从现有列(例如 Body 列)中提取出新的列。这可以通过物化列(以及在更复杂场景中使用物化视图)实现。此操作需要修改 schema。
  • 优化 Map - 默认 schema 使用 Map 类型来存储属性。这些列允许存储任意元数据。由于事件元数据通常不会预先定义,否则就无法存储在像 ClickHouse 这样强类型的数据库中,因此这是一个关键能力。然而,与普通列相比,访问 Map 中的键及其值的效率较低。我们通过修改 schema,并将最常访问的 Map 键提升为顶层列来解决这一问题——参见「使用 SQL 提取结构」。这需要修改 schema。
  • 简化 Map 键访问 - 访问 Map 中的键需要更冗长的语法。用户可以通过别名来缓解此问题。参见「使用别名」以简化查询。
  • 二级索引 - 默认 schema 使用二级索引来加速对 Map 的访问并提升文本查询性能。通常这些索引并非必需,而且会占用额外磁盘空间。可以使用它们,但应通过测试确认确有必要。参见「二级 / Data Skipping 索引」
  • 使用 Codecs - 如果用户了解预期数据,并且有证据表明自定义列的编解码器有助于提升压缩效果,则可以为列自定义 Codecs。

我们将在下文中详细描述上述每个使用场景。

重要: 虽然鼓励用户扩展和修改自己的 schema 以获得最佳压缩率和查询性能,但在可能的情况下,应遵循 OTel schema 对核心列的命名约定。ClickHouse Grafana 插件假定存在一些基础的 OTel 列来辅助构建查询,例如 TimestampSeverityText。日志和追踪所需的列分别记录在此处 [1][2]此处。你可以选择更改这些列名,并在插件配置中覆盖默认值。

使用 SQL 提取结构

无论摄取的是结构化还是非结构化日志,用户通常都需要能够:

  • 从字符串 blob 中提取列。对这些列进行查询会比在查询时直接对字符串执行操作更快。
  • 从 Map 中提取键。默认 schema 会将任意属性放入 Map 类型的列中。此类型提供了无模式(schema-less)的能力,其优势在于,用户在定义日志和 trace 时,不需要为这些属性预先定义列——在从 Kubernetes 收集日志并希望确保 pod(容器组)标签在后续搜索中仍然保留时,这通常是不可能做到的。从 Map 中访问键及其值比在普通 ClickHouse 列上查询要慢。因此,将 Map 中的键提取到根表的列中通常是更理想的做法。

考虑以下查询:

假设我们希望统计在结构化日志中,哪些 URL 路径接收到最多的 POST 请求。JSON blob 作为 String 存储在 Body 列中。此外,如果用户在 collector 中启用了 json_parser,它也可能会以 Map(String, String) 的形式存储在 LogAttributes 列中。

SELECT LogAttributes
FROM otel_logs
LIMIT 1
FORMAT Vertical

Row 1:
──────
Body:           {"remote_addr":"54.36.149.41","remote_user":"-","run_time":"0","time_local":"2019-01-22 00:26:14.000","request_type":"GET","request_path":"\/filter\/27|13 ,27|  5 ,p53","request_protocol":"HTTP\/1.1","status":"200","size":"30577","referer":"-","user_agent":"Mozilla\/5.0 (compatible; AhrefsBot\/6.1; +http:\/\/ahrefs.com\/robot\/)"}
LogAttributes: {'status':'200','log.file.name':'access-structured.log','request_protocol':'HTTP/1.1','run_time':'0','time_local':'2019-01-22 00:26:14.000','size':'30577','user_agent':'Mozilla/5.0 (compatible; AhrefsBot/6.1; +http://ahrefs.com/robot/)','referer':'-','remote_user':'-','request_type':'GET','request_path':'/filter/27|13 ,27|  5 ,p53','remote_addr':'54.36.149.41'}

假设 LogAttributes 已可用,用于统计站点中哪些 URL 路径收到最多 POST 请求的查询如下:

SELECT path(LogAttributes['request_path']) AS path, count() AS c
FROM otel_logs
WHERE ((LogAttributes['request_type']) = 'POST')
GROUP BY path
ORDER BY c DESC
LIMIT 5

┌─path─────────────────────┬─────c─┐
│ /m/updateVariation       │ 12182 │
│ /site/productCard        │ 11080 │
│ /site/productPrice       │ 10876 │
│ /site/productModelImages │ 10866 │
│ /site/productAdditives   │ 10866 │
└──────────────────────────┴───────┘

返回 5 行。用时:0.735 秒。已处理 1036 万行,4.65 GB(1410 万行/秒,6.32 GB/秒)。
内存峰值:153.71 MiB。

请注意此处使用的 map 语法,例如 LogAttributes['request_path'],以及用于从 URL 中去掉查询参数部分的 path 函数

如果用户没有在采集器中启用 JSON 解析,那么 LogAttributes 将为空,因此需要使用 JSON 函数 从 String 类型的 Body 中提取列。

Prefer ClickHouse for parsing

我们通常建议用户在 ClickHouse 中对结构化日志进行 JSON 解析。我们有信心 ClickHouse 提供了最快的 JSON 解析实现。不过,我们也意识到,有些用户可能希望将日志发送到其他系统,而不希望将这部分逻辑写在 SQL 中。

SELECT path(JSONExtractString(Body, 'request_path')) AS path, count() AS c
FROM otel_logs
WHERE JSONExtractString(Body, 'request_type') = 'POST'
GROUP BY path
ORDER BY c DESC
LIMIT 5

┌─path─────────────────────┬─────c─┐
│ /m/updateVariation       │ 12182 │
│ /site/productCard        │ 11080 │
│ /site/productPrice       │ 10876 │
│ /site/productAdditives   │ 10866 │
│ /site/productModelImages │ 10866 │
└──────────────────────────┴───────┘

返回 5 行。耗时:0.668 秒。处理了 1037 万行,5.13 GB(1552 万行/秒,7.68 GB/秒)。
峰值内存使用:172.30 MiB。

现在来看非结构化日志的情况:

SELECT Body, LogAttributes
FROM otel_logs
LIMIT 1
FORMAT Vertical

第 1 行:
──────
Body:           151.233.185.144 - - [22/Jan/2019:19:08:54 +0330] "GET /image/105/brand HTTP/1.1" 200 2653 "https://www.zanbil.ir/filter/b43,p56" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36" "-"
LogAttributes: {'log.file.name':'access-unstructured.log'}

对非结构化日志进行类似查询时,需要在 extractAllGroupsVertical 函数中使用正则表达式。

SELECT
        path((groups[1])[2]) AS path,
        count() AS c
FROM
(
        SELECT extractAllGroupsVertical(Body, '(\\w+)\\s([^\\s]+)\\sHTTP/\\d\\.\\d') AS groups
        FROM otel_logs
        WHERE ((groups[1])[1]) = 'POST'
)
GROUP BY path
ORDER BY c DESC
LIMIT 5

┌─path─────────────────────┬─────c─┐
│ /m/updateVariation       │ 12182 │
│ /site/productCard        │ 11080 │
│ /site/productPrice       │ 10876 │
│ /site/productModelImages │ 10866 │
│ /site/productAdditives   │ 10866 │
└──────────────────────────┴───────┘

返回 5 行。用时:1.953 秒。已处理 1037 万行,3.59 GB(531 万行/秒,1.84 GB/秒)。

对于解析非结构化日志的查询,其复杂性和成本都会增加(注意性能差异),这也是我们建议用户在可能的情况下尽量使用结构化日志的原因。

考虑使用字典

上述查询可以通过利用正则表达式字典进行优化。参见 Using Dictionaries 以获取更多详细信息。

通过将上述查询逻辑前移到写入阶段,这两种用例都可以通过 ClickHouse 来满足。我们在下面探讨几种不同的方法,并指出每种方法适用的场景。

使用 OTel 还是 ClickHouse 进行处理?

用户也可以按此处所述,使用 OTel Collector 的 processors 和 operators 进行处理。在大多数情况下,用户会发现 ClickHouse 在资源利用率和速度方面都明显优于 collector 的 processors。使用 SQL 执行所有事件处理的主要缺点是会将你的解决方案与 ClickHouse 紧密耦合。例如,用户可能希望从 OTel collector 将已处理日志发送到其他目的地,例如 S3。

物化列

物化列是从其他列中提取结构化信息的最简单方案。这类列的值始终在插入时计算,且不能在 INSERT 查询中显式指定。

开销

物化列会带来额外的存储开销,因为在插入时其值会被提取并写入磁盘上的新列。

物化列支持任意 ClickHouse 表达式,并且可以利用任意分析函数来处理字符串(包括正则和搜索)以及URL,执行类型转换从 JSON 中提取值数学运算

我们推荐使用物化列进行基础处理。它们在从 map 中提取值、将其提升为顶层列以及执行类型转换时尤其有用。在非常基础的模式中或与物化视图结合使用时,它们通常最有价值。请参考下面这个日志模式:其中 JSON 已由采集器提取到 LogAttributes 列中:

CREATE TABLE otel_logs
(
        `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
        `TraceId` String CODEC(ZSTD(1)),
        `SpanId` String CODEC(ZSTD(1)),
        `TraceFlags` UInt32 CODEC(ZSTD(1)),
        `SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
        `SeverityNumber` Int32 CODEC(ZSTD(1)),
        `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
        `Body` String CODEC(ZSTD(1)),
        `ResourceSchemaUrl` String CODEC(ZSTD(1)),
        `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `ScopeSchemaUrl` String CODEC(ZSTD(1)),
        `ScopeName` String CODEC(ZSTD(1)),
        `ScopeVersion` String CODEC(ZSTD(1)),
        `ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `RequestPage` String MATERIALIZED path(LogAttributes['request_path']),
        `RequestType` LowCardinality(String) MATERIALIZED LogAttributes['request_type'],
        `RefererDomain` String MATERIALIZED domain(LogAttributes['referer'])
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)

用于对 Body 字符串使用 JSON 函数进行抽取的等效 schema 可以在此处找到。

我们的三个物化列会抽取请求页面、请求类型以及 referrer 的域名。它们访问 Map 的键并对其对应的值应用函数。我们后续的查询速度会显著提升:

SELECT RequestPage AS path, count() AS c
FROM otel_logs
WHERE RequestType = 'POST'
GROUP BY path
ORDER BY c DESC
LIMIT 5

┌─path─────────────────────┬─────c─┐
│ /m/updateVariation       │ 12182 │
│ /site/productCard        │ 11080 │
│ /site/productPrice       │ 10876 │
│ /site/productAdditives   │ 10866 │
│ /site/productModelImages │ 10866 │
└──────────────────────────┴───────┘

5 rows in set. Elapsed: 0.173 sec. Processed 10.37 million rows, 418.03 MB (60.07 million rows/s., 2.42 GB/s.)
Peak memory usage: 3.16 MiB.
注意

默认情况下,物化列不会包含在 SELECT * 的返回结果中。这样可以保持这样一个不变量:SELECT * 的结果始终可以通过 INSERT 语句原样插回同一张表。可以通过将 asterisk_include_materialized_columns 设置为 1 来关闭此行为,也可以在 Grafana 中启用该设置(参见数据源配置中的 Additional Settings -> Custom Settings)。

物化视图

物化视图 提供了一种更强大的方式,用于对日志和追踪应用 SQL 过滤和转换。

物化视图允许用户将计算成本从查询时转移到插入时。ClickHouse 中的物化视图本质上是一个触发器,在数据块插入表时对其运行查询。该查询的结果会被插入到第二个 "target" 表中。

物化视图
实时更新

ClickHouse 中的物化视图会在数据流入其所依赖的表时实时更新,更像是持续更新的索引。相比之下,在其他数据库中,物化视图通常是某个查询的静态快照,必须刷新(类似于 ClickHouse 的 Refreshable Materialized Views)。

与物化视图关联的查询理论上可以是任意查询,包括聚合查询,尽管在 Join 方面存在一些限制。对于日志和追踪所需的转换和过滤工作负载,用户可以认为任意 SELECT 语句都是可行的。

用户应当记住,该查询只是一个触发器,对插入到表中的行(源表)执行,并将结果发送到一个新表(目标表)。

为了确保我们不会在源表和目标表中各持久化一份数据,我们可以将源表的表引擎修改为 Null table engine,同时保留原有的表结构(schema)。我们的 OTel collectors 将继续向该表发送数据。例如,对于日志,otel_logs 表会变为:

CREATE TABLE otel_logs
(
        `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
        `TraceId` String CODEC(ZSTD(1)),
        `SpanId` String CODEC(ZSTD(1)),
        `TraceFlags` UInt32 CODEC(ZSTD(1)),
        `SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
        `SeverityNumber` Int32 CODEC(ZSTD(1)),
        `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
        `Body` String CODEC(ZSTD(1)),
        `ResourceSchemaUrl` String CODEC(ZSTD(1)),
        `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `ScopeSchemaUrl` String CODEC(ZSTD(1)),
        `ScopeName` String CODEC(ZSTD(1)),
        `ScopeVersion` String CODEC(ZSTD(1)),
        `ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1))
) ENGINE = Null

Null 表引擎是一种强大的优化手段——可以把它类比为 /dev/null。这个表本身不会存储任何数据,但任何附加其上的物化视图仍然会在行被丢弃之前,基于插入的行继续执行。

来看下面的查询。它将我们的行转换为希望保留的格式,从 LogAttributes 中提取所有列(我们假设这是由采集器使用 json_parser 算子设置的),并设置 SeverityTextSeverityNumber(基于一些简单条件以及对这些列的定义)。在这个例子中,我们还仅选择那些我们确定会被填充的列——忽略诸如 TraceIdSpanIdTraceFlags 等列。

SELECT
        Body, 
        Timestamp::DateTime AS Timestamp,
        ServiceName,
        LogAttributes['status'] AS Status,
        LogAttributes['request_protocol'] AS RequestProtocol,
        LogAttributes['run_time'] AS RunTime,
        LogAttributes['size'] AS Size,
        LogAttributes['user_agent'] AS UserAgent,
        LogAttributes['referer'] AS Referer,
        LogAttributes['remote_user'] AS RemoteUser,
        LogAttributes['request_type'] AS RequestType,
        LogAttributes['request_path'] AS RequestPath,
        LogAttributes['remote_addr'] AS RemoteAddr,
        domain(LogAttributes['referer']) AS RefererDomain,
        path(LogAttributes['request_path']) AS RequestPage,
        multiIf(Status::UInt64 > 500, 'CRITICAL', Status::UInt64 > 400, 'ERROR', Status::UInt64 > 300, 'WARNING', 'INFO') AS SeverityText,
        multiIf(Status::UInt64 > 500, 20, Status::UInt64 > 400, 17, Status::UInt64 > 300, 13, 9) AS SeverityNumber
FROM otel_logs
LIMIT 1
FORMAT Vertical

Row 1:
──────
Body:           {"remote_addr":"54.36.149.41","remote_user":"-","run_time":"0","time_local":"2019-01-22 00:26:14.000","request_type":"GET","request_path":"\/filter\/27|13 ,27|  5 ,p53","request_protocol":"HTTP\/1.1","status":"200","size":"30577","referer":"-","user_agent":"Mozilla\/5.0 (compatible; AhrefsBot\/6.1; +http:\/\/ahrefs.com\/robot\/)"}
Timestamp:      2019-01-22 00:26:14
ServiceName:
Status:         200
RequestProtocol: HTTP/1.1
RunTime:        0
Size:           30577
UserAgent:      Mozilla/5.0 (compatible; AhrefsBot/6.1; +http://ahrefs.com/robot/)
Referer:        -
RemoteUser:     -
RequestType:    GET
RequestPath:    /filter/27|13 ,27|  5 ,p53
RemoteAddr:     54.36.149.41
RefererDomain:
RequestPage:    /filter/27|13 ,27|  5 ,p53
SeverityText:   INFO
SeverityNumber:  9

1 row in set. Elapsed: 0.027 sec.

我们还提取了上面的 Body 列——以防之后新增了一些当前 SQL 未提取的额外属性。该列在 ClickHouse 中压缩效果很好,而且很少被访问,因此不会影响查询性能。最后,我们通过一次类型转换将 Timestamp 转换为 DateTime(以节省空间——参见 "Optimizing Types")。

条件函数

请注意上面为提取 SeverityTextSeverityNumber 而使用的条件函数。这些函数在构造复杂条件以及检查 map 中的值是否已设置时非常有用——我们在此简单地假设 LogAttributes 中存在所有键。我们建议用户熟悉这些函数——在日志解析时,它们与处理空值的函数一起,是你得力的助手!

我们需要一个表来接收这些结果。下面的目标表与上面的查询相匹配:

CREATE TABLE otel_logs_v2
(
        `Body` String,
        `Timestamp` DateTime,
        `ServiceName` LowCardinality(String),
        `Status` UInt16,
        `RequestProtocol` LowCardinality(String),
        `RunTime` UInt32,
        `Size` UInt32,
        `UserAgent` String,
        `Referer` String,
        `RemoteUser` String,
        `RequestType` LowCardinality(String),
        `RequestPath` String,
        `RemoteAddress` IPv4,
        `RefererDomain` String,
        `RequestPage` String,
        `SeverityText` LowCardinality(String),
        `SeverityNumber` UInt8
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)

此处选择的类型基于在 "Optimizing types" 中讨论的优化。

注意

请注意,我们在这里对 schema(模式)进行了大幅调整。实际场景中,用户很可能还会有希望保留的 Trace 列,以及 ResourceAttributes 列(通常包含 Kubernetes 元数据)。Grafana 可以利用这些 Trace 列在日志与 Trace 之间提供跳转/关联功能——参见 "Using Grafana"

下面,我们创建一个物化视图 otel_logs_mv,它对 otel_logs 表执行上述 SELECT 查询,并将结果写入 otel_logs_v2

CREATE MATERIALIZED VIEW otel_logs_mv TO otel_logs_v2 AS
SELECT
        Body, 
        Timestamp::DateTime AS Timestamp,
        ServiceName,
        LogAttributes['status']::UInt16 AS Status,
        LogAttributes['request_protocol'] AS RequestProtocol,
        LogAttributes['run_time'] AS RunTime,
        LogAttributes['size'] AS Size,
        LogAttributes['user_agent'] AS UserAgent,
        LogAttributes['referer'] AS Referer,
        LogAttributes['remote_user'] AS RemoteUser,
        LogAttributes['request_type'] AS RequestType,
        LogAttributes['request_path'] AS RequestPath,
        LogAttributes['remote_addr'] AS RemoteAddress,
        domain(LogAttributes['referer']) AS RefererDomain,
        path(LogAttributes['request_path']) AS RequestPage,
        multiIf(Status::UInt64 > 500, 'CRITICAL', Status::UInt64 > 400, 'ERROR', Status::UInt64 > 300, 'WARNING', 'INFO') AS SeverityText,
        multiIf(Status::UInt64 > 500, 20, Status::UInt64 > 400, 17, Status::UInt64 > 300, 13, 9) AS SeverityNumber
FROM otel_logs

上面的内容在下图中可视化展示:

Otel MV

如果我们现在重启 "Exporting to ClickHouse" 中使用的 collector 配置,数据就会以我们期望的格式出现在 otel_logs_v2 中。请注意这里使用了类型化的 JSON 提取函数。

SELECT *
FROM otel_logs_v2
LIMIT 1
FORMAT Vertical

Row 1:
──────
Body:           {"remote_addr":"54.36.149.41","remote_user":"-","run_time":"0","time_local":"2019-01-22 00:26:14.000","request_type":"GET","request_path":"\/filter\/27|13 ,27|  5 ,p53","request_protocol":"HTTP\/1.1","status":"200","size":"30577","referer":"-","user_agent":"Mozilla\/5.0 (compatible; AhrefsBot\/6.1; +http:\/\/ahrefs.com\/robot\/)"}
Timestamp:      2019-01-22 00:26:14
ServiceName:
Status:         200
RequestProtocol: HTTP/1.1
RunTime:        0
Size:           30577
UserAgent:      Mozilla/5.0 (compatible; AhrefsBot/6.1; +http://ahrefs.com/robot/)
Referer:        -
RemoteUser:     -
RequestType:    GET
RequestPath:    /filter/27|13 ,27|  5 ,p53
RemoteAddress:  54.36.149.41
RefererDomain:
RequestPage:    /filter/27|13 ,27|  5 ,p53
SeverityText:   INFO
SeverityNumber:  9

返回 1 行。用时:0.010 秒。

下面展示了一个等效的物化视图,它通过使用 JSON 函数从 Body 列中解析并提取各个字段:

CREATE MATERIALIZED VIEW otel_logs_mv TO otel_logs_v2 AS
SELECT  Body, 
        Timestamp::DateTime AS Timestamp,
        ServiceName,
        JSONExtractUInt(Body, 'status') AS Status,
        JSONExtractString(Body, 'request_protocol') AS RequestProtocol,
        JSONExtractUInt(Body, 'run_time') AS RunTime,
        JSONExtractUInt(Body, 'size') AS Size,
        JSONExtractString(Body, 'user_agent') AS UserAgent,
        JSONExtractString(Body, 'referer') AS Referer,
        JSONExtractString(Body, 'remote_user') AS RemoteUser,
        JSONExtractString(Body, 'request_type') AS RequestType,
        JSONExtractString(Body, 'request_path') AS RequestPath,
        JSONExtractString(Body, 'remote_addr') AS remote_addr,
        domain(JSONExtractString(Body, 'referer')) AS RefererDomain,
        path(JSONExtractString(Body, 'request_path')) AS RequestPage,
        multiIf(Status::UInt64 > 500, 'CRITICAL', Status::UInt64 > 400, 'ERROR', Status::UInt64 > 300, 'WARNING', 'INFO') AS SeverityText,
        multiIf(Status::UInt64 > 500, 20, Status::UInt64 > 400, 17, Status::UInt64 > 300, 13, 9) AS SeverityNumber
FROM otel_logs

注意类型

上面的物化视图依赖于隐式类型转换——尤其是在使用 LogAttributes 映射时。ClickHouse 通常会透明地将提取的值转换为目标表的类型,从而简化语法。不过,我们建议用户始终通过将视图中的 SELECT 语句与一个使用相同 schema 的目标表上的 INSERT INTO 语句配合使用来测试视图。这样可以确认类型是否被正确处理。以下情况需要特别注意:

  • 如果某个键在映射中不存在,将返回空字符串。对于数值类型,用户需要将这些值映射为合适的数值。这可以通过条件函数实现,例如 if(LogAttributes['status'] = ", 200, LogAttributes['status']),或者在默认值可接受的情况下,通过类型转换函数实现,例如 toUInt8OrDefault(LogAttributes['status'] )
  • 某些类型不会始终被自动转换,例如数值的字符串表示不会被转换为枚举值。
  • JSON 提取函数在未找到值时,会按其类型返回默认值。请确保这些默认值在语义上是合理的!
Avoid Nullable

在 ClickHouse 中处理可观测性(Observability)数据时避免使用 Nullable。在日志和跟踪数据中,很少需要区分空字符串和 null。该特性会带来额外的存储开销,并对查询性能产生负面影响。更多细节请参见此处

选择主键(排序键)

在提取出所需列之后,就可以开始优化排序键 / 主键了。

可以遵循一些简单规则来帮助选择排序键。下面这些规则有时会互相冲突,因此请按顺序权衡考虑。通过这个过程,用户通常可以识别出若干个键,一般 4–5 个就足够:

  1. 选择与常见过滤条件和访问模式相匹配的列。如果用户在开始可观测性排查时,通常会先按某一特定列(例如 pod(容器组)名称)进行过滤,那么该列将频繁出现在 WHERE 子句中。优先将这些列包含在键中,而不是那些使用频率较低的列。
  2. 优先选择在过滤时能排除掉总行数中很大比例的列,从而减少需要读取的数据量。服务名和状态码通常是不错的候选——状态码只有在用户会按能排除大部分行的取值进行过滤时才适用,例如按 2xx 状态码过滤在大多数系统中会匹配大多数行,而 5xx 错误通常只对应一个很小的子集。
  3. 优先选择很可能与表中其他列高度关联的列。这有助于确保这些值也能被连续存储,从而提升压缩效果。
  4. 对排序键中的列执行 GROUP BYORDER BY 操作时,可以更高效地使用内存。

在确定排序键要使用的列子集之后,必须以特定顺序声明这些列。该顺序会显著影响查询中对排序键后续列进行过滤的效率,以及该表数据文件的压缩率。一般而言,最好按照基数从低到高的顺序来排列键。同时需要平衡的一点是:对在排序键中位置较后的列进行过滤,将不如对元组中靠前的列进行过滤高效。请在这些行为之间做好权衡,并结合你的访问模式加以考虑。最重要的是,要对不同方案进行测试。要进一步了解排序键以及如何优化它们,我们推荐阅读这篇文章

Structure first

我们建议在已经对日志完成结构化之后,再来决定排序键。不要在排序键中使用属性映射中的键或 JSON 提取表达式。请确保你的排序键在表中是作为根级列存在的。

使用 map

前面的示例展示了如何使用 map 语法 map['key'] 来访问 Map(String, String) 列中的值。除了使用 map 表示法来访问嵌套键之外,还可以使用 ClickHouse 提供的专用 map 函数 来过滤或选取这些列。

例如,下面的查询使用 mapKeys 函数,再配合 groupArrayDistinctArray 函数(一种组合器),来找出 LogAttributes 列中所有可用的唯一键。

SELECT groupArrayDistinctArray(mapKeys(LogAttributes))
FROM otel_logs
FORMAT Vertical

第 1 行:
──────
groupArrayDistinctArray(mapKeys(LogAttributes)): ['remote_user','run_time','request_type','log.file.name','referer','request_path','status','user_agent','remote_addr','time_local','size','request_protocol']

结果共 1 行。耗时 1.139 秒。已处理 563 万行,2.53 GB(494 万行/秒,2.22 GB/秒)。
峰值内存占用:71.90 MiB。
避免使用点号

我们不建议在 Map 列名中使用点号,将来可能会取消对此的支持。请改用 _

使用别名

查询 map 类型列比查询普通列要慢——参见 "加速查询"。此外,其语法更为复杂,用户书写起来也较为繁琐。为了解决这一问题,我们建议使用别名(ALIAS)列。

ALIAS 列在查询时计算且不会存储在表中。因此,无法向此类列执行 INSERT 写入值。通过使用别名,我们可以引用 map 的键并简化语法,将 map 中的条目透明地暴露为普通列。请看以下示例:

CREATE TABLE otel_logs
(
        `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
        `TraceId` String CODEC(ZSTD(1)),
        `SpanId` String CODEC(ZSTD(1)),
        `TraceFlags` UInt32 CODEC(ZSTD(1)),
        `SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
        `SeverityNumber` Int32 CODEC(ZSTD(1)),
        `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
        `Body` String CODEC(ZSTD(1)),
        `ResourceSchemaUrl` String CODEC(ZSTD(1)),
        `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `ScopeSchemaUrl` String CODEC(ZSTD(1)),
        `ScopeName` String CODEC(ZSTD(1)),
        `ScopeVersion` String CODEC(ZSTD(1)),
        `ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `RequestPath` String MATERIALIZED path(LogAttributes['request_path']),
        `RequestType` LowCardinality(String) MATERIALIZED LogAttributes['request_type'],
        `RefererDomain` String MATERIALIZED domain(LogAttributes['referer']),
        `RemoteAddr` IPv4 ALIAS LogAttributes['remote_addr']
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, Timestamp)

我们有几个物化列,以及一个 ALIASRemoteAddr,用于访问映射 LogAttributes。现在我们可以通过该列查询 LogAttributes['remote_addr'] 的值,从而简化查询,即:

SELECT RemoteAddr
FROM default.otel_logs
LIMIT 5

┌─RemoteAddr────┐
│ 54.36.149.41  │
│ 31.56.96.51   │
│ 31.56.96.51   │
│ 40.77.167.129 │
│ 91.99.72.15   │
└───────────────┘

返回了 5 行。耗时: 0.011 秒。

此外,通过 ALTER TABLE 命令添加 ALIAS 非常容易。这些列会立即生效,例如:

ALTER TABLE default.otel_logs
        (ADD COLUMN `Size` String ALIAS LogAttributes['size'])

SELECT Size
FROM default.otel_logs_v3
LIMIT 5

┌─Size──┐
│ 30577 │
│ 5667  │
│ 5379  │
│ 1696  │
│ 41483 │
└───────┘

返回 5 行。用时:0.014 秒。
默认排除 Alias

默认情况下,SELECT * 会排除 ALIAS 列。可以通过将 asterisk_include_alias_columns 设置为 1 来关闭此行为。

优化类型

关于类型优化的 ClickHouse 通用最佳实践 同样适用于本 ClickHouse 场景。

使用编解码器

除了类型优化之外,用户在尝试为 ClickHouse Observability 模式优化压缩时,还可以遵循编解码器的一般最佳实践

通常情况下,用户会发现 ZSTD 编解码器非常适用于日志和跟踪数据集。将压缩级别从默认值 1 提高,可能会提升压缩率。不过需要进行验证测试,因为更高的压缩级别会在插入时带来更高的 CPU 开销。通常,我们观察到将该值调高后收益有限。

另外,时间戳列虽然在压缩方面可以从增量编码中获益,但如果该列被用作主键或排序键,已被证明会导致查询性能下降。我们建议用户评估压缩效果与查询性能之间的权衡。

使用字典

Dictionaries 是 ClickHouse 的一项关键特性,用于以内存中的 key-value 形式表示来自各种内部和外部的数据,并针对超低延迟的查找查询进行了优化。

可观测性与字典

字典在多种场景下都非常实用,例如在不减慢摄取过程的情况下即时丰富已摄取的数据,以及总体上提升查询性能,尤其是对 JOIN 查询有明显收益。 虽然在可观测性场景中很少需要使用 JOIN,但字典在数据富化方面仍然很有用——无论是在写入时还是查询时。下面我们分别提供这两种情况的示例。

加速 JOIN

对使用字典加速 JOIN 感兴趣的用户可以在这里找到更多详细信息。

插入时 vs 查询时

字典可以用于在查询时或插入时对数据集进行富化。这两种方式各有优缺点。概括如下:

  • 插入时 - 如果富化值基本不变,并且存在于可用于填充字典的外部数据源中,那么通常适合在插入时进行富化。在这种情况下,在插入时对行进行富化可以避免在查询时对字典进行查找。代价是插入性能会受到影响,并且会有额外的存储开销,因为富化后的值将作为列进行存储。
  • 查询时 - 如果字典中的值经常变化,那么在查询时进行查找通常更合适。这样就避免了在映射值变化时需要更新列(并重写数据)。这种灵活性是以查询时增加查找开销为代价的。如果需要对大量行进行查找,例如在过滤子句中使用字典查找,那么这种查询时的开销通常会比较明显。对于结果富化,即在 SELECT 中使用时,这种开销通常不明显。

我们建议用户先熟悉字典的基础知识。字典提供一个内存查找表,可以通过专用函数检索值。

关于基础富化示例,请参阅字典相关指南此处。在下文中,我们将重点介绍常见的可观测性富化任务。

使用 IP 字典

利用 IP 地址为日志和追踪数据添加带有经纬度的地理信息,是常见的可观测性需求。我们可以使用 ip_trie 结构化字典来实现这一点。

我们使用公开的 DB-IP 城市级数据集,该数据集由 DB-IP.com 提供,并遵循 CC BY 4.0 许可证 的条款。

README 中可以看到,数据的结构如下所示:

| ip_range_start | ip_range_end | country_code | state1 | state2 | city | postcode | latitude | longitude | timezone |

在了解了这一结构之后,我们先通过 url() 表函数来查看一下数据:

SELECT *
FROM url('https://raw.githubusercontent.com/sapics/ip-location-db/master/dbip-city/dbip-city-ipv4.csv.gz', 'CSV', '\n           \tip_range_start IPv4, \n       \tip_range_end IPv4, \n         \tcountry_code Nullable(String), \n     \tstate1 Nullable(String), \n           \tstate2 Nullable(String), \n           \tcity Nullable(String), \n     \tpostcode Nullable(String), \n         \tlatitude Float64, \n          \tlongitude Float64, \n         \ttimezone Nullable(String)\n   \t')
LIMIT 1
FORMAT Vertical
Row 1:
──────
ip_range_start: 1.0.0.0
ip_range_end:   1.0.0.255
country_code:   AU
state1:         Queensland
state2:         ᴺᵁᴸᴸ
city:           South Brisbane
postcode:       ᴺᵁᴸᴸ
latitude:       -27.4767
longitude:      153.017
timezone:       ᴺᵁᴸᴸ

为了简化操作,我们使用 URL() 表引擎创建一个包含相同字段名的 ClickHouse 表对象,并确认总行数:

CREATE TABLE geoip_url(
        ip_range_start IPv4,
        ip_range_end IPv4,
        country_code Nullable(String),
        state1 Nullable(String),
        state2 Nullable(String),
        city Nullable(String),
        postcode Nullable(String),
        latitude Float64,
        longitude Float64,
        timezone Nullable(String)
) ENGINE=URL('https://raw.githubusercontent.com/sapics/ip-location-db/master/dbip-city/dbip-city-ipv4.csv.gz', 'CSV')

select count() from geoip_url;

┌─count()─┐
│ 3261621 │ -- 326 万
└─────────┘

由于我们的 ip_trie 字典要求将 IP 地址范围以 CIDR 表示法表示,我们需要对 ip_range_startip_range_end 进行转换。

可以使用以下查询简洁地计算出每个范围对应的 CIDR:

WITH
        bitXor(ip_range_start, ip_range_end) AS xor,
        if(xor != 0, ceil(log2(xor)), 0) AS unmatched,
        32 - unmatched AS cidr_suffix,
        toIPv4(bitAnd(bitNot(pow(2, unmatched) - 1), ip_range_start)::UInt64) AS cidr_address
SELECT
        ip_range_start,
        ip_range_end,
        concat(toString(cidr_address),'/',toString(cidr_suffix)) AS cidr    
FROM
        geoip_url
LIMIT 4;

┌─ip_range_start─┬─ip_range_end─┬─cidr───────┐
│ 1.0.0.0        │ 1.0.0.255    │ 1.0.0.0/24 │
│ 1.0.1.0        │ 1.0.3.255    │ 1.0.0.0/22 │
│ 1.0.4.0        │ 1.0.7.255    │ 1.0.4.0/22 │
│ 1.0.8.0        │ 1.0.15.255   │ 1.0.8.0/21 │
└────────────────┴──────────────┴────────────┘

4 行数据。耗时: 0.259 秒。
注意

上面的查询内容较多。感兴趣的读者可以查看这篇精彩的说明。否则,只需知道上述查询会为一个 IP 范围计算出 CIDR 即可。

在本例中,我们只需要 IP 范围、国家代码和坐标,因此让我们创建一个新表并插入 Geo IP 数据:

CREATE TABLE geoip
(
        `cidr` String,
        `latitude` Float64,
        `longitude` Float64,
        `country_code` String
)
ENGINE = MergeTree
ORDER BY cidr

INSERT INTO geoip
WITH
        bitXor(ip_range_start, ip_range_end) as xor,
        if(xor != 0, ceil(log2(xor)), 0) as unmatched,
        32 - unmatched as cidr_suffix,
        toIPv4(bitAnd(bitNot(pow(2, unmatched) - 1), ip_range_start)::UInt64) as cidr_address
SELECT
        concat(toString(cidr_address),'/',toString(cidr_suffix)) as cidr,
        latitude,
        longitude,
        country_code    
FROM geoip_url

为了在 ClickHouse 中进行低延迟的 IP 查询,我们将利用字典在内存中存储地理 IP(Geo IP)数据的键到属性的映射关系。ClickHouse 提供了一个 ip_trie 字典结构,用于将网络前缀(CIDR 块)映射到坐标和国家代码。下面的查询基于这种结构布局,并以上述表作为数据源来定义一个字典。

CREATE DICTIONARY ip_trie (
   cidr String,
   latitude Float64,
   longitude Float64,
   country_code String
)
primary key cidr
source(clickhouse(table 'geoip'))
layout(ip_trie)
lifetime(3600);

我们可以从该字典中选取一些行,以确认此数据集可用于查找:

SELECT * FROM ip_trie LIMIT 3

┌─cidr───────┬─latitude─┬─longitude─┬─country_code─┐
│ 1.0.0.0/22 │  26.0998 │   119.297 │ CN           │
│ 1.0.0.0/24 │ -27.4767 │   153.017 │ AU           │
│ 1.0.4.0/22 │ -38.0267 │   145.301 │ AU           │
└────────────┴──────────┴───────────┴──────────────┘

返回 3 行。用时:4.662 秒。
定期刷新

ClickHouse 中的字典会根据其底层表数据以及上文使用的 lifetime 子句定期刷新。要让我们的 Geo IP 字典反映 DB-IP 数据集中的最新变化,只需要将 geoip_url 远程表中的数据重新插入到应用了转换的 geoip 表中即可。

现在我们已经把 Geo IP 数据加载进了 ip_trie 字典(名称也恰好是 ip_trie),就可以用它来执行 IP 地理定位了。可以使用 dictGet() 函数 按如下方式实现:

SELECT dictGet('ip_trie', ('country_code', 'latitude', 'longitude'), CAST('85.242.48.167', 'IPv4')) AS ip_details

┌─ip_details──────────────┐
│ ('PT',38.7944,-9.34284) │
└─────────────────────────┘

返回 1 行。用时:0.003 秒。

注意这里的检索速度。这使我们可以对日志进行富化。在本例中,我们选择在 查询时进行富化(query time enrichment)

回到我们原始的日志数据集,我们可以利用上述结果按国家对日志进行聚合。下面的示例假设我们使用的是之前物化视图生成的 schema,其中包含提取出的 RemoteAddress 列。

SELECT dictGet('ip_trie', 'country_code', tuple(RemoteAddress)) AS country,
        formatReadableQuantity(count()) AS num_requests
FROM default.otel_logs_v2
WHERE country != ''
GROUP BY country
ORDER BY count() DESC
LIMIT 5

┌─country─┬─num_requests────┐
│ IR      │ 7.36 million    │
│ US      │ 1.67 million    │
│ AE      │ 526.74 thousand │
│ DE      │ 159.35 thousand │
│ FR      │ 109.82 thousand │
└─────────┴─────────────────┘

5 rows in set. Elapsed: 0.140 sec. Processed 20.73 million rows, 82.92 MB (147.79 million rows/s., 591.16 MB/s.)
Peak memory usage: 1.16 MiB.

由于 IP 与地理位置之间的映射可能会发生变化,用户通常希望知道请求在发出时是从哪里发起的,而不是这个地址此刻对应的地理位置。基于这一原因,这里更适合在索引时进行富化(index time enrichment)。这可以通过如下所示的物化列来实现,或者在物化视图的 SELECT 子句中实现:

CREATE TABLE otel_logs_v2
(
        `Body` String,
        `Timestamp` DateTime,
        `ServiceName` LowCardinality(String),
        `Status` UInt16,
        `RequestProtocol` LowCardinality(String),
        `RunTime` UInt32,
        `Size` UInt32,
        `UserAgent` String,
        `Referer` String,
        `RemoteUser` String,
        `RequestType` LowCardinality(String),
        `RequestPath` String,
        `RemoteAddress` IPv4,
        `RefererDomain` String,
        `RequestPage` String,
        `SeverityText` LowCardinality(String),
        `SeverityNumber` UInt8,
        `Country` String MATERIALIZED dictGet('ip_trie', 'country_code', tuple(RemoteAddress)),
        `Latitude` Float32 MATERIALIZED dictGet('ip_trie', 'latitude', tuple(RemoteAddress)),
        `Longitude` Float32 MATERIALIZED dictGet('ip_trie', 'longitude', tuple(RemoteAddress))
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)
定期更新

用户通常希望基于最新数据定期更新 IP 富集字典。可以使用字典的 LIFETIME 子句实现这一点,该子句会使字典定期从其底层表中重新加载。要更新底层表,请参阅 "可刷新物化视图"

上述国家和坐标信息不仅支持按国家进行分组和过滤,还提供了更丰富的可视化能力。可参考 "可视化地理数据" 获取灵感。

使用正则表达式字典(User-Agent 解析)

User agent 字符串 的解析是一个经典的正则表达式问题,也是基于日志和追踪数据集中的常见需求。ClickHouse 通过正则表达式树字典(Regular Expression Tree Dictionaries)高效解析 user agent。

在 ClickHouse 开源版中,正则表达式树字典通过 YAMLRegExpTree 字典源类型定义,该类型指定包含正则表达式树的 YAML 文件路径。如果你希望提供自己的正则表达式字典,可以在此处找到所需结构的详细说明。下面我们重点介绍如何使用 uap-core 进行 user-agent 解析,并加载适用于受支持 CSV 格式的字典。此方法兼容 OSS 和 ClickHouse Cloud。

注意

在下列示例中,我们使用了截至 2024 年 6 月的最新 uap-core user-agent 解析正则表达式快照。最新(不定期更新)的文件可以在这里找到。用户可以按照此处中的步骤,将其加载到下文使用的 CSV 文件中。

创建以下 Memory 表。这些表存储用于解析设备、浏览器和操作系统的正则表达式。

CREATE TABLE regexp_os
(
        id UInt64,
        parent_id UInt64,
        regexp String,
        keys   Array(String),
        values Array(String)
) ENGINE=Memory;

CREATE TABLE regexp_browser
(
        id UInt64,
        parent_id UInt64,
        regexp String,
        keys   Array(String),
        values Array(String)
) ENGINE=Memory;

CREATE TABLE regexp_device
(
        id UInt64,
        parent_id UInt64,
        regexp String,
        keys   Array(String),
        values Array(String)
) ENGINE=Memory;

可以使用 url 表函数,将以下公开托管的 CSV 文件中的数据导入这些表:

INSERT INTO regexp_os SELECT * FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/user_agent_regex/regexp_os.csv', 'CSV', 'id UInt64, parent_id UInt64, regexp String, keys Array(String), values Array(String)')

INSERT INTO regexp_device SELECT * FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/user_agent_regex/regexp_device.csv', 'CSV', 'id UInt64, parent_id UInt64, regexp String, keys Array(String), values Array(String)')

INSERT INTO regexp_browser SELECT * FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/user_agent_regex/regexp_browser.csv', 'CSV', 'id UInt64, parent_id UInt64, regexp String, keys Array(String), values Array(String)')

在内存表填充完成后,我们就可以加载正则表达式字典了。请注意,我们需要将键值指定为列——这些将是我们可以从 User-Agent 中提取的属性。

CREATE DICTIONARY regexp_os_dict
(
        regexp String,
        os_replacement String default 'Other',
        os_v1_replacement String default '0',
        os_v2_replacement String default '0',
        os_v3_replacement String default '0',
        os_v4_replacement String default '0'
)
PRIMARY KEY regexp
SOURCE(CLICKHOUSE(TABLE 'regexp_os'))
LIFETIME(MIN 0 MAX 0)
LAYOUT(REGEXP_TREE);

CREATE DICTIONARY regexp_device_dict
(
        regexp String,
        device_replacement String default 'Other',
        brand_replacement String,
        model_replacement String
)
PRIMARY KEY(regexp)
SOURCE(CLICKHOUSE(TABLE 'regexp_device'))
LIFETIME(0)
LAYOUT(regexp_tree);

CREATE DICTIONARY regexp_browser_dict
(
        regexp String,
        family_replacement String default 'Other',
        v1_replacement String default '0',
        v2_replacement String default '0'
)
PRIMARY KEY(regexp)
SOURCE(CLICKHOUSE(TABLE 'regexp_browser'))
LIFETIME(0)
LAYOUT(regexp_tree);

在加载了这些字典之后,我们可以提供一个示例 user-agent 字符串,并测试我们新的字典提取能力:

WITH 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0' AS user_agent
SELECT
        dictGet('regexp_device_dict', ('device_replacement', 'brand_replacement', 'model_replacement'), user_agent) AS device,
        dictGet('regexp_browser_dict', ('family_replacement', 'v1_replacement', 'v2_replacement'), user_agent) AS browser,
        dictGet('regexp_os_dict', ('os_replacement', 'os_v1_replacement', 'os_v2_replacement', 'os_v3_replacement'), user_agent) AS os

┌─device────────────────┬─browser───────────────┬─os─────────────────────────┐
│ ('Mac','Apple','Mac') │ ('Firefox','127','0') │ ('Mac OS X','10','15','0') │
└───────────────────────┴───────────────────────┴────────────────────────────┘

1 row in set. Elapsed: 0.003 sec.

鉴于与 user agent 相关的规则很少发生变化,且该字典只需在出现新的浏览器、操作系统和设备时才进行更新,因此在插入数据时就完成这一步解析是合理的。

我们可以选择使用物化列或物化视图来完成这项工作。下面我们将修改前面使用过的物化视图:

CREATE MATERIALIZED VIEW otel_logs_mv TO otel_logs_v2
AS SELECT
        Body,
        CAST(Timestamp, 'DateTime') AS Timestamp,
        ServiceName,
        LogAttributes['status'] AS Status,
        LogAttributes['request_protocol'] AS RequestProtocol,
        LogAttributes['run_time'] AS RunTime,
        LogAttributes['size'] AS Size,
        LogAttributes['user_agent'] AS UserAgent,
        LogAttributes['referer'] AS Referer,
        LogAttributes['remote_user'] AS RemoteUser,
        LogAttributes['request_type'] AS RequestType,
        LogAttributes['request_path'] AS RequestPath,
        LogAttributes['remote_addr'] AS RemoteAddress,
        domain(LogAttributes['referer']) AS RefererDomain,
        path(LogAttributes['request_path']) AS RequestPage,
        multiIf(CAST(Status, 'UInt64') > 500, 'CRITICAL', CAST(Status, 'UInt64') > 400, 'ERROR', CAST(Status, 'UInt64') > 300, 'WARNING', 'INFO') AS SeverityText,
        multiIf(CAST(Status, 'UInt64') > 500, 20, CAST(Status, 'UInt64') > 400, 17, CAST(Status, 'UInt64') > 300, 13, 9) AS SeverityNumber,
        dictGet('regexp_device_dict', ('device_replacement', 'brand_replacement', 'model_replacement'), UserAgent) AS Device,
        dictGet('regexp_browser_dict', ('family_replacement', 'v1_replacement', 'v2_replacement'), UserAgent) AS Browser,
        dictGet('regexp_os_dict', ('os_replacement', 'os_v1_replacement', 'os_v2_replacement', 'os_v3_replacement'), UserAgent) AS Os
FROM otel_logs

这需要我们修改目标表 otel_logs_v2 的表结构:

CREATE TABLE default.otel_logs_v2
(
 `Body` String,
 `Timestamp` DateTime,
 `ServiceName` LowCardinality(String),
 `Status` UInt8,
 `RequestProtocol` LowCardinality(String),
 `RunTime` UInt32,
 `Size` UInt32,
 `UserAgent` String,
 `Referer` String,
 `RemoteUser` String,
 `RequestType` LowCardinality(String),
 `RequestPath` String,
 `remote_addr` IPv4,
 `RefererDomain` String,
 `RequestPage` String,
 `SeverityText` LowCardinality(String),
 `SeverityNumber` UInt8,
 `Device` Tuple(device_replacement LowCardinality(String), brand_replacement LowCardinality(String), model_replacement LowCardinality(String)),
 `Browser` Tuple(family_replacement LowCardinality(String), v1_replacement LowCardinality(String), v2_replacement LowCardinality(String)),
 `Os` Tuple(os_replacement LowCardinality(String), os_v1_replacement LowCardinality(String), os_v2_replacement LowCardinality(String), os_v3_replacement LowCardinality(String))
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp, Status)

在重启 collector 并按照前文所述步骤摄取结构化日志之后,我们就可以对新提取出的 Device、Browser 和 Os 列进行查询了。

SELECT Device, Browser, Os
FROM otel_logs_v2
LIMIT 1
FORMAT Vertical

行 1:
──────
Device:  ('Spider','Spider','Desktop')
Browser: ('AhrefsBot','6','1')
Os:     ('Other','0','0','0')
用于复杂结构的 Tuple

请注意这些 user agent 列中对 Tuple 的使用。对于层级结构在预先已知的复杂结构,推荐使用 Tuple。子列在提供对异构类型支持的同时,能够保持与常规列相同的性能(不同于 Map 的键)。

延伸阅读

如需查看更多字典示例和详细信息,我们推荐阅读以下文章:

加速查询

ClickHouse 支持多种用于提升查询性能的技术。只有在为最常见的访问模式选择了合适的主键/排序键并最大化压缩之后,才应考虑以下技术。通常,这一选择会以最小的代价带来最大的性能提升。

使用物化视图(增量)进行聚合

在前面的章节中,我们已经探讨了使用物化视图进行数据转换和过滤。物化视图同样可以在插入数据时预先计算聚合结果并将其存储下来。随后在有新数据插入时,再将新的聚合结果合并更新,从而实现在插入阶段就完成聚合计算。

其核心思想是,这些结果通常是原始数据的压缩表示(在聚合场景下可以视作一种部分概要)。当通过一个更简单的查询从目标表中读取这些结果时,查询时间将比在原始数据上执行相同计算更短。

考虑下面的查询示例,我们使用结构化日志计算每小时的总流量:

SELECT toStartOfHour(Timestamp) AS Hour,
        sum(toUInt64OrDefault(LogAttributes['size'])) AS TotalBytes
FROM otel_logs
GROUP BY Hour
ORDER BY Hour DESC
LIMIT 5

┌────────────────Hour─┬─TotalBytes─┐
│ 2019-01-26 16:00:00 │ 1661716343 │
│ 2019-01-26 15:00:00 │ 1824015281 │
│ 2019-01-26 14:00:00 │ 1506284139 │
│ 2019-01-26 13:00:00 │ 1580955392 │
│ 2019-01-26 12:00:00 │ 1736840933 │
└─────────────────────┴────────────┘

5 rows in set. Elapsed: 0.666 sec. Processed 10.37 million rows, 4.73 GB (15.56 million rows/s., 7.10 GB/s.)
Peak memory usage: 1.40 MiB.

我们可以想象,这可能是用户在 Grafana 中绘制的一种常见折线图。这个查询确实非常快——数据集只有 1000 万行,而且 ClickHouse 本身就很快!然而,如果我们将规模扩展到数十亿甚至数万亿行,我们理想情况下希望仍然能够维持这样的查询性能。

注意

如果我们使用 otel_logs_v2 表,这个查询会快 10 倍。该表来自我们之前的物化视图,用于从 LogAttributes map 中抽取 size 键。这里我们仅为演示说明而使用原始数据,如果这是一个常见查询,我们推荐使用前面定义的视图。

如果我们希望在写入时使用物化视图来完成这个计算,就需要一个表来接收结果。这个表每个小时只应保留 1 行。如果针对某个已存在的小时收到一条更新,其它列应合并到该小时已存在的那一行中。要实现这种增量状态的合并,必须在其它列中存储部分聚合状态。

这在 ClickHouse 中需要一种特殊的引擎类型:SummingMergeTree。它会将所有具有相同排序键的行替换为一行,其中数值列的值是求和后的结果。下面的这个表会合并任何具有相同日期的行,对所有数值列进行求和。

CREATE TABLE bytes_per_hour
(
  `Hour` DateTime,
  `TotalBytes` UInt64
)
ENGINE = SummingMergeTree
ORDER BY Hour

为了演示我们的物化视图,假设 bytes_per_hour 表当前为空,尚未接收任何数据。我们的物化视图会对插入到 otel_logs 的数据执行上述 SELECT 查询(将按配置的块大小执行),并将结果写入 bytes_per_hour。语法如下所示:

CREATE MATERIALIZED VIEW bytes_per_hour_mv TO bytes_per_hour AS
SELECT toStartOfHour(Timestamp) AS Hour,
       sum(toUInt64OrDefault(LogAttributes['size'])) AS TotalBytes
FROM otel_logs
GROUP BY Hour

这里的 TO 子句是关键,它表示结果将被写入到哪里,即 bytes_per_hour

如果我们重启 OTel collector 并重新发送日志,bytes_per_hour 表将会根据上述查询结果被增量填充。完成后,我们可以检查 bytes_per_hour 的大小——应该是每小时一行数据:

SELECT count()
FROM bytes_per_hour
FINAL

┌─count()─┐
│     113 │
└─────────┘

1 row in set. Elapsed: 0.039 sec.

通过存储查询结果,我们实际上将这里的行数从 1000 万(otel_logs 中)减少到了 113。关键在于,当有新的日志插入到 otel_logs 表时,其所属小时的最新数值会写入 bytes_per_hour,并在后台自动异步合并——由于我们每小时只保留一行,因此 bytes_per_hour 始终既数据量小又保持最新。

由于行合并是异步进行的,当用户查询时,每小时可能会存在多行。为了确保在查询时将所有尚未合并的行合并,我们有两个选项:

  • 在表名上使用 FINAL 修饰符(我们在上面的计数查询中就是这样做的)。
  • 按最终表中使用的排序键进行聚合,即按 Timestamp 分组并对指标求和。

通常,第二个选项在效率和灵活性方面更好(该表可以用于其他用途),但第一个选项对于某些查询可能更简单。下面我们展示这两种方式:

SELECT
        Hour,
        sum(TotalBytes) AS TotalBytes
FROM bytes_per_hour
GROUP BY Hour
ORDER BY Hour DESC
LIMIT 5

┌────────────────Hour─┬─TotalBytes─┐
│ 2019-01-26 16:00:00 │ 1661716343 │
│ 2019-01-26 15:00:00 │ 1824015281 │
│ 2019-01-26 14:00:00 │ 1506284139 │
│ 2019-01-26 13:00:00 │ 1580955392 │
│ 2019-01-26 12:00:00 │ 1736840933 │
└─────────────────────┴────────────┘

返回 5 行数据。用时:0.008 秒。

SELECT
        Hour,
        TotalBytes
FROM bytes_per_hour
FINAL
ORDER BY Hour DESC
LIMIT 5

┌────────────────Hour─┬─TotalBytes─┐
│ 2019-01-26 16:00:00 │ 1661716343 │
│ 2019-01-26 15:00:00 │ 1824015281 │
│ 2019-01-26 14:00:00 │ 1506284139 │
│ 2019-01-26 13:00:00 │ 1580955392 │
│ 2019-01-26 12:00:00 │ 1736840933 │
└─────────────────────┴────────────┘

返回 5 行数据。用时:0.005 秒。

这将我们的查询耗时从 0.6s 缩短到了 0.008s —— 提速超过 75 倍!

注意

在更大的数据集上执行更复杂的查询时,这种收益会更加显著。示例请参见这里

更复杂的示例

上面的示例使用 SummingMergeTree 对每小时的简单计数进行聚合。若要获取超出简单求和的统计信息,则需要使用不同的目标表引擎:AggregatingMergeTree

假设我们希望计算每天唯一 IP 地址数(或唯一用户数)。对应的查询如下:

SELECT toStartOfHour(Timestamp) AS Hour, uniq(LogAttributes['remote_addr']) AS UniqueUsers
FROM otel_logs
GROUP BY Hour
ORDER BY Hour DESC

┌────────────────Hour─┬─UniqueUsers─┐
│ 2019-01-26 16:00:00 │     4763    │
│ 2019-01-22 00:00:00 │     536     │
└─────────────────────┴─────────────┘

结果集包含 113 行。耗时:0.667 秒。已处理 1037 万行,4.73 GB(每秒 1553 万行,7.09 GB/秒)

为了在增量更新时持久化基数统计,需要使用 AggregatingMergeTree。

CREATE TABLE unique_visitors_per_hour
(
  `Hour` DateTime,
  `UniqueUsers` AggregateFunction(uniq, IPv4)
)
ENGINE = AggregatingMergeTree
ORDER BY Hour

为确保 ClickHouse 明确知道将存储聚合状态,我们将 UniqueUsers 列定义为 AggregateFunction 类型,并指定部分聚合状态所使用的函数(uniq)以及源列的类型(IPv4)。与 SummingMergeTree 类似,具有相同 ORDER BY 键值的行会被合并(如上例中的 Hour)。

对应的物化视图则使用前面的查询:

CREATE MATERIALIZED VIEW unique_visitors_per_hour_mv TO unique_visitors_per_hour AS
SELECT toStartOfHour(Timestamp) AS Hour,
        uniqState(LogAttributes['remote_addr']::IPv4) AS UniqueUsers
FROM otel_logs
GROUP BY Hour
ORDER BY Hour DESC

注意我们在聚合函数末尾追加了后缀 State。这可以确保返回的是函数的聚合状态而不是最终结果。该状态将包含额外信息,从而允许该部分状态与其他状态合并。

在通过重启 Collector 重新加载数据后,我们可以确认在 unique_visitors_per_hour 表中共有 113 行可用。

SELECT count()
FROM unique_visitors_per_hour
FINAL
┌─count()─┐
│   113   │
└─────────┘

1 row in set. Elapsed: 0.009 sec.

我们的最终查询在调用这些函数时需要使用 Merge 后缀(因为这些列存储的是部分聚合状态):

SELECT Hour, uniqMerge(UniqueUsers) AS UniqueUsers
FROM unique_visitors_per_hour
GROUP BY Hour
ORDER BY Hour DESC

┌────────────────Hour─┬─UniqueUsers─┐
│ 2019-01-26 16:00:00 │      4763   │
│ 2019-01-22 00:00:00 │      536    │
└─────────────────────┴─────────────┘

113 rows in set. Elapsed: 0.027 sec.

请注意,这里我们使用 GROUP BY 而不是 FINAL

使用物化视图(增量)实现快速查询

在选择 ClickHouse 排序键时,用户应结合自身的访问模式,将在过滤和聚合子句中经常使用的列包含在排序键中。但在可观测性场景下,这种方式可能会受到限制,因为用户的访问模式更加多样,无法用单一的一组列来概括。默认 OTel schema 中内置的一个示例最能说明这一点。以 traces 的默认 schema 为例:

CREATE TABLE otel_traces
(
        `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
        `TraceId` String CODEC(ZSTD(1)),
        `SpanId` String CODEC(ZSTD(1)),
        `ParentSpanId` String CODEC(ZSTD(1)),
        `TraceState` String CODEC(ZSTD(1)),
        `SpanName` LowCardinality(String) CODEC(ZSTD(1)),
        `SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
        `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
        `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `ScopeName` String CODEC(ZSTD(1)),
        `ScopeVersion` String CODEC(ZSTD(1)),
        `SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
        `Duration` Int64 CODEC(ZSTD(1)),
        `StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
        `StatusMessage` String CODEC(ZSTD(1)),
        `Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
        `Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
        `Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
        `Links.TraceId` Array(String) CODEC(ZSTD(1)),
        `Links.SpanId` Array(String) CODEC(ZSTD(1)),
        `Links.TraceState` Array(String) CODEC(ZSTD(1)),
        `Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
        INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
        INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
        INDEX idx_duration Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)

此 schema 针对按 ServiceNameSpanNameTimestamp 进行过滤进行了优化。在链路追踪中,用户还需要能够根据特定的 TraceId 进行查找,并检索属于该 trace 的 spans。虽然 TraceId 也包含在排序键中,但由于它位于末尾,过滤效率不会那么高,在检索单个 trace 时很可能需要扫描大量数据。

OTel collector 还会创建一个物化视图及其关联表来解决这一问题。该表和视图如下所示:

CREATE TABLE otel_traces_trace_id_ts
(
        `TraceId` String CODEC(ZSTD(1)),
        `Start` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
        `End` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
        INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY (TraceId, toUnixTimestamp(Start))

CREATE MATERIALIZED VIEW otel_traces_trace_id_ts_mv TO otel_traces_trace_id_ts
(
        `TraceId` String,
        `Start` DateTime64(9),
        `End` DateTime64(9)
)
AS SELECT
        TraceId,
        min(Timestamp) AS Start,
        max(Timestamp) AS End
FROM otel_traces
WHERE TraceId != ''
GROUP BY TraceId

该视图可以有效确保表 otel_traces_trace_id_ts 记录了每个 trace 的最小和最大时间戳。该表按 TraceId 排序,使得这些时间戳可以被高效检索。随后,在查询主表 otel_traces 时可以利用这些时间戳范围。更具体地说,当通过 id 检索某个 trace 时,Grafana 会使用以下查询:

WITH 'ae9226c78d1d360601e6383928e4d22d' AS trace_id,
        (
        SELECT min(Start)
          FROM default.otel_traces_trace_id_ts
          WHERE TraceId = trace_id
        ) AS trace_start,
        (
        SELECT max(End) + 1
          FROM default.otel_traces_trace_id_ts
          WHERE TraceId = trace_id
        ) AS trace_end
SELECT
        TraceId AS traceID,
        SpanId AS spanID,
        ParentSpanId AS parentSpanID,
        ServiceName AS serviceName,
        SpanName AS operationName,
        Timestamp AS startTime,
        Duration * 0.000001 AS duration,
        arrayMap(key -> map('key', key, 'value', SpanAttributes[key]), mapKeys(SpanAttributes)) AS tags,
        arrayMap(key -> map('key', key, 'value', ResourceAttributes[key]), mapKeys(ResourceAttributes)) AS serviceTags
FROM otel_traces
WHERE (traceID = trace_id) AND (startTime >= trace_start) AND (startTime <= trace_end)
LIMIT 1000

这里的 CTE 会先确定 trace id ae9226c78d1d360601e6383928e4d22d 对应的最小和最大时间戳,然后再使用这些时间戳去过滤主表 otel_traces,以获取与其关联的 spans。

同样的方法也可以应用于类似的访问模式。我们在数据建模章节中这里探讨了一个类似的示例。

使用投影

ClickHouse 投影允许用户为表指定多个 ORDER BY 子句。

在前面的章节中,我们探讨了如何在 ClickHouse 中使用物化视图来预先计算聚合结果、转换数据行,并针对不同的访问模式优化可观测性查询。

我们提供了一个示例,其中物化视图将行发送到目标表,该目标表使用与接收插入数据的原始表不同的排序键,以优化按 trace id 查找的性能。

投影可用于解决同样的问题,允许用户对非主键列的查询进行优化。

理论上,此功能可用于为表提供多个排序键,但存在一个明显的缺点:数据冗余。具体而言,除了按主键顺序写入数据外,还需要按每个投影指定的顺序写入数据。这会降低插入性能并占用更多磁盘空间。

投影与物化视图

投影提供了许多与物化视图相同的功能,但应谨慎使用,后者通常是更优选择。用户应了解投影的局限性以及适用场景。例如,虽然投影可用于预计算聚合,但我们建议用户使用物化视图来实现此目的。

可观测性与投影

请参考以下查询,该查询根据 500 错误代码筛选 otel_logs_v2 表。这是日志查询中的常见访问模式,用户通常需要按错误代码进行筛选:

SELECT Timestamp, RequestPath, Status, RemoteAddress, UserAgent
FROM otel_logs_v2
WHERE Status = 500
FORMAT `Null`

Ok.

0 rows in set. Elapsed: 0.177 sec. Processed 10.37 million rows, 685.32 MB (58.66 million rows/s., 3.88 GB/s.)
Peak memory usage: 56.54 MiB.
使用 Null 测量性能

此处使用 FORMAT Null 不打印结果。这会强制读取所有结果但不返回,从而防止查询因 LIMIT 子句而提前终止。这样做是为了展示扫描全部 1000 万行所需的时间。

上述查询需要对我们选择的排序键 (ServiceName, Timestamp) 进行线性扫描。虽然可以将 Status 添加到排序键末尾来提升该查询的性能,但我们也可以通过添加投影来实现。

ALTER TABLE otel_logs_v2 (
  ADD PROJECTION status
  (
     SELECT Timestamp, RequestPath, Status, RemoteAddress, UserAgent ORDER BY Status
  )
)

ALTER TABLE otel_logs_v2 MATERIALIZE PROJECTION status

注意,必须先创建投影,然后再将其物化。后一条命令会使数据以两种不同的顺序在磁盘上存储两次。投影也可以在创建表时定义(如下所示),并在插入数据时自动维护。

CREATE TABLE otel_logs_v2
(
        `Body` String,
        `Timestamp` DateTime,
        `ServiceName` LowCardinality(String),
        `Status` UInt16,
        `RequestProtocol` LowCardinality(String),
        `RunTime` UInt32,
        `Size` UInt32,
        `UserAgent` String,
        `Referer` String,
        `RemoteUser` String,
        `RequestType` LowCardinality(String),
        `RequestPath` String,
        `RemoteAddress` IPv4,
        `RefererDomain` String,
        `RequestPage` String,
        `SeverityText` LowCardinality(String),
        `SeverityNumber` UInt8,
        PROJECTION status
        (
           SELECT Timestamp, RequestPath, Status, RemoteAddress, UserAgent
           ORDER BY Status
        )
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)

需要注意的是,如果投影是通过 ALTER 命令创建的,那么在执行 MATERIALIZE PROJECTION 命令时,其创建过程是异步进行的。用户可以通过以下查询来确认该操作的进度,直到 is_done=1 为止。

SELECT parts_to_do, is_done, latest_fail_reason
FROM system.mutations
WHERE (`table` = 'otel_logs_v2') AND (command LIKE '%MATERIALIZE%')

┌─parts_to_do─┬─is_done─┬─latest_fail_reason─┐
│           0 │     1   │                    │
└─────────────┴─────────┴────────────────────┘

1 row in set. Elapsed: 0.008 sec.

如果我们重复执行上述查询,可以看到性能显著提升,代价是需要额外的存储空间(有关如何测量的方法,请参见"测量表大小和压缩率")。

SELECT Timestamp, RequestPath, Status, RemoteAddress, UserAgent
FROM otel_logs_v2
WHERE Status = 500
FORMAT `Null`

返回 0 行。耗时:0.031 秒。处理了 51.42 千行,22.85 MB(1.65 百万行/秒,734.63 MB/秒)。
峰值内存使用:27.85 MiB。

在上面的示例中,我们在投影中指定了先前查询中使用的列。这意味着只有这些指定的列会作为投影的一部分存储在磁盘上,并按照 Status 排序。或者,如果我们在这里使用 SELECT *,则所有列都会被包含在投影中并存储。这样虽然可以让更多查询(使用任意列子集)从该投影中获益,但会带来额外的存储开销。有关磁盘空间占用和压缩情况的衡量,请参见 "Measuring table size & compression"

Secondary/data skipping indices

无论在 ClickHouse 中多么精细地调优主键,某些查询最终仍然仍需要对整张表进行全表扫描。虽然可以通过使用物化视图(以及对某些查询使用 projections)来缓解这一问题,但这需要额外的维护工作,并且要求用户了解这些对象的存在,才能在查询中真正加以利用。传统关系型数据库通常通过二级索引来解决这一问题,但在像 ClickHouse 这样的列式数据库中,这类索引并不高效。取而代之的是,ClickHouse 使用“跳过(Skip)索引”,通过允许数据库跳过包含无匹配值的大块数据,从而显著提升查询性能。

默认的 OTel schema 会尝试使用二级索引来加速对 map 字段的访问。我们发现这些索引整体上效果并不理想,因此不建议在自定义 schema 中照搬;不过,跳过索引在某些场景下依然是有用的。

在尝试应用这些索引之前,用户应先阅读并理解二级索引指南

一般而言,当主键与目标的非主键列/表达式之间存在较强相关性,并且用户要查找的是稀有值(即在许多 granule 中并不常见的值)时,这些索引才会较为有效。

对于可观测性查询,当用户需要执行文本搜索时,辅助索引会非常有用。具体而言,基于 ngram 和 token 的布隆过滤器索引 ngrambf_v1tokenbf_v1 可用于加速在 String 列上使用 LIKEIN 和 hasToken 操作符的搜索。需要注意的是,基于 token 的索引使用非字母数字字符作为分隔符来生成 token。这意味着在查询时只能匹配 token(即完整单词)。如需更细粒度的匹配,可以使用 N-gram 布隆过滤器。该索引将字符串拆分为指定大小的 ngram,从而支持子词匹配。

要评估将生成并进行匹配的 token,可以使用 tokens 函数:

SELECT tokens('https://www.zanbil.ir/m/filter/b113')

┌─tokens────────────────────────────────────────────┐
│ ['https','www','zanbil','ir','m','filter','b113'] │
└───────────────────────────────────────────────────┘

1 row in set. Elapsed: 0.008 sec.

ngram 函数提供了类似的功能,可通过第二个参数指定 ngram 的大小:

SELECT ngrams('https://www.zanbil.ir/m/filter/b113', 3)

┌─ngrams('https://www.zanbil.ir/m/filter/b113', 3)────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ['htt','ttp','tps','ps:','s:/','://','//w','/ww','www','ww.','w.z','.za','zan','anb','nbi','bil','il.','l.i','.ir','ir/','r/m','/m/','m/f','/fi','fil','ilt','lte','ter','er/','r/b','/b1','b11','113'] │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

1 row in set. Elapsed: 0.008 sec.
倒排索引

ClickHouse 还提供了倒排索引作为辅助索引的实验性支持。我们目前不建议将其用于日志数据集,但预计在其达到生产就绪状态后,将取代基于 token 的布隆过滤器。

本示例使用结构化日志数据集。假设需要统计 Referer 列中包含 ultra 的日志条数。

SELECT count()
FROM otel_logs_v2
WHERE Referer LIKE '%ultra%'

┌─count()─┐
│  114514 │
└─────────┘

1 row in set. Elapsed: 0.177 sec. Processed 10.37 million rows, 908.49 MB (58.57 million rows/s., 5.13 GB/s.)

此处需要匹配 ngram 大小为 3,因此创建一个 ngrambf_v1 索引。

CREATE TABLE otel_logs_bloom
(
        `Body` String,
        `Timestamp` DateTime,
        `ServiceName` LowCardinality(String),
        `Status` UInt16,
        `RequestProtocol` LowCardinality(String),
        `RunTime` UInt32,
        `Size` UInt32,
        `UserAgent` String,
        `Referer` String,
        `RemoteUser` String,
        `RequestType` LowCardinality(String),
        `RequestPath` String,
        `RemoteAddress` IPv4,
        `RefererDomain` String,
        `RequestPage` String,
        `SeverityText` LowCardinality(String),
        `SeverityNumber` UInt8,
        INDEX idx_span_attr_value Referer TYPE ngrambf_v1(3, 10000, 3, 7) GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY (Timestamp)

此处的索引 ngrambf_v1(3, 10000, 3, 7) 接受四个参数。最后一个参数(值为 7)表示种子值。其他参数分别表示 ngram 大小(3)、值 m(过滤器大小)以及哈希函数数量 k(7)。km 需要调优,其取值基于唯一 ngram/token 的数量以及过滤器产生真阴性结果的概率——从而确认某个值不存在于 granule 中。我们建议使用这些函数来帮助确定这些值。

如果调优得当,这里的加速效果可能会非常可观:

SELECT count()
FROM otel_logs_bloom
WHERE Referer LIKE '%ultra%'
┌─count()─┐
│   182   │
└─────────┘

1 row in set. Elapsed: 0.077 sec. Processed 4.22 million rows, 375.29 MB (54.81 million rows/s., 4.87 GB/s.)
Peak memory usage: 129.60 KiB.
示例仅供参考

以上内容仅作示例说明。我们建议用户在插入日志时就提取其结构,而不是尝试通过基于 token 的 Bloom 过滤器来优化文本搜索。不过,在某些情况下,用户会有堆栈跟踪或其他体积较大的字符串,这类数据由于结构不够固定,使用文本搜索仍然可能是有用的。

关于使用 Bloom 过滤器的一些通用指南:

Bloom 过滤器的目标是过滤粒度,从而避免为某一列加载全部值并执行线性扫描。EXPLAIN 子句配合参数 indexes=1 可用于确认被跳过的粒度数量。请参考下方针对原始表 otel_logs_v2 和带有 ngram Bloom 过滤器的表 otel_logs_bloom 的返回结果。

EXPLAIN indexes = 1
SELECT count()
FROM otel_logs_v2
WHERE Referer LIKE '%ultra%'

┌─explain────────────────────────────────────────────────────────────┐
│ Expression ((Project names + Projection))                          │
│   Aggregating                                                      │
│       Expression (Before GROUP BY)                                 │
│       Filter ((WHERE + Change column names to column identifiers)) │
│       ReadFromMergeTree (default.otel_logs_v2)                     │
│       Indexes:                                                     │
│               PrimaryKey                                           │
│               Condition: true                                      │
│               Parts: 9/9                                           │
│               Granules: 1278/1278                                  │
└────────────────────────────────────────────────────────────────────┘

返回 10 行。耗时:0.016 秒。

EXPLAIN indexes = 1
SELECT count()
FROM otel_logs_bloom
WHERE Referer LIKE '%ultra%'

┌─explain────────────────────────────────────────────────────────────┐
│ Expression ((Project names + Projection))                          │
│   Aggregating                                                      │
│       Expression (Before GROUP BY)                                 │
│       Filter ((WHERE + Change column names to column identifiers)) │
│       ReadFromMergeTree (default.otel_logs_bloom)                  │
│       Indexes:                                                     │
│               PrimaryKey                                           │ 
│               Condition: true                                      │
│               Parts: 8/8                                           │
│               Granules: 1276/1276                                  │
│               Skip                                                 │
│               Name: idx_span_attr_value                            │
│               Description: ngrambf_v1 GRANULARITY 1                │
│               Parts: 8/8                                           │
│               Granules: 517/1276                                   │
└────────────────────────────────────────────────────────────────────┘

通常只有当布隆过滤器本身比该列更小时,它才会更快。如果它更大,那么性能收益很可能可以忽略不计。使用以下查询将过滤器的大小与该列进行比较:

SELECT
        name,
        formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
        formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
        round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (`table` = 'otel_logs_bloom') AND (name = 'Referer')
GROUP BY name
ORDER BY sum(data_compressed_bytes) DESC

┌─name────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ Referer │ 56.16 MiB       │ 789.21 MiB        │ 14.05 │
└─────────┴─────────────────┴───────────────────┴───────┘

1 row in set. Elapsed: 0.018 sec.

SELECT
        `table`,
        formatReadableSize(data_compressed_bytes) AS compressed_size,
        formatReadableSize(data_uncompressed_bytes) AS uncompressed_size
FROM system.data_skipping_indices
WHERE `table` = 'otel_logs_bloom'

┌─table───────────┬─compressed_size─┬─uncompressed_size─┐
│ otel_logs_bloom │ 12.03 MiB       │ 12.17 MiB         │
└─────────────────┴─────────────────┴───────────────────┘

1 row in set. Elapsed: 0.004 sec.

在上面的示例中,我们可以看到二级 Bloom filter 索引的大小为 12MB——几乎只有该列本身压缩后 56MB 的五分之一。

Bloom filter 可能需要进行较为细致的调优。我们建议参考此处的说明,以帮助确定最优配置。Bloom filter 在插入和合并阶段的开销也可能较大。用户应在将 Bloom filter 引入生产环境之前,先评估其对插入性能的影响。

关于二级跳过索引的更多细节可以在此处找到。

从 Map 中提取数据

Map 类型在 OTel schema 中非常常见。该类型要求键和值使用相同的数据类型,这对于存储 Kubernetes label 等元数据已经足够。请注意,当查询 Map 类型的某个子键时,会加载整个父列。如果该 map 拥有大量键,那么相比将该键单独建成一列,这会导致需要从磁盘读取更多数据,从而带来显著的查询开销。

如果你经常查询某个特定键,建议将其移动到根级别的专用列中。这通常是在完成部署并观察到常见访问模式之后才会进行的工作,在生产环境之前往往难以准确预估。有关如何在部署后修改 schema,请参见“管理 schema 变更”

Measuring table size & compression

ClickHouse 被用于可观测性的主要原因之一是其出色的压缩能力。

除了显著降低存储成本之外,磁盘上的数据更少也意味着更少的 I/O,以及更快的查询和写入。与 CPU 开销相比,I/O 的减少将远远抵消任何压缩算法带来的额外开销。因此,在优化 ClickHouse 查询性能时,提高数据压缩率应当是首要关注点。

有关如何评估压缩效果的详细信息,请参阅此处