第 20 章 · 版本号、序号与幂等
这一章解决:闭环 2 的两个故意保留的缺陷(重复加分 / 状态版本乱)背后是同一类问题。网络上每一次包都可能重复、乱序、迟到,写代码时如果默认「我每次只收到一份、按发出顺序、立刻收到」,bug 就会随机出现。
先看一下
闭环 2 缺陷 1:长按 Score 按钮分数加得比预期快。本地冷却挡了正常玩家的手抖,挡不住改客户端,也挡不住「先按一下后又因为 UI 刷新延迟再按一下」的真实重复。
闭环 2 缺陷 2:反复 Restart → Start → Score 时,远端客户端短暂看到 phase=INGAME 但 totalScore=0 而 phaseStartServerTime 还是上一局的时间。OnDeserialization 在一帧里把这三个字段中的某一两个先应用了,UI 看到中间态。
两个缺陷的共同结构:网络上的事件不是「按发出顺序、各自独立、恰好一次」到达。这一章给三个工具,每个解决一类不一致。
这一章会拿到什么
requestId单调递增 +lastProcessedRequestId数组:请求幂等stateVersion:让所有GameState字段一起跨版本的整体性保护OnDeserialization里的 gating 模式:客户端只在stateVersion变化时应用整批字段- 三个工具各自适合的场景,以及为什么不能合并成一个工具
依赖前面
- 第 12 章
requestId/requestType/requestPayload字段命名 - 第 12 章
lastProcessedRequestId[]数组(按playerId索引) - 闭环 2 故意保留的三个缺陷
- 第 19 章状态同步 vs 命令同步的判断维度
三类不一致是不同的问题
Section titled “三类不一致是不同的问题”写代码前先把问题切开。下面三类听起来像「网络不稳」,背后机制不同,需要的工具也不同。
重复:同一条消息被处理了两次。可能是发送方在 1 帧内连续发了两次(UI 双击),可能是接收方在 RequestSerialization 失败重试时把同一份字段值再应用了一次,也可能是 Owner 转移期间新旧 Owner 都处理了同一个请求。幂等是防它的工具。
乱序:A 客户端先发的请求比 B 客户端后发的请求晚到。同步变量的写权归属、SendCustomNetworkEvent 的派发顺序在多客户端场景下都不保证全局有序。版本号 + 单调比对是防它的工具:丢弃比已知最新版本旧的请求。
整体性破裂:GameState 上五个字段一起改,但远端客户端在一帧里看到「三个字段是新值、两个还是旧值」的中间态。OnDeserialization gating 是防它的工具:所有字段同时归属于一个版本号,客户端只在版本号变了的那一刻才应用整批。
闭环 2 缺陷 1 是重复,缺陷 2 是整体性破裂。两个用同一套工具修不对。
工具 1:请求幂等
Section titled “工具 1:请求幂等”闭环 2 已经埋了 requestId 和 lastProcessedRequestId[] 的字段,但 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 拦掉。
它修了什么、不修什么
Section titled “它修了什么、不修什么”修了:同一玩家同一个请求被处理两次(UI 双击 / 字段被重发 / Owner 转移期间双处理)。
不修:同一玩家两个不同请求被处理两次(玩家手动按了两下 Score 按钮,按出两个不同的 requestId,每个都是合法请求)。这种「玩家真的连续按了两次」的场景要靠冷却挡,而冷却分两层:
- 本地冷却(
PlayerLobbyState上requestCooldown):挡正常玩家手抖。 - GameState 端冷却(
GameState上lastHandleTime[playerId]):挡改客户端。
闭环 2 已经写了本地冷却,缺 GameState 端冷却。闭环 3 会补。
lastProcessedRequestId[] 的几个细节
Section titled “lastProcessedRequestId[] 的几个细节”- 数组长度:和场景里玩家上限一致,外面包一层
GetPlayerSlot(playerId)。不要用playerId % length取模写入,它会把不同玩家压到同一槽。最小实现可以直接检查0 <= playerId < length,更稳的实现是按加入顺序维护映射表。 - 玩家离开:
OnPlayerLeft时清理或冻结这一槽。清理适合「离开就丢弃本局请求状态」;冻结适合还要保留结算日志的玩法。关键是不要让旧请求序号影响新加入者。 - 迟入玩家:迟入玩家进来时
requestId=0,他第一次IssueRequest拿到requestId=1,对比自己的槽位为 0 时通过;如果槽位里残留旧值,他第一次请求会被丢。
工具 2:状态版本号
Section titled “工具 2:状态版本号”闭环 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 后,OnPreSerialization 在 RequestSerialization 实际发送前的本帧执行一次。这一时机给「打版本号」一个稳定入口:每次 Owner 决定要发送一批字段时,版本号自动递增。
OnPreSerialization 是 Owner 本地的回调,它只在 Owner 调用 RequestSerialization 后才会跑。也就是说版本号只在「真的有一次发送」时才递增,本地改了字段但没调 RequestSerialization 是不会涨的。这一点把「字段改了」和「字段被发出去了」区分开。
继续保留 continuous sync 的对象不适合这套版本号:continuous 自身按帧节奏检查变更并发送,没有「这次到底要不要发」的明确入口。把 stateVersion 用在 manual sync 对象(GameState)上;PlayerLobbyState 虽然也是 manual sync,但它是每位玩家自己的请求对象,不需要把多个全局字段打成一批。
版本号不是单调时间
Section titled “版本号不是单调时间”新接管的 Owner 会怎么算 stateVersion?
候选队列接管时,新 Owner 在 OnOwnershipTransferred 里继承当前 stateVersion 值(同步变量本身就传过来了),下一次 OnPreSerialization 就是 stateVersion + 1。版本号全局单调,跨 Owner 转移仍然递增。
这一点和「时间戳」不同:Networking.GetServerTimeInSeconds() 是物理时间,可能在 Owner 转移瞬间往回跳一两毫秒。stateVersion 不会跳。任何「我看到的状态比之前看到的旧」都可以靠版本号比较直接判断,不靠时间。
工具 3:OnDeserialization gating
Section titled “工具 3:OnDeserialization gating”有了版本号,远端客户端的应用逻辑就可以改成「只在版本变化时整批应用」。
// 远端客户端(非 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 幂等不互相替代
Section titled “它和 requestId 幂等不互相替代”requestId 幂等保护请求处理:同一玩家发出的同一请求,GameState Owner 端只处理一次。
stateVersion gating 保护状态应用:远端客户端把字段批量应用到 UI 时只应用一次。
两者一上一下,缺一不可。只用 requestId 不够:Owner 端处理对了,但远端客户端可能在中间态下渲染。只用 stateVersion 也不够:Owner 端可能把同一请求处理了两次,导致 totalScore += 1 跑了两次,版本号涨成 +2,远端只看到一次 OnDeserialization,但分数已经多了 1。
三个工具的边界
Section titled “三个工具的边界”| 工具 | 解决 | 不解决 | 落地章 |
|---|---|---|---|
requestId 幂等 | 同一玩家同一请求被重复处理 | 玩家真的连按两次(不同请求) | 第 12 章已埋 / 闭环 3 |
| GameState 端冷却 | 玩家高频发送同类型请求(含改客户端) | 单次合法请求被重复处理 | 闭环 3 |
stateVersion + gating | UI 看到中间态、远端重复应用 | 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字段,但不走OnDeserializationgating(仍然让字段直接驱动 UI)。版本号字段值有什么用?什么时候这一字段开始有意义? - 设计一个场景:玩家点 Score 按钮 → 客户端 A 发请求 → Owner 处理后 → A 因为网络问题没收到
OnDeserialization→ A 又点了一次。这是重复还是两个不同请求?requestId幂等管不管这种情况?
本章不写完整代码,由闭环 3 落地。设计自检:
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 长按 Score 按钮(含 GameState 端冷却) | 加分速度被冷却拍平,每秒不超过 1 分 | … |
| 2. A 与 B 同时发不同请求 | 两位的 requestId 各自递增,互不干扰,都被处理 | … |
| 3. 反复 Restart → Start → Score | UI 上看不到 phase=INGAME 但 totalScore=0 的中间态 | … |
| 4. 迟入玩家加入 INGAME | 进来一次性看到正确 phase / totalScore / phaseStartServerTime,没有闪烁 | … |
| 5. Owner 转移瞬间 | stateVersion 继续单调递增,没回退 | … |
第 1、3 行是闭环 3 要修的两个缺陷。第 4 行是 gating 模式的副作用:迟入恢复也是「一次性整批应用」。
- 能默写「重复 / 乱序 / 整体性破裂」三类不一致,并指出各自的工具
- 能解释为什么
lastProcessedRequestId必须按玩家分槽,不能共用 - 能解释为什么
stateVersion在OnPreSerialization里 ++,不在改字段时 ++ - 能解释为什么
requestId幂等和stateVersiongating 不互相替代 - 能识别闭环 2 三个缺陷各自需要哪个工具
- VRChat Creator Docs · Networking and Synchronization —
OnPreSerialization/OnPostSerialization/OnDeserialization的官方说明。 - 第 4 章 · 手动同步的生命周期 — Manual / Continuous 区别、
RequestSerialization语义、OnPreSerialization/OnPostSerialization/OnDeserialization时机。 - 第 12 章 · 请求式架构 —
requestId/lastProcessedRequestId字段命名与初版骨架。 - 闭环 2 · PlayerObject + GameState 的中级一局 — 三个故意保留的缺陷。
- 闭环 3 · 加上命令版本号的工程化一局 — 本章三个工具的完整落地。
- 附录 · 术语表 —
Idempotent/Request ID/Last Processed Request Id/Version Number的客观定义。