快速基于 ClickHouse + Grafana 搭建可观测性解决方案 - 分布式链路追踪篇(ClickHouse 官方博客)

引言

在 ClickHouse,我们认为可观测性仅仅是另一个实时分析问题。作为一款高性能的实时分析数据库,ClickHouse 被用于多种场景,包括时间序列数据的实时分析。其应用场景的多样性推动了大量分析函数的发展,这些函数有助于查询大多数数据类型。这些查询特性和高压缩率使得越来越多的用户开始利用 ClickHouse 来存储可观测性数据。这类数据通常以三种形式出现:logs(日志)、metrics(指标)和 traces(追踪)数据。在这篇博客中,作为可观测性系列的第二篇文章,我们将探讨如何在 ClickHouse 中收集、存储和查询 trace(追踪)数据。

本文主要关注使用 OpenTelemetry 收集追踪数据并将其存储到 ClickHouse 中。结合 Grafana 和最近在 ClickHouse 插件方面的发展,追踪数据可以轻松地可视化,并且可以与日志指标相结合,从而深入理解系统的行为和性能,在检测和诊断问题时提供帮助。

我们尽量确保所有示例都可以被复现。虽然本文着重于数据收集和可视化的基础知识,但我们还提供了一些关于模式优化的小贴士。为了示例的目的,我们从官方 OpenTelemetry 示例项目进行了分叉,增加了对 ClickHouse 的支持,并包含了一个开箱即用的 Grafana 仪表板来可视化追踪数据。

什么是 Traces(追踪数据)?

Telemetry(遥测)是指系统关于自身行为产生的数据。这类数据可以表现为日志、指标和追踪数据等形式。追踪记录了请求(由应用程序或终端用户发起)在微服务和 serverless 架构等多服务架构中的传播路径。一条追踪数据由多个跨度(span)组成,每个跨度代表一个工作单元或操作。跨度提供了操作详情,主要是执行所需的时间,此外还包括其他元数据和相关日志消息。这些跨度按树状结构组织,第一个跨度关联根节点,覆盖整个追踪从开始到结束的过程。在这个根节点及其后的每个跨度之下,捕获子操作。随着我们在树中导航,我们可以看到构成上层的子操作和步骤。这为我们提供了越来越多的上下文,了解原始请求执行的工作。这一过程如下图所示:

当追踪数据与指标和日志结合时,对于深入了解系统的运行情况以及检测和解决问题至关重要。

什么是 OpenTelemetry?

OpenTelemetry 项目是一个供应商中立的开源框架,它包含 SDK、API 和组件,允许摄入、转换并将可观测性数据发送到后端。更具体地说,它主要包括以下几个核心组件:

  • 一系列规格和约定 ,规定了如何收集和存储指标、日志和追踪数据。这包括针对特定语言代理的建议,以及基于protobuf的 OpenTelemetry 行协议 (OTLP) 的完整规范。这通过提供客户端-服务器接口和消息格式的完整描述,使数据可以在服务之间传输。
  • 特定语言的库和 SDK,用于仪器化、生成、收集和导出可观测性数据。这对于收集追踪数据尤其重要。
  • OTEL Collector,采用 Golang 编写,提供了一个供应商无关的实现,用于接收、处理和导出可观测性数据。OTEL Collector 通过支持 Prometheus 和 OTLP 等多种输入格式,以及广泛的导出目标(包括 ClickHouse),提供了一个集中式的处理网关。

总之,OpenTelemetry 标准化了日志、指标和追踪数据的收集。重要的是,它不负责将这些数据存储在可观测性后端中------这就是 ClickHouse 发挥作用的地方!

为什么选择 ClickHouse?

Trace 数据通常表示为一张表,每个跨度为一行,因此可以被视为另一种实时分析问题。除了为此类数据类型提供高压缩比外,ClickHouse 还提供了一个丰富的 SQL 接口,附加有分析函数,使得查询追踪变得简单。与 Grafana 结合使用时,用户可以获得一种成本效益高的方式来存储和可视化追踪数据。尽管其他存储解决方案可能提供类似的压缩水平,但 ClickHouse 在低延迟查询方面独树一帜,它是全球最快的分析型数据库。实际上,这些特性使得 ClickHouse 成为了许多商业可观测性解决方案首选的后端,例如:Signoz.ioHighlight.ioqrynBetterStack,或者像 UberCloudflareGitlab 这样的自建大规模可观测性平台。

Instrumentation(仪器化)库

为最流行的编程语言提供了仪器化库。这些库既提供了自动仪器化代码的功能,利用应用程序/服务框架来捕获最常见的指标和追踪数据,也提供了手动仪器化技术。虽然自动仪器化通常已足够,后者允许用户针对其代码的特定部分进行仪器化,可能更详细地捕捉应用程序特定的指标和追踪信息。

