9.1 心跳超时与技能冷却
第 6 章我们给单条连接写了读超时与看门狗,第 7 章又给会话表加了清理。那时用的都是「每连接一个 ticker」,在几百条连接下完全没问题。但一个 MMO 网关要同时扛住几万条连接,而每个在线玩家身上还挂着几十个定时器:技能冷却、Buff 到期、体力恢复、每日重置、公告轮播……如果每个都对应一个 time.Timer 或 time.AfterFunc,光是定时器的挂载、触发、回收就会把 CPU 啃掉一大块。
这一节先不谈「怎么实现时间轮」——那是 9.2 的事。这一节要回答更前面一步的问题:这些定时器真的都需要一个独立定时器吗? 答案往往是不需要。我们会把玩家身上所有的时间需求按「尺度」分成三类,给其中两类写出根本不用挂定时器的结构,只在最后一类才引入真正的调度器。
这背后是一条更普遍的原则:在服务端,能用「状态」表达的,就不要用「事件」。 一个定时器本质上是一个「未来的事件」,而事件需要调度、需要回调、需要在正确的上下文里执行、需要处理执行时的重入与并发。而一个到期时间戳只是一个「状态」,状态只需要被读取和比较。把未来的事件提前降级成当前的状态,是削掉定时器数量最有效的一刀。
三种时间尺度,三种组织方式
先把需求拆开看。同一台服务器上,「时间」这件事其实有好几种截然不同的粒度:
| 需求 | 典型尺度 | 精度要求 | 是否需要「到期事件」 | 合适的组织方式 |
|---|---|---|---|---|
| 心跳超时 | 15–60 秒 | 秒级 | 否,扫描时判定即可 | 单节拍扫描一张表 |
| 技能冷却 / Buff 到期 | 1–30 秒 | 百毫秒级 | 否,被查询时惰性判定 | 存到期时间戳,查询时比较 |
| 体力恢复 / 每日重置 | 分钟–小时 | 分钟级 | 是,到点要主动发奖励 | 真正的定时器 / 时间轮 |
差别在最后一列:前两类根本不需要「到点触发回调」。心跳是否超时,只在看门狗扫描那一刻判断就够了,没人要求「在静默的第 45 秒整点上报事件」;技能是否冷却完毕,也只在玩家按下技能那一刻判断就够了,没人在意「冷却在 3.2 秒时结束」这个瞬间。凡是「只在被问到时才需要答案」的时间判断,都可以做成纯状态查询,一个定时器都不用挂。
真正需要独立定时器的,是「到点必须主动做点什么」的那类:体力攒满要推一条消息、每日五点要重置副本次数。这类任务数量少、周期长,用 9.2 的时间轮统管即可。
这里有个反直觉的取舍需要点破:游戏逻辑其实普遍不需要高精度定时。 玩家按技能那一刻,服务端只关心「冷却有没有好」,早判定 50 毫秒还是晚判定 50 毫秒,对体验毫无影响;心跳晚 100 毫秒被判超时,玩家也感知不到。真正对时间敏感的是帧同步游戏里的逻辑帧,那要求确定性的固定步长(9.3 展开),而它对「毫秒级回调」的要求同样是 0——它要求的是「每一帧的逻辑一模一样」,不是「回调在哪个真实瞬间发生」。
把这条想清楚,海量定时器的优化方向就明确了:尽量把「定时器」降级成「一个到期时间戳 + 一次比较」。 一个 time.Timer 的成本不只是那次 AfterFunc 调用,还包括它在运行时堆里的存在、被唤醒时的调度、以及触发时对回调的调用。当同一个玩家身上挂着「技能 A 冷却、技能 B 冷却、Buff 剩余、体力恢复计时、连击窗口」五六个定时器,几万在线玩家就是几十万个活跃定时器——每一个都是一次潜在的 GC 压力与调度抖动。而惰性判定把这一整类成本压成了「一次 map 查找」。
代价是内存:每个冷却条目在 map 里常驻,直到被查询时删除。所以惰性方案的前提是查询频率足够高——只要玩家还在操作,冷却条目就会被反复读取并清理。反过来,如果某个条目可能几个月都没人查(比如一个已删号角色的残留),惰性方案就会漏内存,这时才需要 9.2 那种能按实体批量清理的调度器来兜底。
与站内既有 practice 文章的分工
站内 game/server/practice 下已有一批围绕「时间与定时」的架构文章,它们谈的是方案取舍与工程落地;本书负责的是把关键零件从零写出来、在本机真跑出数字。分工如下:
| 站内既有文章 | 它负责 | 本节负责 |
|---|---|---|
| 游戏服务器如何设计可靠的心跳机制 | 心跳频率、NAT 保活、双向探测的策略取舍 | 用单节拍扫描代替每连接定时器的实现 |
| 游戏服务器冷却令牌服务架构设计 | 冷却令牌的集中式服务与跨进程一致性 | 单进程内惰性冷却表的从零实现 |
| 游戏服务器持久化定时器架构设计 | 定时器如何落库、重启后如何恢复 | 内存态时间轮的挂载与级联(见 9.2) |
| 游戏服务器体力服务架构设计 | 体力按时间恢复的业务规则与边界 | 分钟级「到点触发」任务在时间轮上的挂法 |
一句话总结分工:既有文章回答「怎么设计」,本书回答「怎么写、跑起来多快」。
从零实现:两个不挂定时器的结构
下面这段是 L1,go test 可以直接跑。它包含两个结构:Cooldown 用一张 map 存到期时间戳,靠查询惰性过期;Watchdog 用一张表 + 一次 Sweep 扫描代替每连接一个 time.Timer。注意两者的回调/删除都在锁外或惰性完成,避免在持锁时执行用户代码。
// file: internal/timing/cooldown.go
// tier: L1
// verify: go test ./internal/timing/
package timing
import (
"sync"
"time"
)
// Clock 抽象单调时间来源,便于测试注入确定性时间。
type Clock func() time.Time
func clockOr(now Clock) Clock {
if now == nil {
return time.Now
}
return now
}
// Cooldown 管理「实体 × 技能」的冷却。冷却不需要毫秒精度,也不需要到期通知——
// 它只在被查询时回答「是否已过 readyAt」。因此它不挂任何定时器,只存一个到期
// 时间戳,过期条目由下一次查询惰性删除。这是海量短周期定时器的第一层优化:
// 能懒判定的,就不要真的挂定时器。
type Cooldown struct {
mu sync.Mutex
now Clock
readyAt map[key]time.Time
}
type key struct {
entity uint64
skill uint32
}
// NewCooldown 建一个冷却表。now 为 nil 时用 time.Now。
func NewCooldown(now Clock) *Cooldown {
return &Cooldown{now: clockOr(now), readyAt: make(map[key]time.Time)}
}
// Ready 报告技能当前是否可用;顺带清掉已过期的条目。
func (c *Cooldown) Ready(entity uint64, skill uint32) bool {
c.mu.Lock()
defer c.mu.Unlock()
k := key{entity, skill}
t, ok := c.readyAt[k]
if !ok {
return true
}
if !c.now().Before(t) {
delete(c.readyAt, k)
return true
}
return false
}
// Use 尝试触发一次技能:冷却中返回 false,否则写入冷却并返回 true。
func (c *Cooldown) Use(entity uint64, skill uint32, cd time.Duration) bool {
c.mu.Lock()
defer c.mu.Unlock()
k := key{entity, skill}
if t, ok := c.readyAt[k]; ok && c.now().Before(t) {
return false
}
c.readyAt[k] = c.now().Add(cd)
return true
}
// Remaining 返回剩余冷却;已就绪返回 0。
func (c *Cooldown) Remaining(entity uint64, skill uint32) time.Duration {
c.mu.Lock()
defer c.mu.Unlock()
t, ok := c.readyAt[key{entity, skill}]
if !ok {
return 0
}
if d := t.Sub(c.now()); d > 0 {
return d
}
return 0
}
// Len 报告当前挂起的冷却条目数(含尚未惰性清理的过期条目)。
func (c *Cooldown) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.readyAt)
}
// Watchdog 用「一个共享 ticker 扫描一张表」取代「每连接一个 time.Timer」,
// 用于心跳超时。超时判定不需要独立定时器,只需要固定节拍的扫描。
type Watchdog struct {
mu sync.Mutex
now Clock
lastSeen map[uint64]time.Time
onExpire func(id uint64)
}
// NewWatchdog 建一个看门狗。onExpire 在锁外被调用,因此里面可以安全地回写本表。
func NewWatchdog(now Clock, onExpire func(id uint64)) *Watchdog {
return &Watchdog{now: clockOr(now), lastSeen: make(map[uint64]time.Time), onExpire: onExpire}
}
// Touch 记录一次活跃(收到任意字节都算)。
func (w *Watchdog) Touch(id uint64) {
w.mu.Lock()
w.lastSeen[id] = w.now()
w.mu.Unlock()
}
// Forget 主动移除一条连接(正常断开时调用)。
func (w *Watchdog) Forget(id uint64) {
w.mu.Lock()
delete(w.lastSeen, id)
w.mu.Unlock()
}
// Sweep 扫描一次:把静默超过 timeout 的连接判为超时、移出表、逐个回调。
// 整表快照在锁内完成,回调在锁外逐个执行。
func (w *Watchdog) Sweep(timeout time.Duration) int {
cutoff := w.now().Add(-timeout)
w.mu.Lock()
var expired []uint64
for id, t := range w.lastSeen {
if t.Before(cutoff) {
expired = append(expired, id)
delete(w.lastSeen, id)
}
}
w.mu.Unlock()
for _, id := range expired {
if w.onExpire != nil {
w.onExpire(id)
}
}
return len(expired)
}
// Len 报告被跟踪的连接数。
func (w *Watchdog) Len() int {
w.mu.Lock()
defer w.mu.Unlock()
return len(w.lastSeen)
}
有三处设计值得单独说。第一,Cooldown 的过期是惰性的:Ready 在返回 true 的同时把那条记录删掉,所以内存不会无限增长——只有「冷却中且从没被再查过」的条目会多留一会儿,量级可控。第二,Sweep 把「遍历 + 删除」放在锁内、把 onExpire 回调放在锁外,因为回调里极可能要写会话表或发消息,持锁回调是死锁的经典来源。第三,两个结构都接受一个 Clock 注入点,测试因此能用假时间把「5 秒冷却」压成纳秒级断言。
实测:挂载与扫描的成本
下面这组基准在本机(Go 1.26,Apple M1 Pro,macOS arm64)实跑,同机相对量级,绝对数值不可移植。N 固定为 10000:
| 基准 | ns/op | 每次挂载 | 内存/次 | 分配/次 |
|---|---|---|---|---|
BenchmarkAfterFuncMount(每定时器一个 time.Timer) | 1,198,960 | 约 120 ns | 约 120 B | 1 |
BenchmarkWheelMount(时间轮 O(1) 挂载,见 9.2) | 77.29 | 约 77 ns | 79 B | 1 |
BenchmarkSharedSweep(10000 条表扫描一次) | 90,288 | 约 9 ns/条 | 0 B | 0 |
两个结论。其一,time.AfterFunc 的挂载其实并不慢——Go 运行时内部早就有按 P 分片的 64 槽时间轮,单次挂载约 120 ns 已经很好。所以「时间轮比 time.AfterFunc 快 N 倍」这种说法是站不住的:本项目自写的时间轮只快约 1.5 倍。真正值得自写的理由不是速度,而是控制权:回调在哪一拍执行、能不能按实体批量取消、能不能整表快照,这些是 time.AfterFunc 给不了的(9.2 展开)。
其二,共享扫描是零分配的:10000 条连接扫一遍约 90 µs、9 ns/条。这解释了为什么心跳超时应该做成扫描而不是定时器——如果给每条连接挂一个 time.Timer,10000 条连接就是 10000 次挂载约 1.2 ms 加约 1.2 MB 内存,而扫描方案在稳态下是 0 字节分配。能扫描的,就不要挂定时器。
把两个方案放在一起看更清楚:扫描方案的成本是「O(N) 每拍」,挂定时器方案的成本是「O(1) 每挂载」。当 N 很大、但每拍只做一次比较时,扫描反而是常数级的小开销;而当定时器数量远小于扫描对象数量时(比如只有 100 个到点任务,却有 5 万条连接),扫描一张 5 万行的表就纯属浪费。所以选型的分界不是「时间轮 vs 定时器」,而是**「扫描的集合大不大、扫描频率高不高」**:心跳表的集合就是全部连接,天生适合扫描;而冷却的集合只包含「当前正在冷却的玩家」,用 map 惰性判定最省。
还有一个工程细节:Sweep 的节拍不能等于 timeout,否则判定误差最大能到一个 timeout。第 6 章的经验是取 timeout / 8 左右——误差小于一拍,又不会让扫描本身成为负担。10000 条连接按 timeout/8 = 2.5 秒 扫描一次,摊到每秒只有约 36 µs,可以忽略。
本节小结
- 时间需求按尺度分三类:秒级心跳、百毫秒级冷却、分钟级到点任务;前两类不需要「到期事件」,做成惰性状态查询即可。
Cooldown用map+ 到期时间戳 + 惰性过期,Watchdog用单节拍Sweep,两者都不挂定时器。- 实测:自写时间轮挂载只比
time.AfterFunc快约 1.5 倍,自写的真正价值是控制权而非速度;共享扫描零分配,是心跳超时的正解。 - 需要「到点主动触发」的第三类任务,交给 9.2 的哈希时间轮。
- 选型分界不是「时间轮 vs 定时器」,而是「扫描集合大不大、频率高不高」:心跳表是全部连接,适合扫描;冷却表只是正在冷却的玩家,适合惰性 map。
- 总原则一句话:能把未来事件降级成当前状态的,就不要挂定时器。
阅读导航:上一节:目录 · 下一节:9.2 时间轮从零实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。