---
name: signoz
title: SigNoz 架构分析
date: 2026-09-13
---

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

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

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

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

## 总览

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

![overview](https://assets.javadoop.com/imgs/20510079/signoz/overview.png)

三个角色：

- 应用侧用 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 写出来，并加上注释：

```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](https://assets.javadoop.com/imgs/20510079/signoz/traceparent.png)

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 表的一行。我把这张表的关键列列出来，省略了一些兼容性的别名列，大家看列名就能明白它做了什么：

```sql
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](https://assets.javadoop.com/imgs/20510079/signoz/traceparent.png)

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

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

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

```yaml
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 表就没什么新东西了：

```sql
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 的思路一样。核心是两张表：

```sql
-- 每个数据点一行，这张表非常大，但每行很窄
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 两个成熟项目的肩膀上，把它们之间那一段该怎么接讲清楚了，这篇文章也算是把这一段讲了一遍。

（全文完）
