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


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


二、存档架构:双写 + 三级 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 秒内重连,服务端会直接从队列里捞出最新存档返回给客户端,而不是读云端的旧数据。这避免了"断线重连发现进度回档"的体验问题。


三、防作弊:加了什么、为什么这么加
架构搭好后,安全问题就很清晰了——本地那份 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% 容差
截断到理论最大值
```
这一层主要防的是"断网 → 改存档 → 上线同步"这条攻击路径。就算绕过了本地签名,服务端也会根据时间窗口卡住异常增长。


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


五、实际效果和踩过的坑
效果
上线半个月,通过服务端增量校验拦截到的异常存档约 200+ 次(大部分是同一批用户反复尝试)。签名校验拦截了更多,但没有精确统计(客户端侧直接丢弃走云端了)。
正常玩家完全无感知——断网照常玩,上线自动同步,没有任何额外加载时间。
踩过的坑
- 签名密钥不能太简单:一开始用的是固定字符串,被人提取后批量改档。后来改成设备指纹混合,至少做到"一机一密"。
- 增量校验的阈值要留容差:我们刚上线时阈值卡得太紧,有些极端玩法(比如高爆发 Boss 关一波拿很多币)被误判。后来加了 1.5 倍容差 + 白名单关卡加成。
- 离线时长计算要用服务端时间:一开始用的是客户端 os.time(),结果有人改系统时间绕过。改成"上次服务端成功保存的时间戳"后解决。
- 快速重连时不要重复校验:玩家断线 2 秒内重连,队列里的存档已经校验过了,直接返回就行。如果再校验一次,因为时间差太小容易触发误判。


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


