《游戏服务端编程实践》11.5 实战:服务间事件调度系统

实现一个服务间事件调度器,覆盖订阅注册、按 topic 路由、按玩家或房间 key 串行、并发上限、指数退避重试、死信与重放,并测试同 key 顺序、跨 key 并行、重试后进死信以及修复后的死信重放,最后给出分区与消费者数量的取舍,帮助读者把顺序保证与吞吐目标同时落地。

一、调度器不是消息队列

消息队列负责持久化和投递,调度器负责“谁来处理、按什么顺序处理、失败怎么办”。把两者混在一起,常见结果是:所有事件挤在一个全局队列里,顺序有了但吞吐上不去;或者每个消费者各自抢消息,吞吐有了但同一玩家的两个事件被并发处理,状态互相覆盖。

游戏业务里更自然的切分单位是玩家、房间、公会这类聚合键。调度器的核心任务,就是让同一个 key 的事件串行,不同 key 之间尽量并行。

二、调度器的职责边界

职责要做的事不该做的事
订阅注册维护 topic 到 handler 的映射,启动时自检在运行中随意替换 handler
路由按事件 topic 找到处理器,未知 topic 明确报错静默丢弃路由不到的事件
key 串行同一聚合键的事件按入队顺序执行用全局锁把所有 key 一起串行
并发上限限制同时执行的 key 数量,保护下游用无限 goroutine 追求吞吐
重试有上限、有退避、可观测无上限重试或立即重试打爆下游
死信保留最终失败的事件、错误和次数,并暴露死信计数只打日志,不留可重放的数据

职责边界清楚之后,调度器可以保持很小:它不解析业务字段,只认 topic、key 和 handler;业务幂等由 handler 自己保证。这样调度器可以复用到充值、邮件、战斗结算等多个域。

三、顺序保证与并行度

场景顺序要求调度方式
同一玩家连续扣款严格按事件产生顺序以 player: 为 key 串行
不同玩家之间无顺序要求不同 key 并行

同一 key 串行不代表整个系统串行。调度器为每个活跃 key 维护一条队列,只有队首事件在处理;worker 数量限制的是同时活跃的 key 数,而不是事件总数。这样热点玩家不会阻塞其他玩家,但热点 key 本身仍然是单线程,容量规划要单独看它。

分区与消费者数量的关系也要提前想清楚。若上游按 key 分区,一个分区只能由一个消费者处理,消费者数量超过分区数时多出来的实例只能空闲;扩容前先确认分区数。重平衡会把分区搬到别的实例,搬迁期间可能出现重复投递,handler 仍要幂等。

四、失败处理

重试退避。 第一次失败等 base,第二次等 2*base,之后指数增长并设上限。没有退避的重试会把瞬时故障放大成雪崩;没有上限的重试会让一条坏事件永远占着 key。尝试次数、退避时长和最终结果都要计数。

死信与重放。 达到上限的事件进入死信,保留原事件、尝试次数、最后错误和时间。死信不是垃圾桶:要有查询入口、告警阈值和重放工具。修复下游后重放,仍走原来的 topic 和 key,顺序才能保持。

幂等键。 重试和重平衡都会造成重复。事件 ID 挡住同一条消息重投,业务键(订单号、道具流水号)挡住语义重复,两者都要有。只做其中一层,迟早会遇到另一层的问题。

调度器自身的高可用。 调度器重启后不能丢已经受理的事件,因此入队要先落持久化队列;多实例部署时按 key 分区认领,避免两个实例同时处理同一个 key。不要用数据库行锁做全局串行,那会把并行度降到 1。

五、失败场景分析

全局单队列。 所有事件进一个 channel,由一个 worker 顺序处理。顺序完全正确,但吞吐被单核限制,一个慢 handler 阻塞全服。按 key 分队列、key 之间并行,才能同时保住顺序和吞吐。

重试没有上限。 某条事件因为脏数据永远失败,调度器每隔几毫秒重试一次,日志和下游 QPS 都被它占满。必须有最大尝试次数,并把最终失败转入死信。

死信只记录不告警。 死信表每天增长,没人知道玩家道具已经漏发。死信数量、最老死信年龄要接入告警;重放要有审计记录,避免重复补发。

重平衡期间重复消费。 分区从实例 A 搬到实例 B,A 的未确认事件被 B 重新拿到。handler 若不幂等,就会出现双倍奖励。搬迁窗口内要允许重复,但不能允许重复生效。

六、Go 实现:Dispatcher

dispatcher.go 用 topic 注册 handler,用聚合 key 划分 lane。Submit 只负责入队和调度;runLane 串行消费一个 key;deliver 在 key 内做退避重试,超过上限写入死信。Workers 限制同时活跃的 key 数,处理结果和死信分别通过 Stats、DeadLetters 暴露。

