一、调度器不是消息队列
消息队列负责持久化和投递,调度器负责“谁来处理、按什么顺序处理、失败怎么办”。把两者混在一起,常见结果是:所有事件挤在一个全局队列里,顺序有了但吞吐上不去;或者每个消费者各自抢消息,吞吐有了但同一玩家的两个事件被并发处理,状态互相覆盖。
游戏业务里更自然的切分单位是玩家、房间、公会这类聚合键。调度器的核心任务,就是让同一个 key 的事件串行,不同 key 之间尽量并行。
二、调度器的职责边界
| 职责 | 要做的事 | 不该做的事 |
|---|---|---|
| 订阅注册 | 维护 topic 到 handler 的映射,启动时自检 | 在运行中随意替换 handler |
| 路由 | 按事件 topic 找到处理器,未知 topic 明确报错 | 静默丢弃路由不到的事件 |
| key 串行 | 同一聚合键的事件按入队顺序执行 | 用全局锁把所有 key 一起串行 |
| 并发上限 | 限制同时执行的 key 数量,保护下游 | 用无限 goroutine 追求吞吐 |
| 重试 | 有上限、有退避、可观测 | 无上限重试或立即重试打爆下游 |
| 死信 | 保留最终失败的事件、错误和次数,并暴露死信计数 | 只打日志,不留可重放的数据 |
职责边界清楚之后,调度器可以保持很小:它不解析业务字段,只认 topic、key 和 handler;业务幂等由 handler 自己保证。这样调度器可以复用到充值、邮件、战斗结算等多个域。
三、顺序保证与并行度
| 场景 | 顺序要求 | 调度方式 |
|---|---|---|
| 同一玩家连续扣款 | 严格按事件产生顺序 | 以 player: |
| 不同玩家之间 | 无顺序要求 | 不同 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,避免一个长队列把并发槽位长期占住却无活可干。
七、测试覆盖
| 测试 | 断言 |
|---|---|
| TestSameKeyKeepsOrder | 20 条同 key 事件按提交顺序执行 |
| TestDifferentKeysRunInParallel | 两个 key 在两个 worker 上同时进入 handler |
| TestWorkerLimitCapsConcurrency | 5 个 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 没有幂等保护。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。