《MMO 服务器开发》13.2 公会仓库与捐献流水

公会仓库是多人共享的资产容器,捐献则是一份只增不改的流水账。本节用 aegis 从零实现:仓库按物品种类限槽但允许无限堆叠,捐献用请求号幂等去重避免重试翻倍,提现按职位控权并按日限额。重点讲清「先判幂等再写库存」的顺序为什么是正确性的关键,并用真跑的 L1 测试验证重试不翻倍、超额提现被拦、跨日重置生效。

13.2 公会仓库与捐献流水

第 8 章的背包是一个人的资产:所有者唯一、读写都在玩家自己的逻辑体里、并发修改靠单执行体串行化。公会仓库不同,它是一份多人共享的资产——谁都能往里放,有权限的人才能往外拿,而「谁贡献了多少」还要单独记一笔账。多所有者把问题从「并发」变成了「归属与审计」:仓库里的东西是公会的,但每件东西的来源要能追到具体的人。

由此引出本节要解决的两件事。第一,仓库与流水不能各写各的。玩家捐 100 个材料,仓库要加 100,流水要记一行、贡献要点数加 100;这三件事要么全成,要么全不成,绝不能出现「东西进了仓库、贡献没记」或者反过来。第二,捐献必须幂等。捐献是最容易被重试打中的操作之一:客户端等不到响应会重发、断线重连会补发、玩家手抖会连点——如果服务端把同一笔捐献算两遍,仓库凭空多出 100 个材料,贡献榜也虚高一倍。这类 bug 在线上极难追,因为账面上一切自洽,只有对不上「玩家实际拥有的东西」时才暴露。

本节的做法:仓库用「按物品种类限槽、槽内无限堆叠」的结构,把容量约束说清楚;捐献用一张只增不改的流水表 + 请求号去重,让重试变成无副作用的重复读;提现则把权限与每日限额都压在写库存之前。下面的代码是 L1,go test 直接能跑。

共享仓库:限槽不限量

仓库最容易配错的一个参数是「容量」。若容量指「总件数」,那么一个玩家捐 1000 个药水就会把仓库塞爆,而这类材料在 MMO 里遍地都是。真正该限的是物品种类数——100 个槽位意味着最多 100 种不同的物品,每种能堆多少不设上限。这样容量是可控的(槽位数固定),玩家体验也对(捐一万个材料只占一个槽)。

Warehouse 的两个方法各有讲究。Deposit 只在「新种类且槽位已满」时失败,堆叠到已有种类永远不会失败;Withdraw 在数量不足时返回错误而不是把数量扣成负数,并且在数量刚好扣完时删除整个槽,让种类计数保持准确。这三个行为在 L1 测试里各有一条断言。

捐献流水:先判幂等,再写库存

流水是一份只增不改的账:每笔捐献分配一个单调递增的 Seq,客户端用它分页;同一笔捐献被重试时,返回的是原来那一行而不是新增一行。幂等靠一张 requestID → Seq 的映射实现。

但真正容易写错的地方在 GuildStore.Deposit 的语句顺序。如果先写库存、再查幂等,那么一次重试会先给仓库加上 100,然后才发现是重复请求——仓库已经脏了。正确顺序是先查幂等、命中就直接返回原记录、根本不碰库存。这个顺序不是风格问题,是正确性问题。

下面这段是 L1,go test 可以直接跑:

// file: internal/guild/storage.go
// tier: L1
// verify: go test ./internal/guild/ -run 'TestWarehouse|TestLedger|TestStore' -v

package guild

import (
	"errors"
	"time"
)

// ItemID is a config-table item id, not a bag instance id (卷二共享约定).
type ItemID uint32

var (
	ErrBadAmount     = errors.New("amount must be positive")
	ErrWarehouseFull = errors.New("warehouse slot capacity reached")
	ErrNotEnoughItem = errors.New("not enough items in warehouse")
	ErrWithdrawLimit = errors.New("daily withdraw limit reached")
)

// Warehouse is a guild-shared item store keyed by config item id. capacity caps
// the number of distinct item types, not the total count: stacking a million
// potions in one slot costs one slot.
type Warehouse struct {
	slots    map[ItemID]int64
	capacity int
}

func NewWarehouse(capacity int) *Warehouse {
	if capacity <= 0 {
		capacity = 100
	}
	return &Warehouse{slots: make(map[ItemID]int64), capacity: capacity}
}

// Deposit adds count of item id. It fails only when a brand-new type would exceed
// the slot capacity; stacking onto an existing slot never does.
func (w *Warehouse) Deposit(id ItemID, count int64) error {
	if count <= 0 {
		return ErrBadAmount
	}
	cur, ok := w.slots[id]
	if !ok && len(w.slots) >= w.capacity {
		return ErrWarehouseFull
	}
	w.slots[id] = cur + count
	return nil
}

