一、异步保存解决什么,又引入什么
同步保存把序列化、网络和磁盘延迟压进逻辑帧,热点玩家频繁保存时,一次慢查询就能拖住整个场景。异步保存把这些工作挪到独立 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,最后一批在线数据直接丢失。
- 队列满时静默丢弃关键资产保存,玩家交易结果凭空消失。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。