《游戏服务端编程实践》14.1 心跳与健康检查

从心跳间隔、超时窗口与连续丢失次数的关系讲起,解释为什么秒级摘除容易误杀,区分 liveness 与 readiness、启动探针、优雅下线、连接排空,分析心跳风暴、批量上报、跨机房时钟与网络抖动,最后给出带容忍次数和恢复确认的 Go 心跳检测器、测试与常见错误清单。

一、心跳要回答的三个问题

心跳不是“每隔一秒发一个包”这么简单。一个可用的心跳机制必须回答三个问题:多久没有消息就算异常、异常持续多久才采取动作、动作是摘除流量还是重启进程。这三个问题分别对应间隔、超时和容忍次数,任何一个参数拍脑袋都会在故障时放大损失。

参数含义常见起点调错的后果
Interval发送方承诺的心跳周期1 到 5 秒,按业务敏感度选择太短制造无效流量,太长故障发现慢
Timeout单次心跳允许的最大静默时间2 到 3 倍 Interval等于 Interval 时任何抖动都算丢包
MissesBeforeEvict连续超时多少次才摘除3 次左右,结合业务容忍度为 1 时秒级误杀,过大时故障扩散
BeatsBeforeReady恢复后连续多少次心跳才重新加入2 次左右为 1 时抖动节点反复进出

1.1 超时不能等于间隔

很多实现把 Timeout 直接设成 Interval,理由是“上一秒没收到就该判死”。这忽略了三件事:发送方的定时器会有几十毫秒抖动,网络会排队,接收方的扫描循环也可能被 GC 或日志阻塞。只要一次心跳晚到,节点就被判为异常,而它其实一直健康。

超时窗口的意义是吸收“单次晚到”,容忍次数的意义是吸收“连续晚到”。一个粗略的关系是:Timeout ≥ 2 × Interval,摘除时间约为 Timeout + (MissesBeforeEvict - 1) × Interval。这个公式只是估算,真实取值要用线上延迟分布和故障演练确认,不能照抄。

1.2 “连续”必须由检测器定义

“连续丢失三次”听起来明确,实现里却容易变成“三次扫描没看到心跳”。如果扫描周期与心跳周期不一致,一次网络抖动可能被算成两次丢失。更稳妥的做法是让检测器显式记录上次心跳时间,每次扫描只判断“距离上次心跳是否超过超时窗口”,超时一次记一次丢失,收到心跳立即清零。

二、liveness 与 readiness 不是一回事

liveness 回答“进程还活着吗”,失败通常意味着重启;readiness 回答“现在能接流量吗”,失败意味着从负载均衡或匹配池里摘掉,但进程继续运行。把两者混成一个探针,是游戏服务端最常见的健康检查错误。

探针回答的问题失败后的动作游戏服务端里的典型用法
liveness进程是否卡死、能否继续工作重启容器或进程检测死锁、永久阻塞的 tick 循环
readiness是否准备好接收玩家与请求摘除流量,不重启地图未加载完、依赖未连上、正在排空
startup启动阶段是否还在正常推进延迟 liveness 判定加载配置、预热缓存、注册到服务发现

2.1 启动探针与冷启动

游戏服启动往往要加载地图、怪物表、活动配置,还要连接数据库和 Redis。如果 liveness 从进程启动就开始计时,冷启动稍慢就会被反复重启,形成“启动—被杀—再启动”的循环。启动探针的作用是给冷启动一个独立预算:在预算内只判断“启动是否还在推进”,不触发重启。

2.2 优雅下线与连接排空

摘除流量不等于连接立刻消失。玩家可能正处在战斗结算、交易确认或副本翻牌阶段。正确的下线顺序是:先把 readiness 置为 false,让新玩家不再被分配进来;再通知网关停止接受新连接;然后等待现有连接完成关键操作,或主动把玩家迁移到其他节点;最后关闭监听并退出。

