服务上线后,「它现在怎么样」「这次请求为什么慢」成了日常问题。可观测性用三支柱回答:日志说「发生了什么」、指标说「整体趋势如何」、链路说「这一次请求经过了哪里」。Clojure 生态里,Timbre 负责结构化日志、OpenTelemetry 负责链路与指标,Ring 中间件把它们串起来。本文从日志讲到 trace 传播与采样,帮你把三支柱接成一条能真正定位问题的观测流水线。
1. 可观测性三支柱
1.1 三支柱的分工
| 支柱 | 回答的问题 | 典型工具 |
|---|---|---|
| 日志 | 具体发生了什么 | Timbre、Logback |
| 指标 | 整体趋势与告警 | metrics、Prometheus |
| 链路 | 这一次请求走了哪 | OpenTelemetry |
1.2 为什么 Clojure 需要结构化日志
;; 非结构化:只能人看,机器难解析
(println "user 42 login failed from 10.0.0.1")
;; 结构化:可查询、可聚合、可告警
(log/info :user-login-failed {:user-id 42 :ip "10.0.0.1" :reason :bad-password})
心智:日志的消费方不只是人,还有日志系统(检索、聚合、告警)。结构化日志让「字段」可被机器索引,是把日志变成数据的前提。
1.3 观测的三层成本
成本从低到高:指标 < 日志 < 链路
采样策略:链路必采样、日志按级别、指标全量
心法:三支柱不是「都要全量」,而是各按特性取舍——指标便宜可全量、日志按级别过滤、链路开销大必须采样。
2. Timbre 结构化日志
2.1 依赖与基本用法
;; deps.edn
{:deps {com.taoensso/timbre {:mvn/version "6.6.1"}}}
(require '[taoensso.timbre :as log])
(log/info "服务启动" {:port 8080})
(log/warn "连接池接近上限" {:active 95 :max 100})
(log/error (ex-info "支付失败" {:order-id 7}) "处理订单出错")
2.2 结构化字段
;; 推荐:事件名 + map,字段可被索引
(log/info :order-created
{:order-id 1234
:user-id 42
:amount 199.00
:currency :cny})
2.3 级别与输出
(log/set-min-level! :debug) ;; 全局
(log/merge-config!
{:min-level [[#{"app.db.*"} :debug]
[#{"*"} :info]]
:output-fn :inherit})
心法:日志内容用「事件名 + 字段 map」——事件名稳定、字段可查询。别把变量拼进字符串里,那等于把数据埋进了散文。
3. 日志上下文与 MDC
3.1 请求上下文
一次请求里所有日志都该带上 request-id、user-id,否则排查时无法串联:
(require '[taoensso.timbre :as log])
(defn with-context [f ctx]
(log/with-context+ ctx (f)))
;; 用法
(with-context handle-request
{:request-id (str (random-uuid))
:user-id 42
:path "/api/orders"})
3.2 Ring 中间件注入上下文
(defn wrap-request-context [handler]
(fn [req]
(let [ctx {:request-id (or (get-in req [:headers "x-request-id"])
(str (random-uuid)))
:method (:request-method req)
:path (:uri req)}]
(log/with-context+ ctx
(handler (assoc req :request-context ctx))))))
3.3 MDC 与线程传递
关键:MDC 是「线程局部」的
go-block 会切换线程 -> 上下文可能丢
future/线程池 -> 上下文不自动传递
对策:
显式把 ctx 作为参数传递(推荐)
或在 go/thread 启动时手动绑定
心法:上下文用「显式传递」而非「隐式线程局部」——core.async 的 go-block 会在线程间跳,MDC 靠不住。把 context 当成数据显式传,跨线程也不会丢。
4. 日志格式与采集
4.1 JSON 输出
(require '[taoensso.timbre :as log]
'[cheshire.core :as json])
(log/merge-config!
{:output-fn
(fn [{:keys [level msg_ context timestamp]}]
(json/generate-string
{:ts (str timestamp)
:level (name level)
:message (force msg_)
:ctx @context}))})
输出示例:
{"ts":"2026-10-02T11:00:00Z","level":"info","message":"order-created","ctx":{"request-id":"...","order-id":1234}}
4.2 采集链路
应用 stdout(JSON 行)
-> 容器运行时收集
-> 日志代理(Fluent Bit / Vector)
-> 日志后端(Loki / Elasticsearch)
-> 查询与告警(Grafana)
4.3 字段规范
| 字段 | 含义 | 必须 |
|---|---|---|
| ts | 时间戳 | 是 |
| level | 级别 | 是 |
| message | 事件名 | 是 |
| request-id | 请求标识 | 是 |
| user-id | 用户标识 | 视场景 |
| duration-ms | 耗时 | 视场景 |
心法:日志格式一旦定,就当成接口对待——字段名、类型、必填项都要有规范,否则日志系统里的查询会变成一场考古。
5. 指标:metrics 与导出
5.1 用 metrics 库
;; deps.edn
{:deps {io.dropwizard.metrics/metrics-core {:mvn/version "4.2.25"}}}
(require '[metrics.core :as m]
'[metrics.timers :as timers])
(def registry (m/new-registry))
(def req-timer (timers/timer registry "http.requests"))
;; 记录一次请求耗时
(timers/time! req-timer
(handle-request req))
;; 计数器
(def login-counter (m/counter registry "auth.logins"))
(m/inc! login-counter)
5.2 导出到 Prometheus
;; 用 prometheus 客户端暴露 /metrics
(require '[io.prometheus.client :as prom]
'[io.prometheus.client.exporter.common :as common])
(def req-duration
(prom/histogram "http_request_duration_seconds" "请求耗时"
["method" "path" "status"]))
(defn instrument [handler]
(fn [req]
(let [t (prom/start-timer req-duration
(name (:request-method req))
(:uri req))]
(let [resp (handler req)]
(prom/observe-duration! t (str (:status resp)))
resp))))
5.3 该埋哪些指标
黄金四信号(Google SRE):
Latency 延迟(分位数 p50/p95/p99)
Traffic 流量(QPS)
Errors 错误率
Saturation 饱和度(连接池、队列水位)
心法:指标选「黄金四信号」,别贪多——延迟、流量、错误率、饱和度覆盖了绝大多数告警需求。指标爆炸(几千个)比没有指标更难用。
6. OpenTelemetry 埋点
6.1 依赖与初始化
;; deps.edn
{:deps {io.opentelemetry/opentelemetry-api {:mvn/version "1.40.0"}
io.opentelemetry/opentelemetry-sdk {:mvn/version "1.40.0"}
io.opentelemetry/opentelemetry-exporter-otlp {:mvn/version "1.40.0"}}}
(require '[clojure.java.io :as io])
(import '[io.opentelemetry.sdk OpenTelemetrySdk]
'[io.opentelemetry.sdk.trace SdkTracerProvider]
'[io.opentelemetry.exporter.otlp.trace OtlpGrpcSpanExporter])
(def tracer-provider
(-> (SdkTracerProvider/builder)
(.addSpanProcessor
(-> (io.opentelemetry.sdk.trace.export.BatchSpanProcessor/builder
(OtlpGrpcSpanExporter/builder
(.setEndpoint "http://collector:4317") (.build)))
(.build)))
(.build)))
(def open-telemetry
(-> (OpenTelemetrySdk/builder)
(.setTracerProvider tracer-provider)
(.build)))
(def tracer (.get (.getTracerProvider open-telemetry) "my-service" "1.0.0"))
6.2 手动创建 span
(defn process-order [order]
(let [span (.spanBuilder tracer "process-order")
_ (.setAttribute span "order.id" (str (:id order)))
_ (.setAttribute span "order.amount" (double (:amount order)))
span (.startSpan span)]
(try
(let [result (do-work order)]
(.setStatus span io.opentelemetry.api.trace.StatusCode/OK)
result)
(catch Exception e
(.recordException span e)
(.setStatus span io.opentelemetry.api.trace.StatusCode/ERROR)
(throw e))
(finally
(.end span)))))
6.3 自动埋点 Ring
自动埋点思路:
1. 从请求头提取 traceparent(上游传播的上下文)
2. 创建服务端 span,作为父
3. 处理请求时,后续 span 挂在这个父下
4. 响应时把新 traceparent 写回头
心法:链路的价值在「跨服务串联」——单个服务内的 span 意义有限,只有把 traceparent 在 HTTP 头里传下去,才能看到「一次下单经过了网关、订单、支付、库存」。手动埋点从入口开始。
7. Trace 传播与跨服务
7.1 W3C traceparent
traceparent: 00-<trace-id>-<span-id>-<flags>
示例:00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
7.2 提取与注入
(require '[io.opentelemetry.api.trace :as trace])
(import '[io.opentelemetry.context Context]
'[io.opentelemetry.api.trace.propagation W3CTraceContextPropagator])
(def propagator (W3CTraceContextPropagator/getInstance))
(defn extract-context [req]
(let [getter (reify io.opentelemetry.context.propagation.TextMapGetter
(get [_ carrier key] (get-in carrier [:headers key]))
(keys [_ carrier] (keys (:headers carrier))))]
(.extract propagator Context/root req getter)))
(defn inject-headers [ctx]
(let [setter (reify io.opentelemetry.context.propagation.TextMapSetter
(set [_ carrier key value] (assoc-in carrier [:headers key] value)))]
(.inject propagator ctx {} setter)))
7.3 跨服务调用
;; 出站请求带上 traceparent
(defn call-downstream [req url]
(let [ctx (:otel-context req)
headers (inject-headers ctx)]
(http/get url {:headers (merge {"content-type" "application/json"}
(:headers headers))})))
心法:trace 传播是「协议」不是「库功能」——W3C traceparent 是标准头,任何 HTTP 客户端都能带。只要入口提取、出口注入,链路就跨服务连上了。
8. 错误上报与采样
8.1 错误分级
分级处理:
WARN —— 可自愈(重试成功、降级命中)
ERROR —— 需关注(业务失败、依赖异常)
FATAL —— 需告警(进程级、数据损坏)
上报到 Sentry 之类时只报 ERROR 及以上
8.2 采样策略
| 策略 | 说明 | 适用 |
|---|---|---|
| 头部采样 | 入口决定是否采 | 高流量、成本敏感 |
| 尾部采样 | 完整 trace 后再定 | 需保留错误链路 |
| 按错误采 | 错误必采 | 排错优先 |
| 按比例采 | 固定比例 | 基线观测 |
;; 按比例采样(父级无决策时)
(def sampler
(io.opentelemetry.sdk.trace.samplers.Sampler/traceIdRatioBased 0.1))
;; 尾部采样:始终保留有错误的 trace
;; 由 collector 侧配置(如 otel-collector 的 tail_sampling)
8.3 采样与日志的配合
实践:
日志:按级别全量(便宜),错误必留
链路:按比例 + 错误必采
指标:全量
三者在同一 request-id 下可关联
心法:采样不是「丢数据」,而是「按价值分配预算」——错误链路必须留,正常链路按比例留。日志里带上 request-id,就能从「一条错误日志」跳到「完整链路」。
9. 生产实践与排错
9.1 一次慢请求的排查路径
1. 指标发现 p99 升高 -> 确认不是整体劣化
2. 按 request-id 检索日志 -> 找到慢请求样本
3. 用 trace-id 看链路 -> 定位慢在哪一跳
4. 看该跳的日志字段 -> 确认是依赖慢还是自己慢
9.2 常见坑
| 坑 | 现象 | 规避 |
|---|---|---|
| 日志拼字符串 | 字段不可查 | 结构化字段 |
| MDC 跨 go-block 丢 | 上下文缺失 | 显式传递 |
| 全量链路 | 成本爆炸 | 采样 |
| 指标维度爆炸 | 存储告警 | 限制标签基数 |
| 日志级别全 debug | 磁盘打满 | 生产 info 起 |
9.3 观测的闭环
发现问题(指标告警)
-> 定位(日志 + 链路)
-> 修复
-> 加监控/加日志(防止复发)
心法:可观测性的目标不是「数据多」,而是「问题来时能定位」——每一次排错都应反哺观测:把这次缺的字段、缺的指标补上,下次同类问题就能秒定位。
10. 速查表与一句话记忆
| 需求 | 工具或写法 |
|---|---|
| 结构化日志 | Timbre + 字段 map |
| 请求上下文 | log/with-context+ |
| Ring 注入 | wrap-request-context 中间件 |
| JSON 日志 | 自定义 output-fn |
| 指标 | metrics 库 + counter/timer |
| 指标导出 | Prometheus 客户端 |
| 黄金信号 | 延迟/流量/错误/饱和度 |
| 链路 | OpenTelemetry tracer |
| 传播 | W3C traceparent |
| 采样 | traceIdRatioBased / 尾部采样 |
| 错误上报 | 只报 ERROR 及以上 |
| 关联 | request-id 贯穿三支柱 |
一句话记忆:可观测性三支柱 = 日志说「发生了什么」(Timbre 结构化、事件名 + 字段、上下文显式传递)→ 指标说「趋势如何」(黄金四信号、Prometheus 导出)→ 链路说「走了哪里」(OpenTelemetry span、W3C traceparent 跨服务传播)→ 采样按价值分配预算(错误必留、正常按比例)→ 三支柱靠 request-id 关联 → 排错后反哺观测形成闭环——目标不是数据多,而是问题来时能定位。
延伸阅读
- Clojure 微服务架构实战 — 服务治理与观测
- Clojure 网络服务深入 — Ring 中间件与服务端
- Clojure REST API 设计实战 — 请求上下文与错误模型
- Clojure 并发设计模式 — 线程与上下文传递
- Clojure 错误处理模式 — 异常分级与上报
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。