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 秒即可。