几年前,我写过一篇 APM 相关的文章,介绍了我们自己开发的一套以 metrics 为主的系统,这个系统现在还在很好地跑着。后来又写了一篇介绍 Prometheus 的文章,介绍了它的各种数据结构以及怎么在 Grafana 里面制图。

今天要介绍的是开源社区非常火的 APM 项目 SigNoz,它的特点是它集齐了 Logs、Traces 和 Metrics 的功能,而且部署成本很低,如果是初创公司,流量不是特别大,可以直接无脑选择它。

这篇文章不是安装教程,也不是使用教程,本文只关心一件事:数据是怎么流动的。traces、logs、metrics 这三种数据分别长什么样,客户端怎么采集,服务端收到以后怎么落到 ClickHouse,采样在哪一层做,以及这套东西的难点和瓶颈在哪里。目标是让大家读完以后,脑子里有一个 SigNoz 的数据处理模型,遇到问题知道往哪里看,或者作为你做架构选型的一个参考。

本文假设读者熟悉 Logs、Traces、Metrics,知道它们分别解决的是什么问题,如果了解 ClickHouse 更好。

总览

先看一张总图,SigNoz 的数据链路其实非常短:

overview

三个角色:

  • 应用侧用 OpenTelemetry 的 SDK 或者 agent 采集数据,通过 OTLP 协议(gRPC 走 4317 端口,HTTP 走 4318 端口)发出去。SigNoz 自己不提供任何客户端,完全依赖 OpenTelemetry。(OpenTelemetry 如今几乎就是 APM 的标准,所有新起的 APM 系统几乎都直接支持它,上面说的 OTLP 就是指 OpenTelemetry Protocol)
  • signoz-otel-collector 是 SigNoz 基于 OpenTelemetry Collector 做的发行版,它负责接收、批处理,然后用自己写的三个 exporter 分别把三种数据写入 ClickHouse
  • query-service 负责把页面上的查询翻译成 ClickHouse SQL,前端只是展示

在进入细节之前,先给大家一个总的印象:SigNoz 的服务端非常"薄"。它不像 Cat 那样在服务端内存里做大量的实时统计,而是把原始数据尽量原样写进 ClickHouse,聚合的活全部交给 ClickHouse 在查询时来做。这一点是理解 SigNoz 的关键,三种数据的处理都是这个思路,后面会反复看到。

下面我们按 traces、logs、metrics 的顺序,逐个把"数据长什么样、客户端怎么采、服务端怎么存"讲一遍。traces 最复杂,篇幅也最长,所以先说它,logs 和 metrics 看完 traces 以后会轻松很多。

Traces

一条 span 长什么样

SigNoz 没有自己的数据模型,它完整地使用 OpenTelemetry 的模型,所以这一节说的其实是 OTel 的 span。我把 OTLP 的 protobuf 结构按 JSON 写出来,并加上注释:

{
  // 1. 身份,这两个是最重要的字段,后面单独说
  "trace_id": "5b8aa5a2d2c872e8321cf37308d69df2",   // 16 字节,通常显示为 32 位 hex
  "span_id": "051581bf3cb55c13",                    // 8 字节,16 位 hex
  "parent_span_id": "",                             // 空表示这是 root span

  // 2. 这个 span 干了什么
  "name": "GET /api/orders/{id}",                   // 操作名,注意不能带动态参数,否则基数爆炸
  "kind": "SPAN_KIND_SERVER",                       // SERVER / CLIENT / INTERNAL / PRODUCER / CONSUMER
  "start_time_unix_nano": 1726200000000000000,
  "end_time_unix_nano":   1726200000123000000,      // 没有 duration 字段,duration 是算出来的

  // 3. 结果,code 决定了这个 span 在后台展示的时候,是不是红色的
  "status": { "code": "STATUS_CODE_ERROR", "message": "timeout" },  // 只有三个值 UNSET / OK / ERROR

  // 4. 属性,就是 key-value,语义约定规定了常用的 key 怎么命名
  "attributes": {
    "http.request.method": "GET",
    "http.route": "/api/orders/{id}",
    "http.response.status_code": 500,
    "user.id": "u_1001"                             // 业务自己加的
  },

  // 5. 事件,带时间戳的点,挂在 span 上,异常也是走这里
  "events": [
    {
      "name": "exception",
      "time_unix_nano": 1726200000120000000,
      "attributes": {
        "exception.type": "java.net.SocketTimeoutException",
        "exception.message": "Read timed out",
        "exception.stacktrace": "java.net.SocketTimeoutException: Read timed out\n\tat ..."
      }
    }
  ],

  // 6. 链接,指向其他 trace 里的 span,主要用在消息队列这种异步场景,先不用关注
  "links": []
}

