跳至主要内容

为可观察性设计 Schema

我们建议用户始终为日志和跟踪创建自己的 Schema,原因如下:

  • 选择主键 - 默认 Schema 使用 ORDER BY,它针对特定的访问模式进行了优化。您的访问模式不太可能与此一致。
  • 提取结构 - 用户可能希望从现有列中提取新列,例如 Body 列。这可以通过物化列(以及更复杂情况下的物化视图)来完成。这需要更改 Schema。
  • 优化 Maps - 默认 Schema 使用 Map 类型来存储属性。这些列允许存储任意元数据。虽然这是一个基本功能,因为来自事件的元数据通常没有预先定义,因此无法以其他方式存储在像 ClickHouse 这样的强类型数据库中,但访问 Map 的键及其值不像访问普通列那样高效。我们通过修改 Schema 并确保最常访问的 Map 键为顶级列来解决此问题 - 请参阅“使用 SQL 提取结构”。这需要更改 Schema。
  • 简化 Map 键访问 - 访问 Map 中的键需要更详细的语法。用户可以使用别名来缓解此问题。请参阅“使用别名”以简化查询。
  • 二级索引 - 默认 Schema 使用二级索引来加速对 Map 的访问并加速文本查询。这些通常不需要并且会占用额外的磁盘空间。可以使用它们,但应进行测试以确保它们是必需的。请参阅“二级/数据跳过索引”。
  • 使用编解码器 - 如果用户了解预期数据并有证据表明这可以提高压缩率,则他们可能希望自定义列的编解码器。

我们将在下面详细描述上述每个用例。

重要提示:虽然鼓励用户扩展和修改其 Schema 以实现最佳压缩和查询性能,但他们应尽可能遵守 OTel Schema 的核心列命名。ClickHouse Grafana 插件假设存在一些基本的 OTel 列来帮助构建查询,例如 Timestamp 和 SeverityText。日志和跟踪所需的列在此处记录 [1][2]此处。您可以选择更改这些列名,覆盖插件配置中的默认值。

使用 SQL 提取结构

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

  • 从字符串 Blob 中提取列。查询这些列将比在查询时使用字符串操作更快。
  • 从 Map 中提取键。默认 Schema 将任意属性放置到 Map 类型的列中。此类型提供了一种无 Schema 的功能,其优点是用户在定义日志和跟踪时不需要预定义属性的列 - 通常,在从 Kubernetes 收集日志并希望确保保留 pod 标签以供以后搜索时,这是不可能的。访问 Map 的键及其值比查询 ClickHouse 普通列慢。因此,通常需要将 Map 中的键提取到根表列中。

考虑以下查询

假设我们希望使用结构化日志统计哪些 URL 路径接收最多的 POST 请求。JSON Blob 作为字符串存储在 Body 列中。此外,如果用户在收集器中启用了 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 rows in set. Elapsed: 0.735 sec. Processed 10.36 million rows, 4.65 GB (14.10 million rows/s., 6.32 GB/s.)
Peak memory usage: 153.71 MiB.

请注意此处使用 Map 语法,例如 LogAttributes['request_path'],以及 path 函数 用于从 URL 中剥离查询参数。

如果用户未在收集器中启用 JSON 解析,则 LogAttributes 将为空,这将迫使我们使用 JSON 函数 从字符串 Body 中提取列。

我们通常建议用户在 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 rows in set. Elapsed: 0.668 sec. Processed 10.37 million rows, 5.13 GB (15.52 million rows/s., 7.68 GB/s.)
Peak memory usage: 172.30 MiB.

现在考虑非结构化日志的相同情况

SELECT Body, LogAttributes
FROM otel_logs
LIMIT 1
FORMAT Vertical

Row 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 rows in set. Elapsed: 1.953 sec. Processed 10.37 million rows, 3.59 GB (5.31 million rows/s., 1.84 GB/s.)

解析非结构化日志的查询复杂性和成本增加(请注意性能差异)是我们建议用户尽可能始终使用结构化日志的原因。

上述查询可以优化为利用正则表达式字典。有关更多详细信息,请参阅“使用字典”。

