数据管理
用于可观测性的 ClickHouse 部署通常涉及大型数据集,需要进行管理。ClickHouse 提供了许多功能来帮助进行数据管理。
分区
ClickHouse 中的分区允许根据列或 SQL 表达式在磁盘上逻辑地分离数据。通过逻辑地分离数据,每个分区可以独立地进行操作,例如删除。这允许用户在时间或 数据过期/有效删除数据从集群 中高效地移动分区(以及子集)。
分区是在表首次定义时通过 PARTITION BY
子句指定的。此子句可以包含任何列上的 SQL 表达式,其结果将定义将行发送到的分区。
数据部分在磁盘上的每个分区中逻辑地关联(通过一个公共文件夹名称前缀),并且可以独立地查询。对于以下示例,默认的 otel_logs
架构通过表达式 toDate(Timestamp)
按天进行分区。当行插入 ClickHouse 时,将对每行计算此表达式,如果该分区存在,则将其路由到结果分区(如果该行是该天的第一行,则将创建该分区)。
CREATE TABLE default.otel_logs
(
...
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)
可以对分区执行 一系列操作,包括 备份、列操作、变异 更改/删除 按行的数据)以及 索引清除(例如,辅助索引)。
例如,假设我们的 otel_logs
表按天进行分区。如果使用结构化日志数据集填充它,它将包含几天的数据
SELECT Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC
┌────────day─┬───────c─┐
│ 2019-01-22 │ 2333977 │
│ 2019-01-23 │ 2326694 │
│ 2019-01-26 │ 1986456 │
│ 2019-01-24 │ 1896255 │
│ 2019-01-25 │ 1821770 │
└────────────┴─────────┘
5 rows in set. Elapsed: 0.058 sec. Processed 10.37 million rows, 82.92 MB (177.96 million rows/s., 1.42 GB/s.)
Peak memory usage: 4.41 MiB.
可以使用简单的系统表查询找到当前分区
SELECT DISTINCT partition
FROM system.parts
WHERE `table` = 'otel_logs'
┌─partition──┐
│ 2019-01-22 │
│ 2019-01-23 │
│ 2019-01-24 │
│ 2019-01-25 │
│ 2019-01-26 │
└────────────┘
5 rows in set. Elapsed: 0.005 sec.
我们可能还有另一个表 otel_logs_archive
,用于存储旧数据。可以通过分区有效地将数据移动到该表(这只是一个元数据更改)。
CREATE TABLE otel_logs_archive AS otel_logs
--move data to archive table
ALTER TABLE otel_logs
(MOVE PARTITION tuple('2019-01-26') TO TABLE otel_logs_archive
--confirm data has been moved
SELECT
Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC
┌────────day─┬───────c─┐
│ 2019-01-22 │ 2333977 │
│ 2019-01-23 │ 2326694 │
│ 2019-01-24 │ 1896255 │
│ 2019-01-25 │ 1821770 │
└────────────┴─────────┘
4 rows in set. Elapsed: 0.051 sec. Processed 8.38 million rows, 67.03 MB (163.52 million rows/s., 1.31 GB/s.)
Peak memory usage: 4.40 MiB.
SELECT Timestamp::Date AS day,
count() AS c
FROM otel_logs_archive
GROUP BY day
ORDER BY c DESC
┌────────day─┬───────c─┐
│ 2019-01-26 │ 1986456 │
└────────────┴─────────┘
1 row in set. Elapsed: 0.024 sec. Processed 1.99 million rows, 15.89 MB (83.86 million rows/s., 670.87 MB/s.)
Peak memory usage: 4.99 MiB.
这与其他技术形成对比,其他技术需要使用 INSERT INTO SELECT
以及将数据重写到新的目标表。
在表之间移动分区 需要满足几个条件,最重要的是表必须具有相同的结构、分区键、主键以及索引/投影。有关如何在
ALTER
DDL 中指定分区的详细说明,请参阅 此处。
此外,可以通过分区有效地删除数据。这比替代技术(变异或轻量级删除)要高效得多,应优先考虑。
ALTER TABLE otel_logs
(DROP PARTITION tuple('2019-01-25'))
SELECT
Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC
┌────────day─┬───────c─┐
│ 2019-01-22 │ 4667954 │
│ 2019-01-23 │ 4653388 │
│ 2019-01-24 │ 3792510 │
└────────────┴─────────┘
当使用设置
ttl_only_drop_parts=1
时,TTL 会利用此功能。有关更多详细信息,请参见“使用 TTL 进行数据管理”。
应用
上面说明了如何通过分区有效地移动和操作数据。实际上,用户最有可能在可观测性用例中利用分区操作来完成两种场景
- 分层架构 - 在存储层之间移动数据(请参阅“存储层”),从而允许构建热冷架构。
- 有效删除 - 当数据达到指定的 TTL 时(请参阅“使用 TTL 进行数据管理”)。
我们将在下面详细探讨这两者。
查询性能
虽然分区可以帮助提高查询性能,但这在很大程度上取决于访问模式。如果查询只针对少数分区(理想情况下为一个分区),那么性能可能会提高。这通常只有在分区键不在主键中并且您正在对其进行过滤时才有用。但是,需要覆盖多个分区的查询可能比不使用分区时执行得更糟(因为可能存在更多部分)。如果分区键已经是主键中的早期条目,则针对单个分区的优势将不那么明显,甚至不存在。如果每个分区中的值都是唯一的,则分区也可以用于 优化 GROUP BY 查询。但是,一般来说,用户应该确保主键已优化,并且只在访问模式访问特定可预测的每天子集(例如,按天分区,大多数查询都在最后一天)的特殊情况下才将分区视为查询优化技术。请参阅 此处,了解此行为的示例。
使用 TTL(生存时间)进行数据管理
生存时间 (TTL) 是由 ClickHouse 提供支持的可观测性解决方案中的一个关键功能,用于有效的数据保留和管理,特别是在不断生成大量数据的情况下。在 ClickHouse 中实施 TTL 允许自动过期和删除旧数据,确保以最佳方式使用存储并保持性能,无需人工干预。此功能对于保持数据库精简、降低存储成本以及通过专注于最相关和最新的数据来确保查询保持快速和高效至关重要。此外,它还有助于通过系统地管理数据生命周期来遵守数据保留策略,从而增强可观测性解决方案的整体可持续性和可扩展性。
TTL 可以指定在 ClickHouse 的表级别或列级别。
表级 TTL
日志和跟踪的默认架构都包含一个 TTL,用于在指定时间段后使数据过期。这在 ClickHouse 导出器中以 ttl
键指定,例如:
exporters:
clickhouse:
endpoint: tcp://127.0.0.1:9000?dial_timeout=10s&compress=lz4&async_insert=1
ttl: 72h
此语法目前支持 Golang Duration 语法。我们建议用户使用 h
并确保它与分区周期保持一致。例如,如果按天进行分区,请确保它是天数的倍数,例如 24h、48h、72h。 这将自动确保将 TTL 子句添加到表中,例如,如果 ttl: 96h
。
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(4)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1
默认情况下,TTL 过期的数据将在 ClickHouse 合并数据部分 时删除。当 ClickHouse 检测到数据已过期时,它会执行非计划合并。
TTL 不会立即应用,而是按计划应用,如上所述。MergeTree 表设置
merge_with_ttl_timeout
设置在重复执行删除 TTL 合并之前的最小延迟(以秒为单位)。默认值为 14400 秒(4 小时)。但这只是最小延迟,可能需要更长时间才能触发 TTL 合并。如果该值过低,它将执行许多非计划合并,这可能会消耗大量资源。可以使用命令ALTER TABLE my_table MATERIALIZE TTL
强制执行 TTL 过期。
重要说明:我们建议使用设置 ttl_only_drop_parts=1
(由默认架构应用)。启用此设置时,ClickHouse 会在其中所有行都过期时删除整个部分。删除整个部分而不是部分清除 TTL 行(在 ttl_only_drop_parts=0
时通过资源密集型变异实现)允许具有更短的 merge_with_ttl_timeout
时间并降低对系统性能的影响。如果数据按您执行 TTL 过期的时间单位(例如,天)进行分区,则部分自然只包含来自定义间隔的数据。这将确保 ttl_only_drop_parts=1
可以有效地应用。
列级 TTL
上面的示例在表级别使数据过期。用户也可以在列级别使数据过期。随着数据老化,这可以用于删除在调查中其值不值得保留其资源开销的列。例如,我们建议保留 Body
列,以防添加新的动态元数据,这些元数据在插入时尚未提取,例如新的 Kubernetes 标签。经过一段时间(例如 1 个月)后,可能很明显这些额外的元数据没有用 - 因此限制了保留 Body
列的价值。
下面,我们展示了如何在 30 天后删除 Body
列。
CREATE TABLE otel_logs_v2
(
`Body` String TTL Timestamp + INTERVAL 30 DAY,
`Timestamp` DateTime,
...
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)
指定列级 TTL 需要用户指定自己的架构。这无法在 OTel 收集器中指定。
重新压缩数据
虽然我们通常建议使用ZSTD(1)
来处理可观察性数据集,但用户可以尝试使用不同的压缩算法或更高级别的压缩,例如ZSTD(3)
。除了可以在模式创建时指定之外,压缩还可以配置为在设定时间段后更改。如果编解码器或压缩算法提高了压缩率但导致查询性能下降,这可能很合适。这种权衡在较旧的数据上可能是可以接受的,因为较旧的数据查询频率较低,但对较新的数据来说是不合适的,因为较新的数据在调查中使用频率更高。
下面是一个例子,说明我们在 4 天后使用ZSTD(3)
压缩数据,而不是将其删除。
CREATE TABLE default.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)
TTL Timestamp + INTERVAL 4 DAY RECOMPRESS CODEC(ZSTD(3))
我们建议用户始终评估不同压缩级别和算法对插入和查询性能的影响。例如,delta 编解码器可以帮助压缩时间戳。但是,如果这些时间戳是主键的一部分,则过滤性能可能会受到影响。
有关配置 TTL 的更多详细信息和示例,请参见此处。有关如何为表和列添加和修改 TTL 的示例,请参见此处。有关 TTL 如何启用存储层级(例如冷热架构),请参见“存储层”。
存储层
在 ClickHouse 中,用户可以在不同的磁盘上创建存储层,例如,将热数据/最近数据存储在 SSD 上,而将较旧的数据存储在 S3 上。这种架构允许使用更便宜的存储来存储较旧的数据,因为较旧的数据在调查中使用频率较低,因此查询 SLA 较高。
ClickHouse Cloud 使用存储在 S3 上的单个数据副本,并使用 SSD 支持的节点缓存。因此,ClickHouse Cloud 中不需要存储层。
创建存储层需要用户创建磁盘,然后使用这些磁盘制定存储策略,以及在表创建期间可以指定的卷。数据可以根据填充率、分区大小和卷优先级自动在磁盘之间移动。更多详细信息,请参见此处。
虽然可以使用ALTER TABLE MOVE PARTITION
命令手动在磁盘之间移动数据,但也可以使用 TTL 控制数据在卷之间的移动。完整的示例可以在此处找到。
管理模式更改
日志和跟踪模式在系统的生命周期中会不可避免地发生变化,例如,当用户监控具有不同元数据或 Pod 标签的新系统时。通过使用 OTel 模式生成数据并在结构化格式中捕获原始事件数据,ClickHouse 模式将能够抵御这些更改。但是,随着新元数据的出现和查询访问模式的改变,用户将希望更新模式以反映这些发展。
为了在模式更改期间避免停机,用户有几个选择,我们将在下面介绍。
使用默认值
可以使用DEFAULT
值 向模式中添加列。如果在 INSERT 期间未指定,则将使用指定的默认值。
可以在修改任何物化视图转换逻辑或 OTel 收集器配置之前进行模式更改,这些更改会导致发送这些新列。
更改模式后,用户可以重新配置 OTeL 收集器。假设用户正在使用“使用 SQL 提取结构”中概述的推荐流程,其中 OTeL 收集器将数据发送到使用物化视图的 Null 表引擎,该物化视图负责提取目标模式并将结果发送到目标表以进行存储,则可以使用ALTER TABLE ... MODIFY QUERY
语法 修改视图。假设我们有以下目标表及其对应的物化视图(类似于“使用 SQL 提取结构”中使用的物化视图),用于从 OTel 结构化日志中提取目标模式
CREATE TABLE default.otel_logs_v2
(
`Body` String,
`Timestamp` DateTime,
`ServiceName` LowCardinality(String),
`Status` UInt16,
`RequestProtocol` LowCardinality(String),
`RunTime` 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)
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['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
假设我们希望从LogAttributes
中提取新的列Size
。我们可以通过ALTER TABLE
添加它,并指定默认值
ALTER TABLE otel_logs_v2
(ADD COLUMN `Size` UInt64 DEFAULT JSONExtractUInt(Body, 'size'))
在上面的示例中,我们将默认值指定为LogAttributes
中的size
键(如果不存在,它将为 0)。这意味着访问此列的查询对于没有插入该值的行的行必须访问 Map,因此速度会比较慢。我们也可以轻松地将其指定为一个常量,例如 0,从而降低后续查询针对没有该值的行的成本。查询此表表明该值已如预期从 Map 中填充
SELECT Size
FROM otel_logs_v2
LIMIT 5
┌──Size─┐
│ 30577 │
│ 5667 │
│ 5379 │
│ 1696 │
│ 41483 │
└───────┘
5 rows in set. Elapsed: 0.012 sec.
为了确保此值在所有未来的数据中都被插入,我们可以使用以下所示的ALTER TABLE
语法修改物化视图
ALTER TABLE otel_logs_mv
MODIFY QUERY
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
后续行将在插入时填充Size
列。
创建新表
作为上述流程的替代方案,用户可以简单地创建一个具有新模式的新目标表。然后,可以使用上面的ALTER TABLE MODIFY QUERY.
修改任何物化视图以使用新表。使用这种方法,用户可以对表进行版本化,例如otel_logs_v3
。
这种方法会使用户拥有多个表需要查询。要跨表查询,用户可以使用merge
函数,该函数接受表名的通配符模式。我们在下面展示了如何查询otel_logs
表的 v2 和 v3 版本
SELECT Status, count() AS c
FROM merge('otel_logs_v[2|3]')
GROUP BY Status
ORDER BY c DESC
LIMIT 5
┌─Status─┬────────c─┐
│ 200 │ 38319300 │
│ 304 │ 1360912 │
│ 302 │ 799340 │
│ 404 │ 420044 │
│ 301 │ 270212 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.137 sec. Processed 41.46 million rows, 82.92 MB (302.43 million rows/s., 604.85 MB/s.)
如果用户希望避免使用merge
函数,并向最终用户公开一个组合了多个表的表,可以使用Merge 表引擎。我们在下面展示了如何实现
CREATE TABLE otel_logs_merged
ENGINE = Merge('default', 'otel_logs_v[2|3]')
SELECT Status, count() AS c
FROM otel_logs_merged
GROUP BY Status
ORDER BY c DESC
LIMIT 5
┌─Status─┬────────c─┐
│ 200 │ 38319300 │
│ 304 │ 1360912 │
│ 302 │ 799340 │
│ 404 │ 420044 │
│ 301 │ 270212 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.073 sec. Processed 41.46 million rows, 82.92 MB (565.43 million rows/s., 1.13 GB/s.)
这可以在添加新表时使用EXCHANGE
表语法进行更新。例如,要添加一个 v4 表,我们可以创建一个新表并将其与之前版本进行原子交换。
CREATE TABLE otel_logs_merged_temp
ENGINE = Merge('default', 'otel_logs_v[2|3|4]')
EXCHANGE TABLE otel_logs_merged_temp AND otel_logs_merged
SELECT Status, count() AS c
FROM otel_logs_merged
GROUP BY Status
ORDER BY c DESC
LIMIT 5
┌─Status─┬────────c─┐
│ 200 │ 39259996 │
│ 304 │ 1378564 │
│ 302 │ 820118 │
│ 404 │ 429220 │
│ 301 │ 276960 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.068 sec. Processed 42.46 million rows, 84.92 MB (620.45 million rows/s., 1.24 GB/s.)