《游戏服务端编程实践》9.2.2 触发条件与事件总线

任务进度不应该由战斗、背包、场景代码各自直接修改。本篇把击杀、采集、到达统一成事件,讨论事件结构、订阅顺序、同步与异步边界、幂等键与事件风暴,并给出带去重和 panic 隔离的事件总线实现,最后总结重入与条件校验的常见错误、边界与实现中的取舍。

一、任务系统只是事件订阅者

如果战斗代码里直接调用 quest.OnKill(player, monster),任务系统就和战斗逻辑绑死了。每加一种任务目标,都要回到战斗、背包、场景代码里补调用,漏一处就永远无法完成。

事件总线把关系倒过来:战斗只发布“发生了什么”,任务系统订阅自己关心的事件。任务之外,成就、统计、邮件、运营埋点也订阅同一批事件,新增消费者不需要改发布方。

但事件总线不是银弹。它把编译期调用变成运行期匹配,错误从“方法不存在”变成“订阅没生效”。类型名拼错、订阅顺序不对、重复事件没有去重,都会表现成“任务偶尔不加进度”。

所以事件总线的重点不是“解耦”两个字,而是把事件契约、顺序、幂等和失败隔离定义清楚。

二、事件结构:类型、来源、目标、参数、幂等键

一个事件至少需要五个字段。少一个,订阅方就要靠上下文猜,猜错时很难发现。

字段作用典型值缺失后果
类型决定谁订阅kill、collect、reach无法路由或路由到错误订阅者
来源谁产生的战斗、背包、场景无法追责,也无法按来源过滤
目标事件作用对象怪物模板 ID、物品 ID、区域 ID任务无法匹配目标
参数数量与附加信息amount=1、quality=rare只能按一次处理,批量事件失真
幂等键唯一标识这次事实类型:来源:业务序号重连或重试导致重复计数

事件类型用过去式或名词,表示已经发生的事实;不要用命令式。kill 是事实,KillMonster 是命令。命令应该由玩家请求进入服务端后转成事实,再发布事件。

目标字段要使用稳定 ID,而不是显示名。怪物改名、物品本地化、区域重命名都不应该影响任务进度。显示名只用于 UI,匹配逻辑只认 ID。

参数字段不要把整个实体塞进去。事件里放快照会带来生命周期和序列化问题,放太多字段又会让契约难以演进。只放订阅方真正需要的数量、品质、来源标记。

幂等键必须由发布方生成,不能由订阅方补。订阅方不知道事件是否已经被别的消费者处理过,也无法判断重连后收到的是新事件还是旧事件。

三、同步还是异步,顺序怎么保证

同步发布最简单:发布方调用 Publish,所有订阅者在当前线程依次执行,返回后任务进度已经更新。任务、成就这类要求“玩家看到结果一致”的逻辑适合同步。

异步发布适合邮件、日志、运营统计。它们失败不应该阻塞战斗,延迟几秒也能接受。但异步会带来顺序问题:同一玩家的击杀事件如果进入不同队列,任务进度可能先加后减。

顺序保证靠分区,不靠全局队列。以玩家 ID 或实体 ID 做分区键,同一分区内串行消费,不同分区可以并行。全局单队列能保证顺序,但吞吐会被最慢的消费者拖住。

同步发布也要定义订阅顺序。任务系统可能依赖成就系统先更新,或者统计系统依赖任务完成事件。把顺序写进注册顺序,并在文档里标注,比靠 map 遍历的偶然顺序可靠。

不要在持有任务锁时发布事件。订阅者回调可能再次进入任务系统,形成锁重入;Go 的互斥锁不可重入,结果就是死锁。正确顺序是先改状态、释放锁,再发布完成事件。

同步发布还有一个容易被忽略的成本:订阅者越多,战斗主循环越慢。发布前要评估每个订阅者的最坏耗时,把可能访问网络或磁盘的订阅者移到异步队列,不能假设“回调都很轻”。

订阅关系也要能退订。副本结束、活动关闭、玩家下线时,相关监听器如果继续留在总线里,事件仍会被分发,轻则浪费 CPU,重则访问已经释放的对象。

总线本身不负责持久化。进程崩溃时,已经发布但还没处理的事件会丢失。任务进度可以靠任务状态重算,奖励和邮件不能,所以这类副作用应该走持久化队列,而不是内存总线。

四、条件表达式:等级、物品、时间、区域

