文档目录

1.1 配套代码:四层指标的埋点骨架

对应小节:1.1 指标分四层 这份代码是可以直接抄进项目的骨架。

一、注册表与四层指标的对应关系

// src/main/kotlin/metrics/MetricsRegistry.kt
package metrics

import com.zaxxer.hikari.HikariDataSource
import io.micrometer.core.instrument.Gauge
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.binder.jvm.*
import io.micrometer.core.instrument.binder.system.ProcessorMetrics
import io.micrometer.core.instrument.binder.system.FileDescriptorMetrics
import io.micrometer.prometheusmetrics.PrometheusConfig
import io.micrometer.prometheusmetrics.PrometheusMeterRegistry
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.atomic.AtomicInteger

val registry: PrometheusMeterRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)

fun registerFourLayers(ds: HikariDataSource, executor: ThreadPoolExecutor) {

    // ══════════════════════════════════════════════════════════
    // 第 3 层:资源层 —— 消耗了什么
    // 这些 binder 会自动注册一批指标,不用手写
    // ══════════════════════════════════════════════════════════
    JvmMemoryMetrics().bindTo(registry)      // jvm_memory_used_bytes / max_bytes
    JvmGcMetrics().bindTo(registry)          // jvm_gc_pause_seconds(含分位数直方图)
    JvmThreadMetrics().bindTo(registry)      // jvm_threads_live / states
    ClassLoaderMetrics().bindTo(registry)    // 类加载数量(冷启动相关)
    ProcessorMetrics().bindTo(registry)      // system_cpu_usage / process_cpu_usage
    FileDescriptorMetrics().bindTo(registry) // 文件描述符使用(连接泄漏的第一信号)

    // ══════════════════════════════════════════════════════════
    // 第 4 层:饱和度层 —— 离上限还有多远(最重要的一层)
    // ══════════════════════════════════════════════════════════

    // 数据库连接池:pending 是最关键的指标
    Gauge.builder("db.pool.pending") { ds.hikariPoolMXBean?.threadsAwaitingConnection ?: 0 }
        .description("正在等待连接的线程数(> 0 说明已经排队)")
        .register(registry)

    Gauge.builder("db.pool.active") { ds.hikariPoolMXBean?.activeConnections ?: 0 }
        .description("正在被使用的连接数")
        .register(registry)

    Gauge.builder("db.pool.idle") { ds.hikariPoolMXBean?.idleConnections ?: 0 }
        .register(registry)

    Gauge.builder("db.pool.total") { ds.hikariPoolMXBean?.totalConnections ?: 0 }
        .register(registry)

    // 线程池队列深度:持续非空说明处理不过来
    Gauge.builder("executor.queue.depth") { executor.queue.size.toDouble() }
        .description("线程池等待队列长度")
        .register(registry)

    Gauge.builder("executor.queue.remaining") { executor.queue.remainingCapacity().toDouble() }
        .description("队列剩余容量(接近 0 说明即将开始拒绝)")
        .register(registry)

    Gauge.builder("executor.active") { executor.activeCount.toDouble() }
        .register(registry)
}

为什么 pending 比 active 更重要:active 高只说明「连接都在忙」,这是正常的;pending > 0 说明「已经有请求在排队等连接」——这是已经出问题的信号。

二、业务层与延迟层的埋点

// src/main/kotlin/metrics/BusinessMetrics.kt
package metrics

import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import java.time.Duration

/**
 * 桶边界必须围绕自己的 SLO 设置。
 * 默认桶在 5~50ms 区间区分度很差,会让 P99 严重失真。
 */
val sloBuckets: List<Duration> = listOf(
    1, 2, 5, 10, 20, 30, 50, 75, 100, 150, 200, 300, 500, 1000, 2000
).map { Duration.ofMillis(it.toLong()) }

class BusinessMetrics(private val registry: MeterRegistry) {

    // ── 业务层 ────────────────────────────────────────────────
    private val requestsTotal = Counter.builder("app.requests.total")
        .description("总请求数(含失败)")
        .register(registry)

    private val requestsFailed = Counter.builder("app.requests.failed")
        .description("失败请求数(含错误与超时)")
        .register(registry)

    // ── 延迟层 ────────────────────────────────────────────────
    private val requestTimer = Timer.builder("app.request.duration")
        .description("请求耗时")
        .publishPercentileHistogram(true)                     // 关键:发布直方图
        .serviceLevelObjectives(*sloBuckets.toTypedArray())   // 关键:自定义桶边界
        .minimumExpectedValue(Duration.ofMillis(1))
        .maximumExpectedValue(Duration.ofSeconds(5))
        .register(registry)

    fun recordSuccess() = requestsTotal.increment()
    fun recordFailure() = requestsTotal.increment().also { requestsFailed.increment() }
    fun timer(): Timer = requestTimer
}

三、在 Ktor 中接入(不用手写 HTTP 埋点)

// src/main/kotlin/Application.kt
package app

import io.ktor.server.application.*
import io.ktor.server.metrics.micrometer.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig
import metrics.registry
import metrics.sloBuckets

fun Application.module() {
    install(MicrometerMetrics) {
        registry = metrics.registry
        // 全站统一使用自定义桶边界,而不是默认桶
        distributionStatisticConfig = DistributionStatisticConfig.builder()
            .percentilesHistogram(true)
            .serviceLevelObjectives(*sloBuckets.toTypedArray())
            .minimumExpectedValue(java.time.Duration.ofMillis(1))
            .maximumExpectedValue(java.time.Duration.ofSeconds(5))
            .build()
    }

    routing {
        get("/metrics") {
            call.respondText(metrics.registry.scrape())   // Prometheus 抓取端点
        }
    }
}

四、怎么验证四层都齐了

访问 /metrics,应该能找到下面这些指标。缺哪一组,就说明哪一层没埋:

层级 应该看到的指标前缀 缺了会怎样
业务层 app_requests_total、app_requests_failed_total 不知道服务是否正常
延迟层 app_request_duration_seconds_bucket 无法算真实百分位
资源层 jvm_memory_used_bytes、jvm_gc_pause_seconds、process_cpu_usage 不知道消耗了什么
饱和度层 db_pool_pending、executor_queue_depth 只能知道「慢」,不知道「为什么慢」

一个快速自检:如果你无法回答「这个接口的 P99 里,等数据库占了多少毫秒」,说明第 3 层和第 4 层的埋点还不够(需要 03 节 的依赖级埋点)。

五、三个必须注意的坑

❶ 标签基数

// ❌ 灾难:每个用户 ID 一个时间序列,Prometheus 内存会爆
registry.counter("request", "userId", userId)

// ❌ 同样糟糕:URL 全路径(含 ID)
registry.counter("request", "uri", "/orders/12345")

// ✅ 正确:用路由模板 + 可枚举维度
registry.counter("request", "uri", "/orders/{id}", "status", "200", "method", "GET")

规则:标签值必须是可枚举的有限集合。用户 ID、traceId、订单号、时间戳绝对不能当标签。

❷ 别忘了 /metrics 端点本身

抓取 /metrics 会产生请求——如果它被计入 SLO 统计,会污染你的数据。做法:把指标端点排除在业务指标之外(Ktor 的 Micrometer 插件可以按路径过滤),并且不要让压测流量打到它。

❸ 观测本身有成本

/metrics 每次抓取都要序列化所有指标。指标太多、抓取太频繁(比如 1 秒一次)会带来可观开销。压测时用 5 秒间隔,生产用 15–30 秒即可。