跳转到内容

第 20 章 · 版本号、序号与幂等

约 9 分钟 难度:3 动手章

这一章解决:闭环 2 的两个故意保留的缺陷(重复加分 / 状态版本乱)背后是同一类问题。网络上每一次包都可能重复、乱序、迟到,写代码时如果默认「我每次只收到一份、按发出顺序、立刻收到」,bug 就会随机出现。

先看一下

闭环 2 缺陷 1:长按 Score 按钮分数加得比预期快。本地冷却挡了正常玩家的手抖,挡不住改客户端,也挡不住「先按一下后又因为 UI 刷新延迟再按一下」的真实重复。

闭环 2 缺陷 2:反复 Restart → Start → Score 时,远端客户端短暂看到 phase=INGAMEtotalScore=0phaseStartServerTime 还是上一局的时间。OnDeserialization 在一帧里把这三个字段中的某一两个先应用了,UI 看到中间态。

两个缺陷的共同结构:网络上的事件不是「按发出顺序、各自独立、恰好一次」到达。这一章给三个工具,每个解决一类不一致。

这一章会拿到什么

  • requestId 单调递增 + lastProcessedRequestId 数组:请求幂等
  • stateVersion:让所有 GameState 字段一起跨版本的整体性保护
  • OnDeserialization 里的 gating 模式:客户端只在 stateVersion 变化时应用整批字段
  • 三个工具各自适合的场景,以及为什么不能合并成一个工具

依赖前面

  • 第 12 章 requestId / requestType / requestPayload 字段命名
  • 第 12 章 lastProcessedRequestId[] 数组(按 playerId 索引)
  • 闭环 2 故意保留的三个缺陷
  • 第 19 章状态同步 vs 命令同步的判断维度

写代码前先把问题切开。下面三类听起来像「网络不稳」,背后机制不同,需要的工具也不同。

重复:同一条消息被处理了两次。可能是发送方在 1 帧内连续发了两次(UI 双击),可能是接收方在 RequestSerialization 失败重试时把同一份字段值再应用了一次,也可能是 Owner 转移期间新旧 Owner 都处理了同一个请求。幂等是防它的工具。

乱序:A 客户端先发的请求比 B 客户端后发的请求晚到。同步变量的写权归属、SendCustomNetworkEvent 的派发顺序在多客户端场景下都不保证全局有序。版本号 + 单调比对是防它的工具:丢弃比已知最新版本旧的请求。

整体性破裂GameState 上五个字段一起改,但远端客户端在一帧里看到「三个字段是新值、两个还是旧值」的中间态。OnDeserialization gating 是防它的工具:所有字段同时归属于一个版本号,客户端只在版本号变了的那一刻才应用整批。

闭环 2 缺陷 1 是重复,缺陷 2 是整体性破裂。两个用同一套工具修不对。


闭环 2 已经埋了 requestIdlastProcessedRequestId[] 的字段,但 HandleScore 里没用上。本节补完这一段。

每位玩家在自己 PlayerLobbyState 上有一个 [UdonSynced] int requestId,从 0 开始。每次 IssueRequest 调用时 requestId++ 然后 RequestSerialization

GameState 上有一个 [UdonSynced] int[] lastProcessedRequestId,按受控玩家槽位索引。GameState Owner 在轮询每位玩家请求时,先把 playerId 转成 slot 并检查范围,再对比当前 playerLobbyState.requestId > lastProcessedRequestId[slot],只有「真的更新了」才处理。

// GameState · 请求轮询的核心判断(6 行)
int slot = GetPlayerSlot(who.playerId);
if (slot < 0) return;
int reqId = pls.RequestId;
if (reqId <= lastProcessedRequestId[slot]) return; // 重复,跳过
DispatchByType(pls.RequestType, pls.RequestPayload, who);
lastProcessedRequestId[slot] = reqId;
RequestSerialization();