上面这些是 span 自己的字段,但是在 OTLP 的传输结构里,span 不是孤零零发出去的,客户端会缓存一段时间的 span 一起发出去,它外面还包了两层:

ResourceSpans                       // 描述"谁"产生的数据
  resource.attributes               // service.name, host.name, k8s.pod.name, deployment.environment ...
  ScopeSpans                        // 描述"哪个埋点库"产生的数据
    scope.name / scope.version      // 比如 io.opentelemetry.spring-webmvc-6.0
    spans[]                         // 上面那种 span,一次可以发一批

Resource 这一层很重要,service.name 就是从这里来的。因为同一个进程发出去的所有 span 的 resource 都一样,所以 OTLP 把它提到外面只传一份,后面我们会看到 SigNoz 在存储的时候也利用了这个特点。

简单总结一下,一条 span 就是:我是谁(resource),我叫什么(name、kind),我从什么时候到什么时候(start、end),我的结果如何(status),我有哪些标签(attributes),我身上发生了什么事(events),以及我在树里的位置(trace_id、span_id、parent_span_id)。

span 就是 transaction,event 附着在 span 上

用过 Cat 的读者应该已经发现了,OTel 的 span 和 Cat 的 Transaction 几乎就是一个东西:都有开始时间和耗时,都可以嵌套,都可以携带 key-value 数据。Cat 的 Event 表示"发生了某件事",没有耗时,OTel 的 span event 也是这个意思。不过有两个区别值得说一下。

第一,Cat 的 Transaction 有 type 和 name 两级维度,Event 也可以参与统计,比如统计某个 event 发生了多少次。OTel 的 span 只有一个 name,type 的信息藏在 kind 和 attributes 里(比如 db.system 是 mysql 就说明这是一个 SQL 调用),而 span event 基本不参与任何统计,它就是 span 上的一条带时间的注解,你可以理解为附着在 span 上的结构化日志

第二,也是最本质的区别,Cat 的 Transaction 通过 children 持有子节点,客户端在 root transaction complete 的时候,把整棵 message tree 一次性投递出去,服务端收到的就是一棵完整的树。而 OTel 的 span 之间没有对象引用,只有 parent_span_id 这一个字符串,SDK 是每个 span 一结束(调用 end()),就把它扔进 BatchSpanProcessor 的队列,攒够一批或者到时间就发出去,完全不管它的父亲和兄弟有没有结束。所以服务端收到的是一堆零散的 span,同一个 trace 的 span 可能分散在不同的批次、不同的时间、甚至不同的 collector 实例上,那棵树是在查询的时候,靠 trace_id 把 span 捞出来,再按 parent_span_id 在内存里重新拼起来的。

这个设计让客户端变得非常简单,不用担心树太大、不用担心内层 transaction 忘了 complete 这些问题,但是它把复杂度推给了服务端和采样,后面讲 tail sampling 的时候大家就能体会到了。

客户端怎么采集

traces 靠埋点。自动埋点覆盖 web 框架入口、HTTP client、数据库驱动、消息队列客户端这些地方,Java 这边就是一个 javaagent,启动参数加上就行;手动埋点用 tracer.spanBuilder(...).startSpan() 自己开 span。每个 span 结束时进入 BatchSpanProcessor 的队列,攒批通过 OTLP 发送。

在 Rust 中会稍稍麻烦些,因为 Rust 没有 javaagent 这种运行时织入的东西,会比较麻烦些,不过 tracing、tracing-opentelemetry 等 crate 已经非常好用了,无非就是自己需要手动织入。

跨进程的部分靠 context propagation。A 服务调 B 服务的时候,A 的 HTTP client 埋点会在请求头里塞一个 traceparent:

traceparent

B 的 server 埋点解析这个头,用同一个 trace_id 开新的 span,parent_span_id 就是头里的那个 span_id。这是 W3C Trace Context 规范定的格式,各种语言的 SDK 都遵守,所以异构系统之间也能串起来。消息队列的场景也是一样的,PRODUCER 把 traceparent 塞进消息的 header,CONSUMER 取出来。

异常怎么挂到 span 上

