在大型项目中往往是多个服务并行运行的,比如我曾经呆过的一家公司将一个系统就拆成了这几个服务

  1. 后台服务
  2. 用户中心鉴权服务
  3. 客户端服务
  4. 数据统计服务
  5. 第三方平台: 比如微信公众号,Ai 相关服务

一些中间服务之间的通信采用的是 RPC, 同时这些服务都是互相调用的,往往 A 依赖于 B,B也会去依赖于 A,这也造成了这个系统出了问题其实是很难排查出问题的 因为我们不确定是什么服务引起的,缺乏一套系统的链路追踪。

那么一套系统的链路追踪应该是怎么样子的呢? 我认为它一定具备下面几点特性

  1. 它一定是有一个 trace id 是可以串联起所有的 服务的
  2. 系统核心位置加上对应的观测指标,比如当前系统接口吞吐是多少,这个慢 SQL 耗时多长时间,任务平均执行时长是多长
  3. 它的日志系统一定是规范的,关键地方都打上了日志,报错的地方都打上日志
  4. 日志聚合指标聚合,这些日志和指标都是需要去收集起来方便可以快速追踪,比如我们可以很快的根据一个 traceid 去查询起整个链路

日志 SDK 设计

为此我们设计了一个 trace SDK, 使用 Go 语言,这个 sdk 主要有两个核心的设计:

  1. 提供了一个强大的链路 id 生成器, 这个生成器不仅可以生成一个 trace id, 还提供了 层次 id,可以展示当前的服务位于第几个层次第几次调用,比如 1.2
  2. 将这个 trace id 注入到了每一个 log, 中,同时提供了统一的 业务log api,比如打印出当前 SQL 执行的耗时,这样方便我们在查看这个链路的时候可以快速定位瓶颈
l, _ := gotrace.NewLogger("user-svc", "/var/log/app", gotrace.Config{Stdout: true})
defer l.Close()

// 结构化 trace 日志
gotrace.LogDBEntry(l, tracer, "db", "users", "SELECT 1", 5)

// 标准 slog API,同一条输出链路
l.Slog().Info("hello", "key", "value")

日志存储方式设计

这种 trace 日志的存储,也是非常值得考量的一个地方,因为考虑到这种日志其实是写多读少的特性,所以我们可以借助 rocksdb 来完成设计,在go 语言中我更倾向于使用 pebble

  • 上游:系统产生的链路日志(Trace Logs)推送到 Kafka
  • 消费者组:部署多个“日志存储服务”实例,以消费者组方式接入 Kafka。实例数不超过 Kafka Partition 数,保证并行消费
  • 存储层:每个实例在本地磁盘上管理多个按日期命名的 Pebble 数据库

在使用 pebble 的时候呢,每一个 key 就是 trace id, value 是当前这个 id 下面所有日志的拼接 写入时候使用 merge 操作,可以把对应的数据加到 value 的末尾上面去

日志聚合计算

日志聚合其实非常简单,直接广播所有的节点即可,各个节点按照对应的 trace id 去查询对应的 value,如果查询到即可返回,通常只会有一个节点返回对应的数据,这是因为日志是按照时间 存储的,所以只会存储在一个库上面。