这两种用例都可以通过 ClickHouse 将上述查询逻辑移动到插入时间来满足。我们将在下面探讨几种方法,重点介绍每种方法何时适用。

用户还可以使用此处描述的 OTel Collector 处理器和操作符执行处理。在大多数情况下,用户会发现 ClickHouse 比收集器的处理器更节省资源且速度更快。在 SQL 中执行所有事件处理的主要缺点是您的解决方案与 ClickHouse 的耦合。例如,用户可能希望将已处理的日志从 OTel 收集器发送到其他目标,例如 S3。

物化列 物化列为从其他列中提取结构提供了最简单的解决方案。此类列的值始终在插入时计算,不能在 INSERT 查询中指定。

物化列会产生额外的存储开销,因为值会在插入时提取到磁盘上的新列中。

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

我们建议在基本处理中使用物化列。它们对于从 Map 中提取值、将它们提升到根列以及执行类型转换特别有用。当在非常基本的 Schema 中使用或与物化视图结合使用时,它们通常最有帮助。考虑以下来自收集器已将 JSON 提取到 LogAttributes 列的日志的 Schema

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)

可以使用 JSON 函数从字符串 Body 中提取的等效 Schema 可在此处找到 此处

我们的三个物化视图列提取请求页面、请求类型和推荐人的域名。这些访问 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 中启用它(请参阅数据源配置中的“附加设置 -> 自定义设置”)。

物化视图

物化视图提供了一种更强大的方法来将 SQL 过滤和转换应用于日志和跟踪。

物化视图允许用户将计算成本从查询时间转移到插入时间。ClickHouse 物化视图只是一个触发器,在数据块插入表时在其上运行查询。此查询的结果将插入到第二个“目标”表中。

NEEDS ALT

ClickHouse 中的物化视图会随着数据流入其所基于的表而实时更新,其功能更类似于持续更新的索引。相反,在其他数据库中,物化视图通常是查询的静态快照,必须刷新(类似于 ClickHouse 可刷新物化视图)。

与物化视图关联的查询理论上可以是任何查询,包括聚合,尽管 连接存在一些限制。对于日志和跟踪所需的转换和过滤工作负载,用户可以考虑任何 SELECT 语句都是可能的。

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

为了确保我们不会将数据持久化两次(在源表和目标表中),我们可以将源表的表引擎更改为Null 表引擎,同时保留原始架构。我们的 OTel 收集器将继续将数据发送到此表。例如,对于日志,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(基于一些简单的条件和这些列的定义)。在这种情况下,我们也只选择我们知道会被填充的列 - 忽略诸如 TraceId、SpanId 和 TraceFlags 之类的列。

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(以节省空间 - 请参阅“优化类型”)并进行转换。

请注意上面用于提取SeverityTextSeverityNumber条件语句的使用。这些对于制定复杂的条件和检查映射中是否设置了值非常有用 - 我们天真地假设所有键都存在于 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)

此处选择的类型基于“优化类型”中讨论的优化。

请注意我们如何彻底改变了我们的架构。实际上,用户可能还希望保留 Trace 列以及ResourceAttributes列(这通常包含 Kubernetes 元数据)。Grafana 可以利用跟踪列在日志和跟踪之间提供链接功能 - 请参阅“使用 Grafana”。

下面,我们创建一个物化视图otel_logs_mv,它对otel_logs表执行上述选择并将其结果发送到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

以上内容在下面进行了可视化

NEEDS ALT

如果我们现在重新启动“导出到 ClickHouse”中使用的收集器配置,数据将以我们所需的格式出现在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 row in set. Elapsed: 0.010 sec.

