Skip to content

健康检查

服务活着不等于服务可用——探针说了算

这是什么

服务启动了,端口监听了——但数据库连不上、Redis 挂了、磁盘满了,没人知道,直到用户报错。

健康检查就是给每个关键依赖插一根探针。IHealthCheckService 一声令下,所有探针并发执行,几秒内告诉你整个系统是绿是黄还是红。

你的服务 ─── 探针 ─── 数据库    ✅ Healthy
           ─── 探针 ─── Redis    ⚠️ Degraded(响应超过 1 秒)
           ─── 探针 ─── 磁盘     ❌ Unhealthy(不足 100MB)

聚合结果 → Degraded

不用等到用户发现。容器平台(Kubernetes、Nomad)定期调 checkHealth(),不健康就自动切流量——这是基础设施层的最后一道防线。


快速开始

引入 soulsoft_extensions_healthcheckssoulsoft_extensions_injection 后,3 步搞定:

cangjie
import soulsoft_extensions_injection.*
import soulsoft_extensions_healthchecks.*

main(): Int64 {
    // 1. 注册:启动健康检查 + 添加探针
    let services = ServiceCollection()
    services.addHealthChecks()
        .addCheck("database") {
            HealthCheckResult.healthy(description: "连接正常")
        }

    let provider = services.build()

    // 2. 拿到服务
    let svc = provider.getOrThrow<IHealthCheckService>()

    // 3. 执行——所有探针并发跑,返回聚合报告
    let report = svc.checkHealth()
    println("状态:${report.status},耗时:${report.totalDuration.toMilliseconds()}ms")
    return 0
}

运行输出:

状态:Healthy,耗时:1ms

整个流程只有三个角色:

步骤做什么用的类型
注册启动健康检查,链式添加探针addHealthChecks()addCheck(...)
获取从容器解析服务IHealthCheckService
执行并发执行探针,返回报告checkHealth()HealthReport

下面逐一展开。


核心设计

健康检查系统的设计思路:你只定义"查什么",框架接管"怎么查"。

ServiceCollection


addHealthChecks()
    ├── 注册 HealthCheckServiceOptions(IOptions 模式)
    ├── 注册 IHealthCheckService → HealthCheckService(Singleton)
    └── 返回 HealthChecksBuilder

         ├── .addCheck("db", { ... })      ← Lambda 探针
         ├── .addCheck("redis", { ... })
         └── .addCheck<DiskHealthCheck>("disk") ← 类型探针

执行时:
IHealthCheckService.checkHealth()

    ├── 从 Options 读取所有注册的探针
    ├── spawn 并发执行每个探针(各自独立的 DI Scope)
    ├── timeout 超时自动返回 Unhealthy
    └── 聚合所有结果 → HealthReport
  • 注册期:探针挂到 HealthChecksBuilder,存入 HealthCheckServiceOptions,什么都不执行
  • 执行期checkHealth() 一声令下,所有探针 spawn 并发跑,各自有超时兜底
  • 聚合期:取所有探针中最差的状态作为最终结果——只要有一个 Unhealthy,整体就是 Unhealthy
  • 隔离:每个探针运行在独立的 DI Scope 中——一个探针打开数据库连接,另一个探针不会共享它

注册健康检查

addHealthChecks() 返回 HealthChecksBuilder,支持两种注册方式:

方式一:Lambda 注册(简单探针)

一行逻辑的探针,直接传闭包:

cangjie
let services = ServiceCollection()
services.addHealthChecks()
    .addCheck("ping") {
        HealthCheckResult.healthy(description: "ping 正常")
    }

闭包签名是 () -> HealthCheckResult,框架内部用 DelegateHealthCheck 包装成 IHealthCheck

带标签和超时的完整写法:

cangjie
services.addHealthChecks()
    .addCheck("redis",
        timeout: Duration.second * 3,
        tags: ["storage", "critical"]) {
        // 探针逻辑:连接 Redis 并 PING
        HealthCheckResult.healthy(description: "PONG")
    }

方式二:IHealthCheck 类型注册(复杂探针)