OTel 里没有专门的"错误"类型,异常就是一个名字叫 exception 的 span event。你在代码里调用 span.recordException(e),或者自动埋点捕获到未处理异常的时候,SDK 做的事情就是往当前 span 的 events 里追加一条(Rust 没有异常这个概念,tracing-opentelemetry 的对应做法是在 span 上记一个名为 error 的字段,它会转成一条 exception 事件,只是 Rust 的 error 默认不带调用栈,所以 exception.stacktrace 里基本是空的或者只有 error 的 source 链):

event.name = "exception"
event.attributes:
  exception.type       // 异常类名
  exception.message    // e.getMessage()
  exception.stacktrace // 完整堆栈

同时通常会把 span 的 status 设为 ERROR。注意这是两个独立的动作,只 recordException 不设 status 的话,span 在 SigNoz 里不会被标记为出错,这是新手比较容易踩的一个坑。Rust 里也一样,记了 error 字段以后,还要另外把 otel.status_code 这个字段设成 ERROR,span 才会变红。

服务端这边,traces exporter 在处理每个 span 的 events 时,会检查 event 名字是不是 exception(或者以 .exception 结尾),是的话就把它单独抽出来,写到 signoz_error_index_v2 这张表里,一行一个异常,带上 trace_id、span_id、service_name、exception.type、exception.message、exception.stacktrace,另外生成一个用来分组的 group id,算法是 md5(serviceName + exception.type)。页面上的 Exceptions 视图就是查这张表,按 group id 聚合出每种异常的次数、首次和最近出现时间,点进去可以通过 trace_id 跳到对应的链路。功能上和 Cat 的 Problem 报表是一个意思,只是 Cat 在服务端内存里按分钟做了计数,SigNoz 是存一行算一行。

服务端怎么落库

traces exporter 拿到一批 span 以后,每个 span 变成 signoz_index_v3 表的一行。我把这张表的关键列列出来,省略了一些兼容性的别名列,大家看列名就能明白它做了什么:

CREATE TABLE signoz_traces.signoz_index_v3
(
    -- 排序键相关,下面解释
    ts_bucket_start      UInt64,            -- timestamp 向下取整到 30 分钟
    resource_fingerprint String,            -- resource 属性集合的哈希

    -- span 本身的字段,基本是原样搬过来
    timestamp            DateTime64(9),
    trace_id             FixedString(32),
    span_id              String,
    parent_span_id       String,
    name                 LowCardinality(String),
    kind                 Int8,
    duration_nano        UInt64,            -- end - start,写入时算好
    status_code          Int16,
    status_message       String,
    has_error            Bool,              -- status_code == ERROR

    -- attributes 按类型拆成三个 Map
    attributes_string    Map(LowCardinality(String), String),
    attributes_number    Map(LowCardinality(String), Float64),
    attributes_bool      Map(LowCardinality(String), Bool),
    resources_string     Map(LowCardinality(String), String),

    -- events 和 links 序列化成 JSON 字符串数组,不做结构化
    events               Array(String),
    links                String,

    -- 常用属性提升为独立列,方便过滤
    response_status_code LowCardinality(String),
    http_method          LowCardinality(String),
    db_name              LowCardinality(String),
    `resource_string_service$$name` LowCardinality(String)
    -- ... 还有 http_route、rpc_method 等一批物化列
)
ENGINE = MergeTree
PARTITION BY toDate(timestamp)
ORDER BY (ts_bucket_start, resource_fingerprint, has_error, name, timestamp)
TTL toDateTime(timestamp) + toIntervalSecond(1296000)   -- 15 天

几个设计点值得说一下。

第一是 ORDER BY。如果看过我之前写的 ClickHouse 那篇就知道,MergeTree 的 ORDER BY 决定了数据的物理排布,也决定了什么查询快。这里排在最前面的是 30 分钟的时间桶和 resource fingerprint,意思是:同一个服务在同一个半小时内的 span,在磁盘上是挨在一起的。这正好对应最常见的查询模式,选一个时间范围,选一个服务,然后过滤。exporter 在写入的时候会对每个 span 的 resource 属性算一个 fingerprint,同时把 fingerprint 到属性集合的映射写到一张很小的 traces_v3_resource 表里。查询的时候先去小表里按 service.name 找出 fingerprint 列表,再拿这个列表去大表里做主键过滤,这就是前面说的 OTLP 把 resource 提到外面只传一份,在存储层的对应做法。

