下载 App

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

精华04/26128 浏览开发交流
一个从多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 做了大量无效/重
精华
65 赞
20 回复
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
27 赞
6 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
122 赞
44 回复
01:27
《大二暑假,我用AI做了一款属于自己的游戏》截图
《大二暑假,我用AI做了一款属于自己的游戏》
本人自制游戏现已加入taptap封闭测试(仅适配安卓机型)欢迎大家前来尝试(八月十号可以正式开始游玩) 如果有建议或者bug反馈可以加入qq群928501443 #发现好游戏 #游戏日常 #浅评一下 #今天游戏圈发生了啥
3 赞
开发心得-界面布局心得截图
开发心得-界面布局心得
大家好,我正在用 TapTap 制造制作一款2D 治愈林间徒步旅行游戏《徒步荒野》。 我分享一下我的开发心得,希望能有用,下面是一些界面布局的心得。 第一步:定版式、操作 先确定游戏是横版还是竖版、需要哪些操作按钮,敲定整体玩法思路。 我的玩法灵感参考《边境之旅》,主打散步看风景、沿途触发随机事件。 原版是 3D,考虑到自己是新手,选择2D 横版街机式来做,制作更简单、好上手。 第二步:敲定全局美
6 赞
2 回复
嗯,真的,随便了(并不是什么下台阶)截图
嗯,真的,随便了(并不是什么下台阶)
只是觉得,为以前这样让朋友推荐这个平台的我,感到。。。无所谓了。你们喜欢吧。
1 赞
3 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
游戏自荐《少女终焉契约》截图
游戏自荐《少女终焉契约》
之前玩放置类型的rpg游戏的时候我就在想,现在游戏的操作有的太过于繁琐了,有没有一款休闲一点的,想上线就上线,离线的时候角色能自动行动的游戏呢?感谢TapTap制造,我按照我的想法开发出了现在这款游戏。 一、故事背景和UI 游戏的故事背景是传统的异世界穿越故事,玩家被女神召唤到异世界,随后组建工会,招募美少女,打怪升级探索世界。所以我选择了更加二次元萌系画风的UI风格,主页为各个主要的功能,点开来
8 赞
3 回复
塔拉拉20万分经验分享截图
塔拉拉20万分经验分享
前言 不是教程也不是官方,只是分享一下自己开发过程中遇到的问题和经验。 自上个月拿到资格后,便废寝忘食开始了塔拉拉的无代码做游戏探索,一开始想做一个简单的游戏先试试效果,然后就开始了一个本来是小游戏却消耗近20w分的项目,先说说这其中遇到的问题和积分消耗大户。 一、开始开发 本地新建文件夹,写好你这个游戏的策划案,主文件介绍核心玩法(重中之重)、美术风格,然后分文件介绍游戏中的各种功能/系统,做好
74 赞
19 回复
感谢官方大大真的让我通过了截图
感谢官方大大真的让我通过了
我抱着试一试的心态,用了这个聚光灯活动,活动免费白嫖的10万积分,做我不擅长的叙事游戏,我跟官方审核说清楚原因,升级开发者计划二用来做新游戏,没想到真的通过,牛牛牛( ఠൠఠ )ノ点赞点赞👍,效率真高,不过我记得开发者计划二以前不是有每日积分的吗,怎么只剩下周期积分了[表情_疑惑][表情_疑惑]
15 赞
16 回复