游戏丢档坏档?单KEY合并+clientCloud补档方案全解

精华04/2661 浏览开发心得
一个从多KEY分散存档到单KEY合并、再到客户端异构备份的存档架构演进记录。
适用于使用 serverCloud / clientCloud 的小游戏开发者。
horizontal linehorizontal line
前言
做游戏最怕什么?玩家丢档。辛辛苦苦打了几十层塔、刷了一背包装备,结果重新上线发现存档没了,或者数据错乱——这对玩家体验是毁灭性的。我在开发过程中踩过存档的坑,从最初的"多个KEY各存一部分"到现在的"单KEY原子读写 + clientCloud异构备份",中间经历了几次架构调整。这篇文章把整个思路理清楚,希望对同样用云变量做存档的开发者有参考价值。
horizontal linehorizontal line
一、多KEY存档的问题最初的做法
最早我用 clientCloud 存档,每个数据用一个独立的KEY:
gold         → 金币
level        → 等级
exp          → 经验
mogu         → 莫古(永久货币)
stamina      → 体力
equipment_data → 装备数据
equip_next_id  → 装备ID计数器
last_stamina_time → 上次体力恢复时间
brightness   → 亮度设置
music_vol    → 音量设置
...
十几个KEY,每个存一个值,读的时候 BatchGet 一把全拉,写的时候 BatchSet 一把全推。
看起来没什么问题,直到游戏功能越来越多。
问题一:KEY越来越多,管理混乱
随着系统扩展(装备、食物背包、塔内临时状态、三色钥匙……),KEY的数量不断膨胀。每加一个新系统就要新增KEY,读写逻辑分散在各处,改一个地方容易漏另一个。
问题二:部分写入失败 = 数据不一致
多个KEY分开写入,网络层面它们是独立的请求。如果写到一半断网了——金币扣了但装备没存上,或者等级存了但经验没更新——数据就不一致了。虽然 BatchSet 在接口层面是"一次调用",但底层是否保证原子性取决于平台实现。多个KEY意味着更多不确定性。
问题三:存档版本迁移困难
游戏更新后数据结构变了(比如新增了食物系统),需要做存档迁移。多KEY的情况下,迁移逻辑要判断每个KEY是否存在、是否是旧格式,逻辑非常碎。
horizontal linehorizontal line
二、单KEY合并:
一个JSON打包所有数据核心思路把所有游戏数据序列化成一个JSON,用一个KEY存储。一次读、一次写,原子性天然保证。lua
复制-- 构建存档数据
local saveData = {
    save_version = 2,  -- 版本号,迁移用
    -- 永久属性
    mogu = player.mogu,
    stamina = player.stamina,
    skin = player.skin,
    maxHp = player.maxHp,
    patk = player.patk,
    -- ...更多永久属性
    -- 装备系统(整体序列化)
    equipmentData = EquipmentInventory.Serialize(),
    equipNextId = EquipmentSystem.GetNextId(),
    -- 食物背包(整体序列化)
    foodData = FoodInventory.Serialize(),
    -- 塔内状态(仅在塔内时保存)
    isInTower = isInTower,
    towerPlayer = isInTower and { gold = ..., level = ..., keys = ... } or nil,
    towerState = isInTower and TowerGenerator.SerializeState() or nil,
}
-- 一次性写入
local jsonStr = cjson.encode(saveData)
serverCloud:Set(userId, "save_data", saveData, { ... })
优势
对比项多KEY单KEY原子性部分写入风险一次读写,要么全成功要么全失败新增字段加KEY + 改读写逻辑JSON里加一个字段就行版本迁移逐KEY判断,逻辑碎一个 save_version 字段统一管理数据一致性多KEY之间可能不一致单文档,天然一致可读性分散在各处一个 buildSaveData() 看到全貌存档前置校验单KEY方案的另一个好处是:读出来之后可以做整体校验,不合格就不用。lua
复制-- 读档后的校验流程
local ok, data = pcall(cjson.decode, jsonStr)
-- 第一关:JSON能解码吗?
if not ok or type(data) ~= "table" then
    -- 坏档!JSON损坏,走补档逻辑
    return fallbackToClientBackup()