// Withdraw removes count of item id, reporting ErrNotEnoughItem instead of going
// negative. Empty slots are deleted so the type count is accurate.
func (w *Warehouse) Withdraw(id ItemID, count int64) error {
	if count <= 0 {
		return ErrBadAmount
	}
	cur, ok := w.slots[id]
	if !ok || cur < count {
		return ErrNotEnoughItem
	}
	if cur == count {
		delete(w.slots, id)
	} else {
		w.slots[id] = cur - count
	}
	return nil
}

func (w *Warehouse) Count(id ItemID) int64 { return w.slots[id] }
func (w *Warehouse) Types() int            { return len(w.slots) }

// DonationEntry is one append-only donation record. Seq is monotonic and is the
// id the client uses to paginate the ledger.
type DonationEntry struct {
	Seq      uint64
	PlayerID PlayerID
	ItemID   ItemID
	Count    int64
	Gold     int64
	At       time.Time
}

// Ledger records donations idempotently. The same request id never produces two
// entries, so a client retry cannot double a member's contribution.
type Ledger struct {
	entries []DonationEntry
	seq     uint64
	byReq   map[uint64]uint64   // requestID -> Seq
	total   map[PlayerID]uint64 // 累计贡献点
}

func NewLedger() *Ledger {
	return &Ledger{byReq: make(map[uint64]uint64), total: make(map[PlayerID]uint64)}
}

// Donate appends a donation for reqID. If reqID was seen before, it returns the
// original entry with dup=true and changes nothing. Contribution points are
// item count + gold; the weighting is a placeholder for the economy table.
func (l *Ledger) Donate(reqID uint64, p PlayerID, item ItemID, count, gold int64, at time.Time) (DonationEntry, bool, error) {
	if count < 0 || gold < 0 || (count == 0 && gold == 0) {
		return DonationEntry{}, false, ErrBadAmount
	}
	if seq, ok := l.byReq[reqID]; ok {
		return l.entries[seq-1], true, nil
	}
	l.seq++
	e := DonationEntry{Seq: l.seq, PlayerID: p, ItemID: item, Count: count, Gold: gold, At: at}
	l.entries = append(l.entries, e)
	l.byReq[reqID] = e.Seq
	l.total[p] += uint64(count) + uint64(gold)
	return e, false, nil
}

func (l *Ledger) Contribution(p PlayerID) uint64 { return l.total[p] }
func (l *Ledger) Len() int                       { return len(l.entries) }

// Entries returns a copy so callers cannot mutate the ledger's backing array.
func (l *Ledger) Entries() []DonationEntry {
	out := make([]DonationEntry, len(l.entries))
	copy(out, l.entries)
	return out
}

// GuildStore wires a warehouse and a ledger to a roster so permission checks,
// idempotency and daily limits all live in one place. The ordering inside each
// method is deliberate and load-bearing.
type GuildStore struct {
	guild     *Guild
	wh        *Warehouse
	ledger    *Ledger
	dailyCap  int64
	withdrawn map[PlayerID]int64 // 今日已提数量
}

func NewGuildStore(g *Guild, wh *Warehouse, dailyWithdrawCap int64) *GuildStore {
	return &GuildStore{guild: g, wh: wh, ledger: NewLedger(), dailyCap: dailyWithdrawCap, withdrawn: make(map[PlayerID]int64)}
}

// Deposit puts items into the shared warehouse and records the donation in the
// same call. The idempotency check comes FIRST: if it ran after the stock write,
// a retry would add the items twice before noticing the duplicate.
func (s *GuildStore) Deposit(actor PlayerID, reqID uint64, item ItemID, count int64, at time.Time) (DonationEntry, bool, error) {
	if !s.guild.can(actor, PermStorageDeposit) {
		return DonationEntry{}, false, ErrPermissionDenied
	}
	if seq, ok := s.ledger.byReq[reqID]; ok {
		return s.ledger.entries[seq-1], true, nil
	}
	if err := s.wh.Deposit(item, count); err != nil {
		return DonationEntry{}, false, err
	}
	return s.ledger.Donate(reqID, actor, item, count, 0, at)
}