任务接取条件不只是前置任务。等级、持有物品、游戏内时间、所在区域、阵营声望都可能参与判断。把这些条件写死在代码里,运营每次改活动都要发版。

条件表达式可以配置成“字段、比较符、值”的列表。服务端只实现有限的字段与比较符,配置加载时做白名单校验,拒绝未知字段和未知操作符。

条件字段示例比较方式注意事项
等级player.level大于等于用服务端等级,不信客户端上报
物品inventory.item_id数量大于等于绑定物品与临时物品要区分
时间server.game_time区间包含使用统一时区与服务器时钟
区域world.zone_id等于以权威坐标计算,不读客户端位置

条件求值必须在服务端完成。客户端可以预判并置灰按钮,但最终判定以服务端为准。否则改客户端就能绕过等级和物品要求。

时间条件要明确是自然时间还是游戏内时间。自然时间受时区和夏令时影响,游戏内时间由服务器统一推进。两者混用会让活动在部分大区提前或延后开启。

区域条件要用服务端碰撞或网格结果。客户端上报的坐标可以被伪造,传送、坐骑、副本切换也可能让客户端位置短暂不一致。

五、失败场景:事件风暴与重入

一次范围伤害击杀二十只怪,如果每只怪发布一个事件,任务系统要处理二十次;如果每只怪又触发掉落、经验、成就,事件数量会成倍放大。这就是事件风暴。

事件风暴的直接后果是帧时间抖动。发布方在战斗主循环里同步调用所有订阅者,订阅者里又有数据库或网络操作,一次群攻就能把服务器卡住几百毫秒。

解决办法不是简单改成异步,而是先合并。同一帧内同一玩家、同一类型、同一目标的事件可以聚合数量,再发布一次带 amount 的事件。任务进度只关心总数,不关心中间过程。

重入是另一个坑。任务监听器在进度达标后直接发布“任务完成”事件,完成事件的订阅者又去改任务状态,状态变化再次触发进度检查。没有终止条件时,事件会在两个处理器之间来回弹。

正确做法是把“进度更新”和“完成通知”分开。监听器只更新进度并收集完成列表,等当前发布返回后,再由上层统一发布完成事件。完成事件是通知,不再反向修改任务状态。

订阅者 panic 也必须隔离。一个统计插件空指针,不应该让后面的任务监听器收不到事件。每个回调单独 recover,把 panic 转成错误返回,发布方决定记录还是告警。

六、Go 实现:Bus 与 QuestListener

下面的总线在锁内完成去重和订阅者快照,在锁外依次执行回调。单个回调失败或 panic 不会中断后续订阅者,错误通过 errors.Join 汇总返回。

// file: ch09/quest/bus.go
// tier: L1
// verify: go test ./ch09/quest/

package quest

import (
	"errors"
	"fmt"
	"strconv"
	"sync"
)

var (
	ErrEventInvalid = errors.New("bus: invalid event")
	ErrHandlerPanic = errors.New("bus: handler panicked")
)

// Event 是已经发生的事实,不是命令。Key 用于幂等去重,生产环境应由
// “类型 + 来源 + 业务序号”组合生成,不能使用随机数。
type Event struct {
	Type   string
	Source string
	Target string
	Params map[string]string
	Key    string
}

func (e Event) validate() error {
	if e.Type == "" {
		return fmt.Errorf("%w: missing type", ErrEventInvalid)
	}
	if e.Key == "" {
		return fmt.Errorf("%w: missing idempotency key", ErrEventInvalid)
	}
	return nil
}

func (e Event) amount() int {
	if raw, ok := e.Params["amount"]; ok {
		n, err := strconv.Atoi(raw)
		if err == nil && n > 0 {
			return n
		}
	}
	return 1
}

type Handler func(Event) error

type subscription struct {
	id uint64
	fn Handler
}

type PublishResult struct {
	Delivered int
	Duplicate bool
	Failed    int
	Panicked  int
}

type Bus struct {
	mu   sync.Mutex
	next uint64
	subs map[string][]subscription
	seen map[string]struct{}
}

func NewBus() *Bus {
	return &Bus{
		subs: make(map[string][]subscription),
		seen: make(map[string]struct{}),
	}
}