// file: ch11/dispatcher/dispatcher.go
// tier: L1
// verify: go test ./ch11/dispatcher/

// Package dispatcher 提供按聚合 key 串行、带重试与死信的事件调度器。
package dispatcher

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

var (
	ErrEmptyKey   = errors.New("dispatcher: empty aggregate key")
	ErrNoHandler  = errors.New("dispatcher: no handler for topic")
	ErrBadHandler = errors.New("dispatcher: nil handler")
)

// Event 是调度器的最小输入。Key 是聚合键,例如 player:<id> 或 room:<id>。
type Event struct {
	ID      string
	Topic   string
	Key     string
	Payload []byte
}

// Handler 返回错误表示本次投递失败,由调度器决定重试或进死信。
type Handler func(ctx context.Context, ev Event) error

// Config 控制并发上限、重试上限和退避区间。
type Config struct {
	Workers       int
	MaxAttempts   int
	BaseBackoff   time.Duration
	MaxBackoff    time.Duration
	DeadLetterCap int
}

func (c Config) normalized() Config {
	if c.Workers <= 0 {
		c.Workers = 1
	}
	if c.MaxAttempts <= 0 {
		c.MaxAttempts = 1
	}
	if c.BaseBackoff < 0 {
		c.BaseBackoff = 0
	}
	if c.MaxBackoff < c.BaseBackoff {
		c.MaxBackoff = c.BaseBackoff
	}
	if c.DeadLetterCap <= 0 {
		c.DeadLetterCap = 1000
	}
	return c
}

// DeadLetter 保留最终失败的事件、尝试次数和最后一次错误。
type DeadLetter struct {
	Event    Event
	Attempts int
	Err      string
	At       time.Time
}

// Stats 是累计计数器,便于接指标和告警。
type Stats struct {
	Received     uint64
	Delivered    uint64
	Attempts     uint64
	Retried      uint64
	DeadLettered uint64
}

// lane 是一个聚合键的队列。running 保证同一时刻只有一个 worker 处理该 key。
type lane struct {
	key     string
	queue   []Event
	running bool
}

// Dispatcher 把事件路由到 topic handler,并按 key 串行执行。
type Dispatcher struct {
	cfg Config

	mu       sync.Mutex
	handlers map[string]Handler
	lanes    map[string]*lane
	active   int
	dead     []DeadLetter
	stats    Stats
	sleep    func(time.Duration)
	wg       sync.WaitGroup
}

// New 创建调度器。Workers 是同时执行的聚合键数量上限,不是事件吞吐上限。
func New(cfg Config) *Dispatcher {
	cfg = cfg.normalized()
	return &Dispatcher{
		cfg:      cfg,
		handlers: make(map[string]Handler),
		lanes:    make(map[string]*lane),
		sleep:    time.Sleep,
	}
}

// Register 注册 topic handler。重复注册同一 topic 会被拒绝,避免路由歧义。
func (d *Dispatcher) Register(topic string, h Handler) error {
	if topic == "" {
		return errors.New("dispatcher: empty topic")
	}
	if h == nil {
		return ErrBadHandler
	}
	d.mu.Lock()
	defer d.mu.Unlock()
	if _, ok := d.handlers[topic]; ok {
		return fmt.Errorf("dispatcher: topic %q already registered", topic)
	}
	d.handlers[topic] = h
	return nil
}

// Submit 把事件放入对应 key 的队列,并在有空闲 worker 时立即调度。
func (d *Dispatcher) Submit(ev Event) error {
	if ev.Key == "" {
		return ErrEmptyKey
	}
	d.mu.Lock()
	defer d.mu.Unlock()
	if _, ok := d.handlers[ev.Topic]; !ok {
		return ErrNoHandler
	}
	l := d.lanes[ev.Key]
	if l == nil {
		l = &lane{key: ev.Key}
		d.lanes[ev.Key] = l
	}
	l.queue = append(l.queue, ev)
	d.stats.Received++
	d.scheduleLocked()
	return nil
}

// scheduleLocked 在持锁状态下挑选未运行的 key 启动 worker。
func (d *Dispatcher) scheduleLocked() {
	for d.active < d.cfg.Workers {
		var next *lane
		for _, l := range d.lanes {
			if !l.running && len(l.queue) > 0 {
				next = l
				break
			}
		}
		if next == nil {
			return
		}
		next.running = true
		d.active++
		d.wg.Add(1)
		go d.runLane(next)
	}
}

// runLane 串行消费一个 key 的队列,消费完后释放 worker 给其他 key。
func (d *Dispatcher) runLane(l *lane) {
	defer d.wg.Done()
	for {
		d.mu.Lock()
		if len(l.queue) == 0 {
			l.running = false
			d.active--
			d.scheduleLocked()
			d.mu.Unlock()
			return
		}
		ev := l.queue[0]
		l.queue = l.queue[1:]
		d.mu.Unlock()
		d.deliver(ev)
	}
}