连接排空需要两个上限:时间上限和剩余连接数上限。只设时间上限,长连接可能拖到超时被强杀;只设连接数上限,个别卡住的连接会让进程永远退不出去。两个条件满足其一就进入强制关闭,并记录被中断的连接与玩家 ID,便于补偿。

三、心跳风暴与批量上报

当每个节点每秒都向中心上报一次心跳,节点数上升后,中心会在同一秒收到成倍请求。更糟的是所有节点往往在同一时刻启动、同一时刻扫描,形成整齐的脉冲。解决方案不是简单加机器,而是打散与合并。

  • 给心跳周期加随机抖动,让发送时刻分散在一个窗口内。
  • 中心侧按节点聚合,只保留“上次心跳时间”和“连续丢失次数”,不必保存每条心跳。
  • 允许批量上报:一次请求携带多个节点或分片的摘要,降低请求数。
  • 对同一节点的重复心跳做幂等处理,避免重试把计数打乱。

心跳风暴还会反噬检测本身:中心忙于处理请求,扫描线程被挤占,于是更多节点超时,触发更多重连与上报。给心跳通道设置独立的有界队列和优先级,比单纯扩容更能阻止雪崩。

四、跨机房时钟与网络抖动

跨机房部署时,不要用两台机器的墙上时钟直接相减。NTP 可能回拨,容器时钟可能漂移,跨地域 RTT 也可能从几十毫秒跳到几百毫秒。检测器应使用本机单调时钟计算“距离上次心跳多久”,跨机时间只用于日志排序,不用于判定生死。

网络抖动要按链路区分。同机房可以容忍 2 倍间隔,跨机房可能需要 3 到 5 倍;这不是“偏心”,而是承认物理链路的长尾延迟更长。把阈值做成按区域可配置,并在故障演练中验证,比全站一个常数更可靠。

五、失败场景:秒级摘除引发的重连风暴

某游戏把心跳间隔设为 1 秒、超时设为 1 秒、连续丢失 1 次即摘除。一次机房网络抖动持续了 300 毫秒,同时部分节点发生 GC 停顿。中心在下一轮扫描时把大量房间服判为异常并摘除,网关立即让玩家重连到剩余节点。剩余节点瞬间过载,tick 延迟上升,更多心跳超时,摘除范围继续扩大。

时间事件结果
T+0网络抖动与 GC 叠加部分心跳晚到
T+1s扫描发现超时,按 1 次丢失摘除大量房间服被移出
T+2s玩家集中重连剩余节点连接数与消息量暴涨
T+5s新节点 tick 超时第二轮摘除开始
T+30s抖动结束系统已进入雪崩,恢复远慢于故障本身

如果超时是 3 秒、连续丢失 3 次才摘除,同样的抖动最多让节点进入可疑状态,不会触发大规模迁移。故障域的大小应由“误杀成本”和“真实故障发现时间”共同决定,而不是追求最短检测时间。

六、可运行实现:带容忍次数的心跳检测器

下面的实现把时间作为参数注入,检测器本身不调用 time.Now。Observe 记录心跳,Sweep 由扫描循环周期调用;只有连续超时达到阈值才摘除,被摘除的节点需要连续收到指定次数的心跳才重新加入。

// file: ch14/heartbeat/heartbeat.go
// tier: L1
// verify: go test ./ch14/heartbeat/

package heartbeat

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

// State 是检测器对单个节点的判定结果。
type State uint8

const (
	// Unknown 表示还没有收到过心跳,此时既不能判活也不能判死。
	Unknown State = iota
	// Healthy 表示最近一次扫描时,距离上次心跳仍在超时窗口内。
	Healthy
	// Suspect 表示已经超出超时窗口,但连续丢失次数还没达到摘除阈值。
	Suspect
	// Evicted 表示连续丢失达到阈值,节点应从可用列表中摘除。
	Evicted
)