// Subscribe 按注册顺序回调,返回的函数用于退订。回调在发布线程同步执行。
func (b *Bus) Subscribe(eventType string, fn Handler) func() {
	if fn == nil {
		return func() {}
	}
	b.mu.Lock()
	b.next++
	id := b.next
	b.subs[eventType] = append(b.subs[eventType], subscription{id: id, fn: fn})
	b.mu.Unlock()
	return func() { b.unsubscribe(eventType, id) }
}

func (b *Bus) unsubscribe(eventType string, id uint64) {
	b.mu.Lock()
	defer b.mu.Unlock()
	list := b.subs[eventType]
	for i, s := range list {
		if s.id == id {
			b.subs[eventType] = append(list[:i], list[i+1:]...)
			return
		}
	}
}

// Publish 对相同 Key 只分发一次;单个订阅者失败或 panic 不影响其后的订阅者。
func (b *Bus) Publish(ev Event) (PublishResult, error) {
	if err := ev.validate(); err != nil {
		return PublishResult{}, err
	}
	b.mu.Lock()
	if _, ok := b.seen[ev.Key]; ok {
		b.mu.Unlock()
		return PublishResult{Duplicate: true}, nil
	}
	b.seen[ev.Key] = struct{}{}
	handlers := append([]subscription(nil), b.subs[ev.Type]...)
	b.mu.Unlock()

	var res PublishResult
	var errs []error
	for _, s := range handlers {
		func() {
			defer func() {
				if r := recover(); r != nil {
					res.Panicked++
					errs = append(errs, fmt.Errorf("%w: %v", ErrHandlerPanic, r))
				}
			}()
			if err := s.fn(ev); err != nil {
				res.Failed++
				errs = append(errs, err)
				return
			}
			res.Delivered++
		}()
	}
	return res, errors.Join(errs...)
}

// QuestListener 把战斗、采集、到达事件翻译成任务进度;任务只是订阅者之一。
func QuestListener(log *QuestLog) Handler {
	return func(ev Event) error {
		kind, ok := objectiveKind(ev.Type)
		if !ok {
			return nil
		}
		log.Advance(kind, ev.Target, ev.amount())
		return nil
	}
}

func objectiveKind(eventType string) (ObjectiveKind, bool) {
	switch eventType {
	case "kill":
		return KindKill, true
	case "collect":
		return KindCollect, true
	case "reach":
		return KindReach, true
	case "count":
		return KindCount, true
	default:
		return 0, false
	}
}

Publish 在分发前把幂等键写入 seen,因此同一个键只会分发一次。这个选择偏向“至少不重复计数”,代价是处理器失败后无法用同一个键重试;需要重试的场景应该换新键并让处理器自身幂等。

QuestListener 只做一件事:把事件类型翻译成任务目标类型,再调用任务日志的 Advance。任务系统不知道事件从哪里来,总线也不知道任务如何存储,两边只共享事件结构。

七、测试与运行

测试覆盖四项:订阅顺序、重复事件去重、订阅者 panic 隔离、任务监听器推进进度。顺序测试同时注册两个 kill 订阅者和一个 collect 订阅者,确认只有匹配类型的回调按注册顺序执行。

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

本机执行结果如下。gofmt 与 go vet 没有输出,表示格式与静态检查通过。

ok  	gspractice/ch09/quest	0.475s

小结

事件总线把任务系统从战斗、背包、场景代码里解耦出来,但代价是契约从编译期移到运行期。事件必须带类型、来源、目标、参数和幂等键,类型表示事实而不是命令。

同步总线适合任务与成就,异步总线适合日志与统计;顺序靠分区键保证,不靠全局队列。订阅者要隔离 panic,发布要在锁外执行,完成事件不能在进度回调里同步反向修改状态。

常见错误与边界

  • 事件类型用命令式命名,发布方与订阅方对“已经发生”还是“准备发生”理解不一致。
  • 事件不带目标 ID,任务用显示名匹配,改名后进度丢失。
  • 幂等键由订阅方生成,重连后的重复事件仍然被计数。
  • 在任务锁内发布事件,订阅者回调再次进入任务系统导致死锁。
  • 进度回调里同步发布完成事件,完成事件又修改进度,形成重入循环。
  • 群攻逐只发布事件,没有按帧聚合,事件数量随怪物数量线性膨胀。
  • 订阅者 panic 没有 recover,一个插件异常拖垮整条总线。
  • 条件表达式允许任意字段和操作符,配置错误在运行时才暴露。
  • 时间条件混用自然时间与游戏内时间,不同大区活动开启时间不一致。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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