一、扩展方式:开格子、开背包页、仓库
背包容量不是一次性给满的。产品通常把扩容做成养成线:开格子、开背包页、开仓库。三种方式对应不同的数据结构与付费点,不能只当成“把 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 重复不处理,后续强化和交易指向错误对象。
- 扩容不幂等,重发请求扣两次费用。
- 迁移在上线脚本里批量改数据,没有备份和回滚路径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。