游戏丢档坏档?单KEY合并+clientCloud补档方案全解
精华04/2661 浏览开发心得
一个从多KEY分散存档到单KEY合并、再到客户端异构备份的存档架构演进记录。
适用于使用 serverCloud / clientCloud 的小游戏开发者。


前言
做游戏最怕什么?玩家丢档。辛辛苦苦打了几十层塔、刷了一背包装备,结果重新上线发现存档没了,或者数据错乱——这对玩家体验是毁灭性的。我在开发过程中踩过存档的坑,从最初的"多个KEY各存一部分"到现在的"单KEY原子读写 + clientCloud异构备份",中间经历了几次架构调整。这篇文章把整个思路理清楚,希望对同样用云变量做存档的开发者有参考价值。


一、多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是否存在、是否是旧格式,逻辑非常碎。


二、单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 直接崩


四、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、结构不完整),不仅不写主存档,也不更新备份——保持备份是"上一次好的状态"。


五、客户端存档防篡改风险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
实际建议对于大多数小游戏,策略一(哈希校验)+ 策略二(关键数值上限检查)已经足够。完全防住专业逆向者是不现实的,但能挡住绝大多数"随便改改试试"的情况。


六、完整架构总结┌─────────────────────────────────────────────┐
│ 游戏逻辑 │
│ 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备份好好(异构隔离)好中
七、最后存档系统不出事的时候谁都不关心它,出事的时候就是最大的事故。以上方案不一定是最优解,但核心思路应该是通用的:
- 合并:减少存储单元数量,降低不一致风险
- 校验:写入前拦截坏数据,读取后验证再使用
- 异构备份:不同来源、不同时机、不同链路的备份才有意义