只要 lastProcessedRequestId[slot] 这条记忆是对的,重复请求都会被这一行 return 拦掉。

修了:同一玩家同一个请求被处理两次(UI 双击 / 字段被重发 / Owner 转移期间双处理)。

不修:同一玩家两个不同请求被处理两次(玩家手动按了两下 Score 按钮,按出两个不同的 requestId,每个都是合法请求)。这种「玩家真的连续按了两次」的场景要靠冷却挡,而冷却分两层:

  • 本地冷却PlayerLobbyStaterequestCooldown):挡正常玩家手抖。
  • GameState 端冷却GameStatelastHandleTime[playerId]):挡改客户端。

闭环 2 已经写了本地冷却,缺 GameState 端冷却。闭环 3 会补。

  • 数组长度:和场景里玩家上限一致,外面包一层 GetPlayerSlot(playerId)。不要用 playerId % length 取模写入,它会把不同玩家压到同一槽。最小实现可以直接检查 0 <= playerId < length,更稳的实现是按加入顺序维护映射表。
  • 玩家离开OnPlayerLeft 时清理或冻结这一槽。清理适合「离开就丢弃本局请求状态」;冻结适合还要保留结算日志的玩法。关键是不要让旧请求序号影响新加入者。
  • 迟入玩家:迟入玩家进来时 requestId=0,他第一次 IssueRequest 拿到 requestId=1,对比自己的槽位为 0 时通过;如果槽位里残留旧值,他第一次请求会被丢。

闭环 2 的 GameState 字段(phase / totalScore / phaseStartServerTime)改的时候各自独立,没人保证它们「一起切到下一版」。引入一个统一计数器:

// GameState(闭环 3 会真正落地这个字段)
[UdonSynced] private int stateVersion;
// 每次有 GameState 字段被改时(闭环 3 会用 manual sync):
private void OnPreSerialization()
{
stateVersion += 1;
}

这一字段不参与 UI 渲染,只是「这一批字段属于哪个版本」的标签。stateVersion 单调递增。所有 GameState 字段的语义合约变成「字段值有效,前提是 stateVersion 是当前最新值」。

切到 manual sync 后,OnPreSerializationRequestSerialization 实际发送前的本帧执行一次。这一时机给「打版本号」一个稳定入口:每次 Owner 决定要发送一批字段时,版本号自动递增。

OnPreSerialization 是 Owner 本地的回调,它只在 Owner 调用 RequestSerialization 后才会跑。也就是说版本号只在「真的有一次发送」时才递增,本地改了字段但没调 RequestSerialization 是不会涨的。这一点把「字段改了」和「字段被发出去了」区分开。

继续保留 continuous sync 的对象不适合这套版本号:continuous 自身按帧节奏检查变更并发送,没有「这次到底要不要发」的明确入口。把 stateVersion 用在 manual sync 对象(GameState)上;PlayerLobbyState 虽然也是 manual sync,但它是每位玩家自己的请求对象,不需要把多个全局字段打成一批。

新接管的 Owner 会怎么算 stateVersion

候选队列接管时,新 Owner 在 OnOwnershipTransferred继承当前 stateVersion 值(同步变量本身就传过来了),下一次 OnPreSerialization 就是 stateVersion + 1。版本号全局单调,跨 Owner 转移仍然递增。

这一点和「时间戳」不同:Networking.GetServerTimeInSeconds() 是物理时间,可能在 Owner 转移瞬间往回跳一两毫秒。stateVersion 不会跳。任何「我看到的状态比之前看到的旧」都可以靠版本号比较直接判断,不靠时间。


有了版本号,远端客户端的应用逻辑就可以改成「只在版本变化时整批应用」。

// 远端客户端(非 Owner)
private int lastAppliedStateVersion = -1;
public override void OnDeserialization()
{
if (stateVersion == lastAppliedStateVersion) return; // 版本没变,跳过
lastAppliedStateVersion = stateVersion;
ApplyState(); // 把 phase / totalScore / phaseStartServerTime 一起应用到 UI
}