为了本篇博客的目的,我们只关注追踪信息的捕获。OpenTelemetry 示例应用由一个微服务架构组成,其中包含许多依赖服务,每个服务使用不同的语言,为实施者提供参考。下面的简单示例展示了如何仪器化一个 Python Flask API 来收集追踪数据:

py 复制代码
# These are the necessary import declarations
from opentelemetry import trace

from random import randint
from flask import Flask, request

# Acquire a tracer
tracer = trace.get_tracer(__name__)

app = Flask(__name__)

@app.route("/rolldice")
def roll_dice():
	return str(do_roll())

def do_roll():
	# This creates a new span that's the child of the current one
	with tracer.start_as_current_span("do_roll") as rollspan:
    	res = randint(1, 6)
    	rollspan.set_attribute("roll.value", res)
    	return res

对于每个库的详细指南超出了本篇博客的范围,我们鼓励用户阅读与其使用的语言相关的文档

OTEL Collector

OTEL 收集器接收来自可观测性来源的数据,如来自仪器化库的追踪数据,处理这些数据,并将其导出到目标后端。OTEL 收集器还可以通过支持多种输入格式(如 Prometheus 和 OTLP)以及广泛的导出目标(包括 ClickHouse)提供一个集中式处理网关。

Collector(收集器)使用了管道的概念。这些管道可以是日志、指标或追踪类型,并且由 receiver(接收器)、processor(处理器)和 exporter(导出器)组成。

在这个架构中的接收器充当 OTEL 数据的输入。这可以通过拉取或推送模型实现。虽然这可以在多种协议上发生,但从仪器化库来的追踪数据将通过 OTLP 使用 gRPC 或 HTTP 推送。随后处理器运行在这些数据上,提供过滤、批处理和丰富功能。最后,导出器通过推送或拉取的方式将数据发送到后端目的地。在我们的案例中,我们将把数据推送到 ClickHouse。

需要注意的是,虽然收集器更常被用作 gateway(网关)/aggregator(聚合器),处理诸如批处理和重试等任务,但它也可以作为代理本身部署------这对于日志收集非常有用,正如我们在之前的帖子中所述。OTLP 代表了网关和代理实例之间通信的 OpenTelemetry 数据标准,可以通过 gRPC 或 HTTP 实现。对于追踪数据的收集目的,收集器仅仅作为网关部署,如下所示:

对于高负载环境,可以构建更高级的架构。我们推荐这个优秀的视频讨论可能的选择

ClickHouse 支持

通过社区贡献,ClickHouse 在 OTEL 导出器中得到了支持,支持日志、追踪和指标。与 ClickHouse 的通信通过官方 Go 客户端以优化的原生格式和协议进行。在使用 OpenTelemetry 收集器之前,用户应考虑以下几点:

https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/clickhouseexporter

  • 代理使用的 ClickHouse 数据模型和模式是硬编码的。截至本文撰写时,没有能力更改所使用的类型或编解码器。通过在部署连接器之前创建表来缓解这个问题,从而强制执行您的 schema。

  • 导出器不是随核心 OTEL 发布一起分发的,而是作为 contrib 镜像的一个扩展。实际上这意味着在任何 HELM chart 中使用正确的 Docker 镜像。对于更轻量级的部署,用户可以构建自定义收集器镜像,仅包含所需的组件。

  • 截至版本 0.74,如果未设置为 default(如 demo 分支中所使用的),用户应在部署前预先在 ClickHouse 中创建数据库。

    sql 复制代码
    CREATE DATABASE otel
  • 导出器处于 Alpha 阶段,用户应遵循 OpenTelemetry 提供的建议

示例应用

OpenTelemetry 提供了一个演示应用,给出了 OpenTelemetry 实施的实际示例。这是一个分布式微服务架构,支持一个销售望远镜的网上商店。这个电子商务用例有助于创建一系列简单易懂的服务机会,例如推荐、支付和货币转换。网店受到负载生成器的影响,导致每个已仪器化的服务生成日志、追踪和指标。除了为实践者提供一个学习如何在其首选语言中进行仪器化的现实示例外,此演示还允许供应商展示其 OpenTelemetry 与其可观测性后端的集成。本着这一精神,我们分叉了这个应用并做了必要的更改,以便将追踪数据存储在 ClickHouse 中。

请注意上述架构中使用的语言范围和处理诸如支付和推荐等操作的组件数量。建议用户查看代码以了解其首选语言的服务。由于存在作为网关的收集器,因此无需对任何仪器化代码进行修改。这种架构分离是 OpenTelemetry 的一个明显优势 ------ 只需更改收集器中的目标导出器即可更改后端。

本地部署

演示使用每个服务的 Docker 容器。可以使用 docker compose官方文档 中概述的步骤来部署演示,将 ClickHouse 分支替换为原始仓库。

sh 复制代码
git clone https://github.com/ClickHouse/opentelemetry-demo.git
cd opentelemetry-demo/
docker compose up --no-build

