《MMO 服务器开发》8.2 并发修改与事务

同一个背包会被战斗结算、任务奖励、交易行回调同时修改。本节用「副本 + 提交 + 回滚」把一组改动包成事务,用原子指针让读路径完全无锁,并用操作号实现幂等,挡住客户端重发与上游事件重放;配套的 32 goroutine 并发加物用例在 go test -race 下真跑。

8.2 并发修改与事务

8.1 把背包的状态和堆叠算法定了下来,但那份实现有一个隐含前提:同一时刻只有一个人在改它。真实服务端没有这个前提——一次副本结算可能同时触发掉落入包、任务进度加物、成就计数;交易行成交回调又在另一个 goroutine 里把道具划走。这些改动落在同一个背包上,谁也不等谁。

并发带来两个必须分开处理的问题。第一是数据竞争:两个 goroutine 同时改 slots 和 index,轻则丢更新,重则 map 并发写直接 panic。第二是业务原子性:一次「扣除 5 个强化石、把装备 +1」必须整体成功或整体失败,绝不能出现「石头扣了、装备没强化」这种对不上账的中间态。

这一节用一层薄薄的事务把这两件事一起解决:写操作走「副本 + 提交 + 回滚」,读操作走无锁的原子指针。8.1 那份「先校验后执行」的基础函数,正是这里能做成事务的前提。

为什么「一把大锁」不够

最直接的做法是给整个背包套一把 sync.Mutex,每个改动函数进来先加锁。它能挡住数据竞争,却挡不住业务原子性:如果一次操作要跨多个函数调用(先 Remove 材料、再改强化等级),锁只保证「每个函数内部不被打断」,两个函数之间照样可能被别的操作插进来。要把它俩绑成一体,就得让调用方自己持锁——而调用方是业务代码,迟早有人忘了。

另一条路是悲观长锁:调用方拿锁、改完再放。问题是锁一旦跨越业务逻辑,持有时长就不可控,一个慢查询能把整个角色的所有背包操作堵死。对单服几千个在线角色来说,这把锁会变成吞吐瓶颈。

我们选第三条路:状态整体替换。背包的全部可变状态是一个 *bagState,放在 atomic.Pointer 后面。写事务先深拷贝一份副本,在副本上跑完所有改动,成功才把指针一次性换成副本;失败就丢弃副本,已提交状态一个字节都没动。这样:

  • 原子性:指针替换是单个原子操作,读方要么看到旧状态、要么看到新状态,永远看不到「改了一半」。
  • 回滚:副本丢弃即回滚,不需要写任何补偿逻辑。
  • 读无锁:读方直接 Load 指针,不经过互斥锁,不会和写事务抢锁。

代价是每次写事务要拷贝一份状态。对单个角色的背包(几十到几百格)来说,这次拷贝远小于一次数据库往返,完全可接受——真正贵的是跨角色的全服状态,那属于第 14 章排行榜要另说的分层问题。

事务:副本、提交、回滚

Bag.Do 是唯一写入口。它先加锁串行化写事务(保证同一时刻只有一个副本在改),再把当前状态 clone 成副本,把副本交给回调 fn 去改;fn 返回 nil 才 Store 新副本,返回错误就整份丢弃。

这里 clone 的深拷贝是正确性的关键。slots 里放的是 *ItemStack 指针,如果只复制切片头、让新旧两份共享同一批指针,那么副本上对某个堆叠 Count += 1 会顺着指针改到原状态,回滚立刻失效。所以 clone 必须把每个非空槽位复制成新的 *ItemStack 对象,equip 与 index 两张 map 也要重建。这一步看起来笨,却是「回滚免费」的全部来源。

Tx 是回调拿到的可写视图,只暴露 Add、Remove、Count 三个方法。它在副本上操作,所以同一事务里前面几步的改动后面几步能看见——Remove 扣完材料,Count 立刻能读到新数量。回调之外的代码拿不到 Tx,也就无法在事务外偷偷改状态。

幂等:挡住重发与重放

网络世界不可避免会有重发:客户端超时重传、网关重试、上游事件至少一次投递。如果每次重发都照常执行,玩家可能收到两份奖励。Do 的第二个参数 opID 就是为此准备的:非 0 时,同一个 opID 只会真正生效一次,之后重复调用直接返回 nil。