// Withdraw lets a permitted member take items out, capped per player per day.
// The cap is checked before the stock write so a rejected withdraw leaves the
// warehouse untouched.
func (s *GuildStore) Withdraw(actor PlayerID, item ItemID, count int64) error {
	if !s.guild.can(actor, PermStorageWithdraw) {
		return ErrPermissionDenied
	}
	if count <= 0 {
		return ErrBadAmount
	}
	if s.dailyCap > 0 && s.withdrawn[actor]+count > s.dailyCap {
		return ErrWithdrawLimit
	}
	if err := s.wh.Withdraw(item, count); err != nil {
		return err
	}
	s.withdrawn[actor] += count
	return nil
}

// ResetDaily clears the per-player withdraw counters. The scheduler that calls it
// at the 04:00 boundary is the topic of 10.3.
func (s *GuildStore) ResetDaily() { clear(s.withdrawn) }

三个要点。第一,GuildStore 把仓库、流水、权限三者绑在一个入口,任何一次捐献都同时经过「权限门 → 幂等门 → 库存写 → 流水写」,不会出现某条路径绕过某一环。第二,Deposit 的幂等命中直接返回 l.entries[seq-1],即原封不动地返回第一次那一行——客户端重试拿到和第一次完全一样的响应,天然幂等,不需要额外的一致性协议。第三,每日限额 dailyCap 存在 withdrawn 这张按玩家计数的表里,ResetDaily 在 04:00 边界清空它——「每日」到底是几点到几点是 10.3 的主题(时区窗口与重置边界),本节只把重置的接口留出来,不自己实现时间判断。

真跑与实测结论

测试里三条边界各一个用例:重试不翻倍、超额提现被拦、跨日重置生效。

// snippet: internal/guild/storage_test.go(节选)

func TestStoreDepositThenWithdraw(t *testing.T) {
	g := NewGuild(1, "g", 100, "leader", JoinOpen, 10, base)
	g.RequestJoin(101, "m", base)
	s := NewGuildStore(g, NewWarehouse(10), 50)

	// A plain member may deposit but not withdraw.
	if _, _, err := s.Deposit(101, 1, 7, 100, base); err != nil {
		t.Fatalf("member deposit: %v", err)
	}
	if err := s.Withdraw(101, 7, 1); !errors.Is(err, ErrPermissionDenied) {
		t.Fatalf("member withdraw: want ErrPermissionDenied, got %v", err)
	}
	if err := g.SetRank(100, 101, RankOfficer); err != nil {
		t.Fatalf("promote: %v", err)
	}
	if err := s.Withdraw(101, 7, 30); err != nil {
		t.Fatalf("officer withdraw 30: %v", err)
	}
	if err := s.Withdraw(101, 7, 30); !errors.Is(err, ErrWithdrawLimit) {
		t.Fatalf("second withdraw: want ErrWithdrawLimit, got %v", err)
	}
	s.ResetDaily()
	if err := s.Withdraw(101, 7, 30); err != nil {
		t.Fatalf("after reset: %v", err)
	}
}

func TestStoreRetriedDepositAddsStockOnce(t *testing.T) {
	g := NewGuild(1, "g", 100, "leader", JoinOpen, 10, base)
	s := NewGuildStore(g, NewWarehouse(10), 0)
	if _, _, err := s.Deposit(100, 9, 3, 4, base); err != nil {
		t.Fatalf("deposit: %v", err)
	}
	if _, dup, err := s.Deposit(100, 9, 3, 4, base); err != nil || !dup {
		t.Fatalf("retry deposit: dup=%v err=%v", dup, err)
	}
	if got := s.wh.Count(3); got != 4 {
		t.Fatalf("stock = %d, want 4 (added once)", got)
	}
}

本机(Go 1.26.0,macOS arm64)实测,go test ./internal/guild/ 全绿、go vet 无告警。同机相对量级,绝对数值不可移植:

断言结果说明
同请求号捐两次仓库只加一次(4)幂等命中返回原记录
流水 Len()1重试不新增行
贡献点数只算一次total 不被重试翻倍
普通成员提现ErrPermissionDenied无 PermStorageWithdraw
当日提现 30 后再提 30(上限 50)ErrWithdrawLimit限额在写库存前判定
ResetDaily 后再提 30成功跨日重置生效

两个结论。其一,幂等的价值全在「顺序」上:把幂等判断放在写库存之前,重试就是零副作用的重复读;放在之后,重试就是一次真实的重复写。TestStoreRetriedDepositAddsStockOnce 直接断言库存停在 4——这是整节最该被复制的模式。其二,限额与权限都在写之前判定,被拒绝的提现不会在仓库里留下任何痕迹。下一节把这套公会状态接到公会战上:报名与锁定,会用到 13.1 的职位权限和这里的「先判后写」。

阅读导航:上一节:13.1 入会、职位与权限 · 下一节:13.3 公会战报名与锁定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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