// String 让状态在日志与指标里保持可读。
func (s State) String() string {
	switch s {
	case Unknown:
		return "unknown"
	case Healthy:
		return "healthy"
	case Suspect:
		return "suspect"
	case Evicted:
		return "evicted"
	default:
		return fmt.Sprintf("state(%d)", uint8(s))
	}
}

var (
	// ErrInvalidInterval 表示心跳间隔没有配置成正数。
	ErrInvalidInterval = errors.New("heartbeat: interval must be positive")
	// ErrTimeoutTooSmall 表示超时窗口没有明显大于心跳间隔。
	ErrTimeoutTooSmall = errors.New("heartbeat: timeout must be greater than interval")
	// ErrInvalidMisses 表示摘除阈值没有配置成正数。
	ErrInvalidMisses = errors.New("heartbeat: misses before evict must be positive")
	// ErrInvalidRecovery 表示重新加入所需的连续心跳数没有配置成正数。
	ErrInvalidRecovery = errors.New("heartbeat: beats before ready must be positive")
)

// Config 描述一个节点的检测参数。
//
// Interval 是发送方承诺的心跳周期。Timeout 是一次心跳可以被容忍的
// 最大静默时间,必须严格大于 Interval:如果两者相等,任何一个网络
// 抖动都会被当成丢包。MissesBeforeEvict 是连续超出 Timeout 的扫描
// 次数,BeatsBeforeReady 是节点被摘除后重新加入所需的连续心跳数。
type Config struct {
	Interval          time.Duration
	Timeout           time.Duration
	MissesBeforeEvict int
	BeatsBeforeReady  int
}

func (c Config) validate() error {
	if c.Interval <= 0 {
		return ErrInvalidInterval
	}
	if c.Timeout <= c.Interval {
		return ErrTimeoutTooSmall
	}
	if c.MissesBeforeEvict <= 0 {
		return ErrInvalidMisses
	}
	if c.BeatsBeforeReady <= 0 {
		return ErrInvalidRecovery
	}
	return nil
}

// Snapshot 是某一时刻的只读观测结果。
type Snapshot struct {
	State    State
	Misses   int
	LastSeen time.Time
}

// Detector 维护单个节点的心跳状态。
//
// 检测完全由调用方注入的时间驱动:Observe 在收到心跳时调用,
// Sweep 由扫描循环按 Interval 周期调用。把时间作为参数传入,
// 既避免了测试里 sleep 的不确定性,也让跨机房时钟问题在调用层
// 显式暴露,而不是藏在 time.Now 里。
type Detector struct {
	mu       sync.Mutex
	cfg      Config
	lastSeen time.Time
	misses   int
	beats    int
	state    State
}

// NewDetector 校验配置并创建一个未收到任何心跳的检测器。
func NewDetector(cfg Config) (*Detector, error) {
	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return &Detector{cfg: cfg, state: Unknown}, nil
}

// Observe 记录一次心跳到达,并返回记录后的状态。
//
// 健康节点的任何一次心跳都会把丢失计数清零。被摘除的节点不会
// 因为一次心跳立刻复活,而要连续收到 BeatsBeforeReady 次心跳,
// 防止抖动中的单个包把故障节点重新塞回流量。
func (d *Detector) Observe(now time.Time) State {
	d.mu.Lock()
	defer d.mu.Unlock()

	d.lastSeen = now
	if d.state == Evicted {
		d.beats++
		if d.beats < d.cfg.BeatsBeforeReady {
			return d.state
		}
		d.misses = 0
		d.beats = 0
		d.state = Healthy
		return d.state
	}
	d.misses = 0
	d.beats = 0
	d.state = Healthy
	return d.state
}

