《游戏服务端编程实践》9.1.3 背包扩展与持久化

对比开格子、开背包页与仓库三种扩展方式,分析整包序列化和逐物品行在并发、审计与查询上的取舍,说明快照版本、容量字段与加载修复规则,并用 Go 实现 Snapshot/Restore 往返、损坏数据修复、版本迁移与扩容测试,最后给出上线顺序与备份建议。

一、扩展方式:开格子、开背包页、仓库

背包容量不是一次性给满的。产品通常把扩容做成养成线:开格子、开背包页、开仓库。三种方式对应不同的数据结构与付费点,不能只当成“把 capacity 调大”。

扩展方式数据结构玩家感知主要风险
开格子同一槽位数组扩容背包多几格旧快照容量不兼容
开背包页多个固定容量页多一页标签页间移动与排序复杂
开仓库独立背包实例与随身背包分离跨容器事务与同步

开格子最简单:容量从 40 扩到 50,原有槽位索引不变。它的风险在持久化:如果快照里写死了容量,旧快照恢复后只有 40 格,新开的 10 格会丢。容量必须作为快照字段,或者由服务端按当前配置覆盖,不能依赖数组长度。

开背包页适合“每页有独立主题”的设计,比如材料页、装备页、任务页。页与页之间移动物品时,必须保证实例只存在于一个页,不能复制。页的容量可以独立扩展,但总索引要能定位到“第几页第几格”。

仓库适合存放大量材料与收藏品。仓库通常是独立容器,有自己的容量、权限和日志。随身背包与仓库之间转移物品是一个跨容器事务:两边都要成功,或者两边都不变。不要把仓库当成“第二个背包数组”塞进同一个结构,否则事务边界会模糊。

扩展还要考虑“降容”。付费扩容一般不降;但活动临时背包、赛季仓库会到期。降容前必须检查是否有物品,不能静默截断。正确做法是先提示、再要求玩家清空,最后由后台任务在确认无物品后回收容量。

二、容量上限与价格曲线

容量上限决定经济系统的天花板。上限太低,玩家很快满仓,体验差;上限太高,付费动力不足,数据库行数也会膨胀。常见做法是分段定价:前几格便宜,中段递增,后段很贵。

价格曲线要与产出挂钩。用固定金币价格,通胀后扩容变得毫无成本;用动态价格,又会让玩家觉得被针对。更稳的是“固定价格 + 稀缺道具”,前者提供可预期目标,后者控制节奏。

容量上限还要和堆叠上限一起看。单堆 99、40 格,理论容量是 3960 个材料;如果把堆叠上限提到 999,同样的格子能装十倍,扩容付费点就被削弱。数值设计时要把“格数 × 堆叠”当成总容量,而不是只看格数。

服务端要限制单角色背包实例数量。开页、开仓库、活动背包如果都能无限创建,一个角色可能持有成千上万个容器,登录加载和存档都会变慢。上限应该由配置给出,并在创建入口统一校验。

扩容操作本身要幂等。客户端可能重复请求,网关可能重发;同一个扩容请求执行两次不能扣两次钱。用请求 ID 或“目标容量”做幂等键:当前容量已经达到目标,直接返回成功,不再扣费。

三、整包序列化还是逐物品行

持久化有两条路:整包序列化(一个角色一行 JSON/二进制)和逐物品行(每个实例一行数据库记录)。

方式写入读取适用规模主要问题
整包序列化一次写整包一次读整包中小规模、单服并发写覆盖、难查询
逐物品行按实例增删改按角色查询大规模、需审计事务多、行数膨胀
混合热数据整包、冷数据行分层加载大型项目两层一致性

整包序列化的优点是简单:一次读写,天然原子,适合把背包当成角色存档的一部分。缺点是并发写容易覆盖:两个请求同时读到旧包,各自修改后写回,后写的会丢掉先写的变更。必须用版本号或乐观锁,冲突时重读重试。

逐物品行的优点是变更粒度小:加一件写一行,删一件删一行,天然支持审计和查询。缺点是事务多,一次批量整理可能涉及几十行;行数膨胀后,登录查询也会变慢。需要索引、分页和归档策略。

混合方案把经常变化的随身背包整包存,把仓库、邮件附件按行存。它兼顾了性能与审计,但两层之间的一致性要额外维护:转移物品时,整包和行记录必须在同一事务或同一个补偿流程里。

无论选哪种,都不要把“整包 JSON”直接当数据库查询接口。想查“谁有某件物品”,整包方案只能全表扫描;如果运营确实需要这类查询,应该额外写索引表或事件流,而不是解析所有 JSON。