第二是 attributes 的存法。span 的 attributes 是任意的 key-value,SigNoz 没有为每个 key 建列,而是按值的类型拆成三个 Map 列,查询的时候用 attributes_string['user.id'] 这种语法。Map 列过滤是要扫数据的,所以对于常用的属性,SigNoz 又把它们提升成了独立的物化列,比如 http_method、db_name,页面上的"索引字段"功能就是让你自己再指定一些。这种"通用 Map 加少数物化列"的方案,是 ClickHouse 存半结构化数据的常规套路。

第三是 events 的存法。events 直接序列化成 JSON 字符串数组塞进去,不做任何结构化,只有 exception 事件被单独抽到了 error index 表。这意味着你没法按 event 的属性去过滤 span,SigNoz 也确实没提供这个能力。

除了主表,写入的时候还会附带做几件事。tag_attributes_v2 表记录见过的每个属性 key 和它的值(用于页面上的自动补全),这张表写失败不阻塞主流程,而且 exporter 会定期从库里读一份"值太多的 key"的名单,对这些 key 直接跳过不记录值,这是防高基数的一个手段。另外还有几个物化视图挂在主表上,trace_summary 按 trace_id 聚合出每个 trace 的开始时间、结束时间和 span 数,dependency_graph_minutes 从 CLIENT 和 SERVER 类型的 span 里按分钟统计服务之间的调用关系,用来画服务拓扑图。

查询这边,trace 详情页的逻辑就是 select * where trace_id = ?,把所有 span 捞回 query-service,在 Go 的内存里按 parent_span_id 拼成树,再返回给前端画瀑布图。这里有个细节,trace_id 不在 ORDER BY 里,只有一个 bloom filter 跳数索引,所以按 trace_id 查其实是需要缩小时间范围的,query-service 会先去 trace_summary 拿这个 trace 的起止时间,再带着时间条件去主表查。

sampling

SigNoz 自身默认不做任何采样,收到多少 span 就存多少。控制数据量有三个地方可以下手,从前往后分别是 SDK、collector 的 probabilistic sampler、collector 的 tail sampler。

先说 head sampling,在 SDK 里配置,比如 Java agent 加两个环境变量:

OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

这两个环境变量是 OTel 规范里定的,不只 Java,Rust 的 opentelemetry_sdk 也认。意思是 root span 按 trace_id 的比例采 10%,非 root span 跟随父亲的决定。决定是在 trace 一开始就做的,结果写进 traceparent 头的 flags 里跟着请求一起传播。注意看前面介绍的跨服务请求的 header 中 traceparent 的最后一部分:

traceparent

所以下游服务不需要重新决策,一个 trace 要么全留要么全丢。它的问题也很明显,决策的时候请求还没执行,不知道会不会报错、会不会慢,错误请求和正常请求被丢的概率是一样的。

然后是 collector 里的 probabilistic_sampler 处理器,按百分比丢 span。它对 trace_id 做哈希来决定去留,同一个 trace_id 在任何一个 collector 实例上算出的结果都一样,所以即使 span 分散到了不同实例,也能保证一个 trace 的完整性。效果和 head sampling 差不多,只是把决策从应用侧挪到了 collector 侧,方便统一管理。

真正有价值的是 tail sampling,它是在 trace 结束以后再决定要不要留,所以可以做到"错误的全留、慢的全留、正常的只留 5%":

processors:
  tail_sampling:
    decision_wait: 10s          # 收到一个 trace 的第一个 span 后,等 10 秒再决策
    num_traces: 50000           # 内存里最多缓存多少个 trace,超了就把最老的踢掉
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 1000 }
      - name: sample-rest
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

多个 policy 之间是 OR 的关系,命中任何一条就保留。

这个功能的代价来自于前面说的那个本质区别:span 是零散到达的。要在 trace 结束后做决策,collector 就必须把一个 trace 的所有 span 在内存里攒齐,攒的时候还不知道 trace 什么时候结束,只能靠 decision_wait 这个固定的等待时间来猜。内存占用等于新 trace 的速率乘以等待时间乘以每个 trace 的大小,流量一大很容易超过 num_traces 的上限,超了以后最老的 trace 会没经过评估就被丢掉。

更麻烦的是多实例。一个 trace 的 span 来自多个服务,如果 collector 部署了多个实例做负载均衡,同一个 trace 的 span 就会落到不同实例上,每个实例都只看到半棵树,决策就是错的。所以官方推荐的做法是两层 collector,第一层不做采样,只用 loadbalancing exporter 按 trace_id 做一致性哈希,把同一个 trace 的 span 全部转发到第二层的同一个实例,第二层再做 tail sampling。