end
-- 第二关:版本号存在吗?
if not data.save_version then
    -- 结构异常,可能是残档
    return fallbackToClientBackup()
end
-- 第三关:关键字段完整吗?
if data.mogu == nil or data.stamina == nil then
    -- 字段缺失,可能是写入中断的半成品
    return fallbackToClientBackup()
end
-- 第四关:数值合法吗?(递归检查)
local valid, badKey = validateNumbers(data, "save")
if not valid then
    -- 存在 NaN 或 inf,数据已污染
    return fallbackToClientBackup()
end
-- 全部通过,正常使用存档
applyLoadedData(data)
校验不过 → 不覆盖、不使用 → 走补档。 这个"前置否判断"是整个方案的关键:宁可用旧一点的备份档,也不让坏数据进入游戏。
三、为什么不用服务端多副本防坏档?"听起来很合理"的方案直觉上会想:服务端多存几份不就行了?save_data_v1  → 最新存档
save_data_v2  → 上一次的存档
save_data_v3  → 上上次的存档
三个KEY轮替覆盖,坏了一个还有两个备份。
我最终没有采用这个方案,
原因如下:问题一:坏数据写入时不知道自己坏了存档损坏的常见原因不是"写入中断",而是游戏逻辑BUG导致写入了错误数据。比如:
  • 一个计算公式产生了 NaN,NaN 写进了存档
  • 某个模块返回 nil,被存档系统当成合法值序列化了
  • cjson.encode 遇到 NaN/inf 会输出非法JSON,下次 decode 直接崩
horizontal linehorizontal line
四、clientCloud 补档:
异构冗余核心逻辑serverCloud(主存档)  ←→  写入/读取  ←→  游戏逻辑
clientCloud(备份档)  ←   定期同步   ←   游戏逻辑
读档时:
  serverCloud 读取成功 + 校验通过 → 使用
  serverCloud 读取失败 / 校验不过 → 从 clientCloud 恢复
为什么是"异构"?
serverCloud 和 clientCloud 是两条完全独立的存储链路:对比项serverCloudclientCloud存储位置服务端(云端)客户端本地 + 云同步写入时机关键操作后 + 定时60秒与主存档错开,比如120秒写入代码路径服务端 serverCloud:Set()客户端 clientCloud:BatchSet()受服务端BUG影响是否(客户端独立写入)受客户端篡改影响否可能(见下方防篡改章节)关键点:两套存储的写入时机不同、代码路径不同、受影响的故障域不同。 一方出问题时,另一方大概率还是好的。补档触发流程读 serverCloud
  ↓
JSON解码成功? ──否──→ 读 clientCloud 备份
  ↓ 是                       ↓
版本号存在? ──否──→ 读 clientCloud 备份
  ↓ 是                       ↓
关键字段完整? ─否──→ 读 clientCloud 备份
  ↓ 是                       ↓
数值合法? ────否──→ 读 clientCloud 备份
  ↓ 是                       ↓
正常加载              clientCloud 校验
                           ↓
                      通过 → 使用备份 + 回写 serverCloud
                      不通过 → 初始状态(新档)
备份写入策略lua
复制-- 备份存档不是每次都写,而是:
-- 1. 与主存档错开时间,避免同时写入同一份坏数据
-- 2. 只在主存档校验通过后,才把同一份数据写入备份
-- 3. 备份频率低于主存档(比如每120秒或每次安全退出时)
function SaveSystem.WriteBackup(saveData)
    -- 前提:saveData 已通过 validateNumbers 校验
    local jsonStr = cjson.encode(saveData)
    clientCloud:BatchSet(
)
        :SetString("save_backup", jsonStr)
        :SetInt("backup_version", saveData.save_version)
        :SetInt("backup_time", os.time(
))
        :Save("backup", { ... })
end
关键设计:备份只接受校验通过的数据。 如果主存档在写入前发现数据异常(NaN、结构不完整),不仅不写主存档,也不更新备份——保持备份是"上一次好的状态"。
horizontal linehorizontal line
五、客户端存档防篡改风险clientCloud 的数据在客户端可见,理论上玩家可以修改。如果补档机制无脑信任客户端数据,玩家就可以伪造一份"金币999999"的备份档,然后触发补档流程白捡数据。防篡改策略策略一:哈希校验存档写入时,对数据计算哈希,哈希值和数据一起存:lua
复制-- 写入备份时
local jsonStr = cjson.encode(saveData)
local hash = computeHash(jsonStr, SECRET_SALT)
clientCloud:BatchSet()
    :SetString("save_backup", jsonStr)
    :SetString("backup_hash", hash)
    :Save(...)
