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