存档系统踩坑经验总结(serverCloud + C/S 架构)
精华04/01104 浏览开发心得
存档系统踩坑经验总结(serverCloud + C/S 架构)
==================================================
项目开发过程中遇到的真实 Bug,
适用范围:UrhoX + serverCloud 的 C/S 多人游戏项目。
---
一、问题清单总览
| # | 问题 | 现象 | 根因 | 严重程度 |
|---|------|------|------|----------|
| 1 | inflight 卡死导致存档停写 | 玩家数据永久丢失 | BatchSet 回调丢失,inflight 永不清空 | 致命 |
| 2 | 断线时脏数据未持久化 | 重连后回档 | 防抖窗口内断线,数据只在内存没写云端 | 严重 |
| 3 | 货币复制漏洞 | 钻石/金币被刷 | 商店扣款后客户端旧值覆盖服务端 | 严重 |
| 4 | 钻石购买材料回档 | 材料消失 | 部分材料发放只改内存没走 MoneyAdd | 严重 |
| 5 | 商店操作后 ISCORE 未标脏 | 药水数量回档 | buy/ad 修改了 ISCORE 字段但没标脏 | 中等 |
| 6 | ISCORE 被客户端旧值覆盖 | 药水/天赋点回档 | 同步消息携带旧值覆盖服务端权威值 | 中等 |
| 7 | 重连重复加载 | 数据错乱 | 异步加载期间客户端重试触发二次加载 | 中等 |
---
二、逐个问题详解
========================================
问题 #1: inflight 卡死 — 存档系统的头号杀手
========================================
【现象】
玩家持续游戏,但所有数据变更不再写入云端。
即使触发 Flush,数据也只停留在内存。
重连后加载的是很久以前的旧数据。
【根因】
ServerSave 采用双缓冲设计:inflight(正在写入)和 dirtySlots(等待下一轮)。
Flush 时检查 `if self.inflight_ then return end`,有 inflight 就跳过。
如果 BatchSet 的回调因为网络异常**永远不返回**,inflight 永不清空,
后续所有 Flush 调用都被跳过,存档系统等于停摆。
【关键代码路径】
```
Flush()
→ self.inflight_ = payloads -- 标记 inflight
→ batch:Save(callbacks) -- 发起异步写入
→ 如果回调丢失 → inflight_ 永不清空
→ 下一次 Flush() → if self.inflight_ then return → 永远跳过
```
【教训】
1. 异步操作必须有超时机制,不能无限等待回调
2. inflight 应设超时上限(如 30s),超时后强制清空并重新标脏
3. 增加 inflight 持续时间监控,超过阈值打 ERROR 日志
【建议防御代码】
```lua
-- 在 Update 中检测 inflight 超时
if self.inflight_ and self.inflightStartTime_ then
local elapsed = os.clock() - self.inflightStartTime_
if elapsed > 30 then
print("[ServerSave] ERROR: inflight timeout, force reset")
-- 将 inflight 的槽重新标脏
for slotKey in pairs(self.inflight_) do
self.dirtySlots_[slotKey] = true
end
self.inflight_ = nil
self.inflightStartTime_ = nil
end
end
```
========================================
问题 #2: 断线时脏数据未持久化
========================================
【现象】
玩家在防抖间隔(5s)内断线,最近的操作全部丢失。
例如:刚捡了一把传奇装备,2 秒后断线,重连后装备消失。
【根因】
防抖机制的盲区:脏数据还在 dirtySlots_ 中等待下一次 Flush,
但玩家已经断开,Update 不再调用,数据永远不会写入。
旧的 FlushOnDisconnect 实现是:标脏全部 → 清空 inflight → 调 Flush()。
问题是 Flush() 依赖 self.player_ 引用来序列化,而断线后 player 引用可能已失效。
【解决方案】
断线时立即**同步快照**当前数据(不依赖后续 player 引用),
然后用独立的重试写入函数异步提交:
```lua
function ServerSave:FlushOnDisconnect()
-- 1. 同步快照:立即序列化全部数据
local payloads = {}
for _, slotKey in pairs(SaveKeys.SCORE) do
payloads[slotKey] = SaveSerializer.SerializeSlot(slotKey, player, extra)
end
-- 2. 清除常规状态(已快照)
self.dirtySlots_ = {}
self.inflight_ = nil
-- 3. 独立重试写入(数据已快照,不依赖 player 引用)
self:WriteWithRetry_(payloads, iscoreValues, MAX_RETRY)
end
```
【教训】
1. 断线保存必须先快照再写入,不能依赖后续引用
2. 断线写入要有独立重试,不复用常规 Flush 流程
3. 客户端 Stop() 中也要做最后一次状态同步
========================================
问题 #3: 货币复制漏洞(Money delta 竞争条件)
========================================
【现象】
玩家在商店花 100 钻石买药水,但钻石没减少,药水也拿到了。
或者:花钻石买金币后,金币翻倍了。
【根因:时序竞争】
```
时间线:
T=0s 服务端: diamonds=1000, 商店操作: diamonds-=100 → diamonds=900
T=2s 客户端定时同步: 发送 diamonds=1000(旧值,还没收到商店结果)
T=2s 服务端 HandleSyncPlayerState: delta=1000-900=+100 → MoneyAdd(100)
→ diamonds 被加回 1000!
```
客户端的 5s 定时同步携带的是操作前的旧值,
服务端按 delta 计算认为"玩家赚了 100 钻石",反而加了回去。
【解决方案:时间锁】
商店操作成功后,锁定对应货币字段 12 秒(> 2 个同步周期):
```lua
-- 商店操作成功后
if ok then
moneyLockExpiry_[connKey] = os.clock() + 12
end
-- 同步处理时检查锁
local moneyLocked = moneyLockExpiry_[connKey] and os.clock() < moneyLockExpiry_[connKey]
if moneyLocked then
-- 跳过客户端 delta,保持服务端权威值
else
-- 正常 delta 同步
end
```
【教训】
1. 服务端权威操作(商店/交易)后,必须阻断客户端旧值同步
2. 时间锁 > 2 个同步周期,确保客户端已收到最新值
3. 货币类数据的 delta 同步天然存在竞争,任何修改货币的服务端操作都要考虑
========================================
问题 #4: 钻石购买材料回档
========================================
【现象】
用钻石购买材料(碎布、魔尘、稀有精华等),退出重进后材料消失,
但钻石已经扣除。
【根因】
BuyDiamondItem 事务中,只有 gold 类型走了 `c:MoneyAdd` 原子持久化,
其他材料类型(scraps、magicDust、rareEssence、legendSoul)只调用
GrantDiamondItem_ 修改 player 内存,未写入 serverCloud.money。
```lua
-- 旧代码(错误)
if itemCfg.grantType == "gold" then
c:MoneyAdd(uid, SaveKeys.MONEY.GOLD, itemCfg.grantAmount) -- 只有 gold 持久化
end
ShopSystem.GrantDiamondItem_(player, itemCfg) -- 其他类型只改内存
```
【解决方案】
建立完整的 grantType → MoneyKey 映射,所有类型统一走 MoneyAdd:
```lua
local GRANT_TO_MONEY = {
gold = SaveKeys.MONEY.GOLD,
scraps = SaveKeys.MONEY.SCRAPS,
magicDust = SaveKeys.MONEY.MAGIC_DUST,
rareEssence = SaveKeys.MONEY.RARE_ESSENCE,
legendSoul = SaveKeys.MONEY.LEGEND_SOUL,
}
local grantMoneyKey = GRANT_TO_MONEY[itemCfg.grantType]
if grantMoneyKey then
c:MoneyAdd(uid, grantMoneyKey, itemCfg.grantAmount)
end
```
【教训】
1. 所有货币变更必须走 serverCloud 原子接口(MoneyAdd/MoneyCost),不能只改内存
2. 新增货币类型时,必须同步更新所有发放/消耗路径的持久化映射
3. 事务失败回滚必须覆盖所有已修改的字段,包括发放的材料
========================================
问题 #5: 商店操作后 ISCORE 未标脏
========================================
【现象】
在商店购买药水后重进游戏,药水数量恢复到购买前的值。
【根因】
HandleShopAction 中 buy 和 ad 分支修改了 player 内存数据
(hpPotionCount、mpPotionCount 等 ISCORE 字段),
但未调用 `ss:MarkIscoresDirty()`。
ServerSave.Flush() 只写入标记为脏的数据。
ISCORE 字段没被标脏 → Flush 不会写入 → 重连加载旧值。
```lua
-- 旧代码(错误)
elseif actionType == "buy" then
ok, msg = ShopSystem.BuyItem(player, actionParam)
-- 缺少:ss:MarkIscoresDirty()
-- 修复后
elseif actionType == "buy" then
ok, msg = ShopSystem.BuyItem(player, actionParam)
if ok then
ss:MarkIscoresDirty() -- 药水数量存在 ISCORE 槽
end
```
【教训】
1. 任何修改数据的操作,都必须标记对应的脏槽
2. ISCORE 和 Score 是独立的脏标记,两者互不触发
3. 检查清单:修改了 player 的哪些字段?这些字段存在哪个槽?对应的槽标脏了吗?
========================================
问题 #6: ISCORE 被客户端旧值覆盖
========================================
【现象】
和问题 #3 类似,但发生在 ISCORE 字段上。
商店买了药水后,下一次客户端同步把旧的药水数量覆盖回来。
【根因】
moneyLockExpiry_ 时间锁最初只保护 Money 字段,
没有保护 ISCORE 中商店可修改的字段(hpPotionCount、mpPotionCount、talentPoints)。
客户端的 5s 定时同步仍携带旧的药水数量,
HandleSyncPlayerState 的 ISCORE 区域无条件接受客户端值。
【解决方案】
将 moneyLockExpiry_ 的保护范围扩展到 ISCORE 中商店可修改的字段:
```lua
-- 锁定期内跳过药水/天赋点的客户端同步
if not moneyLocked then
local clientHpPotion = eventData["HpPotionCount"]:GetInt()
-- ... 正常同步
end
```
【教训】
1. 时间锁的保护范围必须覆盖**所有**受服务端操作影响的字段
2. 不仅是 Money,ISCORE、Score 中被服务端修改的字段都需要保护
3. 新增商店操作时,自查:这个操作修改了哪些字段?这些字段在同步时受保护吗?
========================================
问题 #7: 重连重复加载
========================================
【现象】
偶发的数据错乱、状态不一致。
【根因】
异步加载期间(serverCloud BatchGet 还没返回),
客户端因超时重试发送了第二次 ClientReady,
触发第二次异步加载。两次加载的回调交错执行,覆盖彼此的结果。
【解决方案】
用 loadingInProgress_ 标记防止重复加载:
```lua
if loadingInProgress_[connKey] then
print("[Server] INFO: 存档加载中,忽略重复 ClientReady")
return
end
loadingInProgress_[connKey] = true
tempSave:Load(function(success, ...)
loadingInProgress_[connKey] = nil
-- 处理加载结果...
end)
```
【教训】
1. 异步操作必须有守卫,防止重复触发
2. 守卫标记要在回调中清除,无论成功失败
3. 玩家断线时也要清理守卫标记
---
三、通用开发规范(存档相关)
1. 【修改即标脏】
修改 player 任何字段后,立即标记对应的脏槽。
自查三连:改了哪些字段?存在哪个槽?标脏了吗?
2. 【货币必走原子接口】
所有货币变更必须通过 serverCloud.money 的 Add/Cost 接口。
绝不能只改 player 内存中的数值。
新增货币类型时,同步更新所有发放/消耗路径的映射表。
3. 【服务端修改后加锁】
服务端主动修改 player 数据后(商店/交易/奖励),
必须锁定对应字段 > 2 个同步周期,防止客户端旧值覆盖。
4. 【异步操作三件套】
- 超时机制:不能无限等待回调
- 重入守卫:防止重复触发
- 失败重试:带最大次数限制
5. 【断线保存要快照】
断线时立即同步序列化当前数据,不依赖后续的 player 引用。
用独立的重试函数写入,不复用常规 Flush 流程。
6. 【客户端关闭时最后同步】
客户端 Stop() 中执行最后一次 SyncPlayerStateToServer,
减少防抖窗口内的数据丢失。
7. 【新增功能自查清单】
每次新增涉及数据变更的功能时,逐项检查:
- [ ] 修改的字段对应哪个存档槽?标脏了吗?
- [ ] 涉及货币吗?走了原子接口吗?
- [ ] 服务端主动修改了吗?同步锁加了吗?
- [ ] 有异步操作吗?超时/重入/重试都有吗?
- [ ] 事务失败时回滚覆盖了所有已修改字段吗?
---
四、架构级经验
1. 【三层分离是基础】
运行时状态(Player)、序列化格式(SaveSerializer)、持久化通道(ServerSave)
三者独立,互不侵入。换存储方案只改 ServerSave,游戏逻辑一行不动。
2. 【分槽存储避免单点故障】
按业务领域分槽(装备、背包、技能、元数据...),
某个槽损坏不会连带其他数据。单槽体积 < 13KB。
3. 【短键名节省配额】
序列化用短键名(guildName → gn),大量数据省 30-40% 体积。
反序列化必须给默认值(data.gn or "默认值"),天然兼容新字段。
4. 【防抖 + 双缓冲是标配】
5s 防抖合并写入,inflight + pending 双缓冲,
generation 计数器防止过期回调干扰。
但必须给 inflight 加超时,否则可能卡死(问题 #1)。