我们已经修改了 docker-compose 文件,以包含一个 ClickHouse 实例,在其中存储数据,并对其他服务可用,名称为 clickhouse

使用 Kubernetes 部署

可以轻松地使用 官方说明 在 Kubernetes 中部署演示。我们建议复制 values 文件 并修改 收集器配置。一个示例 values 文件,将所有跨度发送到 ClickHouse Cloud 实例,可以在这里找到。可以下载并使用修改后的 helm 命令进行部署。

sh 复制代码
helm install -f values.yaml my-otel-demo open-telemetry/opentelemetry-demo

集成 ClickHouse

在此文中,我们专注于导出追踪数据。尽管日志和指标也可以存储在 ClickHouse 中,但我们为了简化起见使用默认配置。日志默认未启用,而指标则发送到 Prometheus。

要将追踪数据发送到 ClickHouse,必须通过文件 otel-config-extras.yaml 添加自定义 OTEL 收集器配置。这将与 主配置 合并,并覆盖任何现有声明。附加配置如下所示:

yaml 复制代码
exporters:
 clickhouse:
   endpoint: tcp://clickhouse:9000?dial_timeout=10s&compress=lz4
   database: default
   ttl_days: 3
   traces_table_name: otel_traces
   timeout: 5s
   retry_on_failure:
     enabled: true
     initial_interval: 5s
     max_interval: 30s
     max_elapsed_time: 300s

processors:
 batch:
   timeout: 5s
   send_batch_size: 100000

service:
 pipelines:
   traces:
     receivers: [otlp]
     processors: [spanmetrics, batch]
     exporters: [logging, clickhouse]

主要更改在于配置 ClickHouse 作为导出器。这里有几个关键设置:

  • endpoint 设置指定了 ClickHouse 主机和端口。注意通信通过 TCP(端口 9000)进行。对于安全连接,应使用 9440 端口,并带有 secure=true 参数,例如 'clickhouse://<username>:<password>@<host>:9440?secure=true'。或者,使用 dsn 参数。注意我们连接到主机 clickhouse。这是添加到本地部署 Docker 映像中的 ClickHouse 容器。可以自由修改此路径,例如指向 ClickHouse Cloud 集群。
  • ttl_days --- 这通过 TTL 功能控制 ClickHouse 中的数据保留。参见"Schema"部分。

我们的追踪管道利用 OTLP 接收器从仪器化库接收追踪数据。然后,此管道将这些数据传递给两个处理器:

Schema(模式)

一旦部署完成,我们可以通过对表 otel_traces 执行简单的 SELECT 来确认追踪数据正被发送到 ClickHouse。这代表了所有跨度发送的主要数据。请注意,我们使用 clickhouse-client 访问容器(它应该在主机上的默认 9000 端口上暴露)。

sh 复制代码
SELECT *
FROM otel_traces
LIMIT 1
FORMAT Vertical

Row 1:
──────
Timestamp:      	2023-03-20 18:04:35.081853291
TraceId:        	06cabdd45e7c3c0172a8f8540e462045
SpanId:         	b65ebde75f6ae56f
ParentSpanId:   	20cc5cb86c7d4485
TraceState:
SpanName:       	oteldemo.AdService/GetAds
SpanKind:       	SPAN_KIND_SERVER
ServiceName:    	adservice
ResourceAttributes: {'telemetry.auto.version':'1.23.0','os.description':'Linux 5.10.104-linuxkit','process.runtime.description':'Eclipse Adoptium OpenJDK 64-Bit Server VM 17.0.6+10','service.name':'adservice','service.namespace':'opentelemetry-demo','telemetry.sdk.version':'1.23.1','process.runtime.version':'17.0.6+10','telemetry.sdk.name':'opentelemetry','host.arch':'aarch64','host.name':'c97f4b793890','process.executable.path':'/opt/java/openjdk/bin/java','process.pid':'1','process.runtime.name':'OpenJDK Runtime Environment','container.id':'c97f4b7938901101550efbda3c250414cee6ba9bfb4769dc7fe156cb2311735e','os.type':'linux','process.command_line':'/opt/java/openjdk/bin/java -javaagent:/usr/src/app/opentelemetry-javaagent.jar','telemetry.sdk.language':'java'}
SpanAttributes: 	{'thread.name':'grpc-default-executor-1','app.ads.contextKeys':'[]','net.host.name':'adservice','app.ads.ad_request_type':'NOT_TARGETED','rpc.method':'GetAds','net.host.port':'9555','net.sock.peer.port':'37796','rpc.service':'oteldemo.AdService','net.transport':'ip_tcp','app.ads.contextKeys.count':'0','app.ads.count':'2','app.ads.ad_response_type':'RANDOM','net.sock.peer.addr':'172.20.0.23','rpc.system':'grpc','rpc.grpc.status_code':'0','thread.id':'23'}
Duration:       	218767042
StatusCode:     	STATUS_CODE_UNSET
StatusMessage:
Events.Timestamp:   ['2023-03-20 18:04:35.145394083','2023-03-20 18:04:35.300551833']
Events.Name:    	['message','message']
Events.Attributes:  [{'message.id':'1','message.type':'RECEIVED'},{'message.id':'2','message.type':'SENT'}]
Links.TraceId:  	[]
Links.SpanId:   	[]
Links.TraceState:   []
Links.Attributes:   []