下面显示了一个等效的物化视图,它依赖于使用 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语句以及带有使用相同架构的目标表的INSERT INTO语句来测试他们的视图。这应该可以确认类型是否正确处理。应特别注意以下情况

  • 如果映射中不存在键,则将返回空字符串。在数字的情况下,用户需要将其映射到适当的值。这可以通过条件语句来实现,例如if(LogAttributes['status'] = ", 200, LogAttributes['status'])转换函数(如果默认值可以接受),例如toUInt8OrDefault(LogAttributes['status'] )
  • 某些类型不会始终被转换,例如数字的字符串表示形式不会被转换为枚举值。
  • 如果未找到值,JSON 提取函数将返回其类型的默认值。确保这些值有意义!

避免在 Clickhouse 中使用 Nullable 用于可观察性数据。在日志和跟踪中,很少需要能够区分空和 null。此功能会产生额外的存储开销,并将对查询性能产生负面影响。有关更多详细信息,请参阅此处

选择主键(排序键)

提取所需的列后,您可以开始优化排序/主键。

可以应用一些简单的规则来帮助选择排序键。以下内容有时可能存在冲突,因此请按顺序考虑这些内容。用户可以从此过程中识别多个键,通常 4-5 个就足够了

  1. 选择与您的常用过滤器和访问模式一致的列。如果用户通常通过过滤特定列(例如 pod 名称)来开始可观察性调查,则此列将在WHERE子句中频繁使用。优先将这些列包含在您的键中,而不是那些使用频率较低的列。
  2. 优先选择有助于在过滤时排除大部分总行数的列,从而减少需要读取的数据量。服务名称和状态代码通常是不错的选择 - 在后一种情况下,仅当用户过滤的值排除了大多数行时(例如,过滤 200s 在大多数系统中将匹配大多数行,而与 500 错误相比,这将对应于一小部分)。
  3. 优先选择可能与表中其他列高度相关的列。这将有助于确保这些值也连续存储,从而提高压缩率。
  4. 排序键中的列的GROUP BYORDER BY操作可以更有效地利用内存。

在识别排序键的列子集后,必须按特定顺序声明它们。此顺序会显著影响查询中辅助键列过滤的效率以及表的 data 文件的压缩率。通常,**最好按基数升序排列键**。这应该与以下事实相平衡:过滤出现在排序键后面的列的效率将低于过滤出现在元组前面的列的效率。平衡这些行为并考虑您的访问模式。最重要的是,测试变体。要进一步了解排序键以及如何优化它们,我们推荐这篇文章

我们建议在构建日志结构后决定排序键。不要在排序键或 JSON 提取表达式中使用属性映射中的键。确保排序键作为表中的根列。

使用映射

前面的示例显示了使用映射语法map['key']访问Map(String, String)列中的值。除了使用映射表示法访问嵌套键之外,ClickHouse 还提供了专门的映射函数用于过滤或选择这些列。

例如,以下查询使用mapKeys函数(然后是groupArrayDistinctArray函数(一个组合器))标识 LogAttributes 列中所有可用的唯一键。

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

Row 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 row in set. Elapsed: 1.139 sec. Processed 5.63 million rows, 2.53 GB (4.94 million rows/s., 2.22 GB/s.)
Peak memory usage: 71.90 MiB.

我们不建议在 Map 列名中使用点,并且可能会弃用其使用。使用_

使用别名

查询映射类型比查询普通列慢 - 请参阅“加速查询”。此外,它在语法上更复杂,用户编写起来可能很麻烦。为了解决后一个问题,我们建议使用别名列。

ALIAS 列在查询时计算,不会存储在表中。因此,不可能将值插入到此类型的列中。使用别名,我们可以引用映射键并简化语法,将映射条目透明地公开为普通列。考虑以下示例

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 rows in set. Elapsed: 0.011 sec.

此外,通过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 rows in set. Elapsed: 0.014 sec.

默认情况下,SELECT *会排除 ALIAS 列。可以通过设置asterisk_include_alias_columns=1禁用此行为。

优化类型

优化类型的Clickhouse 通用最佳实践适用于 ClickHouse 使用案例。

使用编解码器

除了类型优化之外,用户在尝试优化 ClickHouse 可观察性架构的压缩时,还可以遵循编解码器的通用最佳实践

通常,用户会发现ZSTD编解码器非常适用于日志和跟踪数据集。将压缩值从其默认值 1 增加可能会提高压缩率。但是,应对此进行测试,因为较高的值会在插入时产生更大的 CPU 开销。通常,我们发现增加此值带来的收益很少。

此外,时间戳虽然在压缩方面受益于增量编码,但如果此列用于主键/排序键,则已显示会导致查询性能下降。我们建议用户评估各自的压缩与查询性能权衡。

使用字典

字典是 ClickHouse 的一个关键特性,它提供了来自各种内部和外部来源的数据的内存中键值表示,针对超低延迟查找查询进行了优化。

NEEDS ALT

这在各种场景中都非常方便,从在不降低摄取过程速度的情况下动态丰富摄取的数据,到总体上提高查询性能,尤其是 JOIN 的性能。虽然在可观察性用例中很少需要联接,但字典对于丰富目的仍然很有用 - 在插入时和查询时。我们将在下面提供这两个方面的示例。

有兴趣使用字典加速联接的用户可以在这里找到更多详细信息此处

插入时间与查询时间

字典可用于在查询时或插入时丰富数据集。每种方法都有其各自的优缺点。概括来说

  • 插入时丰富 - 如果丰富值不会改变,并且存在于可用于填充字典的外部源中,则通常适合使用此方法。在这种情况下,在插入时丰富行可以避免查询时对字典的查找。但这会以插入性能为代价,并且会增加额外的存储开销,因为丰富值将作为列存储。
  • 查询时丰富 - 如果字典中的值频繁更改,则查询时查找通常更适用。这避免了如果映射值发生更改则需要更新列(并重写数据)。这种灵活性是以查询时查找成本为代价的。如果需要对许多行进行查找,例如在过滤器子句中使用字典查找,则此查询时间成本通常是明显的。对于结果丰富(即在SELECT中),此开销通常不明显。

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

有关简单的丰富示例,请参阅有关字典的指南此处。下面,我们重点介绍常见的可观察性丰富任务。

使用 IP 字典

使用 IP 地址将日志和跟踪与经度和纬度值进行地理丰富是常见的可观察性需求。我们可以使用ip_trie结构化字典来实现这一点。

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

自述文件中,我们可以看到数据结构如下

| 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-- 3.26 million
└─────────┘

因为我们的ip_trie字典要求 IP 地址范围以 CIDR 表示法表示,所以我们需要转换ip_range_startip_range_end

可以使用以下查询简洁地计算每个范围的 CDIR

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.01.0.0.2551.0.0.0/24
1.0.1.01.0.3.2551.0.0.0/22
1.0.4.01.0.7.2551.0.4.0/22
1.0.8.01.0.15.2551.0.8.0/21
└────────────────┴──────────────┴────────────┘

4 rows in set. Elapsed: 0.259 sec.

以上查询中有很多内容。对于有兴趣的人,请阅读这篇优秀的解释。否则,请接受以上内容计算 IP 范围的 CIDR。

出于我们的目的,我们只需要 IP 范围、国家代码和坐标,因此让我们创建一个新表并插入我们的 GeoIP 数据

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 查找,我们将利用字典将 GeoIP 数据的键 -> 属性映射存储在内存中。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/2226.0998119.297 │ CN │
1.0.0.0/24-27.4767153.017 │ AU │
1.0.4.0/22-38.0267145.301 │ AU │
└────────────┴──────────┴───────────┴──────────────┘

3 rows in set. Elapsed: 4.662 sec.

ClickHouse 中的字典会根据底层表数据和上面使用的生命周期子句定期刷新。要更新我们的 GeoIP 字典以反映 DB-IP 数据集中最新的更改,我们只需要将 geoip_url 远程表中的数据重新插入到我们的geoip表中并应用转换即可。

现在我们已将 GeoIP 数据加载到我们的 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 row in set. Elapsed: 0.003 sec.

请注意此处的检索速度。这使我们能够丰富日志。在本例中,我们选择执行查询时丰富

回到我们最初的日志数据集,我们可以使用上述方法按国家/地区聚合我们的日志。以下假设我们使用我们之前物化视图生成的结果模式,该模式具有提取的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 到地理位置的映射可能会更改,因此用户可能希望知道请求在发出时来自何处 - 而不是同一地址的当前地理位置。因此,此处可能更喜欢索引时间丰富。这可以通过使用如下所示的物化列或物化视图中的 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子句来实现,该子句将导致字典定期从底层表重新加载。要更新底层表,请参阅“使用可刷新物化视图”。

上述国家/地区和坐标提供了超越按国家/地区分组和过滤的可视化功能。有关灵感,请参阅“可视化地理数据”。

使用正则表达式字典(用户代理解析)

用户代理字符串的解析是一个经典的正则表达式问题,也是基于日志和跟踪的数据集中的常见需求。ClickHouse 使用正则表达式树字典提供高效的用户代理解析。

正则表达式树字典在 ClickHouse 开源中使用 YAMLRegExpTree 字典源类型定义,该类型提供包含正则表达式树的 YAML 文件的路径。如果您希望提供自己的正则表达式字典,则可以在此处找到有关所需结构的详细信息。下面我们重点介绍使用uap-core进行用户代理解析,并为支持的 CSV 格式加载我们的字典。此方法与 OSS 和 ClickHouse Cloud 兼容。

在下面的示例中,我们使用 2024 年 6 月用户代理解析的最新 uap-core 正则表达式的快照。最新的文件(偶尔更新)可以在此处找到。用户可以按照此处的步骤将其加载到下面使用的 CSV 文件中。

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

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)')