opID 通常取客户端请求头里的 seq,或者上游事件 ID。它把「这次请求」和「这次生效」绑定起来,即使请求被处理了两遍,状态也只往前走一步。注意 opID == 0 表示「不启用幂等」,用于服务端内部确定只会调一次的路径;凡是可能来自网络的入口,都应该带上真实的 opID。

幂等表 seen 目前是内存里的一个 map,重启即清空。跨重启的幂等需要落盘或在接入层做,这是单服内存实现的边界——本书第 18 章讨论持久化时会再回到这一点。

可运行产物:bag.go

下面这段是 L1,go test 可以直接跑。它实现 Bag、Tx、Do 与无锁读路径:

// file: internal/inventory/bag.go
// tier: L1
// verify: go test -race ./internal/inventory/
package inventory

import (
	"sync"
	"sync/atomic"
)

// clone 深拷贝状态。槽位指针必须复制成新对象,否则副本上的改动会顺着
// 指针改到原状态,回滚就失效了。
func (s *bagState) clone() *bagState {
	ns := &bagState{
		slots:    make([]*ItemStack, len(s.slots)),
		equip:    make(map[EquipSlot]*ItemStack, len(s.equip)),
		index:    make(map[ItemInstID]int, len(s.index)),
		nextInst: s.nextInst,
	}
	for i, st := range s.slots {
		if st != nil {
			cp := *st
			ns.slots[i] = &cp
		}
	}
	for slot, st := range s.equip {
		cp := *st
		ns.equip[slot] = &cp
	}
	for k, v := range s.index {
		ns.index[k] = v
	}
	return ns
}

// Reader 是背包的只读视图。所有方法只读,不得保存底层状态引用。
type Reader interface {
	Count(id ItemID) int
	Snapshot() []ItemStack
}

// Bag 是并发安全的背包:写操作走 Do(事务),读操作走 View(无锁)。
//
// 状态是一个 *bagState,放在 atomic.Pointer 后面。写事务在副本上完成后再
// 一次性换指针,所以读路径完全不需要加锁,也不会读到「改了一半」的中间态。
type Bag struct {
	cat   *Catalog
	mu    sync.Mutex               // 串行化写事务;读路径不经过它
	state atomic.Pointer[bagState] // 当前已提交状态
	seen  map[uint64]struct{}      // 已应用的幂等操作号
}

// NewBag 建一个容量为 slots 的背包。
func NewBag(cat *Catalog, slots int) *Bag {
	b := &Bag{cat: cat, seen: make(map[uint64]struct{})}
	b.state.Store(newBagState(slots))
	return b
}

// Tx 是一次事务里的可写视图。它只改私有副本,提交时才换进 Bag。
type Tx struct {
	cat *Catalog
	st  *bagState
}

// Add 向背包加入 count 个 id。
func (t *Tx) Add(id ItemID, count int) error { return t.st.add(t.cat, id, count) }

// Remove 从背包扣减 count 个 id。
func (t *Tx) Remove(id ItemID, count int) error { return t.st.remove(t.cat, id, count) }

// Count 读当前事务副本里的数量,能看到同一事务内前面几步的改动。
func (t *Tx) Count(id ItemID) int { return t.st.Count(id) }

// Do 在一个事务里执行 fn:先复制当前状态,在副本上跑 fn;
// fn 返回 nil 才把副本换进 Bag,返回错误则整份丢弃——这就是天然的回滚。
//
// opID 非 0 时启用幂等:同一个 opID 只会真正生效一次,重复调用直接返回 nil。
// 幂等号通常取「客户端请求的 seq」或上游事件 ID,用来挡住重发与重放。
func (b *Bag) Do(opID uint64, fn func(tx *Tx) error) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	if opID != 0 {
		if _, ok := b.seen[opID]; ok {
			return nil // 幂等:这次请求之前已生效
		}
	}
	next := b.state.Load().clone()
	tx := &Tx{cat: b.cat, st: next}
	if err := fn(tx); err != nil {
		return err // 副本丢弃,已提交状态纹丝不动
	}
	b.state.Store(next)
	if opID != 0 {
		b.seen[opID] = struct{}{}
	}
	return nil
}