每一行代表一个跨度,其中一些也是根跨度。有一些关键字段,基本理解这些字段将使我们能够构建有用的查询。有关追踪元数据的完整描述可在此处找到 这里

https://opentelemetry.io/docs/concepts/signals/traces/

  • TraceId - Trace Id 代表 Span 所属的追踪。
  • SpanId - Span 的唯一 ID。
  • ParentSpanId - Span 的父 Span 的 Span ID。这使得可以构建追踪调用历史。对于根跨度,这将是空的。
  • SpanName - 操作的名称。
  • SpanKind - 创建 Span 时,它的种类要么是客户端、服务器、内部、生产者或消费者。这个种类向追踪后端暗示了如何组装追踪。它实际上描述了 Span 与其子项和父项之间的关系。
  • ServiceName - Span 起源的服务名称,例如 Adservice。
  • ResourceAttributes - 包含元数据的键值对,您可以使用这些元数据来注解 Span,以携带有关正在跟踪的操作的信息。例如,这可能包含 Kubernetes 信息,如 Pod 名称或有关主机的值。请注意,我们的模式强制要求键和值都是字符串类型,并且使用 Map 类型。
  • SpanAttributes - 额外的 Span 层级属性,例如 thread.id
  • Duration - Span 的持续时间,以纳秒为单位。
  • StatusCode - 可能是 UNSET、OK 或 ERROR。当应用程序代码中出现已知错误(如异常)时,会设置后者。
  • Events* - 虽然可能不适合用于仪表板概览,但这些可能会引起应用程序开发者的兴趣。这可以视为 Span 上的一个结构化注解,通常用于标记 Span 持续时间内有意义的单一时刻,例如页面变为交互式时。Events.TimestampEvents.NameEvents.Attributes 可用于重建整个事件 --- 请注意,这依赖于数组位置。
  • Links* - 这些表示与其他 Span 的因果关系。例如,这些可能是由于特定操作而执行的异步操作。由于请求操作而排队的处理任务可能是一个合适的 Span 链接。这里,开发人员可能会将第一个追踪的最后一个 Span 与第二个追踪的第一个 Span 链接起来,以因果关联它们。在 ClickHouse 模式中,我们再次依赖于数组类型,并通过关联列 Links.TraceIdLinks.SpanIdLinks.Attributes 的位置来实现这一点。

请注意,收集器对模式有其偏好,包括强制实施特定的编解码器。虽然这些对于一般情况来说是合理的选择,但它们阻止了用户通过收集器配置调整配置以满足自身需求。希望修改编解码器或 ORDER BY 键的用户(例如,适应特定用户的访问模式),应在提前预创建表。