填充内存表后,我们可以加载正则表达式字典。请注意,我们需要将键值指定为列 - 这些将是我们可以从用户代理中提取的属性。

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);

加载这些字典后,我们可以提供一个示例用户代理并测试我们新的字典提取功能

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.

鉴于围绕用户代理的规则很少更改,并且字典只需要响应新浏览器、操作系统和设备进行更新,因此在插入时执行此提取是有意义的。

我们可以使用物化列或物化视图来执行此工作。下面我们修改之前使用的物化视图

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)

重新启动收集器并根据之前记录的步骤提取结构化日志后,我们可以查询我们新提取的 Device、Browser 和 Os 列。

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

Row 1:
──────
Device: ('Spider','Spider','Desktop')
Browser: ('AhrefsBot','6','1')
Os: ('Other','0','0','0')

请注意这些用户代理列使用元组。对于层次结构预先已知的复杂结构,建议使用元组。子列提供与常规列相同的性能(与映射键不同),同时允许异构类型。

进一步阅读

有关字典的更多示例和详细信息,我们推荐以下文章

加速查询

ClickHouse 支持多种加速查询性能的技术。只有在选择合适的 primary/ordering key 以优化最常用的访问模式并最大化压缩后,才应考虑以下内容。这通常会以最少的努力对性能产生最大的影响。

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

