文档目录

4.6 配套代码:追踪、日志与基数控制

对应小节:4.6 应用可观测性 三部分:① OpenTelemetry 追踪接入;② 日志异步化与降开销;③ 指标基数控制。

一、OpenTelemetry 追踪接入

// build.gradle.kts
dependencies {
    implementation("io.opentelemetry:opentelemetry-api:1.43.0")
    implementation("io.opentelemetry:opentelemetry-sdk:1.43.0")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.43.0")
    implementation("io.opentelemetry:opentelemetry-extension-kotlin:1.43.0")
    implementation("io.opentelemetry.instrumentation:opentelemetry-kotlin-coroutines:1.32.0-alpha")
}
// src/main/kotlin/tracing/Tracing.kt
package tracing

import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.common.AttributeKey
import io.opentelemetry.api.trace.Span
import io.opentelemetry.api.trace.SpanKind
import io.opentelemetry.api.trace.StatusCode
import io.opentelemetry.api.trace.Tracer
import io.opentelemetry.sdk.OpenTelemetrySdk
import io.opentelemetry.sdk.trace.SdkTracerProvider
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
import io.opentelemetry.sdk.trace.samplers.Sampler
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext

object Tracing {
    lateinit var tracer: Tracer
        private set

    fun init(samplingRatio: Double = 0.1) {
        val exporter = OtlpHttpSpanExporter.builder()
            .setEndpoint("http://localhost:4318/v1/traces")
            .build()

        val provider = SdkTracerProvider.builder()
            // ⚠️ Head sampling:按比例采样。已知局限:会漏掉慢请求(见下方说明)
            .setSampler(Sampler.traceIdRatioBased(samplingRatio))
            .addSpanProcessor(BatchSpanProcessor.builder(exporter).build())
            .build()

        tracer = OpenTelemetrySdk.builder()
            .setTracerProvider(provider)
            .build()
            .getTracer("app")
    }
}

/**
 * 给一段挂起代码加 Span。
 * 注意:必须保证 Span 在【所有挂起点】都处于当前上下文,
 * 所以要用 withContext(span.asContextElement())(需要 otel-kotlin 扩展)。
 */
suspend fun <T> traced(
    name: String,
    kind: SpanKind = SpanKind.INTERNAL,
    attributes: Map<String, String> = emptyMap(),
    block: suspend () -> T,
): T {
    val span = Tracing.tracer.spanBuilder(name).setSpanKind(kind).apply {
        attributes.forEach { (k, v) -> setAttribute(AttributeKey.stringKey(k), v) }
    }.startSpan()

    return try {
        withContext(span.asContextElement()) {     // 让挂起点也继承这个 Span
            block()
        }
    } catch (e: Throwable) {
        span.setStatus(StatusCode.ERROR, e.message ?: "error")
        span.recordException(e)
        throw e
    } finally {
        span.end()
    }
}

在路由里使用:

get("/orders/{id}") {
    val id = call.parameters["id"]!!

    traced("GET /orders/{id}", SpanKind.SERVER) {
        val order = traced("db.query.orders") {
            repo.findById(id)                    // Span: 数据库查询
        }
        val user = traced("http.user-service", SpanKind.CLIENT) {
            userClient.fetch(order.userId)        // Span: 下游调用
        }
        call.respond(order.copy(userName = user.name))
    }
}

得到的 Span 树(在 Jaeger/Tempo 里查看):

GET /orders/{id}                          ← 根 Span,200ms
├── db.query.orders                        80ms   ← 一眼看出大头
└── http.user-service                      95ms   ← 和它不相上下

二、采样策略的改进(解决「漏掉慢请求」)

Head sampling 的问题是「随机丢弃」,慢请求本来就少,很可能全被丢掉。三种改进:

// ① 关键路径 100% 采样,其他按比例
val sampler = Sampler.parentBased(
    Sampler.traceIdRatioBased(0.01)      // 默认 1%
)

// ② 在业务代码里对关键操作强制采样
fun createOrder(...) {
    val span = tracer.spanBuilder("createOrder")
        .setSampler(Sampler.alwaysOn())   // 下单必采
        .startSpan()
    // ...
}

// ③ 延迟自适应:请求完成后,如果发现很慢,就强制记录
//    (需要自定义 Sampler 或在应用层做,例如:慢请求额外写一条日志/指标)

生产推荐组合:

流量类型 采样率
核心链路(下单、支付) 100%
一般接口 1%–10%
健康检查、静态资源 0%(不采样)
慢请求 / 错误请求 100%(用 tail sampling 在 collector 侧实现)

Tail sampling 的配置思路(在 OTel Collector 里):

# otel-collector-config.yaml
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow-requests
        type: latency
        latency: { threshold_ms: 500 }      # 超过 500ms 的请求全部保留
      - name: baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 1 }

这是解决「追踪里看不到慢请求」的标准方案——慢请求 100% 保留,其余抽样。

三、日志:异步化与降开销

3.1 Logback 异步配置