还有一个副作用容易被忽略:后面 metrics 一节会说到,Services 页面的 RPS 和错误率是从 span 派生出来的,做了采样以后这些指标就只反映被采样的那部分流量,总量会偏小,错误率会偏高(因为错误全留了)。如果你在意这些指标,要么把派生指标的处理器放在 tail_sampling 前面,要么接受这个偏差。

trace_id 的作用

最后集中说一下 trace_id,这里把它的角色再整理一遍:

  • 身份:它是随机生成的 128 位数字,全局唯一,在 root span 创建的时候生成,之后整个请求链路上所有 span 共享同一个值,所以通常来说它会在网关或者类似网关的入口应用生成
  • 传播:它是 traceparent 头的主体,是跨进程时唯一必须传递的东西(另外一个是 parent span_id)
  • 拼树:服务端收到的 span 是零散的,trace_id 是把它们归为一组的唯一依据,组内再靠 parent_span_id 拼出父子关系
  • 关联:日志和异常都靠 trace_id 挂到链路上,后面 logs 一节会看到 logs_v2 表里专门有这一列
  • 采样一致性:head sampling 和 probabilistic sampler 都是对 trace_id 做哈希来决策的,所以不同服务、不同 collector 实例算出来的结果一致,不会出现半棵树
  • 路由:loadbalancing exporter 按 trace_id 做一致性哈希,保证同一个 trace 落到同一个 tail sampling 实例

顺便说一下,span_id 的角色要小得多,它只在两个地方用:作为 parent_span_id 拼树,以及日志关联到具体的某个 span。

好了,traces 是三种数据里最复杂的,到这里就讲完了。下面的 logs 和 metrics 会轻松很多。

Logs

一条 log record 长什么样

日志在 OTel 里是一个独立的信号,不是 span 的一部分。它的数据模型长这样:

LogRecord
  timestamp                           // 日志产生的时间
  observed_timestamp                  // 采集器看到它的时间,文件采集的场景两者会有差距
  severity_text / severity_number     // INFO / ERROR 以及对应的数字
  body                                // 日志内容,可以是字符串,也可以是结构化的
  attributes                          // key-value,比如 thread.name、logger.name
  trace_id                            // 关联用
  span_id                             // 关联用
  trace_flags                         // 关联用,主要是 sampled 标记

外面同样包着 Resource 和 Scope 两层,service.name 还是从 resource 来。

大家可以看到,模型里专门留了 trace_id 和 span_id 两个字段。所以"日志挂到 span 上"的真实含义是:日志记录里写了 trace_id 和 span_id,然后查询的时候用这两个字段把它们关联起来。日志的传输和存储都和 span 无关,它们只是共享一个 id。页面上从 trace 详情跳到"这个请求的日志",就是拿 trace_id 去日志表里查一遍。

客户端怎么采集

那 trace_id 是怎么进到日志里的呢?有两条路。

第一条路是 SDK 直接接管日志框架。以 Java 为例,OTel 的 javaagent 会自动给 Logback 和 Log4j 装一个 appender 的埋点,应用每打一行日志,agent 就顺手生成一个 OTLP log record,把当前线程的 span context 里的 trace_id 和 span_id 填进去,走 OTLP 发给 collector。这条路日志不落磁盘,trace_id 也不需要开发者操心,是最省事的。Rust 对应的是 opentelemetry-appender-tracing,它是 tracing 的一个 layer,把 tracing::info! 这类事件变成 OTLP log record 发出去。不过 trace_id 能不能自动带上,和 tracing-opentelemetry 的版本以及 feature 开关有关系,我自己是踩过坑的,用之前建议先验证一下。缺点是日志的可靠性和应用进程绑在了一起,进程崩了、网络断了,缓冲区里的日志就没了。

第二条路是日志照常写文件,用 collector 的 filelog receiver 去采集,就是传统的 Filebeat 那种模式。这种情况下,日志文本里原本是没有 trace_id 的,需要先在日志格式里把它打印出来。还是拿 Java 举例,agent 有一个 MDC 埋点,会把 trace_id、span_id、trace_flags 放进 MDC,你在 logback 的 pattern 里写上 %X{trace_id} 就能打出来。Rust 这边没有 MDC,用 tracing-subscriber 输出 JSON 日志的话,需要自己从当前 span 的 OTel context 里把 trace_id 取出来写成一个字段,字段名要和后面 collector 里解析时配的对上。然后在 collector 里用 regex_parser 或者 json_parser 把这个字段解析出来,再用 trace_parser 之类的 operator 把它填到 log record 的 trace_id 字段上。SigNoz 页面上的 logs pipeline 功能,就是让你在界面上配置这一串解析步骤,它最终会变成 collector 的配置。