四、快照要带版本与容量

快照不是“把内存数组转成 JSON”这么简单。一个可恢复的快照至少要有版本号、容量、下一个实例 ID、非空槽位列表。版本号用于迁移,容量用于扩容,next_id 用于避免恢复后新实例与旧实例撞号。

只存非空槽位可以节省空间,但必须同时存绝对槽位号。只存一个数组、恢复时按顺序填充,会让玩家整理过的背包在重登后变样;更严重的是,客户端缓存的槽位引用会错位。

快照的字段要向后兼容。新增字段用可选字段和默认值,不要改旧字段语义;删除字段时先保留读取逻辑,等所有旧快照迁移完再移除。版本号不是装饰,是迁移的分支条件。

恢复时不要信任快照内容。快照可能来自旧版本、手工修复、异常中断,甚至被篡改。加载路径必须做校验:物品定义是否存在、数量是否为正、是否超过堆叠上限、槽位是否越界或重复、实例 ID 是否重复。

校验失败时有两种策略:拒绝加载和修复加载。拒绝加载会让玩家无法登录;修复加载会改变玩家资产。生产环境通常选择修复加载,但必须把每一处修复写入报告,供客服和运营核查。静默修复等于静默丢物品。

五、加载时的数据修复

修复规则要按“最小损失、可解释、可追溯”设计。能保留的尽量保留,不能保留的明确记录丢失数量。

问题修复动作报告字段玩家影响
未知物品丢弃item_id、数量物品消失
数量 ≤ 0丢弃槽位、实例无效堆消失
超过堆叠上限截断到上限原数量、丢失数量多余数量消失
槽位越界/重复移到空槽原槽位、新槽位位置变化
实例 ID 重复重新分配旧 ID、新 ID身份变化
无空槽可放丢弃物品、数量物品消失

修复顺序会影响结果。先处理未知物品和非法数量,再处理超堆叠,最后处理槽位冲突和 ID 重复,能让报告更清晰,也能避免把无效物品移动到空槽后又被丢弃。

实例 ID 重复要特别小心。两件装备共用一个 ID,后续强化、交易、删除都会指向错误对象。恢复时应该保留第一件,给后续重复项分配新 ID,并记录映射;如果业务要求 ID 不可变,则应该拒绝加载并人工处理。

槽位冲突也要区分“越界”和“占用”。越界是快照损坏,占用是同一槽位出现两条记录。两者都可以尝试移到第一个空槽;没有空槽时只能丢弃,并明确记录。不要覆盖已有物品,否则会静默丢失。

修复报告要能回答三个问题:丢了什么、丢了多少、为什么丢。只记录“修复完成”没有意义;线上出问题时,客服需要具体到槽位和实例。

六、失败场景:三次持久化事故

第一个事故是“整包 JSON 当数据库”。运营想查某件活动道具的持有率,工程师只能遍历所有角色的 JSON,跑了几个小时还影响了数据库。根因不是 JSON 不能存,而是没有为查询场景设计索引。

第二个事故是“扩容后旧快照不兼容”。背包从 40 格扩到 50 格,快照里仍写着 40;玩家重启后新格子消失,放在新格子里的物品也读不出来。根因是容量没有作为快照字段,恢复时用了数组长度。

第三个事故是“修复脚本静默丢物品”。脚本把超堆叠截断、把未知物品删除,只输出“修复了 N 个角色”。玩家发现物品少了,客服无法解释。根因是修复没有逐条报告,也没有保留修复前的原始快照。

这三个事故的共同点是:持久化被当成“存一下”,而不是“可恢复、可审计、可迁移”。快照是玩家资产的一部分,任何字段缺失和修复动作都应该被当作正式接口设计。

上线前至少要做一次“旧快照恢复演练”:用生产脱敏快照在新版本加载,检查修复报告、容量、槽位和实例 ID。演练发现的问题,远比上线后发现便宜。

七、Go 实现:Snapshot/Restore 与可审计修复

下面的实现把快照定义为带版本、容量和 next_id 的结构;Restore 逐条校验并修复,所有修复写入 RestoreReport,不会静默丢物品。版本 1 的快照没有容量字段,恢复时按最大槽位推导并记录迁移来源。

// file: ch09/bag/persist.go
// tier: L1
// verify: go test ./ch09/bag/

package bag

import (
	"errors"
	"fmt"
	"sort"
	"time"
)

