下载 App

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

精华04/26130 浏览开发交流
一个从多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. 异构备份:不同来源、不同时机、不同链路的备份才有意义
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
82 赞
23 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
126 赞
44 回复
开发日志(day2)坚持去做就是好的开始截图
开发日志(day2)坚持去做就是好的开始
今天主要是修复了一下昨天遇到的很多问题,闪帧;装备的等级没有随怪物等级参考;再次进入地下城,怪物状态竟然显示(已倒下);不会自动更换目标,导致会反复鞭尸;等很多的问题,不过通过昨天的经验,有效的向ai提出问题并且发送截图发给ai调试,也是把这些问题成功排除,甚至还有多余的free额度来生成新的内容。 新增的内容有返回城镇;返回主界面;中途退出地下城保留进入前的状态;完成地下城的面板结算;当然最有感
1 赞
游戏自荐《少女终焉契约》截图
游戏自荐《少女终焉契约》
之前玩放置类型的rpg游戏的时候我就在想,现在游戏的操作有的太过于繁琐了,有没有一款休闲一点的,想上线就上线,离线的时候角色能自动行动的游戏呢?感谢TapTap制造,我按照我的想法开发出了现在这款游戏。 一、故事背景和UI 游戏的故事背景是传统的异世界穿越故事,玩家被女神召唤到异世界,随后组建工会,招募美少女,打怪升级探索世界。所以我选择了更加二次元萌系画风的UI风格,主页为各个主要的功能,点开来
8 赞
3 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
永恒虚空持续开发日记1——关于游戏中的新名词:场频截图
永恒虚空持续开发日记1——关于游戏中的新名词:场频
写在前边:首先感谢TapTap制造版主大大@汪崽 对于作品潜力的肯定,给与了我积分计划二的权限[表情_不好意思]。这边也在加紧开发改进各种功能中,期望尽快给大家带来成熟的版本。[表情_猫咪举手] 大家好,我是永恒虚空的制作人黑猫不详,大家叫我黑猫就好。从今天开始我将以后置开发日记的形式给大家分享我在TapTap制造中制作的游戏永恒虚空的开发过程,新人希望大家多多关照! 今天先给大家介绍我作品中最重
投票
3 赞
关于制作出质量很高的横版战斗角色的方法截图
关于制作出质量很高的横版战斗角色的方法
其实全篇就一句话,角色为啥做起来这么好看,因为我一帧一帧K的。 在做角色之前,我们需要一个角色的设计稿,这个东西很简单,你把人设写好剩下的交给AI就行了,所有图片,序列帧都可以让AI绘制和处理,学会绘图是你要制作高质量游戏的第一课。 这里拿我在做的角色举例,他的参考图是这样: 当你拥有了一个角色以后,就要想办法让她动起来了。 那么如何让她动起来,答案是序列帧。 在此之前你需要知道,最容易出现一个误
42 赞
12 回复
【UI教程】如何将已有的效果图应用为实际的UI界面截图
【UI教程】如何将已有的效果图应用为实际的UI界面
在经过了一些折腾和比较折磨的尝试后,我总算是摸清楚了嗒啦啦怎么把UI效果图转换为实际游戏中的UI组件,今天就给大家带来这部分的经验分享。 tips:1.本篇教程需要各位掌握最基本的ps使用,具体的操作部分我会大致讲解,但仍需各位自行学习ps的基本操作,部分可以由ai代劳的环节我会注明。 2.本篇教程需要在以我前两篇教程作为前置环节,请在阅读这两篇前置教程后再进行本篇教程内容的学习 在按照前
12 赞
5 回复
【Tap make 塔拉拉之立项干货】萌新看过来!截图
【Tap make 塔拉拉之立项干货】萌新看过来!
题外话:本人也是tap make 使用者之一。塔啦啦忠实信徒、天哥忠实拥护者、八年游戏策划经验的低级策划。但是!我已经做出来一款5000+热度、且稳定可更新的框架、并且还在持续产出更多游戏内容的橘猫! 这几天正好在开新坑。我是3月初加入的tapmake大家庭。两周时间于3月25日将这个作品上架。当时的全凭感觉摸索。如今,自己也是一个有自己项目的小菜鸡了。今天,就来分享一下,如何高效的制作一款高大上
投票精华
69 赞
24 回复
本地部署 AI 对接 TapMaker 指南 —— 以 CodeBuddy 为例V1.1截图
本地部署 AI 对接 TapMaker 指南 —— 以 CodeBuddy 为例V1.1
写在前面 大家好,我是橘猫,和你一样也是TapMaker的萌新玩家。在"全能神"天哥的悉心指导下,我顺利完成了本地开发环境的部署。经过这段时间的实战测试,已经成功打通了「本地开发 → 同步至TapMaker」的完整流程。 以下内容全部来自我的个人实操经验,旨在为同样在探索本地部署的朋友提供一个参考。每个人的电脑环境、网络情况、AI工具版本都不尽相同,文中的步骤仅供交流学习。如果你照着操作后遇到问题
37 赞
21 回复