《游戏服务端编程实践》6.3.3 异步任务与保存队列

把保存从逻辑线程挪到异步队列后,需要处理丢数据窗口、同玩家顺序、批量合并、优先级与背压。本篇给出按 playerID 串行、下线保存优先、关闭前排空的 SaveQueue 实现,并覆盖乱序、合并、队列满和关闭拒绝等测试,同时讨论停服超时后的补偿思路。

一、异步保存解决什么,又引入什么

同步保存把序列化、网络和磁盘延迟压进逻辑帧,热点玩家频繁保存时,一次慢查询就能拖住整个场景。异步保存把这些工作挪到独立 goroutine,逻辑线程只负责提交任务。

代价有三个:提交成功不等于落盘,崩溃会丢掉队列里的数据;同一玩家的多次保存可能并发执行,后提交的旧快照覆盖新快照;队列本身占用内存,生产速度长期高于消费速度就会堆积。队列设计就是围绕这三点做取舍。

二、队列模型

机制解决的问题实现要点
按 playerID 分片串行同一玩家不会并发保存同一 ID 固定映射到同一队列或同一 worker
批量合并高频保存产生大量小写入同一玩家排队任务只保留最后一次快照
优先级下线保存不能被后台快照拖住高优先级任务绕过普通队列限额,优先调度
有界队列防止内存无限增长达到上限时阻塞、拒绝或降级同步保存

分片串行不是“每个玩家一个 goroutine”。玩家数量远大于 CPU 核数,正确做法是固定数量的 worker 或按 ID 哈希到固定队列,队列内串行、队列间并行。批量合并只对“可覆盖”的保存有意义,资产流水、邮件发送这类追加型任务不能合并。

三、背压:阻塞、丢弃还是同步保存

策略行为适用风险
阻塞提交队列满时调用方等待逻辑线程可承受短暂停顿保存变慢会反压到战斗帧
拒绝并降级返回 queue_full,调用方同步保存关键资产、不可丢操作同步保存可能拖慢当前请求
丢弃旧任务只保留最新快照位置、普通统计等可覆盖数据中间状态丢失
无界队列永不拒绝不推荐内存上涨,最终 OOM

没有一种策略适合所有数据。充值、交易结果必须走同步或“拒绝后同步”;位置、在线时长可以合并丢弃;后台统计可以阻塞,但要有超时。把策略写进任务类型,而不是让每个调用方自己猜。

四、停服 flush

停服顺序是:停止接收新任务、通知逻辑层不再产生保存、排空队列、等待在途保存、超时后强制落盘并记录失败列表。只 close 队列不等待,等于主动丢掉最后一批数据。

排空要有总超时。数据库已经不可用时,无限等待只会让停服卡死;超时后应把未完成角色写入本地补偿文件或消息队列,下次启动重放。补偿文件本身要带版本号和 save_id,避免重放时覆盖更新的数据。

五、失败场景:同一玩家并发保存乱序

假设玩家连续两次保存:第一次携带 100 金币,第二次携带 80 金币(消费后)。如果两个任务被不同 worker 并发执行,第一次可能后落盘,数据库最终变成 100 金币,玩家凭空多出 20。版本号能拦截一部分,但前提是两次保存都带正确 expectedVersion,且失败方会重新加载。

更隐蔽的是合并不当:把两个任务的数据字段直接拼在一起,可能得到“新金币 + 旧背包”的混合快照。合并只能以最后一次完整快照为准,或者按字段定义明确的合并规则;不确定时就串行执行,不要合并。

六、Go 实现

SaveQueue 为每个 playerID 最多启动一个 worker,排队任务在下一轮合并为最后一次快照;普通任务受 maxLow 限制,高优先级任务可绕过限额;Close 先拒绝新任务,再 Flush 排空。

// file: ch06/save/queue.go
// tier: L1
// verify: go test ./ch06/save/

package save

import (
	"context"
	"errors"
	"sync"
)

var (
	ErrQueueClosed = errors.New("save queue closed")
	ErrQueueFull   = errors.New("save queue full")
)

type SaveTask struct {
	PlayerID uint64
	Data     PlayerData
	Priority bool
	Done     chan error
}