// View 在无锁前提下把当前状态交给只读函数。fn 里只能读,
// 且不得把参数存起来——它指向的是某个已提交状态的快照。
func (b *Bag) View(fn func(v Reader)) { fn(b.state.Load()) }

// Count 是 View 的快捷方式,读单个物品数量。
func (b *Bag) Count(id ItemID) int {
	var n int
	b.View(func(v Reader) { n = v.Count(id) })
	return n
}

// Snapshot 是 View 的快捷方式,导出背包槽位。
func (b *Bag) Snapshot() []ItemStack { return b.state.Load().Snapshot() }

配套测试里最值得看的是 TestConcurrentAddNoLostUpdate:32 个 goroutine 各自往同一个背包加 50 个铁矿,结束时总数必须精确等于 1600。这是丢更新的经典判据——少一个就说明有事务被覆盖了。TestTransactionRollback 验证回调中途返回错误时,前面已加的物品一并撤销;TestIdempotentByOpID 用同一 opID 连调三次,只应生效一次;TestViewReadsCommittedState 确认无锁读读到的是已提交状态。

// file: internal/inventory/bag_test.go
// tier: L1
// verify: go test -race ./internal/inventory/
package inventory

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

func TestTransactionRollback(t *testing.T) {
	b := NewBag(testCatalog(), 4)
	_ = b.Do(1, func(tx *Tx) error { return tx.Add(1001, 50) })
	// 事务中途失败:前面加的东西必须一并撤销。
	boom := errors.New("boom")
	err := b.Do(2, func(tx *Tx) error {
		if err := tx.Add(1001, 50); err != nil {
			return err
		}
		return boom
	})
	if !errors.Is(err, boom) {
		t.Fatalf("err=%v,期望 boom", err)
	}
	if got := b.Count(1001); got != 50 {
		t.Fatalf("回滚后 count=%d,期望 50", got)
	}
}

func TestIdempotentByOpID(t *testing.T) {
	b := NewBag(testCatalog(), 4)
	const op = 0xABCD
	for i := 0; i < 3; i++ {
		if err := b.Do(op, func(tx *Tx) error { return tx.Add(1001, 10) }); err != nil {
			t.Fatalf("do: %v", err)
		}
	}
	// 同一 opID 三次只应生效一次。
	if got := b.Count(1001); got != 10 {
		t.Fatalf("count=%d,期望 10(幂等)", got)
	}
}

func TestConcurrentAddNoLostUpdate(t *testing.T) {
	b := NewBag(testCatalog(), 64)
	const (
		workers = 32
		perW    = 50
	)
	var wg sync.WaitGroup
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < perW; i++ {
				if err := b.Do(0, func(tx *Tx) error { return tx.Add(2001, 1) }); err != nil {
					t.Errorf("add: %v", err)
					return
				}
			}
		}()
	}
	wg.Wait()
	if got, want := b.Count(2001), workers*perW; got != want {
		t.Fatalf("count=%d,期望 %d(有丢更新)", got, want)
	}
}

func TestViewReadsCommittedState(t *testing.T) {
	b := NewBag(testCatalog(), 4)
	_ = b.Do(0, func(tx *Tx) error { return tx.Add(1001, 7) })
	// 读路径不经锁,读到的必须是某个已提交状态。
	var seen int
	b.View(func(v Reader) { seen = v.Count(1001) })
	if seen != 7 {
		t.Fatalf("View count=%d,期望 7", seen)
	}
}

本机实跑 go test -race ./internal/inventory/ 全绿,并发用例没有报数据竞争。这里不写「快几倍」这类数字——同机相对量级,绝对数值不可移植;这一节真正要证明的是正确性:并发下不丢更新、失败能回滚、重发不重复生效。

本节小结

  • 一把大锁挡得住数据竞争,挡不住跨函数调用的业务原子性;长锁又会把角色操作堵死。
  • 用 atomic.Pointer 做状态整体替换:写事务在深拷贝副本上完成,成功才换指针,失败丢弃副本即回滚。
  • clone 必须深拷贝每个 *ItemStack,否则副本改动会顺着指针漏回原状态。
  • 读路径走 View/Load 完全无锁;写事务用互斥锁串行化。
  • opID 提供幂等,挡住客户端重发与上游事件重放;跨重启的幂等属于持久化话题。

阅读导航:上一节:8.1 物品与堆叠模型 · 下一节:8.3 装备与强化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标