// deliver 在单个 key 内同步重试,失败到上限后写入死信。
func (d *Dispatcher) deliver(ev Event) {
	attempts := 0
	for {
		attempts++
		d.mu.Lock()
		h := d.handlers[ev.Topic]
		d.stats.Attempts++
		d.mu.Unlock()
		err := h(context.Background(), ev)
		if err == nil {
			d.mu.Lock()
			d.stats.Delivered++
			d.mu.Unlock()
			return
		}
		if attempts >= d.cfg.MaxAttempts {
			d.mu.Lock()
			d.stats.DeadLettered++
			d.dead = append(d.dead, DeadLetter{Event: ev, Attempts: attempts, Err: err.Error(), At: time.Now()})
			if len(d.dead) > d.cfg.DeadLetterCap {
				d.dead = append([]DeadLetter(nil), d.dead[len(d.dead)-d.cfg.DeadLetterCap:]...)
			}
			d.mu.Unlock()
			return
		}
		d.mu.Lock()
		d.stats.Retried++
		d.mu.Unlock()
		d.sleep(backoff(d.cfg, attempts))
	}
}

// backoff 返回第 attempt 次失败后的等待时间:base、2*base、4*base,直到上限。
func backoff(cfg Config, attempt int) time.Duration {
	delay := cfg.BaseBackoff
	for i := 1; i < attempt; i++ {
		if delay >= cfg.MaxBackoff/2 {
			return cfg.MaxBackoff
		}
		delay *= 2
	}
	if delay > cfg.MaxBackoff {
		return cfg.MaxBackoff
	}
	return delay
}

// Wait 等待当前已入队的事件全部处理完。它不停止调度器。
func (d *Dispatcher) Wait() {
	d.wg.Wait()
}

// Stats 返回计数器快照。
func (d *Dispatcher) Stats() Stats {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.stats
}

// DeadLetters 返回死信副本,调用方修改切片不会影响调度器。
func (d *Dispatcher) DeadLetters() []DeadLetter {
	d.mu.Lock()
	defer d.mu.Unlock()
	return append([]DeadLetter(nil), d.dead...)
}

// ReplayDeadLetters 把当前死信重新提交,返回成功入队的数量。
func (d *Dispatcher) ReplayDeadLetters() int {
	d.mu.Lock()
	batch := d.dead
	d.dead = nil
	d.mu.Unlock()
	n := 0
	for _, dl := range batch {
		if err := d.Submit(dl.Event); err == nil {
			n++
		}
	}
	return n
}

实现里有两个关键不变量。第一,一个 lane 同一时刻只有一个 goroutine 在跑,队列按 FIFO 出队,因此同 key 顺序不会因为 worker 调度而颠倒。第二,worker 只在 lane 为空时释放,释放时立即尝试调度其他 key,避免一个长队列把并发槽位长期占住却无活可干。

七、测试覆盖

测试断言
TestSameKeyKeepsOrder20 条同 key 事件按提交顺序执行
TestDifferentKeysRunInParallel两个 key 在两个 worker 上同时进入 handler
TestWorkerLimitCapsConcurrency5 个 key 同时提交,峰值并发不超过 2
TestRetryBackoffThenDeadLetter连续失败按 10ms、20ms 退避,3 次后进死信
TestTransientFailureSucceedsOnRetry前两次失败、第三次成功,不产生死信
TestReplayDeadLettersAfterFix修复 handler 后重放,死信清空并成功投递

测试把 sleep 替换为记录函数,因此退避断言不需要真实等待;跨 key 并行用 channel 同步,不依赖固定 sleep 时间。这样在慢机器上也不会偶发失败。

八、运行验证

cd .local/checks/gs-practice-lab && gofmt -l ch11/dispatcher && go vet ./ch11/dispatcher/ && go test ./ch11/dispatcher/ -count=1
ok  	gspractice/ch11/dispatcher	0.331s

gofmt -l 没有输出,go vet 与测试均通过。示例耗时是本次运行值,不作为性能承诺。

小结

事件调度器的价值在于把顺序、并发和失败处理收在一个地方。按聚合 key 串行,跨 key 并行;重试有上限、有退避;死信可查、可告警、可重放;handler 用业务键幂等。分区数决定并行上限,消费者数量不能超过有效分区,扩容前先把这两件事对齐。

常见错误与边界

  • 重试没有上限,坏事件长期占用 key 和下游资源。
  • 死信只写日志,没有查询、告警和重放入口。
  • 只按事件 ID 去重,业务语义重复仍然生效。
  • 重平衡期间假设不会重复投递,handler 没有幂等保护。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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