一、任务系统只是事件订阅者
如果战斗代码里直接调用 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,一个插件异常拖垮整条总线。
- 条件表达式允许任意字段和操作符,配置错误在运行时才暴露。
- 时间条件混用自然时间与游戏内时间,不同大区活动开启时间不一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。