182 lines
5.9 KiB
Go
182 lines
5.9 KiB
Go
// 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")
|
||
}
|