在前面的部分中,我们探讨了使用物化视图进行数据转换和过滤。但是,物化视图也可用于在插入时预先计算聚合并存储结果。此结果可以使用后续插入的结果进行更新,从而有效地允许在插入时预先计算聚合。

这里的主要思想是,结果通常将是原始数据的更小表示(在聚合的情况下为部分草图)。当与用于从目标表读取结果的更简单的查询相结合时,查询时间将比在原始数据上执行相同的计算更快。

考虑以下查询,我们在其中使用结构化日志计算每小时的总流量

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:001661716343
2019-01-26 15:00:001824015281
2019-01-26 14:00:001506284139
2019-01-26 13:00:001580955392
2019-01-26 12:00:001736840933
└─────────────────────┴────────────┘

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映射中提取 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的大小 - 我们应该每小时有 1 行

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修饰符。我们在上面的计数查询中使用了它。根据最终表中使用的排序键(即时间戳)进行聚合,并对指标求和。通常,这种方法更有效率且灵活(该表可用于其他用途),但对于某些查询,前者可能更简单。我们将在下面展示这两种方法

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:001661716343
2019-01-26 15:00:001824015281
2019-01-26 14:00:001506284139
2019-01-26 13:00:001580955392
2019-01-26 12:00:001736840933
└─────────────────────┴────────────┘

5 rows in set. Elapsed: 0.008 sec.

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

┌────────────────Hour─┬─TotalBytes─┐
2019-01-26 16:00:001661716343
2019-01-26 15:00:001824015281
2019-01-26 14:00:001506284139
2019-01-26 13:00:001580955392
2019-01-26 12:00:001736840933
└─────────────────────┴────────────┘