type SaveFunc func(ctx context.Context, task SaveTask) error

// SaveQueue 按 playerID 串行保存;同一玩家排队中的任务合并为最后一次快照。
type SaveQueue struct {
	save   SaveFunc
	maxLow int

	mu       sync.Mutex
	cond     *sync.Cond
	pending  map[uint64][]SaveTask
	active   map[uint64]bool
	inFlight int
	closed   bool
	wg       sync.WaitGroup
}

func NewSaveQueue(save SaveFunc, maxLowPending int) *SaveQueue {
	q := &SaveQueue{save: save, maxLow: maxLowPending, pending: map[uint64][]SaveTask{}, active: map[uint64]bool{}}
	q.cond = sync.NewCond(&q.mu)
	return q
}

func (q *SaveQueue) Enqueue(ctx context.Context, task SaveTask) error {
	if task.PlayerID == 0 {
		return errors.New("save task: player id required")
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	q.mu.Lock()
	defer q.mu.Unlock()
	if q.closed {
		return ErrQueueClosed
	}
	if !task.Priority && q.maxLow > 0 && q.outstandingLocked() >= q.maxLow {
		return ErrQueueFull
	}
	q.pending[task.PlayerID] = append(q.pending[task.PlayerID], task)
	if !q.active[task.PlayerID] {
		q.active[task.PlayerID] = true
		q.wg.Add(1)
		go q.worker(task.PlayerID)
	}
	return nil
}

func (q *SaveQueue) Flush(ctx context.Context) error {
	done := make(chan struct{})
	defer close(done)
	go func() {
		select {
		case <-ctx.Done():
			q.mu.Lock()
			q.cond.Broadcast()
			q.mu.Unlock()
		case <-done:
		}
	}()
	q.mu.Lock()
	defer q.mu.Unlock()
	for q.outstandingLocked() > 0 {
		if err := ctx.Err(); err != nil {
			return err
		}
		q.cond.Wait()
	}
	return nil
}

func (q *SaveQueue) Close(ctx context.Context) error {
	q.mu.Lock()
	if q.closed {
		q.mu.Unlock()
		return nil
	}
	q.closed = true
	q.mu.Unlock()
	if err := q.Flush(ctx); err != nil {
		return err
	}
	q.wg.Wait()
	return nil
}

func (q *SaveQueue) worker(playerID uint64) {
	defer q.wg.Done()
	for {
		q.mu.Lock()
		tasks := q.pending[playerID]
		if len(tasks) == 0 {
			q.active[playerID] = false
			delete(q.pending, playerID)
			q.cond.Broadcast()
			q.mu.Unlock()
			return
		}
		delete(q.pending, playerID)
		q.inFlight++
		merged := tasks[len(tasks)-1]
		q.mu.Unlock()

		err := q.save(context.Background(), merged)

		q.mu.Lock()
		q.inFlight--
		q.cond.Broadcast()
		q.mu.Unlock()
		deliver(tasks, err)
	}
}

func (q *SaveQueue) outstandingLocked() int {
	n := q.inFlight
	for _, tasks := range q.pending {
		n += len(tasks)
	}
	return n
}

func deliver(tasks []SaveTask, err error) {
	for _, task := range tasks {
		if task.Done == nil {
			continue
		}
		select {
		case task.Done <- err:
		default:
		}
	}
}

测试覆盖同一玩家顺序保存、排队任务合并、队列满返回 ErrQueueFull,以及关闭后拒绝新任务:

// file: ch06/save/queue_test.go
// tier: L1
// verify: go test ./ch06/save/

package save

import (
	"context"
	"errors"
	"sync"
	"testing"
)

func TestSaveQueueKeepsPerPlayerOrder(t *testing.T) {
	ctx := context.Background()
	var mu sync.Mutex
	var golds []int64
	calls := 0
	started := make(chan struct{})
	release := make(chan struct{})
	q := NewSaveQueue(func(_ context.Context, task SaveTask) error {
		mu.Lock()
		calls++
		call := calls
		golds = append(golds, task.Data.Gold)
		mu.Unlock()
		if call == 1 {
			close(started)
			<-release
		}
		return nil
	}, 0)
	first := make(chan error, 1)
	second := make(chan error, 1)
	if err := q.Enqueue(ctx, SaveTask{PlayerID: 1, Data: PlayerData{PlayerID: 1, Gold: 10}, Done: first}); err != nil {
		t.Fatal(err)
	}
	<-started
	if err := q.Enqueue(ctx, SaveTask{PlayerID: 1, Data: PlayerData{PlayerID: 1, Gold: 20}, Done: second}); err != nil {
		t.Fatal(err)
	}
	close(release)
	if err := <-first; err != nil {
		t.Fatal(err)
	}
	if err := <-second; err != nil {
		t.Fatal(err)
	}
	if err := q.Flush(ctx); err != nil {
		t.Fatal(err)
	}
	mu.Lock()
	defer mu.Unlock()
	if len(golds) != 2 || golds[0] != 10 || golds[1] != 20 {
		t.Fatalf("saves out of order: %v", golds)
	}
}

func TestSaveQueueMergesPending(t *testing.T) {
	ctx := context.Background()
	var mu sync.Mutex
	var golds []int64
	started := make(chan struct{})
	release := make(chan struct{})
	q := NewSaveQueue(func(_ context.Context, task SaveTask) error {
		mu.Lock()
		golds = append(golds, task.Data.Gold)
		first := len(golds) == 1
		mu.Unlock()
		if first {
			close(started)
			<-release
		}
		return nil
	}, 0)
	if err := q.Enqueue(ctx, SaveTask{PlayerID: 7, Data: PlayerData{PlayerID: 7, Gold: 1}}); err != nil {
		t.Fatal(err)
	}
	<-started
	for _, gold := range []int64{2, 3, 4} {
		if err := q.Enqueue(ctx, SaveTask{PlayerID: 7, Data: PlayerData{PlayerID: 7, Gold: gold}}); err != nil {
			t.Fatal(err)
		}
	}
	close(release)
	if err := q.Flush(ctx); err != nil {
		t.Fatal(err)
	}
	mu.Lock()
	defer mu.Unlock()
	if len(golds) != 2 || golds[0] != 1 || golds[1] != 4 {
		t.Fatalf("pending tasks not merged: %v", golds)
	}
}

func TestSaveQueueBackpressureAndClose(t *testing.T) {
	ctx := context.Background()
	started := make(chan struct{})
	release := make(chan struct{})
	q := NewSaveQueue(func(context.Context, SaveTask) error {
		close(started)
		<-release
		return nil
	}, 1)
	if err := q.Enqueue(ctx, SaveTask{PlayerID: 1, Data: PlayerData{PlayerID: 1}}); err != nil {
		t.Fatal(err)
	}
	<-started
	if err := q.Enqueue(ctx, SaveTask{PlayerID: 2, Data: PlayerData{PlayerID: 2}}); !errors.Is(err, ErrQueueFull) {
		t.Fatalf("want ErrQueueFull, got %v", err)
	}
	close(release)
	if err := q.Close(ctx); err != nil {
		t.Fatal(err)
	}
	if err := q.Enqueue(ctx, SaveTask{PlayerID: 3, Data: PlayerData{PlayerID: 3}}); !errors.Is(err, ErrQueueClosed) {
		t.Fatalf("want ErrQueueClosed, got %v", err)
	}
}

七、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch06/save && go vet ./ch06/save/ && go test ./ch06/save/ -count=1
ok  	gspractice/ch06/save	0.115s

小结

异步保存把延迟移出逻辑帧,但必须同时解决顺序、合并、背压和停服排空。同一玩家串行是正确性底线,批量合并只适用于可覆盖快照,有界队列配合明确降级策略才能避免 OOM;关闭时先停止接收再排空,超时要把失败任务落到可重放的补偿通道。

常见错误与边界

  • 使用无界队列,生产速度长期高于消费速度,最终内存耗尽。
  • 同一玩家被多个 worker 并发保存,旧快照覆盖新快照。
  • 合并任务时按字段拼接,产生新旧混合的数据。
  • 停服只关闭队列不 flush,最后一批在线数据直接丢失。
  • 队列满时静默丢弃关键资产保存,玩家交易结果凭空消失。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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