探针逻辑复杂、需要从 DI 容器注入依赖时,推荐实现 IHealthCheck 接口:

cangjie
import soulsoft_extensions_options.*

class RedisOptions {
    public var connectionString: String = "localhost:6379"
}

class RedisHealthCheck <: IHealthCheck {
    private let options: RedisOptions

    public init(options: IOptions<RedisOptions>) {
        this.options = options.value
    }

    public func check(context: HealthCheckContext): HealthCheckResult {
        // 实际项目中:连接 Redis 并 PING
        HealthCheckResult.healthy(description: "Redis PONG")
    }
}

// 注册选项 → 注册探针,框架通过 DI 容器自动创建实例
services.configure<RedisOptions> { options =>
    options.connectionString = "redis-cluster:6379"
}
services.addHealthChecks()
    .addCheck<RedisHealthCheck>("redis", tags: ["storage"])

ActivatorUtilities.createInstance<T>(sp) 从 DI 容器解析构造函数参数,自动创建 RedisHealthCheck 实例。

注册方式速查

场景用哪种写法
简单检查Lambda.addCheck("name") { HealthCheckResult.healthy() }
需要 DI 注入IHealthCheck 类型.addCheck<MyHealthCheck>("name")
带标签两种都支持addCheck("name", tags: [...]) { ... }
带超时两种都支持addCheck("name", timeout: Duration.second * 5, tags: [...]) { ... }

健康状态

HealthStatus 是三级枚举,数值越小状态越差:

状态数值含义什么时候用
Unhealthy0挂了数据库连不上、Redis 超时、磁盘满了
Degraded1能用但不太行响应时间超过阈值、主节点挂了切到备节点
Healthy2一切正常所有依赖正常响应

聚合规则:取所有探针中最差的。一个 Unhealthy 就拉红全局。


检查结果

HealthCheckResult 通过静态工厂方法创建,不需要 new

cangjie
// 健康——一切正常
HealthCheckResult.healthy(description: "连接正常")

// 降级——能用但有隐患
HealthCheckResult.degraded(
    description: "响应时间 3 秒,超过阈值 1 秒"
)

// 不健康——挂了,附带异常
HealthCheckResult.unhealthy(
    description: "无法连接到数据库",
    exception: Some(ex)
)

每个工厂方法都能附带 data: HashMap<String, Object>,往报告里塞自定义结构化数据:

cangjie
let data = HashMap<String, Object>()
data["free_mb"] = 86
data["threshold_mb"] = 100
let result = HealthCheckResult.unhealthy(
    description: "磁盘不足",
    data: data
)

工厂方法速查

需求方法
健康HealthCheckResult.healthy(description: "ok")
健康+数据HealthCheckResult.healthy(description: "ok", data: map)
降级HealthCheckResult.degraded(description: "slow")
降级+异常HealthCheckResult.degraded(description: "slow", exception: Some(ex))
降级+数据HealthCheckResult.degraded(description: "slow", data: map)
不健康HealthCheckResult.unhealthy(description: "down")
不健康+异常HealthCheckResult.unhealthy(description: "down", exception: Some(ex))
不健康+数据HealthCheckResult.unhealthy(description: "down", data: map)

标签与过滤

探针多起来后,不同场景需要查不同的子集。"启动就绪"只关心关键依赖,"详细诊断"连缓存和队列一起查。标签就是用来区分这些场景的。

注册时打标签:

cangjie
let services = ServiceCollection()
services.addHealthChecks()
    .addCheck("database", tags: ["critical", "storage"]) {
        HealthCheckResult.healthy(description: "连接正常")
    }
    .addCheck("redis", tags: ["storage"]) {
        HealthCheckResult.healthy(description: "PONG")
    }
    .addCheck("disk", tags: ["critical"]) {
        HealthCheckResult.healthy(description: "磁盘充足")
    }

执行时按标签过滤:

cangjie
let provider = services.build()
let svc = provider.getOrThrow<IHealthCheckService>()

// 只检查"critical"标签的探针——启动就绪检查
let readyReport = svc.checkHealth { reg =>
    reg.tags.contains("critical")
}
// 只执行 database 和 disk

// 检查全部
let fullReport = svc.checkHealth()
// 执行 database、redis、disk

checkHealth() 无参版本执行全部;checkHealth(predicate) 按条件筛选:

场景写法
全部执行svc.checkHealth()
按标签过滤svc.checkHealth { reg => reg.tags.contains("critical") }
按名称过滤svc.checkHealth { reg => reg.name == "database" }

超时控制

每个探针可以设独立超时。超时后框架调用 Future.cancel() 发送取消信号,探针主动检查 Thread.currentThread.hasPendingCancellation 并退出:

cangjie
services.addHealthChecks()
    .addCheck("database",
        timeout: Duration.second * 5,
        tags: ["critical"]) {
        var connected = false
        var retries = 0
        while (!connected && retries < 3) {
            // 检查是否已被取消——不检查就一直跑,设超时也没用
            if (Thread.currentThread.hasPendingCancellation) {
                return HealthCheckResult.unhealthy(description: "健康检查超时被取消")
            }
            // 尝试连接数据库...
            retries++
        }
        if (connected) {
            HealthCheckResult.healthy(description: "连接正常")
        } else {
            HealthCheckResult.unhealthy(description: "重试${retries}次后仍无法连接")
        }
    }

不设超时时 timeoutDuration.Zero,表示永不超时。

两个要点:

  • 取消是协作式的——框架通过 Future.cancel() 发信号,线程不会强杀。探针内部必须在循环、阻塞操作前检查 hasPendingCancellation
  • 生产环境每个探针都设超时——避免一个慢查询把整个健康检查卡死

读取报告

checkHealth() 返回 HealthReport,包含聚合状态和每个探针的详细结果:

cangjie
let provider = services.build()
let svc = provider.getOrThrow<IHealthCheckService>()
let report = svc.checkHealth()

// 聚合状态——取最差的那个
println("整体:${report.status}")

// 总耗时——所有探针耗时之和
println("总耗时:${report.totalDuration.toMilliseconds()}ms")

// 逐个探针的详情
for ((name, entry) in report.entries) {
    println("${name}${entry.status}${entry.duration.toMilliseconds()}ms)")
    match (entry.description) {
        case Some(let desc): println("  描述:${desc}")
        case None: {}
    }
    match (entry.exception) {
        case Some(let ex): println("  异常:${ex.message}")
        case None: {}
    }
}

HealthReportEntry 包含六个字段:

字段类型说明
statusHealthStatus该探针的健康状态
durationDuration该探针的执行耗时
description?String可选的描述信息
exception?Exception可选的异常对象
tagsArray<String>该探针的标签集合
dataReadOnlyMap<String, Object>探针附带的结构化数据

最佳实践

三步走:实现 IHealthCheck → 扩展 HealthChecksBuilder → 一行链式注册。

cangjie
import soulsoft_extensions_healthchecks.*
import soulsoft_extensions_injection.*
import soulsoft_extensions_options.*
import std.net.*
import std.time.*

// 1. 定义选项类——探针配置收敛到一个强类型里
class TcpProbeOptions {
    public var host: String = ""
    public var port: UInt16 = 0
}

// 2. 实现 IHealthCheck——通过 IOptionsMonitor 按名称获取配置,避免多次注册互相覆盖
class TcpHealthCheck <: IHealthCheck {
    private let name: String
    private let monitor: IOptionsMonitor<TcpProbeOptions>

    public init(name: String, monitor: IOptionsMonitor<TcpProbeOptions>) {
        this.name = name
        this.monitor = monitor
    }

    public func check(context: HealthCheckContext): HealthCheckResult {
        // 按探针名称取出对应配置,不同探针的配置互不干扰
        let options = monitor.get(name)
        try (socket = TcpSocket(options.host, options.port)) {
            if (Thread.currentThread.hasPendingCancellation) {
                return HealthCheckResult.unhealthy(description: "探测被取消")
            }
            socket.connect()
            HealthCheckResult.healthy(description: "${options.host}:${options.port} 可达")
        } catch (ex: Exception) {
            HealthCheckResult.unhealthy(
                description: "无法连接 ${options.host}:${options.port}",
                exception: Some(ex))
        }
    }
}

// 3. 扩展 HealthChecksBuilder——
//    用命名选项(`configure<T>(name)`)注册配置,每个探针的名称作为选项名,
//    避免多次 addTcpCheck 共用一个 TcpProbeOptions 类型导致配置互相覆盖。
extend HealthChecksBuilder {
    public func addTcpCheck(name: String,
                            timeout!: Duration = Duration.second * 5,
                            tags!: Array<String> = [],
                            configureOptions: (TcpProbeOptions) -> Unit): HealthChecksBuilder {
        // 以探针名称作为选项名——不同探针的 TcpProbeOptions 实例相互隔离
        // 用选项模式,将来可以无缝切换到配置文件绑定,
        _services.configure<TcpProbeOptions>(name, configureOptions)
        this.addCheck(HealthCheckRegistration(name, timeout, tags) { sp =>
            TcpHealthCheck(name, sp.getOrThrow<IOptionsMonitor<TcpProbeOptions>>())
        })
    }
}

// 4. 使用——和其他 addCheck 一样链式调用
let services = ServiceCollection()
services.addHealthChecks()
    .addTcpCheck("postgresql",
        timeout: Duration.second * 5,
        tags: ["critical"]) { options =>
        options.host = "127.0.0.1"
        options.port = 5432
    }
    .addTcpCheck("redis",
        timeout: Duration.second * 3,
        tags: ["storage"]) { options =>
        options.host = "localhost"
        options.port = 6379
    }

let provider = services.build()
let svc = provider.getOrThrow<IHealthCheckService>()

// 启动就绪检查
let startup = svc.checkHealth { reg =>
    reg.tags.contains("critical")
}
if (startup.status == HealthStatus.Unhealthy) {
    println("关键依赖不健康,拒绝启动")
    return 1
}

// 全量检查
println("==== 健康检查 ====")
let full = svc.checkHealth()
for ((name, entry) in full.entries) {
    match (entry.description) {
        case Some(let desc): println("${name}${entry.status}${desc}")
        case None: println("${name}${entry.status}")
    }
}

分层检查策略:

阶段检查范围目的
启动就绪critical 标签关键依赖不通过 → 拒绝启动
存活探测critical 标签定期轻量检查,决定是否切流量
详细诊断全部探针运维排查,看每个依赖的详细状态

addTcpCheck 这个名字可以换成任何语义——addRedisCheckaddDatabaseCheckaddKafkaCheck。模式不变:实现 IHealthCheck + extend HealthChecksBuilder,调用方永远是一行链式注册。


常见问题

健康检查和直接 try-catch 有什么区别?

try-catch 只能抓到当前这次调用失败。健康检查的探针是主动探测——每隔一段时间确认下游还活着。更重要的是,容器平台(Kubernetes、Nomad)依赖健康检查接口来决定是否把流量切走。try-catch 没法替代这个。

为什么每个探针跑在独立的 Scope 里?

防止一个探针污染另一个。比如 DatabaseHealthCheck 打开了一个连接,RedisHealthCheck 不应该共享这个连接——各自在独立 Scope 中创建、执行、释放。

超时后探针会怎样?

超时的探针对应的 spawn 协程会被 cancel(),立即终止执行,返回 HealthStatus.Unhealthy。不会无限等待拖垮整体检查。

多个探针的执行顺序?

并发执行,不分先后。框架用 spawn 把所有探针同时发出去,Future.get() 收结果。慢的探针不阻塞快的。

聚合状态是 Healthy,但某个探针是 Degraded?

不会。聚合取最差状态——只要有一个 Degraded,整体就是 Degraded。只有一个 Unhealthy,整体就是 Unhealthy。只有全部 Healthy 才返回 Healthy

怎么扩展新的探针类型?

两种方式:

  1. 简单addCheck("name") { ... } 写闭包
  2. 复杂:实现 IHealthCheck 接口,addCheck<MyCheck>("name") 注册

推荐把可复用的探针(如 RedisHealthCheckDatabaseHealthCheck)封装成单独的类型,方便多个项目共享。