《MMO 服务器开发》9.1 心跳超时与技能冷却

心跳超时、技能冷却、Buff 到期是三类时间尺度完全不同的定时任务,却常被同一套「一个任务一个 time.Timer」的写法一锅端。本节先厘清三种尺度,再给 aegis 写两个真正省掉定时器的结构:惰性判定的冷却表与单节拍扫描的心跳看门狗,并用本机基准对比每定时器一个堆节点与共享扫描的成本。

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 B1
BenchmarkWheelMount(时间轮 O(1) 挂载,见 9.2)77.29约 77 ns79 B1
BenchmarkSharedSweep(10000 条表扫描一次)90,288约 9 ns/条0 B0

两个结论。其一,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 时间轮从零实现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标