这两种方案都很多公司采用,普遍来说,如果是 Kubernetes 环境走第二条路的居多,由 DaemonSet 部署的 collector 采集每个 pod 的 stdout。但是我们公司的日志是不走文件的,每个微服务通过 Kafka 发送出去。

服务端怎么落库

logs exporter 的处理和 traces 是同一套路,看完前面那段,logs_v2 表就没什么新东西了:

CREATE TABLE signoz_logs.logs_v2
(
    ts_bucket_start      UInt64,            -- 同样是 30 分钟桶
    resource_fingerprint String,            -- 同样有一张 logs_v2_resource 小表
    timestamp            UInt64,
    observed_timestamp   UInt64,
    id                   String,            -- ksuid,用于翻页
    trace_id             String,
    span_id              String,
    trace_flags          UInt32,
    severity_text        LowCardinality(String),
    severity_number      UInt8,
    body                 String,
    attributes_string    Map(LowCardinality(String), String),
    attributes_number    Map(LowCardinality(String), Float64),
    attributes_bool      Map(LowCardinality(String), Bool),
    resources_string     Map(LowCardinality(String), String),
    scope_name           String,
    scope_version        String
    -- ...
)
ENGINE = MergeTree
PARTITION BY toDate(timestamp / 1000000000)
ORDER BY (ts_bucket_start, resource_fingerprint, severity_text, timestamp, id)
TTL ... 15

ORDER BY 里多了一个 severity_text,因为"只看 ERROR 日志"是日志系统最常见的过滤条件。attributes 还是三个 Map 加物化列的套路,也有对应的 tag_attributes_v2 表做自动补全。

唯一值得单独说的是 body 的全文搜索。ClickHouse 不是 Elasticsearch,没有倒排索引,SigNoz 在 body 列上建的是 ngram bloom filter 跳数索引,它能帮你跳过肯定不包含某个子串的数据块,但命中的块还是要一行一行扫过去。所以日志的关键字搜索在大时间范围下会明显比按属性过滤慢,这是拿 ClickHouse 做日志系统的固有代价,换来的是压缩率和写入吞吐远好于 ES。实际使用中,先用 service、severity、时间范围把数据缩小,再搜关键字,体验会好很多。

Metrics

metrics 和前两者的根本区别

traces 和 logs 都是"事件",一个请求、一行日志,发生一次记录一次。metrics 不是事件,它是"值",比如当前有多少个活跃连接、到现在为止处理了多少个请求、最近一分钟的响应时间分布。OTel 的 metrics 模型主要有这几种类型:

  • gauge:一个瞬时值,比如内存使用量,采集的时候是多少就是多少
  • sum:一个累加值,比如请求总数。它有一个重要的属性叫 temporality,cumulative 表示上报的是从进程启动到现在的累计值,delta 表示上报的是距上次上报以来的增量
  • histogram:一组预先定义好的桶,每个桶记录落入的次数,再加上总和与总数,用来算分位数
  • exponential histogram:桶边界按指数增长的 histogram,不用预先定义桶,精度更好,用得还不多
  • summary:客户端直接算好的分位数,OTel 不推荐用,主要为了兼容 Prometheus

同样,外面包着 Resource 和 Scope,每个数据点上还有自己的 attributes,在 metrics 的语境里习惯叫 labels。

客户端怎么采集

metrics 不靠埋点,靠定时导出。SDK 里注册的 instrument(counter、histogram、gauge)在内存里持续累加,一个后台线程按固定周期(默认 60 秒)把当前的聚合结果导出成 OTLP 的 metrics 数据发出去。所以 metrics 从客户端出去的时候就已经是统计值了,比如某个 histogram 在这 60 秒里各个 bucket 的计数,不是原始事件。这一点和 traces 完全不同,也是 metrics 数据量远小于 traces 的原因。

Java agent 自带了 JVM 的指标(内存、GC、线程)和 HTTP、数据库客户端的指标,应用自己想加的话用 Micrometer 或者 OTel 的 meter API。Rust 没有 runtime 指标这一说,没有 GC,线程也是自己管的,进程级能看的主要是 tokio 的 runtime 指标(tokio-metrics),业务指标用 OTel 的 meter API 或者 metrics 这个 crate 自己打。另外 SigNoz 也支持用 collector 的 prometheus receiver 去抓已有的 /metrics 端点,所以已经有 Prometheus 体系的团队可以直接把数据导过来。