这一段「门」做了两件事。

第一,整体性保护。同一帧里 OnDeserialization 可能因为字段变更触发,但 stateVersion 还没变(没到下一次 RequestSerialization),整批字段不会被分批应用。即使 VRChat 网络层在某一帧只把部分字段同步过来,UI 也不会被中间态污染。

第二,重复应用保护。Owner 转移、迟入恢复都会重新触发 OnDeserialization,但只要 stateVersion 没变,UI 就不会重渲染。

requestId 幂等保护请求处理:同一玩家发出的同一请求,GameState Owner 端只处理一次。

stateVersion gating 保护状态应用:远端客户端把字段批量应用到 UI 时只应用一次。

两者一上一下,缺一不可。只用 requestId 不够:Owner 端处理对了,但远端客户端可能在中间态下渲染。只用 stateVersion 也不够:Owner 端可能把同一请求处理了两次,导致 totalScore += 1 跑了两次,版本号涨成 +2,远端只看到一次 OnDeserialization,但分数已经多了 1。



工具解决不解决落地章
requestId 幂等同一玩家同一请求被重复处理玩家真的连按两次(不同请求)第 12 章已埋 / 闭环 3
GameState 端冷却玩家高频发送同类型请求(含改客户端)单次合法请求被重复处理闭环 3
stateVersion + gatingUI 看到中间态、远端重复应用Owner 端处理逻辑本身的 bug闭环 3

闭环 2 三个缺陷里:

  • 缺陷 1 重复加分:根源是「玩家高频发送」+「HandleScore 没幂等也没冷却」。修法:requestId 幂等 + GameState 端冷却。
  • 缺陷 2 状态版本乱:根源是没有版本号。修法:stateVersion + gating。
  • 缺陷 3 玩家串味:和这三个工具无关,靠 OnPlayerLeft 显式清理回调修。闭环 3 一并补。

挑一条试。

  • lastProcessedRequestId[] 数组改成 lastProcessedRequestId 单个 int(共用),跑闭环 2 看 A 和 B 同时按 Score 时会发生什么。能否复现「B 的请求被 A 的 requestId 拦掉」的现象?
  • 闭环 2 上手动加 stateVersion 字段,但OnDeserialization gating(仍然让字段直接驱动 UI)。版本号字段值有什么用?什么时候这一字段开始有意义?
  • 设计一个场景:玩家点 Score 按钮 → 客户端 A 发请求 → Owner 处理后 → A 因为网络问题没收到 OnDeserialization → A 又点了一次。这是重复还是两个不同请求requestId 幂等管不管这种情况?

本章不写完整代码,由闭环 3 落地。设计自检:

场景预期实际
1. 长按 Score 按钮(含 GameState 端冷却)加分速度被冷却拍平,每秒不超过 1 分
2. A 与 B 同时发不同请求两位的 requestId 各自递增,互不干扰,都被处理
3. 反复 Restart → Start → ScoreUI 上看不到 phase=INGAMEtotalScore=0 的中间态
4. 迟入玩家加入 INGAME进来一次性看到正确 phase / totalScore / phaseStartServerTime,没有闪烁
5. Owner 转移瞬间stateVersion 继续单调递增,没回退

第 1、3 行是闭环 3 要修的两个缺陷。第 4 行是 gating 模式的副作用:迟入恢复也是「一次性整批应用」。


  • 能默写「重复 / 乱序 / 整体性破裂」三类不一致,并指出各自的工具
  • 能解释为什么 lastProcessedRequestId 必须按玩家分槽,不能共用
  • 能解释为什么 stateVersionOnPreSerialization 里 ++,不在改字段时 ++
  • 能解释为什么 requestId 幂等和 stateVersion gating 不互相替代
  • 能识别闭环 2 三个缺陷各自需要哪个工具