sh 复制代码
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)),
	`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)
TTL toDateTime(Timestamp) + toIntervalDay(3)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1

除了 TTL(见下文)之外,关于此模式还有一些重要的观察结果:

  • ORDER BY - 我们 schema 中的 ORDER BY 子句决定了数据如何排序并存储在磁盘上。这也会影响 稀疏索引的构建,并且最重要的是直接影响我们的压缩级别和查询性能。当前的子句 (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId) 按照从右到左的顺序对数据进行排序,并针对首先按 ServiceName 过滤的查询进行了优化。对后续排序的列的过滤限制将变得越来越无效。如果您的访问模式因诊断工作流程的不同而不同,您可能需要修改此顺序和使用的列。这样做时,请考虑最佳实践以确保 键得到最优利用

  • PARTITION BY - 此子句会导致数据在磁盘上物理分离。虽然这对于高效删除数据很有用(见下文的 TTL),但它可能会 正面和负面影响查询性能。基于分区表达式 toDate(Timestamp),它按天创建分区,针对最近数据(例如最后 24 小时)的查询将会受益。对于跨越多个分区/天数的查询(只有在您扩展保留期限超过默认的 3 天时才可能发生),相反可能会受到负面影响。如果您将数据保留期限扩展到几个月或几年,或者您的访问模式需要针对更宽的时间范围,则应考虑使用不同的表达式,例如按周分区,如果您的 TTL 为一年的话。

  • Map - 在上述 schema 中,Map 类型广泛用于属性。这是因为键在这里是动态的且与应用程序相关。Map 类型的灵活性是有用的,但也存在一定的代价。访问 Map 的键需要读取并加载整个列。因此,访问 Map 的键的成本比将键作为根级别的显式列更高------尤其是如果 Map 很大且包含许多键的情况下。性能差异取决于 Map 的大小,但可能会相当显著。为了应对这一问题,用户应该 将经常查询的 Map 键/值对物化 到根级别的列中。这些 物化列 将在插入时从相应的 Map 值填充,并可用于快速访问。下面是一个示例,我们将 Map 列 ResourceAttributes 中的键 host.name 物化到根级别的列 Host 中:

sql 复制代码
CREATE TABLE otel_traces
  (
      `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
      `HostName` String MATERIALIZED ResourceAttributes['host.name'],
      `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
       ....
  )
  ENGINE = MergeTree
  PARTITION BY toDate(Timestamp)
  ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
  TTL toDateTime(Timestamp) + toIntervalDay(3)

另外,这可以在数据插入后,一旦识别出您的访问模式时再进行:

sql 复制代码
ALTER TABLE otel_traces ADD COLUMN HostName String MATERIALIZED ResourceAttributes['host.name'];

ALTER TABLE otel_traces MATERIALIZE COLUMN HostName;

此过程需要使用变异(mutation),这可能会 I/O 密集且应谨慎安排。Map 类型还要求值必须是相同类型,在本例中为字符串类型。这种类型信息的丢失可能会在查询时需要进行类型转换。此外,用户应该了解访问 Map 键所需的语法 - 见"查询追踪"部分。

TTL

通过收集器参数 ttl_days,用户能够通过 ClickHouse 的 TTL 功能控制数据的过期时间。这个值反映在表达式 TTL toDateTime(Timestamp) + toIntervalDay(3) 中,默认值为 3。超过这个时间的数据将通过一个异步后台进程被删除。有关 TTL 的更多详细信息,请参阅 这里

上述模式使用了 PARTITION BY 来辅助 TTL 功能。具体来说,当结合参数 ttl_only_drop_parts=1 使用时,这允许一天的数据被有效地删除。如上所述,这可能会 正面和负面影响查询

Trace Id Materialized View(物化视图)

除了主表之外,ClickHouse 导出器还会创建一个 materialized view。Materialized view 是一种特殊的触发器,它会在数据插入到目标表时存储 SELECT 查询的结果。这个目标表可以汇总数据(使用聚合操作),并以针对特定查询优化的格式存储。在导出器的情况下,会创建以下视图:

sql 复制代码
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

这个具体的 materialized view 执行 GROUP BY TraceId 并识别每个 ID 的最大和最小时间戳。这在插入到表 otel_traces 的每一组数据(可能是数百万行)时执行。汇总后的数据随后被插入到目标表 otel_traces_trace_id_ts 中。下面展示了一些来自该表的行及其模式:

sql 复制代码
SELECT *
FROM otel_traces_trace_id_ts
LIMIT 5

┌─TraceId──────────────────────────┬─────────────────────────Start─┬───────────────────────────End─┐
│ 000040cf204ee714c38565dd057f4d97 │ 2023-03-20 18:39:44.064898664 │ 2023-03-20 18:39:44.066019830 │
│ 00009bdf67123e6d50877205680f14bf │ 2023-03-21 07:56:30.185195776 │ 2023-03-21 07:56:30.503208045 │
│ 0000c8e1e9f5f910c02a9a98aded04bd │ 2023-03-20 18:31:35.967373056 │ 2023-03-20 18:31:35.968602368 │
│ 0000c8e1e9f5f910c02a9a98aded04bd │ 2023-03-20 18:31:36.032750972 │ 2023-03-20 18:31:36.032750972 │
│ 0000dc7a6d15c638355b33b3c6a8aaa2 │ 2023-03-21 00:31:37.075681536 │ 2023-03-21 00:31:37.247680719 │
└──────────────────────────────────┴───────────────────────────────┴───────────────────────────────┘

5 rows in set. Elapsed: 0.009 sec.


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))
TTL toDateTime(Start) + toIntervalDay(3)

如所示,目标表 otel_traces_trace_id_ts 使用 (TraceId, toUnixTimestamp(Start)) 作为其 ORDER BY 键。这样就可以让用户快速确定特定追踪的时间范围。

我们在下面的 "查询追踪" 部分探讨了这个 materialized view 的价值,但我们发现它在加速执行 TraceId 查找的更广泛的查询方面的作用有限。然而,它确实提供了一个很好的入门示例供用户借鉴。

用户可能希望扩展或修改这个 materialized view。例如,可以在 materialized view 的聚合和目标表中添加一个 ServiceName 数组,以便快速识别追踪的服务。可以通过在部署收集器之前预创建表和 materialized view 来实现这一点,或者 在创建后修改视图和表。用户还可以向主表附加新的 materialized view 以满足其他访问模式需求。请参阅 我们最近的博客 获取更多详细信息。

最后,上述功能也可以通过 projections 实现。尽管它们没有提供所有 Materialized view 的功能,但它们直接包含在表定义中。与 Materialized Views 不同,projections 会被原子性地更新,并与主表保持一致,ClickHouse 会在查询时自动选择最优版本。

查询追踪数据

导出器文档 提供了一些优秀的入门查询示例。需要更多灵感的用户可以参考我们在下面展示的仪表板中的这些查询。在查询追踪数据时有几个重要的概念:

https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/clickhouseexporter/README.md#traces
https://gist.github.com/gingerwizard/dbc250063933d76462faf117b4d56b9a

  • 在主 otel_traces 表上进行 TraceId 查找可能会比较耗时,尽管有布隆过滤器的存在。如果你需要深入查看某个特定的追踪记录,可以利用 otel_traces_trace_id_ts 表来确定追踪记录的时间范围 ------ 如上所述。这个时间范围随后可以作为附加过滤条件应用于 otel_traces 表,该表包含了时间戳作为 ORDER BY 键的一部分。如果在查询中应用服务名称作为过滤条件,则查询可以进一步优化(尽管这会限制到特定服务的跨度)。考虑以下两种查询变体及其各自的时间,这两种查询都会返回与追踪相关的跨度。

只使用 otel_traces 表:

sql 复制代码
SELECT
        Timestamp,
    TraceId,
    SpanId,
    SpanName
FROM otel_traces
WHERE TraceId = '0f8a2c02d77d65da6b2c4d676985b3ab'
ORDER BY Timestamp ASC

50 rows in set. Elapsed: 0.197 sec. Processed 298.77 thousand rows, 17.39 MB (1.51 million rows/s., 88.06 MB/s.)

利用我们的 otel_traces_trace_id_ts 表,并使用得到的时间来作为过滤条件:

sql 复制代码
WITH '0f8a2c02d77d65da6b2c4d676985b3ab' AS trace_id,
     (
        SELECT min(Start)
        FROM otel_traces_trace_id_ts
  	   WHERE TraceId = trace_id
     ) AS start,
     (
  	   SELECT max(End) + 1
  	   FROM otel_traces_trace_id_ts
  	   WHERE TraceId = trace_id
     ) AS end
 SELECT  Timestamp,
     TraceId,
     SpanId,
     SpanName
 FROM otel_traces
 WHERE (TraceId = trace_id) AND (Timestamp >= start) AND (Timestamp <= end)
 ORDER BY Timestamp ASC

 50 rows in set. Elapsed: 0.110 sec. Processed 225.05 thousand rows, 12.78 MB (2.05 million rows/s., 116.52 MB/s.)
sql 复制代码
SELECT
    toStartOfHour(Timestamp) AS hour,
    count(*),
    lang,
    avg(Duration) AS avg,
    quantile(0.9)(Duration) AS p90,
    quantile(0.95)(Duration) AS p95,
    quantile(0.99)(Duration) AS p99
FROM otel_traces
WHERE (ResourceAttributes['host.name']) = 'bcea43b12a77'
GROUP BY
    hour,
    ResourceAttributes['telemetry.sdk.language'] AS lang
ORDER BY hour ASC

确定可用于查询的 Map 键可能很具挑战性 ------ 特别是如果应用程序开发人员添加了自定义元数据。使用一个聚合组合函数,下面的查询可以识别 ResourceAttributes 列中的键。根据需要调整以适应其他列,例如 SpanAttributes

sql 复制代码
SELECT groupArrayDistinctArray(mapKeys(ResourceAttributes)) AS `Resource Keys`
FROM otel_traces
FORMAT Vertical

Row 1:
──────
Resource Keys:    ['telemetry.sdk.name','telemetry.sdk.language','container.id','os.name','os.description','process.pid','process.executable.name','service.namespace','telemetry.auto.version','os.type','process.runtime.description','process.executable.path','host.arch','process.runtime.version','process.runtime.name','process.command_args','process.owner','host.name','service.instance.id','telemetry.sdk.version','process.command_line','service.name','process.command','os.version']

1 row in set. Elapsed: 0.330 sec. Processed 1.52 million rows, 459.89 MB (4.59 million rows/s., 1.39 GB/s.)

使用 Grafana 进行可视化与诊断

我们推荐使用 Grafana 结合官方 ClickHouse 插件来可视化和分析追踪数据。之前的文章视频教程已深入解析了此插件的使用方法。近期,我们对该插件进行了升级,新增了利用追踪面板展示追踪数据的功能,这不仅作为一种可视化方式,还能够融入到 Grafana 的 Explore 界面中。追踪面板对列有严格的命名和类型要求,遗憾的是,这些要求当前与 OTEL 规范不完全一致。以下查询能产生适合在追踪可视化中展示的结果:

sql 复制代码
WITH
	'ec4cff3e68be6b24f35b4eef7e1659cb' AS trace_id,
	(
    	SELECT min(Start)
    	FROM otel_traces_trace_id_ts
    	WHERE TraceId = trace_id
	) AS start,
	(
    	SELECT max(End) + 1
    	FROM otel_traces_trace_id_ts
    	WHERE TraceId = trace_id
	) AS end
SELECT
	TraceId AS traceID,
	SpanId AS spanID,
	SpanName AS operationName,
	ParentSpanId AS parentSpanID,
	ServiceName AS serviceName,
	Duration / 1000000 AS duration,
	Timestamp AS startTime,
	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 (Timestamp >= start) AND (Timestamp <= end)
ORDER BY startTime ASC

通过 Grafana 中的变量数据链接功能,用户可以创建复杂的交互工作流,实现图表的动态过滤。下面的仪表板包含了多种可视化元素:

https://grafana.com/docs/grafana/latest/dashboards/variables/
https://grafana.com/docs/grafana/latest/panels-visualizations/configure-data-links/

  • 以堆叠柱状图展示服务请求量概览
  • 多线图展示各服务的 99% 延迟
  • 条形图展示每个服务的错误率
  • 按 traceId 聚合的追踪列表------服务在此作为跨度链中的首项
  • 当筛选到特定追踪时填充的追踪面板

此仪表板为我们提供了关于错误和性能的基本诊断能力。OpenTelemetry demo 中包含了一些现有场景,用户可以在其中启用特定服务问题。其中一个场景涉及推荐服务的内存泄漏。虽然没有指标数据无法完成整个问题解决流程,但我们仍能识别出有问题的追踪记录。示例如下:

此仪表板现已随插件一起打包提供,并且可通过示例 demo获取。

使用 Parameterized Views(参数化视图)

上述查询可能相当复杂。例如,我们不得不使用 arrayMap 函数来确保属性结构正确。我们可以将此工作推迟到查询时的物化或默认列处理,从而简化查询。然而,这仍然需要编写较多的SQL代码。在Explore视图中可视化追踪时,这一点尤其繁琐。

为了简化查询语法,ClickHouse 提供了参数化视图功能。参数化视图类似于普通视图,但可以在创建时带有不立即解析的参数。这些视图可以通过表函数来使用,表函数将视图名称作为函数名,参数值作为其参数。这能极大地减少 Grafana 用户所需的 ClickHouse 查询语法。下面创建了一个接受 traceId 作为参数并返回追踪面板所需结果的视图。尽管参数化视图中最近新增了对 CTE 的支持,但下面示例中我们还是使用了较早的简单查询:

sql 复制代码
CREATE VIEW trace_view AS
SELECT
	TraceId AS traceID,
	SpanId AS spanID,
	SpanName AS operationName,
	ParentSpanId AS parentSpanID,
	ServiceName AS serviceName,
	Duration / 1000000 AS duration,
	Timestamp AS startTime,
	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:String}

运行此视图时,我们只需传入一个 traceId,例如,

sql 复制代码
SELECT *
FROM trace_view(trace_id = '1f12a198ac3dd502d5201ccccad52967')

这样可以显著降低查询追踪记录的复杂度。接下来,我们使用 Grafana 的 Explore 视图来查询特定追踪。请注意,需将 Format 值设为 Trace 才能呈现追踪图:

参数化视图最适合于那些用户执行常规任务,需要即席分析的常见工作负载场景,比如检查特定追踪记录。

压缩

ClickHouse 用于存储追踪数据的一个优势在于其高压缩比。使用下面的查询,我们可以看到对于这个演示生成的追踪数据,我们实现了大约 9 到 10 倍的压缩率。这个数据集是在使用提供的 负载生成器服务 对演示进行压力测试时生成的,测试条件为 2000 个虚拟用户持续 24 小时。我们已经将这个数据集公开供大众使用。可以按照 这里的步骤 将数据插入到 ClickHouse 中。为了托管这些数据,我们建议使用 ClickHouse Cloud 的开发服务(16GB 内存,两核),这对于这种规模的数据集来说已经足够。

sh 复制代码
SELECT
	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_traces'
ORDER BY sum(data_compressed_bytes) DESC

┌─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ 13.68 GiB   	  │ 132.98 GiB    	  │  9.72 │
└─────────────────┴───────────────────┴───────┘

1 row in set. Elapsed: 0.003 sec.

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_traces'
GROUP BY name
ORDER BY sum(data_compressed_bytes) DESC

┌─name───────────────┬─compressed_size─┬─uncompressed_size─┬───ratio─┐
│ ResourceAttributes │ 2.97 GiB    	   │ 78.49 GiB     	   │   26.43 │
│ TraceId        	 │ 2.75 GiB    	   │ 6.31 GiB      	   │	2.29 │
│ SpanAttributes 	 │ 1.99 GiB    	   │ 22.90 GiB     	   │   11.52 │
│ SpanId         	 │ 1.68 GiB    	   │ 3.25 GiB      	   │	1.94 │
│ ParentSpanId   	 │ 1.35 GiB    	   │ 2.74 GiB      	   │	2.02 │
│ Events.Timestamp   │ 1.02 GiB        │ 3.47 GiB      	   │ 	3.4  │
│ Timestamp      	 │ 955.77 MiB  	   │ 1.53 GiB      	   │	1.64 │
│ Duration       	 │ 619.43 MiB  	   │ 1.53 GiB      	   │	2.53 │
│ Events.Attributes  │ 301.09 MiB      │ 5.12 GiB      	   │   17.42 │
│ Links.TraceId  	 │ 36.52 MiB       │ 1.60 GiB      	   │   44.76 │
│ Events.Name    	 │ 22.92 MiB       │ 248.91 MiB    	   │   10.86 │
│ Links.SpanId   	 │ 17.77 MiB       │ 34.49 MiB     	   │	1.94 │
│ HostName       	 │ 8.32 MiB        │ 4.56 GiB      	   │  561.18 │
│ StatusCode     	 │ 1.11 MiB    	   │ 196.80 MiB        │  177.18 │
│ StatusMessage  	 │ 1.09 MiB    	   │ 219.08 MiB    	   │  201.86 │
│ SpanName       	 │ 538.55 KiB  	   │ 196.82 MiB    	   │  374.23 │
│ SpanKind       	 │ 529.98 KiB  	   │ 196.80 MiB    	   │  380.25 │
│ ServiceName    	 │ 529.09 KiB  	   │ 196.81 MiB    	   │   380.9 │
│ TraceState     	 │ 138.05 KiB  	   │ 195.93 MiB    	   │ 1453.35 │
│ Links.Attributes   │ 11.10 KiB   	   │ 16.23 MiB     	   │ 1496.99 │
│ Links.TraceState   │ 1.71 KiB    	   │ 2.03 MiB          │ 1218.44 │
└────────────────────┴─────────────────┴───────────────────┴─────────┘

20 rows in set. Elapsed: 0.003 sec.

我们将在本系列的后续博客中探讨如何优化模式以及如何进一步提高压缩率。

后续工作

当前的 ClickHouse 导出器处于 Alpha 阶段。这一状态反映了它发布的相对较新以及成熟度。虽然我们计划在这个导出器上投入资源,但我们已经识别出一些挑战和可能的改进方向:

  • Schema(模式) - 当前 schema 包含了一些可能过早的优化措施。例如,使用布隆过滤器可能对于某些工作负载来说并不必要。如下面所示,这些过滤器会占用一定的空间(大约占总数据大小的 1%)。
sh 复制代码
SELECT
  formatReadableSize(sum(secondary_indices_compressed_bytes)) AS compressed_size,
  formatReadableSize(sum(secondary_indices_uncompressed_bytes)) AS uncompressed_size
 FROM system.parts
 WHERE (table = 'otel_traces') AND active
 
 ┌─compressed_size─┬─uncompressed_size─┐
 │ 425.54 MiB      │ 1.36 GiB          │
 └─────────────────┴───────────────────┘

我们发现 TraceId 列上的过滤器非常有效,值得加入到 schema 中。这似乎抵消了大部分物化视图带来的好处,后者增加了额外的查询和维护复杂性,其价值令人怀疑。不过,它提供了一个很好的例子,说明了物化视图如何可以用来加速查询。我们没有足够的证据证明其他布隆过滤器的价值,因此我们建议用户自行尝试。

  • High memory(高内存消耗) - 我们发现 OTEL 收集器非常耗内存。在早期配置中,我们使用 batch processor 在 5 秒后或者当批处理达到 100,000 行时向 ClickHouse 发送数据。虽然这优化了 ClickHouse 的插入操作并且遵循了 最佳实践,但在高负载下可能会非常耗内存 ------ 特别是在 收集日志 时。这个问题可以通过减少刷新时间和/或批处理大小来缓解。需要注意的是,这样做需要进行调优,否则可能导致 ClickHouse 中积累过多的分区。另外,用户也可以选择使用 异步插入 来发送数据。这会减少批处理大小,但在导出器中仅支持通过 HTTP 协议。要激活此功能,请在导出器配置中使用 connection_params,例如,
yaml 复制代码
connection_params:
 	async_insert: 1
 	wait_for_async_insert: 0

需要注意的是,这种方式对于 ClickHouse 的插入操作来说效率较低。

结论

这篇博客展示了如何使用 OpenTelemetry 轻松地收集和存储追踪数据到 ClickHouse。我们已经为 ClickHouse 分叉了 OpenTelemetry 的 demo 项目,探讨了使用 Grafana 查询和可视化技术的方法,并指出了减少查询复杂性和未来项目工作的几个方向。对于更进一步的阅读,我们鼓励用户探索本文之外的主题,例如如何大规模部署 OTEL 收集器、如何处理背压以及如何保证交付。我们将在后续的博文中探讨这些主题,并在我们的 ClickHouse 实例中添加指标数据,然后再探讨如何优化 Schema 以及如何管理数据生命周期。

更多