5 rows in set. Elapsed: 0.005 sec.

这将我们的查询速度从 0.6 秒提高到了 0.008 秒,提升了 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:004763

2019-01-22 00:00:00536
└─────────────────────┴────────────┘

113 rows in set. Elapsed: 0.667 sec. Processed 10.37 million rows, 4.73 GB (15.53 million rows/s., 7.09 GB/s.)

为了持久化基数计数以进行增量更新,需要 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键值的行将被合并(在上面的示例中为小时)。

关联的物化视图使用之前的查询

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:004763

2019-01-22 00:00:00536
└─────────────────────┴─────────────┘

113 rows in set. Elapsed: 0.027 sec.

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

使用物化视图(增量)进行快速查找

用户在选择 ClickHouse 排序键时应考虑其访问模式,以及在过滤和聚合子句中经常使用的列。这在可观测性用例中可能具有限制性,因为用户具有更多样化的访问模式,这些模式无法封装在一组列中。这在默认 OTel 架构中内置的示例中得到了最好的说明。考虑跟踪的默认架构

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)

此架构针对按ServiceNameSpanNameTimestamp进行过滤进行了优化。在跟踪中,用户还需要能够按特定TraceId执行查找并检索关联跟踪的跨度。虽然这在排序键中存在,但它位于末尾意味着过滤效率不会很高,并且可能意味着在检索单个跟踪时需要扫描大量数据。

OTel 收集器还安装了一个物化视图和关联的表来解决此挑战。表和视图如下所示

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具有跟踪的最小和最大时间戳。此表按TraceId排序,允许有效地检索这些时间戳。然后,这些时间戳范围可用于查询主otel_traces表。更具体地说,在通过其 ID 检索跟踪时,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 标识跟踪 ID ae9226c78d1d360601e6383928e4d22d的最小和最大时间戳,然后使用它过滤主otel_traces以获取其关联的跨度。

同样的方法可以应用于类似的访问模式。我们在数据建模此处探讨了一个类似的示例。

使用投影

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

在前面的部分中,我们探讨了如何在 ClickHouse 中使用物化视图来预计算聚合、转换行并优化可观测性查询以适应不同的访问模式。

我们提供了一个示例,其中物化视图将行发送到目标表,该表与接收插入的原始表的排序键不同,以便优化按跟踪 ID 进行查找。

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

理论上,此功能可用于为表提供多个排序键,但有一个明显的缺点:数据重复。具体来说,需要按照主主键的顺序写入数据,以及为每个投影指定的顺序写入数据。这将减慢插入速度并消耗更多磁盘空间。

投影提供了与物化视图许多相同的功能,但应谨慎使用,后者通常更受青睐。用户应了解缺点以及何时适用。例如,虽然投影可用于预计算聚合,但我们建议用户为此使用物化视图。

NEEDS ALT

考虑以下查询,该查询按 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.

我们在这里不使用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─┐
01 │ │
└─────────────┴─────────┴────────────────────┘

1 row in set. Elapsed: 0.008 sec.

如果我们重复上述查询,我们可以看到性能有了显著提高,但代价是增加了存储空间(有关如何衡量这一点,请参阅“测量表大小和压缩”)。

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

0 rows in set. Elapsed: 0.031 sec. Processed 51.42 thousand rows, 22.85 MB (1.65 million rows/s., 734.63 MB/s.)
Peak memory usage: 27.85 MiB.

在上面的示例中,我们在投影中指定了早期查询中使用的列。这意味着只有这些指定的列将作为投影的一部分存储在磁盘上,并按 Status 排序。如果我们在这里改用SELECT *,则将存储所有列。虽然这将允许更多查询(使用任何列的子集)受益于投影,但将产生额外的存储空间。有关衡量磁盘空间和压缩,请参阅“测量表大小和压缩”。

辅助/数据跳过索引

