告别ELK,APO提供基于ClickHouse开箱即用的高效日志方案——APO 0.6.0发布

ELK一直是日志领域的主流产品,但是ElasticSearch的成本很高,查询效果随着数据量的增加越来越慢。业界已经有很多公司,比如滴滴、B站、Uber、Cloudflare都已经使用ClickHose作为ElasticSearch的替代品,都取得了不错的效果,实现了降本增效,费用节约大多在50%以上。但是目前使用ClickHose作为日志方案,存在以下问题。

  • 主流的Vector+ClickHose并未实现开箱即用,有许多的管理配置工作
  • 绝大多数方案不支持近似全文检索的功能(该功能很重要)
  • 使用双数组或者Map的表结构查询效率不高
  • ClickVisual是最接近的开箱即用的日志方案,也存在以下问题:

○强依赖Kafka,对于某些中小用户而言方案不够灵活,不友好

○未引入Vector,原生的ClickHose Kafka引擎在大流量情况下可能导致ClickHose内存爆掉(感谢社区大佬 十四反馈)

主流的Vector+ClickHouse方案并未实现开箱即用

目前业界很多公司都是基于Vector+ClickHouse的方案来实现日志的采集和存储,该方案需要管理维护的工作量相对而言比较高,适用于动手能力强的公司。

维护工作:为每种日志手动维护一张表

每个公司的部门团队可能日志规范都不完全一致,如果需要对日志内容进行快速搜索定位故障,就需要提前想好ClickHouse的表结构,然后调整Vector的配置文件,最终实现Vector根据不同日志格式,parse成不同的日志表字段,写入不同的日志表。

比如每种日志都得建立以下类似的表结构,才能完成日志按照ip、url等字段的索引实现快速搜索。但是另外一个部门的日志也许就不需要IP和url字段,那么该部门得重新设计表结构。

复制代码
CREATE TABLE log
(
    `ip` String,
    `time` Datetime,
    `url` String,
    `status` UInt8,
    `size` UInt32,
    `agent` String
)
ENGINE = MergeTree
ORDER BY date(time)

使用双数组或者Map的表结构查询效率不高

为了能够规避这些维护工作,所以很多公司对固定日志表结构进行了调整,常见的有两种方案,一种是双数组方案,另外一种就是Map方案。

Uber和Signoz的日志实现方案都是基于双数组

其日志表结构类似于下面这种

复制代码
CREATE TABLE <table_name>
(
      //Common metadata fields.
      _namespace             String,
      _timestamp              Int64,
      hostname               String,
      zone                   String,
      ...

     //Raw log event.
     _source                 String,

    //Type-specific field names and field values.
    string.names             Array(String),
    string.values            Array(String),
    number.names             Array(String), 
    number.values            Array(Float64),
    bool.names               Array(String),
    bool.values              Array(UInt8),

    //Materialized fields
    bar.String,              String
    foo.Number               Float64,
   ...
)
...

滴滴、B站等日志实现是基于Map结构

引入Map结构能够动态实现日志关键字段搜索

复制代码
CREATE TABLE ck_bamai_stream.cn_bmauto_local
(
    `logTime` Int64 DEFAULT 0, --Log打印的时间
    `logTimeHour` DateTime MATERIALIZED toStartOfHour(toDateTime(logTime / 1000)),--将Log
    `odinLeaf` String DEFAULT '',
    `uri` LowCardinality(String) DEFAULT '',
    `traceid` string DEFAULT '',
    `cspanid` String DEFAULT '',
    `dltag` String DEFAULT '',
    `spanid` String DEFAULT '',
    `message` String DEFAULT '',
    `otherColumn` Map<String,String>
    `_sys_insert_time` DateTime MATERIALIZED now()
)
ENGINE =MergeTree
PARTITION BY toYYYYMMDD(logTimeHour)
ORDER BY(logTimeHour,odinLeaf,uri,traceid)
TTL _sys_insert_time +toIntervalDay(7),_sys_insert_time + toIntervalDay(3)To VOLUME 'hdfs
SETTINGS index_granularity = 8192,min_bytes_for_wide_part=31457280

