游戏断网也能玩还不怕作弊?聊聊我的离线存档防篡改方案

前天 13:30134 浏览开发心得
我做的一款武侠 RPG(常驻服架构,支持单机离线游玩),开发过程中遇到一个很经典的矛盾:
玩家断网了还想玩 vs 断网期间的存档怎么防作弊
网上能搜到的方案要么是"全程联网否则踢下线",要么是"离线随便玩上线覆盖",都不太理想。这里分享一下我们最终落地的方案,希望对同样做联网+离线混合架构的朋友有帮助。
horizontal linehorizontal line

一、先说核心矛盾

我们的目标很明确:
  • 电梯里没信号,玩家打到一半不能白打
  • 网络抖动,玩家进度不能丢
  • 但也不能让玩家断网期间随便改存档,上线后把假数据同步到云端
这三个需求同时满足,难点在于:客户端必须有一份可用的本地存档(否则断网没法玩),但本地文件天然是不可信的
horizontal linehorizontal line

二、存档架构:双写 + 三级 Fallback

我们的存档系统分客户端和服务端两部分,核心思路是"每次保存双写,加载时多级兜底"。

2.1 保存流程(每 30 秒自动触发 + 切场景立即触发)

客户端 G 对象序列化为 JSON
        │
        ├──① 写入本地 save_cache.json(必定执行,无论网络状态)
        │
        └──② 发送 C2S_SaveUpdate 到服务端(有网才发)
                │
                服务端收到后 → BatchSet 写入 serverCloud(10 个字段原子写入)
                        │
                        ├── 写入成功 → 标记 dirty=false
                        └── 写入失败 → 保持 dirty=true,下线时入队重试
关键点:本地写入永远是第一步,不依赖网络。就算 serverConn_ 是 nil,本地缓存也一定会更新。

2.2 加载流程(三级 Fallback)

客户端发送 C2S_SaveLoad
        │
        ├── 服务端正常返回 status="loaded" ──→ 使用云端数据(同时刷新本地缓存)
        │
        ├── 服务端返回 "no_save" / "error" ──→ 读本地 save_cache.json 兜底
        │
        └── 8 秒超时无响应 ──→ 读本地缓存,进入离线模式
这套 Fallback 保证了一件事:无论服务器炸了、网络断了、还是云端数据损坏了,玩家都能进游戏

2.3 服务端离线写入队列

玩家突然断线时,可能还有脏数据没写入云端。我们在服务端做了一套离线写入队列:
玩家断线
   │
   ├── 尝试立即写入 serverCloud
   │       │
   │       ├── 成功 → 数据安全落云
   │       └── 失败(429限流)→ 降级入队
   │
   └── 入队后由定时器驱动(每 0.3 秒消费一条)
           │
           ├── 去重合并:同一玩家只保留最新存档
           ├── 429 重试:最多 5 次,每次间隔 2 秒
           └── 兜底刷写:每 5 分钟扫描所有脏缓存强制入队
还有一个细节:快速重连拦截。如果玩家断线后 3 秒内重连,服务端会直接从队列里捞出最新存档返回给客户端,而不是读云端的旧数据。这避免了"断线重连发现进度回档"的体验问题。
horizontal linehorizontal line

三、防作弊:加了什么、为什么这么加

架构搭好后,安全问题就很清晰了——本地那份 save_cache.json 是明文 JSON,能改。我们的防御分三层:

第一层:本地存档签名校验(防文件篡改)

本地缓存写入时,对 JSON 内容做 HMAC-SHA256 签名,签名密钥由设备指纹 + 硬编码盐混合生成。读取时验签,不匹配则丢弃本地缓存,强制走云端加载。
写入: save_cache.json + save_cache.sig
读取: 验证 HMAC(json_content, device_key + salt) == sig
      不匹配 → 丢弃本地缓存,走云端
这一层的目标不是"绝对安全"(逆向总能找到密钥),而是拦截 90% 的手动改文件行为。大部分玩家不会反编译客户端去提取签名密钥。

第二层:服务端关键字段增量校验(防数值飞跃)

服务端收到 C2S_SaveUpdate 时,不再无脑信任客户端 JSON。对货币、等级、经验这类核心数值做增量校验:
服务端 liveCache 中有上一次的存档快照
   │
   新存档到达 → 对比关键字段变化幅度
   │
   ├── coins 增长 > 单次理论上限 → 拒绝,回滚到上次快照
   ├── level 跳跃 > 1 → 拒绝
   ├── gold(元宝)客户端不允许自行增长 → 拒绝
   └── 正常范围内 → 接受,更新快照
