一、心跳要回答的三个问题
心跳不是“每隔一秒发一个包”这么简单。一个可用的心跳机制必须回答三个问题:多久没有消息就算异常、异常持续多久才采取动作、动作是摘除流量还是重启进程。这三个问题分别对应间隔、超时和容忍次数,任何一个参数拍脑袋都会在故障时放大损失。
| 参数 | 含义 | 常见起点 | 调错的后果 |
|---|---|---|---|
| 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,追求最快发现却制造最大误杀。
- 摘除节点后不排空连接,玩家状态与房间状态出现分裂。
- 所有节点同相位发送心跳,中心侧形成周期性脉冲。
- 用跨机房的墙上时钟相减判断心跳年龄,遇到时钟回拨就误判。
- 节点恢复后立刻全量放量,没有逐步加回的过程。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。