const SnapshotVersion = 2

var ErrBadSnapshot = errors.New("bag: bad snapshot")

// SnapshotSlot 只记录非空槽位。Slot 是绝对槽位号,恢复时保持不变,
// 避免客户端缓存的槽位引用在加载后错位。
type SnapshotSlot struct {
	Slot       int    `json:"slot"`
	InstanceID uint64 `json:"instance_id"`
	ItemID     string `json:"item_id"`
	Count      int    `json:"count"`
	Bound      bool   `json:"bound,omitempty"`
	ExpireUnix int64  `json:"expire_unix,omitempty"`
}

type Snapshot struct {
	Version  int            `json:"version"`
	Capacity int            `json:"capacity"`
	NextID   uint64         `json:"next_id"`
	Slots    []SnapshotSlot `json:"slots"`
}

// Repair 记录恢复时做过的每一处修复,禁止静默丢物品。
type Repair struct {
	Slot       int
	InstanceID uint64
	ItemID     string
	Reason     string
	Lost       int
}

type RestoreReport struct {
	MigratedFrom int
	Repairs      []Repair
}

func (r RestoreReport) LostTotal() int {
	total := 0
	for _, rep := range r.Repairs {
		total += rep.Lost
	}
	return total
}

func (b *Bag) Snapshot() Snapshot {
	snap := Snapshot{Version: SnapshotVersion, Capacity: len(b.slots), NextID: b.nextID}
	for i, it := range b.slots {
		if it == nil {
			continue
		}
		s := SnapshotSlot{
			Slot:       i,
			InstanceID: it.InstanceID,
			ItemID:     it.ItemID,
			Count:      it.Count,
			Bound:      it.Bound,
		}
		if !it.ExpireAt.IsZero() {
			s.ExpireUnix = it.ExpireAt.Unix()
		}
		snap.Slots = append(snap.Slots, s)
	}
	sort.Slice(snap.Slots, func(i, j int) bool { return snap.Slots[i].Slot < snap.Slots[j].Slot })
	return snap
}

// Restore 从快照重建背包,并对损坏数据做可审计修复:
// 未知物品、非法数量直接丢弃;超堆叠截断;槽位冲突迁到空槽;
// 重复 instanceID 重新分配。每处修复都写入 report。
func Restore(defs []ItemDef, snap Snapshot) (*Bag, RestoreReport, error) {
	var report RestoreReport
	if snap.Version < 1 || snap.Version > SnapshotVersion {
		return nil, report, fmt.Errorf("%w: version %d", ErrBadSnapshot, snap.Version)
	}

	capacity := snap.Capacity
	if snap.Version == 1 && capacity <= 0 {
		for _, s := range snap.Slots {
			if s.Slot >= capacity {
				capacity = s.Slot + 1
			}
		}
		report.MigratedFrom = 1
	}
	if capacity <= 0 {
		return nil, report, fmt.Errorf("%w: capacity %d", ErrBadSnapshot, capacity)
	}
	b, err := NewBag(defs, capacity)
	if err != nil {
		return nil, report, err
	}

	maxID := snap.NextID
	for _, s := range snap.Slots {
		if s.InstanceID > maxID {
			maxID = s.InstanceID
		}
	}
	b.nextID = maxID
	seen := make(map[uint64]bool, len(snap.Slots))

	for _, s := range snap.Slots {
		def, ok := b.defs[s.ItemID]
		if !ok {
			report.Repairs = append(report.Repairs, Repair{
				Slot: s.Slot, InstanceID: s.InstanceID, ItemID: s.ItemID,
				Reason: "unknown_item", Lost: s.Count,
			})
			continue
		}
		if s.Count <= 0 {
			report.Repairs = append(report.Repairs, Repair{
				Slot: s.Slot, InstanceID: s.InstanceID, ItemID: s.ItemID,
				Reason: "invalid_count", Lost: max(s.Count, 0),
			})
			continue
		}

		count := s.Count
		if count > def.MaxStack {
			report.Repairs = append(report.Repairs, Repair{
				Slot: s.Slot, InstanceID: s.InstanceID, ItemID: s.ItemID,
				Reason: "overstack", Lost: count - def.MaxStack,
			})
			count = def.MaxStack
		}

		target := s.Slot
		if target < 0 || target >= capacity || b.slots[target] != nil {
			target = b.firstEmpty()
			if target < 0 {
				report.Repairs = append(report.Repairs, Repair{
					Slot: s.Slot, InstanceID: s.InstanceID, ItemID: s.ItemID,
					Reason: "no_space", Lost: count,
				})
				continue
			}
			report.Repairs = append(report.Repairs, Repair{
				Slot: s.Slot, InstanceID: s.InstanceID, ItemID: s.ItemID,
				Reason: "slot_conflict",
			})
		}

		id := s.InstanceID
		if id == 0 || seen[id] {
			b.nextID++
			id = b.nextID
			report.Repairs = append(report.Repairs, Repair{
				Slot: s.Slot, InstanceID: s.InstanceID, ItemID: s.ItemID,
				Reason: "duplicate_instance_id",
			})
		}
		seen[id] = true

		var expire time.Time
		if s.ExpireUnix > 0 {
			expire = time.Unix(s.ExpireUnix, 0)
		}
		b.slots[target] = &Item{
			InstanceID: id,
			ItemID:     s.ItemID,
			Count:      count,
			Bound:      s.Bound,
			ExpireAt:   expire,
		}
	}
	return b, report, nil
}