除了应用上报的指标,SigNoz 还有一类很重要的指标是 collector 从 span 派生出来的。默认配置的 traces pipeline 里有一个 signozspanmetrics 处理器,它拦截流过的每个 span,按 service_name、operation、span_kind、status_code 这些维度累加调用次数和耗时直方图,每 60 秒吐出 signoz_calls_total 和 signoz_latency 这两个指标,写到 metrics 表里。页面上 Services 列表里的 RPS、错误率、P99 就是从这两个指标查出来的。这个处理器做的事情,就是 Cat 的 Transaction 报表做的事情,只是维度固定,不用应用自己上报。前面 sampling 一节说的副作用就是这里来的:处理器只能看到流过它的 span,采样掉的它看不见。

服务端怎么落库

metrics 的存储模型和前面两个完全不同,它是时间序列模型,和 Prometheus 的思路一样。核心是两张表:

-- 每个数据点一行,这张表非常大,但每行很窄
CREATE TABLE signoz_metrics.samples_v4
(
    env          LowCardinality(String),
    temporality  LowCardinality(String),   -- Cumulative / Delta / Unspecified
    metric_name  LowCardinality(String),
    fingerprint  UInt64,                   -- 哪一条时间序列
    unix_milli   Int64,
    value        Float64,
    flags        UInt32
)
ENGINE = MergeTree
PARTITION BY toDate(unix_milli / 1000)
ORDER BY (env, temporality, metric_name, fingerprint, unix_milli)
TTL ... 30

-- 每条时间序列每小时一行,记录它的 labels,这张表相对小
CREATE TABLE signoz_metrics.time_series_v4
(
    env          LowCardinality(String),
    temporality  LowCardinality(String),
    metric_name  LowCardinality(String),
    fingerprint  UInt64,
    unix_milli   Int64,                    -- 取整到小时
    labels       String,                   -- JSON,完整的 label 集合
    type         LowCardinality(String),   -- Gauge / Sum / Histogram ...
    is_monotonic Bool,
    unit         String,
    description  String
    -- ...
)
ENGINE = ReplacingMergeTree
ORDER BY (env, temporality, metric_name, fingerprint, unix_milli)

fingerprint 是理解这个模型的钥匙。一条时间序列由 metric 名字加上它全部的 label 唯一确定,比如 http_requests_total{service="order", method="GET", status="200"} 是一条,status="500" 就是另一条。exporter 对 resource 属性、scope 属性、数据点属性和 metric 名字逐层做哈希,得到一个 64 位的 fingerprint。之后 samples_v4 里只存 fingerprint、时间和值,labels 这种又长又重复的东西放到 time_series_v4 里,每小时每条序列只写一行(exporter 用一个 45 分钟的缓存来避免重复写)。这就是 Prometheus 里 series 和 sample 分离的那个思路,只是换成了 ClickHouse 的两张表。

不同类型的 metric 会被拆成不同数量的 sample。gauge 和 sum 一个数据点就是一个 sample;histogram 一个数据点会拆成五种 sample,名字后面分别带 .count、.sum、.min、.max 和 .bucket,其中 .bucket 会按每个桶生成一条序列,桶的上界作为 le 标签,所以一个 20 桶的 histogram 一个数据点就是 24 行。这也是为什么 histogram 类型的指标是时间序列数量的大头。

查询的时候,页面选一个 metric、选几个 label 做过滤和分组,query-service 先去 time_series_v4 里用 label 条件找出 fingerprint 列表,再去 samples_v4 里按 fingerprint 和时间范围取值,这和 traces 里先查 resource 小表再查大表是一样的套路。rate、increase、分位数这些计算全部在这一步做,SigNoz 不在服务端内存里预先算好 P99 再落库,它落的是原始的 bucket 计数,查 P99 就是 ClickHouse 现场对 bucket 做 histogramQuantile。cumulative 类型的 counter 还要在查询时处理进程重启导致的计数归零。为了让长时间范围的查询扛得住,有几个物化视图把 samples 预聚合到 5 分钟和 30 分钟粒度的表里,时间跨度大的查询自动走聚合表。

总结

把上面的内容压缩成几句话:

  • SigNoz 没有自己的数据模型和客户端,全盘使用 OpenTelemetry,自己做的就是 collector 的三个 exporter 和查询层
  • traces:span 就是 Cat 里的 Transaction,span event 是挂在上面的 Event,异常是名为 exception 的 event。客户端每个 span 独立发送,服务端收到的是零散的 span,树是查询时靠 trace_id 和 parent_span_id 拼出来的。落库时一 span 一行,用 30 分钟时间桶加 resource fingerprint 做 ORDER BY,attributes 拆成三个 Map 列,常用属性提升为物化列
  • logs:独立信号,靠 log record 里的 trace_id 和 span_id 两个字段和 trace 关联。要么 SDK 接管日志框架直发,要么写文件后由 collector 采集并解析出 trace_id。存储套路和 traces 一样,body 的全文搜索靠 ngram 跳数索引,本质还是扫描
  • metrics:客户端定时导出聚合值,collector 还会从 span 派生 RED 指标。存储用 fingerprint 把 sample 和 label 分开,histogram 拆成 bucket 序列,rate 和分位数在查询时算,长范围查询走预聚合表
  • 三个采样点:SDK 的 head sampling、collector 的 probabilistic sampler 和 tail sampling,前两个靠 trace_id 哈希保证一致性,只有 tail sampling 能做到"错误和慢请求全留",代价是内存和两层 collector 的拓扑

数据链路说清楚了,最后我们来想想这套架构的压力点在哪里。我列几个我认为最重要的。

一是高基数。这是所有 APM 系统共同的敌人,Cat 的文档里也反复强调 type 和 name 不能太多,到了 SigNoz 这里问题分成了两个层面。traces 和 logs 层面,attributes 里的 key 如果值太多(比如把 user id、order id 放进去),tag_attributes_v2 表会膨胀,Map 列的压缩率也会变差,SigNoz 靠跳过高基数 key 来缓解,但主表里的数据还是照存不误。metrics 层面更严重,label 组合多一个值就多一条时间序列,time_series_v4 和 samples_v4 的行数是乘法增长的,histogram 还要再乘上桶的个数。span name 里带了动态参数是最常见的事故,比如 GET /api/orders/12345 没有 normalize 成 GET /api/orders/{id},signozspanmetrics 会为每个订单号生成一组时间序列。

二是写入。ClickHouse 的每次 insert 都会生成一个 part,part 太多会触发 too many parts 错误,所以 batch 一定要够大,collector 的 batch 处理器就是干这个的。但 batch 大意味着 collector 内存占用大,而且 collector 是有状态的(batch 队列、spanmetrics 的聚合状态、tail sampling 的缓存都在内存里),它挂了这些数据就丢了,OTel 的 SDK 只有很有限的重试。SigNoz 的默认部署里应用是直连 collector 的,中间没有消息队列兜底,流量大了以后一般会在 collector 前面加一层 Kafka。

三是查询。ClickHouse 擅长的是按 ORDER BY 前缀过滤然后大范围扫描聚合,不擅长点查。所以在 SigNoz 上,选服务、选时间、看统计图都很快,但按 trace_id 找一个 trace、按 user.id 这种非物化列的属性过滤、在日志 body 里搜关键字,本质上都是在扫描,时间范围一大就慢。物化列和 resource fingerprint 这些设计都是在把常见的查询模式往 ORDER BY 上靠,而对不常见的查询模式,SigNoz 基本就是让 ClickHouse 硬扫。另外一个 trace 如果有几万个 span(比如一个批处理任务),query-service 在内存里拼树和前端渲染都会很吃力。

四是 tail sampling 的拓扑。上面说过了,要做对 tail sampling 就得两层 collector 加一致性哈希路由,第二层每个实例都要在内存里攒完整的 trace,这是整个链路里最消耗内存也最容易出问题的环节。很多团队最后的选择是不做 tail sampling,只做 head sampling 加 TTL。

五是存储量。SigNoz 是全量存 span 的,一条 span 落库以后几百字节到几 KB,一个中等规模的微服务系统一天几亿个 span 是很正常的,15 天的默认 TTL 主要是被这个数字逼出来的。好在 ClickHouse 的压缩率很高,这个问题通常比高基数好处理。

我自己看下来的感受是,SigNoz 的代码没有什么特别精巧的地方,它的服务端比 Cat 这类系统薄得多,把统计的活一部分交给了 SDK(metrics 在客户端就聚合好了),大部分交给了 ClickHouse(聚合在查询时算)。这在几年前是不太敢这么做的,现在 ClickHouse 已经足够成熟,这条路就变得很自然了。它的价值在于站在了 OpenTelemetry 和 ClickHouse 两个成熟项目的肩膀上,把它们之间那一段该怎么接讲清楚了,这篇文章也算是把这一段讲了一遍。

(全文完)