无论 ClickHouse 中的主键调整得多么好,一些查询都不可避免地需要进行全表扫描。虽然可以使用物化视图(以及某些查询的投影)来缓解这种情况,但这些需要额外的维护,并且用户需要了解其可用性才能确保利用它们。虽然传统的关联数据库使用辅助索引来解决此问题,但这些在 ClickHouse 等列式数据库中无效。相反,ClickHouse 使用“跳过”索引,通过允许数据库跳过不匹配值的较大数据块来显著提高查询性能。

默认的 OTel 架构使用辅助索引来尝试加速对映射访问的访问。虽然我们发现这些通常无效,并且不建议将它们复制到自定义架构中,但跳过索引仍然很有用。

用户应在尝试应用跳过索引之前阅读并理解辅助索引指南

通常,当主键与目标非主键列/表达式之间存在强相关性,并且用户正在查找罕见值(即在许多粒度中不出现的值)时,它们才有效。

对于可观测性查询,当用户需要执行文本搜索时,辅助索引很有用。具体来说,基于 N 元语法和标记的布隆过滤器索引ngrambf_v1tokenbf_v1可用于加速对使用运算符LIKEIN和 hasToken 的字符串列的搜索。重要的是,基于标记的索引使用非字母数字字符作为分隔符生成标记。这意味着在查询时只能匹配标记(或整个单词)。对于更细粒度的匹配,可以使用N 元语法布隆过滤器。这将字符串拆分为指定大小的 N 元语法,从而允许子词匹配。

为了评估将生成的标记,以及因此匹配的标记,可以使用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 还对倒排索引作为辅助索引提供了实验性支持。我们目前不建议将其用于日志数据集,但预计当它们准备好投入生产时,它们将取代基于标记的布隆过滤器。

出于示例的目的,我们使用结构化日志数据集。假设我们希望计算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.)

这里我们需要匹配大小为 3 的 N 元语法。因此,我们创建一个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/标记的数量以及过滤器导致假阴性的概率(从而确认某个值不存在于数据块中)。我们建议这些函数来帮助确定这些值。

如果调整正确,这里的加速效果将非常显著。

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.

以上仅用于说明目的。我们建议用户在插入时从日志中提取结构,而不是尝试使用基于标记的布隆过滤器来优化文本搜索。但是,在某些情况下,用户可能有堆栈跟踪或其他大型字符串,由于结构不太确定,因此文本搜索可能很有用。

使用布隆过滤器的一些一般准则

布隆过滤器的目标是过滤数据块,从而避免需要加载列的所有值并执行线性扫描。EXPLAIN 子句,使用参数indexes=1,可以用来识别已被跳过的数据块数量。请考虑以下针对原始表otel_logs_v2和带有 ngram 布隆过滤器的表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 rows in set. Elapsed: 0.016 sec.


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.

在上面的示例中,我们可以看到辅助布隆过滤器索引为 12MB - 几乎是列本身压缩大小(56MB)的 5 倍。

布隆过滤器可能需要大量的调整。我们建议遵循这里的说明,这有助于确定最佳设置。布隆过滤器在插入和合并时也可能代价高昂。用户应在将布隆过滤器添加到生产环境之前评估其对插入性能的影响。

有关辅助跳过索引的更多详细信息,请参见此处

从映射中提取

Map 类型在 OTel 架构中很普遍。此类型要求值和键具有相同的类型 - 足够用于元数据,例如 Kubernetes 标签。请注意,当查询 Map 类型的子键时,会加载整个父列。如果映射有很多键,这可能会导致查询性能显著下降,因为需要从磁盘读取比键作为列存在时更多的数据。

如果您经常查询特定键,请考虑将其移到根目录下的专用列中。这通常是在响应常见访问模式并在部署后发生的,并且可能难以在生产之前预测。请参阅“架构演变”了解如何在部署后修改架构。

测量表大小和压缩

ClickHouse 用于可观测性的主要原因之一是压缩。

除了大幅降低存储成本之外,磁盘上的数据越少,I/O 就越少,查询和插入速度就越快。就 CPU 而言,IO 的减少将超过任何压缩算法的开销。因此,在确保 ClickHouse 查询速度很快时,应首先关注改进数据的压缩。

有关测量压缩的详细信息,请参见此处