Create Table <log_app_name> ON CLUSTER ...
{
     _timestamp      Datetime64(3),
     `log,level`     String CODC(ZSTD(1)),
     `log.msg`       String CODC(ZSTD(1)),
     `log.trace_id`  String CODC(ZSTD(1)),
     ...
     string_map MapV2(String, Nullable(String))
        CODEC(ZSTD(1))
     number_map MapV2(String, Nullable(Float64))
        CODEC(ZSTD(1))
     bool_map MapV2(String, Nullable(UInt8))
}
ENGIN = ReplicatedMergeTree(...)
PARTITION BY toYYYYMMDD(_timestamp)
ORDER BY timestamp
TTL  toDateTime(timestamp) + toIntervalDay(...),
  toDateTime(timestamp) + toIntervalDay(...) TO VOLUME `cold_volume`

Map的动态字段搜索效率低

https://clickhouse.ac.cn/docs/knowledgebase/improve-map-performance

根据社区反馈,map底层实现为线性数组,map查询效率通常低于列查询3~10倍,特别是日志量规模越大,map查询效率越低。

同时支持Map类型的最低clickhosue版本为21.11

  • 列式存储优势:

    ClickHouse 的核心优势在于它是列式存储数据库,这意味着当执行查询时,只需要读取查询中涉及的列,而不必加载不相关的列。列式存储还能够通过数据类型特定的压缩技术显著减少 IO 操作,从而加快查询速度

  • 基于 Map 的查询:
    Map 是一种键值对数据结构,在查询时需要额外的开销来解析嵌套结构,并且无法像列式存储那样直接跳过不相关的数据。虽然 ClickHouse 对 Map 数据类型有一些优化,但它在处理复杂结构时往往会比简单的列查询慢

双数组的搜索效率也不高

虽然 ClickHouse 对Array有一定的优化,但双数组结构仍然比单纯的列查询开销大。

影响性能的因素:

  • 多级解析开销:查询双数组时,需要进行多层嵌套解析。例如,访问数组中的子数组意味着需要遍历父数组,然后进一步解析子数组的结构,这比单纯读取一个列复杂得多
  • 随机存取:双数组的访问模式往往比简单的列查询更加随机化。访问数组中的元素可能导致更多的跳转,影响缓存命中率,从而降低性能
  • 内存使用和数据存储:嵌套数组会使得 ClickHouse 的数据存储和内存管理更加复杂,因为数组中的数据长度不固定,导致压缩效果比单纯列差,数据块的大小也更加难以优化

性能上的差距取决于具体的查询模式和数据结构:

  • 在简单查询场景(例如,读取一个基本的列数据),单纯的列查询会比双数组快得多,特别是在处理大规模数据时。性能差距可能达到 数倍甚至十倍 以上,尤其是当查询不涉及嵌套结构时。
  • 在复杂查询场景(例如,查询涉及嵌套数组、需要频繁地进行数组拆解和操作),双数组的查询性能通常明显低于单纯的列查询。查询双数组的额外解析和处理开销,会使查询时间增加。根据不同的嵌套深度和数据量,性能可能下降 数倍。

ClickHouse的官方文档中日志方案也由于引入了Map效率不高

ClickHouse官方blog :

https://ClickHouse.com/blog/storing-log-data-in-ClickHouse-fluent-bit-vector-open-telemetry

提到有以下几种表结构:

OTEL的日志字段表