具体来说,我们维护了一张字段白名单 + 变化阈值表
| 字段 | 允许方向 | 单次最大变化 | 超限处理 |
|------|---------|-------------|---------|
| coins(银两) | 增/减 | +50000 / 次 | 截断到阈值 |
| gold(元宝) | 仅减少 | 增长一律拒绝 | 回滚到服务端值 |
| level | 仅增长 | +1 / 次 | 截断 |
| exp | 增/减 | +当前升级所需经验 | 截断 |
元宝比较特殊——只有服务端的充值/商城流程能增加元宝(通过 ShopServer 的 quota:Add 原子操作),客户端存档里的元宝字段只允许减少(消费)。加载存档时,服务端还会用 iscores 里的商城货币数据覆盖存档中的值,形成双重校验。

第三层:离线时长 + 收益上限(防挂机刷)

如果玩家离线时间过长(比如离线 2 小时后上线),服务端会计算"离线期间理论最大收益",超出部分截断:
```
离线时长 = 当前时间 - 上次云端保存时间
理论最大收益 = 离线时长 × 每秒最大收益率
实际增量 = 新存档值 - 旧存档值
if 实际增量 > 理论最大收益 × 1.5:  -- 留 50% 容差
    截断到理论最大值
```
这一层主要防的是"断网 → 改存档 → 上线同步"这条攻击路径。就算绕过了本地签名,服务端也会根据时间窗口卡住异常增长。
horizontal linehorizontal line

四、商城货币的特殊处理

元宝(付费货币,我这里是看广告获得)的安全级别最高,我们做了完全的服务端权威:
  1. 充值:客户端发起 → 服务端 HandleRecharge 验证 → quota:Add 原子增加到 iscores
  2. 消费:客户端发起购买请求 → 服务端校验余额 → 扣减 → 返回结果
  3. 存档同步:加载存档时,iscores 中的元宝/银两覆盖存档 JSON 中的值
也就是说,元宝的"真相源"始终在服务端的 iscores 里,客户端存档中的元宝字段本质上只是一个"显示用缓存"。
horizontal linehorizontal line

五、实际效果和踩过的坑

效果

上线半个月,通过服务端增量校验拦截到的异常存档约 200+ 次(大部分是同一批用户反复尝试)。签名校验拦截了更多,但没有精确统计(客户端侧直接丢弃走云端了)。
正常玩家完全无感知——断网照常玩,上线自动同步,没有任何额外加载时间。

踩过的坑

  1. 签名密钥不能太简单:一开始用的是固定字符串,被人提取后批量改档。后来改成设备指纹混合,至少做到"一机一密"。
  2. 增量校验的阈值要留容差:我们刚上线时阈值卡得太紧,有些极端玩法(比如高爆发 Boss 关一波拿很多币)被误判。后来加了 1.5 倍容差 + 白名单关卡加成。
  3. 离线时长计算要用服务端时间:一开始用的是客户端 os.time(),结果有人改系统时间绕过。改成"上次服务端成功保存的时间戳"后解决。
  4. 快速重连时不要重复校验:玩家断线 2 秒内重连,队列里的存档已经校验过了,直接返回就行。如果再校验一次,因为时间差太小容易触发误判。
horizontal linehorizontal line

六、方案总结

┌─────────────────────────────────────────────────┐
│                   客户端                         │
│                                                 │
│  G 对象 ──序列化──→ JSON ──签名──→ 本地缓存     │
│     │                               │           │
│     └──────── 发送到服务端 ──────────┘           │
└─────────────────────┬───────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────┐
│                   服务端                         │
│                                                 │
│  收到 JSON → 增量校验 → 异常截断/正常放行        │
│     │                                           │
│     ├── 在线:写入 serverCloud + 更新 liveCache  │
│     └── 离线:入队 → 去重合并 → 定时消费写入     │
│                                                 │
│  元宝/银两:iscores 原子操作,加载时覆盖存档     │
└─────────────────────────────────────────────────┘
核心原则就一句话:
可用性靠客户端兜底,安全性靠服务端把关。本地存档让你能玩,服务端校验让你别想骗。
如果有类似架构需求的朋友欢迎交流,特别是关于离线队列的去重合并和 429 限流重试这块,细节还挺多的。
6
5
1