// Sweep 执行一次周期扫描,调用方应大约每 Interval 调用一次。
//
// 扫描不会推进 lastSeen;只有 Observe 能刷新它。距离上次心跳
// 不超过 Timeout 时视为健康,超过则累计一次连续丢失。累计值
// 达到 MissesBeforeEvict 后节点进入 Evicted,直到 Observe 把它
// 拉回。已摘除的节点不会被扫描自动复活,因此时钟回拨最多造成
// 一次误判,不会让摘除状态来回翻转。
func (d *Detector) Sweep(now time.Time) State {
	d.mu.Lock()
	defer d.mu.Unlock()

	if d.state == Unknown || d.state == Evicted {
		return d.state
	}
	if now.Sub(d.lastSeen) <= d.cfg.Timeout {
		d.misses = 0
		d.state = Healthy
		return d.state
	}
	d.misses++
	if d.misses >= d.cfg.MissesBeforeEvict {
		d.beats = 0
		d.state = Evicted
		return d.state
	}
	d.state = Suspect
	return d.state
}

// Snapshot 返回状态、连续丢失次数和上次心跳时间。
func (d *Detector) Snapshot() Snapshot {
	d.mu.Lock()
	defer d.mu.Unlock()
	return Snapshot{State: d.state, Misses: d.misses, LastSeen: d.lastSeen}
}

// Config 返回创建时校验过的配置副本。
func (d *Detector) Config() Config {
	return d.cfg
}

实现有三个刻意选择。第一,Timeout 必须严格大于 Interval,构造时直接拒绝“超时等于间隔”的配置。第二,Sweep 不会推进 lastSeen,只有真正收到心跳才会刷新,因此扫描频率不会影响判定。第三,已摘除的节点不会被扫描自动复活,恢复必须由连续心跳确认,避免抖动中的单个包让节点反复进出。

测试覆盖以下边界:初始状态为 Unknown、收到心跳后变为 Healthy、偶发一次超时只进入 Suspect、连续丢失达到阈值才 Evicted、被摘除后需要连续两次心跳才恢复、已摘除节点不会被扫描复活。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch14/heartbeat
go vet ./ch14/heartbeat/
go test ./ch14/heartbeat/ -count=1

6.1 指标与日志

至少记录四类数据:节点当前状态、连续丢失次数、上次心跳时间、状态切换次数。状态切换要带原因,例如“连续 3 次超时”还是“恢复确认完成”。没有这些信息,事故复盘时只能看到“节点被摘了”,却不知道是网络问题、GC 还是配置错误。

6.2 摘除之后还要做什么

摘除只是第一步。网关要停止向该节点分配新连接,已有连接要按业务优先级迁移或断开;房间服要保存可恢复的房间状态;匹配系统要避免把同一队伍的玩家分到不同节点。摘除后不排空连接,等于把故障从“一个节点不可用”扩散成“一批玩家数据不一致”。

七、与 14.2 的衔接

心跳解决了“节点还在不在”的问题,但节点短暂不可用时,调用方仍然会收到错误。下一篇 /game-server-in-action-14-2/ 会讨论重试、断路器和回退:哪些错误值得重试、退避与抖动如何避免重试风暴、断路器如何在关闭、打开、半开之间切换。

小结

心跳参数不是越小越好。间隔决定流量与发现速度,超时吸收单次晚到,容忍次数吸收连续异常,恢复确认防止抖动节点反复进出。liveness 负责重启,readiness 负责摘流量,启动探针保护冷启动,优雅下线负责连接排空。把这些边界分开,秒级摘除才不会变成秒级雪崩。

常见错误与边界

  • 把超时设成等于心跳间隔,任何网络抖动都被算成丢包。
  • 只做 liveness 不做 readiness,节点没准备好就被分配玩家。
  • 连续丢失次数设为 1,追求最快发现却制造最大误杀。
  • 摘除节点后不排空连接,玩家状态与房间状态出现分裂。
  • 所有节点同相位发送心跳,中心侧形成周期性脉冲。
  • 用跨机房的墙上时钟相减判断心跳年龄,遇到时钟回拨就误判。
  • 节点恢复后立刻全量放量,没有逐步加回的过程。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist