2026-05-11 01:40:29 +08:00

182 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package healthcheck 定义 room-service 的 gRPC health 探针状态和依赖检查。
package healthcheck
import (
"context"
"sync/atomic"
"time"
"hyapp/pkg/grpchealth"
)
const (
// serviceName 是标准 gRPC health 对外暴露的服务名。
serviceName = "room-service"
// readyBudget 限制 ready 总耗时,避免探针堆积。
readyBudget = 500 * time.Millisecond
// mysqlBudget 限制 MySQL ping 时间MySQL 不可用时不能接新房间命令。
mysqlBudget = 300 * time.Millisecond
// redisBudget 限制 Redis ping 和 lease probe 时间。
redisBudget = 200 * time.Millisecond
// redisLeaseTTL 是健康检查 lease 探针使用的短 TTL。
redisLeaseTTL = 2 * time.Second
)
// RuntimeProbe 覆盖 Room Cell owner、repository 和 directory 等进程内装配状态。
type RuntimeProbe interface {
// HealthCheck 只检查进程内对象是否装配完整,不做慢 IO。
HealthCheck() error
}
// MySQLPinger 抽象 MySQL repository 的轻量连通性检查。
type MySQLPinger interface {
// Ping 验证 MySQL 连接池可用,调用方负责传入短 deadline。
Ping(ctx context.Context) error
}
// RedisProbe 抽象 Redis 当前可访问性和 lease Lua 原子路径探测。
type RedisProbe interface {
// Ping 验证 Redis 基础连通性。
Ping(ctx context.Context) error
// ProbeLease 验证 Redis lease Lua 原子路径可用。
ProbeLease(ctx context.Context, nodeID string, now time.Time, ttl time.Duration) error
}
// State 记录 room-service readiness 所需的进程状态和硬依赖。
type State struct {
// nodeID 是当前 room-service 实例标识Redis lease 探针需要它。
nodeID string
// runtime 是领域服务的进程内装配状态。
runtime RuntimeProbe
// mysql 是持久化连通性检查对象。
mysql MySQLPinger
// redis 是路由目录连通性和 lease 脚本检查对象。
redis RedisProbe
// grpcActive 表示 gRPC listener 已进入 Serve 循环。
grpcActive atomic.Bool
// draining 表示节点正在下线ready 必须失败以停止接新命令和接管。
draining atomic.Bool
}
// NewState 创建 room-service 健康状态所有者。
func NewState(nodeID string, runtime RuntimeProbe, mysql MySQLPinger, redis RedisProbe) *State {
return &State{
nodeID: nodeID,
runtime: runtime,
mysql: mysql,
redis: redis,
}
}
// ServiceName 返回标准 gRPC health 使用的服务边界名。
func (s *State) ServiceName() string {
return serviceName
}
// MarkGRPCServing 标记 gRPC listener 已进入 Serve 循环。
func (s *State) MarkGRPCServing() {
s.grpcActive.Store(true)
}
// MarkGRPCStopped 标记 gRPC listener 已退出 Serve 循环。
func (s *State) MarkGRPCStopped() {
s.grpcActive.Store(false)
}
// MarkDraining 让 ready 立即失败,避免下线节点继续接管新房间。
func (s *State) MarkDraining() {
s.draining.Store(true)
}
// Live 检查本进程关键内存结构,不把 MySQL/Redis 抖动映射为 live 失败。
func (s *State) Live() []grpchealth.Check {
// live 只判断进程是否应该继续存在,不因为 MySQL/Redis 短抖动重启进程。
checks := []grpchealth.Check{
grpchealth.Bool("grpc_server", s.grpcActive.Load(), "gRPC listener is not serving"),
}
checks = append(checks, s.runtimeCheck())
return checks
}
// Ready 检查 room-service 接新命令所需的硬依赖。
func (s *State) Ready(ctx context.Context) []grpchealth.Check {
// ready 是流量入口判定必须包含持久化、Redis lease 和 drain 状态。
ctx, cancel := context.WithTimeout(ctx, readyBudget)
defer cancel()
checks := []grpchealth.Check{
grpchealth.Bool("grpc_listener", s.grpcActive.Load(), "gRPC listener is not serving"),
grpchealth.Bool("draining", !s.draining.Load(), "node is draining"),
}
checks = append(checks, s.runtimeCheck())
checks = append(checks, s.mysqlCheck(ctx))
checks = append(checks, s.redisPingCheck(ctx))
checks = append(checks, s.redisLeaseCheck(ctx))
return checks
}
func (s *State) runtimeCheck() grpchealth.Check {
if s.runtime == nil {
// runtime 缺失说明 App.New 装配异常。
return grpchealth.Fail("room_runtime", "runtime probe is not initialized")
}
if err := s.runtime.HealthCheck(); err != nil {
// 进程内核心对象缺失时不能处理任何房间命令。
return grpchealth.Fail("room_runtime", err.Error())
}
return grpchealth.OK("room_runtime")
}
func (s *State) mysqlCheck(ctx context.Context) grpchealth.Check {
if s.mysql == nil {
// 没有 MySQL repository 时无法保存 command log/snapshot/outbox。
return grpchealth.Fail("mysql", "MySQL repository is not initialized")
}
checkCtx, cancel := context.WithTimeout(ctx, mysqlBudget)
defer cancel()
if err := s.mysql.Ping(checkCtx); err != nil {
// MySQL 不可达时即使内存 Cell 存在,也不能安全接新写命令。
return grpchealth.Fail("mysql", err.Error())
}
return grpchealth.OK("mysql")
}
func (s *State) redisPingCheck(ctx context.Context) grpchealth.Check {
if s.redis == nil {
// 没有 Redis directory 时无法保证单房间单活。
return grpchealth.Fail("redis_ping", "Redis probe is not initialized")
}
checkCtx, cancel := context.WithTimeout(ctx, redisBudget)
defer cancel()
if err := s.redis.Ping(checkCtx); err != nil {
// Redis 不可达时新房间接管和续租语义不可用。
return grpchealth.Fail("redis_ping", err.Error())
}
return grpchealth.OK("redis_ping")
}
func (s *State) redisLeaseCheck(ctx context.Context) grpchealth.Check {
if s.redis == nil {
// lease 探针和 ping 分开,方便定位是连通性还是 Lua 原子路径问题。
return grpchealth.Fail("redis_lease", "Redis probe is not initialized")
}
checkCtx, cancel := context.WithTimeout(ctx, redisBudget)
defer cancel()
if err := s.redis.ProbeLease(checkCtx, s.nodeID, time.Now().UTC(), redisLeaseTTL); err != nil {
// Eval/cjson/写权限异常会直接破坏 owner 仲裁ready 必须失败。
return grpchealth.Fail("redis_lease", err.Error())
}
return grpchealth.OK("redis_lease")
}