<!-- src/main/resources/logback.xml -->
<configuration>
    <!-- 异步 appender:日志写入不阻塞业务线程 -->
    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
        <!-- 队列大小:太小会丢日志,太大会占内存 -->
        <queueSize>8192</queueSize>
        <!-- 丢弃 threshold:队列满时丢弃 TRACE/DEBUG/INFO,保留 WARN/ERROR -->
        <discardingThreshold>0</discardingThreshold>
        <!-- 不丢弃任何级别(适合排障期;生产可设为 20 让 INFO 可丢) -->
        <neverBlock>true</neverBlock>     <!-- ⭐ 队列满时不阻塞业务线程 -->
        <appender-ref ref="FILE"/>
    </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>/var/log/app/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>/var/log/app/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>7</maxHistory>
            <totalSizeCap>5GB</totalSizeCap>
        </rollingPolicy>
        <encoder>
            <!-- ⚠️ 不要用 %L / %line / %caller:获取行号需要构造调用栈,非常贵 -->
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 业务日志走异步 -->
    <logger name="com.example" level="INFO" additivity="false">
        <appender-ref ref="ASYNC_FILE"/>
    </logger>

    <root level="WARN">
        <appender-ref ref="ASYNC_FILE"/>
    </root>
</configuration>

四个关键配置的理由:

配置 理由
AsyncAppender 业务线程只把日志放进队列,不等落盘
neverBlock=true ⭐ 队列满时丢弃日志而不是阻塞业务——这是「宁可丢日志也不拖慢服务」的取舍
不用 %L / %line 获取行号要构造异常栈,单条日志可能贵 10 倍以上
totalSizeCap 防止日志写满磁盘

3.2 代码层面的日志优化

// ❌ 字符串在调用前就拼好了(即使级别不够也会拼接 + 分配)
log.debug("order=${order.id}, items=${order.items.map { it.name }}")

// ✅ 参数化:级别不够时不拼接
log.debug("order={}, itemCount={}", order.id, order.items.size)

// ✅ 真正昂贵的计算:先判断级别
if (log.isDebugEnabled) {
    log.debug("full dump={}", expensiveSerialize(order))
}

// ❌ 热路径上的 INFO 日志(每个请求都写)
log.info("processing order {}", order.id)

// ✅ 降级为 DEBUG,或采样记录
if (order.id % 1000 == 0L) log.info("sampled order {}", order.id)

3.3 验证日志开销

# 方法一:JFR 看文件写入事件
jfr print --events jdk.FileWrite /tmp/recording.jfr | grep -c "app.log"

# 方法二:async-profiler 看分配火焰图里有没有 StringBuilder / String.format
asprof -d 60 -e alloc -f alloc.html "$PID"

# 方法三:单变量实验——把日志级别降到 ERROR,对比 P99
# (这是最直接的因果验证)

四、指标基数控制

4.1 危险的标签

// ❌ 每一个都是灾难
registry.counter("req", "userId", userId)          // 用户 ID:无界
registry.counter("req", "orderId", orderId)        // 订单号:无界
registry.counter("req", "traceId", traceId)        // 追踪 ID:无界
registry.counter("req", "uri", "/orders/$id")      // 具体 URL:无界
registry.counter("req", "timestamp", now.toString()) // 时间戳:无界

// ✅ 可枚举维度
registry.counter("req",
    "uri", "/orders/{id}",        // 路由模板
    "method", "GET",              // 有限集合
    "status", "200",              // 有限集合(或归类为 2xx/4xx/5xx)
    "tenant_type", "free|paid")   // 有限集合

4.2 基数监控与告警

# ① 找出序列数最多的指标
topk(10, count by (__name__) ({__name__=~".+"}))

# ② 找出某个指标里基数最高的标签值
topk(10, count by (uri) (http_server_requests_seconds_count))

# ③ 总数趋势(持续增长 = 有基数泄漏)
sum(count by (__name__) ({__name__=~".+"}))

告警规则:

- alert: MetricCardinalityExplosion
  expr: |
    sum(count by (__name__) ({__name__=~".+"})) > 500000
  for: 10m
  annotations:
    summary: "Prometheus 时间序列总数超过 50 万,可能存在基数泄漏"

4.3 已经在生产踩坑后的处理

# 找出哪个指标贡献了最多序列
curl -s 'http://prometheus:9090/api/v1/query?query=topk(20,count by (__name__)({__name__=~".+"}))' \
  | python3 -m json.tool | head -40

找到后:① 修改埋点(去掉无界标签);② 用 metric_relabel_configs 在抓取时丢弃该指标;③ 重启 Prometheus 或删除相关数据释放内存。

# prometheus.yml 里的应急配置
scrape_configs:
  - job_name: kotlin-app
    metric_relabel_configs:
      # 丢弃某个高基数指标
      - source_labels: [__name__]
        regex: 'some_high_cardinality_metric.*'
        action: drop

五、动手改造

改动 观察什么
把 head sampling 从 1% 改成 100% collector 与存储的压力变化——理解采样的成本
给下单接口单独设 alwaysOn() 追踪里能看到完整的下单链路
把 neverBlock 改成 false 队列满时业务线程被阻塞,P99 上升——验证异步日志的价值
在 pattern 里加上 %L(行号) 用 JFR 的 jdk.FileWrite 对比写入耗时,或直接看 P99 变化
故意给指标加一个 userId 标签,压测 10 分钟 观察 Prometheus 内存与序列数增长——这是最直观的基数教训

六、这段代码的局限

  • OTel 的 Kotlin 协程支持仍在演进:asContextElement() 需要 opentelemetry-kotlin-coroutines 扩展,版本兼容性要注意。
  • Tail sampling 需要额外的 Collector(缓冲全部 span),资源成本不低——小规模场景可以先只用「关键路径 100% + 其余 1%」。
  • AsyncAppender 的 neverBlock=true 会丢日志:这在排障期是问题(关键日志可能丢),可以临时改成阻塞模式或提高队列大小。
  • 基数问题一旦发生,处理成本很高(要清数据/重启 Prometheus),所以最好的策略是在埋点时就控制住。