// Expand 只允许扩大容量;旧槽位索引与物品保持不变。
func (b *Bag) Expand(capacity int) error {
	if capacity <= len(b.slots) {
		return fmt.Errorf("%w: shrink %d -> %d", ErrBadCapacity, len(b.slots), capacity)
	}
	grown := make([]*Item, capacity)
	copy(grown, b.slots)
	b.slots = grown
	return nil
}

几个实现细节。Snapshot 只输出非空槽位,并按槽位号排序,保证同一背包多次快照的结果稳定,便于比较和测试。Restore 先计算所有实例 ID 的最大值,再处理重复 ID,避免新分配的 ID 与后续记录冲突。

超堆叠只截断不扩展:快照说 99 个、上限 20,恢复为 20 并记录丢失 79。未知物品和非法数量直接丢弃,因为它们没有可恢复的语义。槽位冲突优先移到第一个空槽,没有空槽才丢弃,并记录 no_space。

Expand 只允许扩大容量,旧槽位和物品保持不变。扩容后立刻做一次快照,能让新容量落库;如果等到下次自然存档,期间崩溃会回到旧容量。

测试覆盖 JSON 往返一致、损坏数据修复报告、无空槽丢弃、版本 1 迁移、未来版本拒绝、扩容保留物品与拒绝缩容。往返测试用 reflect.DeepEqual 比较修复前后的快照,能同时发现字段遗漏和排序不稳定。

八、迁移、备份与上线顺序

版本迁移要单向、可重复。新版本能读旧快照,旧版本不要求能读新快照;迁移逻辑放在加载路径,读取时转换,写回时使用新版本。不要在上线脚本里批量改所有角色,除非已经做过完整备份和回滚演练。

上线顺序建议:先发布能读新旧两个版本的代码,再观察修复报告;确认无误后,再让新写入使用新版本;最后才清理旧字段和旧代码。这个顺序允许回滚,也避免新旧实例同时写不同格式。

备份要保留原始快照,而不是只保留修复后的结果。修复脚本应该先落原始数据,再落修复报告,最后才写回。这样即使修复规则有误,也能从原始数据重跑。

监控要关注三个指标:修复率、丢失数量、迁移失败数。修复率突然上升,通常意味着某条写入路径产生了非法数据;丢失数量上升,说明问题已经影响玩家资产,需要立即止损。

最后,持久化测试不要只测“正常往返”。正常往返只能证明字段对得上,证明不了损坏数据能被安全处理。把截断、越界、重复、未知物品都写进测试,恢复逻辑才敢上线。

小结

扩展方式决定数据结构:开格子扩同一数组,开页要处理页间移动,仓库是独立容器。持久化可以选择整包、逐物品行或混合,但都要有版本、容量和并发控制。快照必须带版本与容量,恢复必须校验并输出可审计的修复报告。扩容只增不减,迁移单向可重复,备份保留原始数据。

常见错误与边界

  • 把整包 JSON 当查询数据库,运营需求一来就全表扫描。
  • 快照不存容量,扩容后旧快照恢复成旧格数。
  • 只存非空槽位却不存绝对槽位号,恢复后位置错乱。
  • 恢复时信任快照,未知物品和超堆叠直接进内存。
  • 修复脚本静默丢弃物品,只输出汇总数字。
  • 实例 ID 重复不处理,后续强化和交易指向错误对象。
  • 扩容不幂等,重发请求扣两次费用。
  • 迁移在上线脚本里批量改数据,没有备份和回滚路径。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist