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 装备与强化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。