复制代码
CREATE TABLE otel.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)),
    `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `LogAttributes` 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_log_attr_key mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_log_attr_value mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_body Body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1

Vector 字段表

复制代码
CREATE TABLE vector.vector_logs
(
   `file` String,
   `timestamp` DateTime64(3),
   `kubernetes_container_id` LowCardinality(String),
   `kubernetes_container_image` LowCardinality(String),
   `kubernetes_container_name` LowCardinality(String),
   `kubernetes_namespace_labels`  Map(LowCardinality(String), String),
   `kubernetes_pod_annotations`  Map(LowCardinality(String), String),
   `kubernetes_pod_ip` IPv4,
   `kubernetes_pod_ips` Array(IPv4),
   `kubernetes_pod_labels` Map(LowCardinality(String), String),
   `kubernetes_pod_name` LowCardinality(String),
   `kubernetes_pod_namespace` LowCardinality(String),
   `kubernetes_pod_node_name` LowCardinality(String),
   `kubernetes_pod_owner` LowCardinality(String),
   `kubernetes_pod_uid` LowCardinality(String),
   `message` String,
   `source_type` LowCardinality(String),
   `stream` Enum('stdout', 'stderr')
)
ENGINE = MergeTree
ORDER BY (`kubernetes_container_name`, timestamp)

fluent字段表

复制代码
CREATE TABLE fluent.fluent_logs
(
    `timestamp` DateTime64(9),
    `log` String,
    `kubernetes` Map(LowCardinality(String), String),
    `host` LowCardinality(String),
    `pod_name` LowCardinality(String),
    `stream` LowCardinality(String),
    `labels` Map(LowCardinality(String), String),
    `annotations` Map(LowCardinality(String), String)
)
ENGINE = MergeTree
ORDER BY (host, pod_name, timestamp)

日志需要近似全文检索

基于ElasticSearch的日志方案,由于可以基于ElasticSearch实现的日志内容分词,所以很容易实现全文检索,但是基于ClickHouse就很难实现该功能。

那是不是基于ClickHouse的方案就完全没有办法呢?

ClickHouse的索引介绍

  • tokenbf_v1 按非字母数字字符(non-alphanumeric)拆分。相当于按符号分词,而通常日志中会有大量符号

在大牛的文章中,

https://juejin.cn/post/7130514546069864456

详细介绍了全文检索的实现,有兴趣的可以仔细看下大牛的文章。

最理想的日志方案应该满足什么条件?

我们认为理想的基于ClickHouse的日志方案应该满足以下几条:

  • 使用列来进行检索,而不是map或者双array,保证高效的查询效率
  • 用户不需要为了不同部门的日志内容,进行维护单独的表结构
  • 支持对原始日志内容进行近似的全文检索

我们调研了国内外几乎所有基于ClickHouse的日志方案,最后发现国内开源项目ClickVisual项目的思路最相近,ClickVisual几乎可以做到开箱即用。


ClickVisual的方案不足

ClickVisual工作原理:

  • 针对每种日志格式定义不同的parse规则,ClickVisual为每种规则生成一张新的日志表。该日志表存的就是解析之后的日志,之后的日志查询都是针对该日志表。因为解析之后的日志已经按照ClickHouse列存储了,所以关键字段查询是非常快的
  • 基于Kafka表引擎,读取Kafka原始日志,落库至临时表中
  • 魔术开始的地方:
    基于ClickHouse的物化视图,将原始日志中的新增日志_raw_log_内容按照日志解析规则parse成日志列格式,并将解析好的日志存入该规则对应的日志表中

临时表

复制代码
CREATE TABLE default.test_stream
(
    `status` String,
    `timestamp` Float64,
    `message` String CODEC(ZSTD(1))
)
ENGINE = Kafka
SETTINGS kafka_broker_list = '127.0.0.1:9092',
kafka_topic_list = 'test',
kafka_group_name = 'default_test',
kafka_format = 'JSONEachRow', 
kafka_num_consumers = 1,
kafka_skip_broken_messages = 0

物化视图

复制代码
CREATE MATERIALIZED VIEW default.test_view TO default.test
(
    `status` String,
    `_time_second_` DateTime,
    `_time_nanosecond_` DateTime64(9),
    `_raw_log_` String,
    // 日志表的列
    `level` Nullable(String)
    //根据需要调整列
    ...
) AS
SELECT
    status,
    toDateTime(toInt64(timestamp)) AS _time_second_,
    fromUnixTimestamp64Nano(toInt64(timestamp * 1000000000)) AS _time_nanosecond_,
    message AS _raw_log_,
    // 物化视图处理成列
    toNullable(toString(replaceAll(JSONExtractRaw(message, 'level'), '"', ''))) AS level
    // 根据需要添加更多解析规则
    ...
FROM default.test_stream
WHERE 1 = 1

按照日志解析规则将_raw_log_parse成新的真实日志表

复制代码
CREATE TABLE default.test
(
    `status` String,
    `_time_second_` DateTime,
    `_time_nanosecond_` DateTime64(9),
    `_raw_log_` String CODEC(ZSTD(1)),
    // 该列通过物化视图解析得到
    `level` Nullable(String),
    // 根据需要添加更多列
    ...
    INDEX idx_raw_log _raw_log_ TYPE tokenbf_v1(30720, 2, 0) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(_time_second_)
ORDER BY _time_second_
TTL toDateTime(_time_second_) + toIntervalDay(1)
SETTINGS index_granularity = 8192

每当需要分析新的索引字段,clickvisual会执行Add Colum为日志表添加新的列,同时更新物化视图添加新的解析处理规则。

ClickVisual的不足:

根据上述的原理:

  • 不支持高效的近似全文检索,工作原理可以看出由于ClickVisual并未对_raw_log_进行跳数索引,所以也就导致ClickVisual不能高效的支持近似的对原始日志全文检索
  • 由于ClickVisual完全依赖Kafka表引擎来实现日志的摄入,虽然ClickVisual也支持引入ClickHouse的已有日志表结构进行查询,但是很可能并不是直接针对列查询,而是针对map数据查询,只有通过Kafka引擎来摄入的日志才能生成新日志表结构,最终查询才是针对列式查询,才能有较高的查询效率
  • Kafka表引擎读写日志速度无法控制,如果日志量非常多,导致ClickHouse物化视图工作过程中内存爆掉

APO 日志设计方案

ClickVisual已经非常接近理想日志方案了,只是我们需要对ClickVisual的逻辑进行调整。

  1. 不使用ClickHouse的Kafka表引擎来完成日志的摄取工作,而是改成Vector的方式完成日志的摄取工作。这样就不再依赖Kafka,对于中小用户日志规模没有那么大的用户,可以直接使用,而不需要维护Kafka。虽然去掉了Kakfa,同时增加了Vector,但是Vector的运维工作相比kafka而言,Vector几乎不需要运维
  2. 引入了Vector之后,可以通过配置Vector来调整参数,确保在大量日志洪锋的时候,也不至于将ClickHouse内存打爆
  3. 用户如果真的需要引入Kafka,也有已经维护好的Kafka,完全可以使用Vector先将原始日志写入Kafka,然后使用Vector从Kafka读取出来,继续实现后续的日志处理
  4. APO引入了ClickHouse null表引擎,来实现原始日志(从Vector写入的) 转换成按照日志解析格式解析之后的真实日志表。
  5. 所有的查询都是针对真实日志表的列查询,所以性能比较高
  6. 在真实日志表中,额外存储了_raw_log_,配合跳数索引完成 近似日志全文检索。

欢迎使用APO全量日志功能


APO v0.6.0更新日志:

新增功能

  • 支持全量日志的采集、处理与展示功能

缺陷修复

  • 修复服务端点存在特殊字符时,无法获取到依赖延时曲线的问题
  • 修复无故障场景下频繁采集故障现场数据的问题
  • 修复部分场景下数据库调用无指标的问题
  • 修复传统服务器场景下,网络质量状态无法关联到告警的问题
  • 修复传统服务器场景下,OneAgent配置注入失败的问题

其他

  • 允许在创建 ClickHouse 表时选择是否创建副本
  • 向下兼容 ClickHouse 版本,当前支持最低版本为 22.8.x

APO介绍:

国内开源首个 OpenTelemetry 结合 eBPF 的向导式可观测性产品

apo.kindlingx.com

https://github.com/CloudDetail/apo

相关推荐
无级程序员8 小时前
clickhouse创建用户,登录出错的问题,code 516
linux·服务器·clickhouse
yangminlei9 小时前
Elasticsearch 全面解析:从原理到实战的分布式搜索引擎指南
java·elk
我爱娃哈哈9 小时前
SpringBoot + ELK + MDC:分布式系统日志追踪,快速定位跨服务调用链问题
spring boot·后端·elk
billy_gisboy1 天前
01-Windows+DockerDesktop部署ClickHouse
windows·clickhouse
billy_gisboy1 天前
02-Windows DockerDesktop部署ClickHouse,解决指定磁盘数据持久化问题与WSL2调优
windows·clickhouse
hkNaruto1 天前
【运维】低配服务器ClickHouse Docker部署故障复盘与优化配置指南
运维·服务器·clickhouse
有梦想有行动2 天前
ClickHouse的Partition和Part概念
linux·数据库·clickhouse
l1t2 天前
利用DeepSeek辅助翻译clickhouse SQL为DuckDB 格式求解Advent of Code 2025第10题 电子工厂 第二部分
数据库·人工智能·sql·clickhouse·duckdb
l1t2 天前
对clickhouse给出的二分法求解Advent of Code 2025第10题 电子工厂 第二部分的算法理解
数据库·算法·clickhouse
麦聪聊数据2 天前
基于SQL+CDC构建MySQL到ClickHouse的实时链路
sql·mysql·clickhouse