-- 读取备份时
local jsonStr = values["save_backup"]
local hash = values["backup_hash"]
if computeHash(jsonStr, SECRET_SALT) ~= hash then
    -- 数据被篡改,拒绝使用
    return nil
end
盐值(SECRET_SALT)不要硬编码在客户端代码里,可以在服务端生成后下发,或者用 userId 派生。策略二:服务端二次验证补档恢复后,不直接使用全部数据,而是让服务端对关键数值做合理性检查:lua
复制-- 服务端收到客户端补档数据后
function validateBackupData(data)
    -- 数值上限检查
    if data.mogu > MAX_REASONABLE_MOGU then return false end
    if data.stamina > MAX_STAMINA then return false end
    -- 装备品质检查(不可能全是红色品质)
    local redCount = countRedEquipment(data.equipmentData)
    if redCount > REASONABLE_RED_LIMIT then return false end
    return true
end
策略三:时间戳比对备份档携带写入时间戳,补档时检查时间戳的合理性:lua
复制-- 备份时间不应该在未来
if backupTime > os.time() + 60 then
    return false  -- 时间戳被篡改
end
-- 备份时间不应该和最后一次主存档差太远
if math.abs(backupTime - lastServerSaveTime) > 86400 then
    -- 超过一天的时间差,可能是旧备份或伪造
    -- 标记为可疑,可以使用但降级处理
end
实际建议对于大多数小游戏,策略一(哈希校验)+ 策略二(关键数值上限检查)已经足够。完全防住专业逆向者是不现实的,但能挡住绝大多数"随便改改试试"的情况。
horizontal linehorizontal line
六、完整架构总结┌─────────────────────────────────────────────┐
│                  游戏逻辑                     │
│  MarkDirty() → 脏标记 → 定时/关键事件触发保存  │
└─────────────┬───────────────────┬────────────┘
              │                   │
         构建存档数据          构建存档数据
              │                   │
        ┌─────▼─────┐      ┌─────▼──────┐
        │ 数值校验   │      │  数值校验   │
        │ NaN/inf?  │      │  NaN/inf?  │
        │ 结构完整?  │      │  结构完整?  │
        └─────┬─────┘      └─────┬──────┘
              │ 通过              │ 通过
        ┌─────▼─────┐      ┌─────▼──────┐
        │serverCloud│      │clientCloud │
        │  主存档    │      │  备份档    │
        │ (单KEY)   │      │ (单KEY)   │
        └─────┬─────┘      └─────┬──────┘
              │                   │
              └────────┬──────────┘
                       │
              ┌────────▼────────┐
              │    读档流程      │
              │                 │
              │ 1. 读 server    │
              │ 2. 校验通过?    │
              │    是 → 使用     │
              │    否 → 读client │
              │ 3. client校验   │
              │    通过 → 补档   │
              │    不过 → 新档   │
              └─────────────────┘
三层防线层级防什么怎么防第一层:写入前校验防止坏数据进入存档NaN/inf检查、结构校验、类型校验第二层:单KEY原子读写防止半写入导致的不一致一个JSON、一次Set/Get第三层:clientCloud异构备份防止主存档不可用独立存储链路、错开写入时机对比方案选择方案防写入中断防逻辑BUG坏档防服务端故障额外成本多KEY分散存差差差低单KEY好中(写入前校验)差低单KEY + 服务端多副本好差(坏数据覆盖所有副本)中高(配额翻倍)单KEY + clientCloud备份好好(异构隔离)好中
七、最后存档系统不出事的时候谁都不关心它,出事的时候就是最大的事故。以上方案不一定是最优解,但核心思路应该是通用的:
  1. 合并:减少存储单元数量,降低不一致风险
  2. 校验:写入前拦截坏数据,读取后验证再使用
  3. 异构备份:不同来源、不同时机、不同链路的备份才有意义
5
3
1