08-Server-Authority与网络协议
第 8 章:Server Authority 与网络协议
导入
多人游戏里最容易被误解的一句话,就是“服务器权威”。有人把它理解成:所有逻辑都在服务器执行,客户端只负责发按钮和等结果。这个方案确实容易解释,但实际操作起来会让每一个技能都经历明显的输入延迟。玩家按下火球术,等服务器收到请求、完成校验、广播结果,客户端才开始播放起手动画;在高延迟网络下,技能会像“按了但没反应”。
另一个极端是把技能完整地放到客户端执行。客户端立即扣 Mana、判断冷却、生成伤害,然后把“我已经命中了”通知服务器。这种方案手感很好,却把战斗规则交给了不可信的一端:改包或篡改运行时数据之后,客户端可以伪造资源、冷却、命中和伤害。
合理的边界不是“客户端不执行,服务器全执行”,而是:
客户端可以预测本地可见、可逆的表现;服务器负责验证输入,并提交所有会影响公共战斗状态的事实。
8.1 先分清五种东西
8.2.1 Intent:本地意图
Intent 是客户端输入层产生的本地语义:玩家按下了火球术快捷键,希望对某个位置施法。它可以在本地被消费,也可以被序列化为网络请求。
客户端可以先根据 Intent 播放起手动画、显示一个待确认的扣款,或者给本地角色加上 Casting 状态,但这些都属于预测。Intent 也不一定需要上网,例如玩家按下取消键,若取消的是一个完全本地的 UI 预览,就无需发送协议消息。
8.1.2 Request:跨边界请求
Request 是客户端发给服务器的外部输入。它应该尽可能表达“我想尝试做什么”,而不是携带“我已经算出的结果”。
对于火球术,Request 可以包含技能身份、目标数据、客户端时间、请求编号和预测关联键。服务器可以重新计算伤害;客户端提供的是输入和上下文,不是裁决。
8.1.3 Command:服务器内部命令
Command是服务器在完成协议层和游戏规则校验之后,交给能力系统执行的可信内部对象。它通常比 Request 更干净,因为不再包含未经验证的边界输入。
例如,服务器收到 AbilityRequest 后确认:
- 请求来自当前拥有该角色控制权的会话;
SpecId与AbilityId是该角色可用的能力;- 目标数据格式正确,距离和朝向满足规则;
- 当前没有冷却、资源或状态阻断;
- 请求没有被处理过,也没有超过允许的时间窗口。
通过这些检查后,服务器才构造一个内部 FireballCommand,并调用激活、资源消耗、生成投射物、命中和效果结算等逻辑。客户端不能直接发送一个“执行 FireballCommand”的网络消息来绕过校验。
8.1.4 Result:权威裁决或阶段结果
Result 是服务器对某个 Request 或其某个阶段作出的回应。它可以表示成功、拒绝、修正,或者只表示“请求仍在处理”。
一个结果至少要能回答三件事:
- 它对应哪个请求。
- 它属于哪个结果版本或权威 revision。
- 客户端应该确认、回滚,还是以权威数据校正本地状态。
因此,Result 不只是一个 bool success。在实际项目里,推荐使用稳定的 Kind、Code、Stage 和 AuthoritativeState 字段。
8.1.5 Snapshot:可重新建立基线的状态
Snapshot 是服务器在某个权威 revision 下提供的完整状态或可作为基线的状态片段。它不是某次输入的“回放”,而是客户端重新对齐所需的事实。
首屏进入、切换地图、重连、检测到版本缺口,通常都需要 Snapshot。客户端拿到 Snapshot 后可以丢弃无法证明仍然有效的旧预测记录,从新的权威 revision 继续接受输入。
这五类对象的关系可以概括为:
玩家输入 │ ▼Intent ──序列化──> Request ──服务器校验──> Command │ ▼ 权威状态与游戏事件 │ ┌───────────────────┴───────────────────┐ ▼ ▼ Result Snapshot 回应某个请求 重建状态基线不要让同一对象同时承担这五种职责。尤其不要把客户端预测用的对象直接当成服务器要执行的命令,也不要把一条增量 Result 当成重连时可以依赖的完整状态。
8.2 一条能力请求的最小数据模型
下面的代码只是说明协议形状。真正落地时,可以使用二进制序列化、消息对象、NetworkSerializable 或其他传输格式,但语义应该保持清楚。
public readonly struct AbilityRequest{ public readonly string RequestId; public readonly ulong SpecId; public readonly string AbilityId; public readonly ulong PredictionKey; public readonly uint ClientTick; public readonly uint ClientSequence; public readonly TargetData TargetData;}
public readonly struct AbilityResult{ public readonly string RequestId; public readonly ulong PredictionKey; public readonly AbilityResultKind Kind; public readonly AbilityResultStage Stage; public readonly AbilityResultCode Code; public readonly ulong ResultVersion; public readonly ulong AuthorityRevision; public readonly bool Committed; public readonly AuthoritativeState AuthoritativeState;}
public enum AbilityResultKind{ Confirm, Reject, Correct}
public enum AbilityResultStage{ Activation, Impact, Final}8.3 RequestId、PredictionKey 与顺序字段
8.3.1 RequestId 是幂等键,不是顺序号
RequestId 的核心职责是幂等。客户端因为丢包或超时重发同一请求时,必须复用原来的 RequestId。服务器以稳定会话身份和 RequestId 组成去重键:
(PersistentSessionId, RequestId)如果同一个请求已经提交,服务器应该返回之前记录的终态结果,而不是再次扣 Mana、再次生成火球或再次施加效果。
RequestId 不承担顺序。字符串比较大小不能证明哪个请求先到;重新连接后的请求也不应该继续依赖旧连接本地生成的递增值来表达全局顺序。若同一个 RequestId 携带了不同 payload,应当视为协议冲突并拒绝,而不是选择其中一份执行。
服务器端通常需要保存一段时间的请求记录:
RequestId -> Received / Processing / Committed / Rejected 原始摘要、阶段结果、过期时间记录保存多久取决于协议的重发窗口和业务风险。高价值操作需要更长的幂等记忆,或者把最终状态写入持久化事务边界。
8.3.2 PredictionKey 是预测日志关联键
PredictionKey 的职责不同:它让客户端把一个服务器结果映射回本地的 PredictionJournal。客户端预测了 Mana 显示、起手动画和投射物,就需要知道收到结果时应该确认或撤销哪一组本地变化。
PredictionKey 不能让服务器信任客户端。客户端完全可以伪造一个看似合法的 key;服务器仍然必须按照自己的资源、冷却、状态和目标规则判断请求。服务器只需要原样回传它能识别的 key,便于客户端收束预测。
一个预测 key 也不一定代表整个技能的全部生命周期。推荐给预测范围分组,例如:
PredictionKey 1001, Scope = ActivationPredictionKey 1002, Scope = Impact激活确认可以及时确认本地起手、资源显示和冷却 UI;命中和持续效果则由后续权威阶段独立处理。
8.3.3 顺序必须使用专门字段
协议至少要区分以下几种“顺序”:
| 字段 | 表达的含义 | 是否权威 |
|---|---|---|
ClientSequence | 某个客户端输入流中的顺序 | 否 |
ClientTick | 客户端采样输入的时间位置 | 否 |
AuthorityRevision | 服务器状态基线的版本 | 是 |
ResultVersion | 某个结果流中的版本 | 通常由服务器分配 |
服务器可以用 ClientSequence 判断一个角色的输入是否缺号、重复或超出窗口,但不能仅凭它认定世界已经按照客户端顺序提交。客户端收到结果后,应该以 AuthorityRevision 或 ResultVersion 判断新旧,而不是以消息到达顺序覆盖本地状态。
8.4 服务器处理流水线
一条能力请求进入服务器后,建议经过明确的阶段。阶段越清楚,越容易定位“请求收到了但技能没生效”到底是传输问题、校验拒绝,还是后续命中尚未结算。
接收 Request │ ├─ 解析与版本检查 ├─ 身份与拥有权检查 ├─ RequestId 幂等检查 ├─ ClientTick / Sequence 时间窗口检查 ├─ AbilitySpec、状态、资源、冷却检查 ├─ TargetData 与空间规则检查 │ ├─ Reject:记录终态并返回拒绝 │ └─ 构造 Command │ ├─ 提交激活与资源变更 ├─ 生成服务器拥有的能力实例或投射物 ├─ 返回 Activation Result └─ 后续结算 Impact / Effect Result这里的关键是“先幂等,再提交”。如果服务器先扣资源、再发现这是重复请求,恢复就会变得复杂;如果先把请求标记为 Processing,再进入异步命中流程,重复到达时就可以挂接到同一个处理过程,而不是重新创建第二个火球。
8.4.1 激活和命中不是一个事务
能力激活成功,不等于命中一定发生。服务器可以确认“这个角色在合法条件下开始施放”,但投射物可能被销毁、目标离开范围,或者后续碰撞结果决定没有命中。
因此建议至少拆成两个阶段:
Activation:验证能力、消耗 Mana、进入冷却、创建服务器拥有的投射物。Impact:确定命中对象、应用直接伤害、添加Burning,并发送命中结果。
火球术的统一规则是:激活消耗 Mana 30,冷却 2 秒;命中后直接伤害 120,并施加持续 5 秒、每 1 秒结算一次的 Burning。
8.5 重复请求、重复结果与幂等边界
8.5.1 同一 RequestId 的重发
网络超时并不说明服务器没有处理。客户端发送火球请求后没有收到回应,可能是:
- Request 尚未到达服务器;
- 服务器已经处理,但 Result 在返回途中丢失;
- 服务器已经处理并排队等待命中阶段;
- 连接已经断开,客户端暂时无法知道状态。
客户端重发时复用同一个 RequestId。服务器按照记录作出以下处理:
| 服务器记录 | 重发行为 |
|---|---|
| 没有记录 | 接收并进入正常流水线 |
Processing | 不再次执行,返回处理中或继续发送已有阶段结果 |
| 已提交终态 | 重发之前的终态 Result |
| 已明确拒绝 | 重发之前的拒绝 Result |
| 同 ID 不同 payload | 返回协议冲突,不执行 |
“新建一个 RequestId 再试一次”不是普通超时重试的正确做法。这样可能造成一次请求已经扣费,而第二次请求又再次扣费。
8.5.2 Commit、Impact、Effect 也需要幂等
只在入口 Request 上做去重还不够。若命中通知、效果应用或持久化提交也可能重试,就必须在各自的边界建立幂等键。例如:
ActivationCommitId = (RequestId, Activation)ImpactCommitId = (ServerAbilityExecutionId, TargetId, ImpactIndex)EffectApplyId = (EffectEventId, TargetId)同一个命中事件重放时,目标不能再次受到 120 点直接伤害;同一个效果应用事件重放时,也不能重复创建一份本应唯一的 Burning。
8.5.3 结果重复到达
客户端也要能处理重复 Result。已经确认的 Activation Result 再来一次,应该被识别为已收束,不得再次播放一轮不可逆逻辑;已经应用的权威 revision 也不能被同版本的重复消息覆盖成不同状态。
对于表现层,可以选择把重复消息当作刷新确认超时的机会。技能事件和网络通知是两个不同概念。
8.6 乱序与过期
8.6.1 乱序 Result
可靠传输并不等于业务消息永远按你想要的顺序到达。不同 RPC、不同通道、重连补发和异步命中都可能让客户端先收到后产生的结果。
客户端处理 Result 时,先检查:
AuthorityRevision是否早于当前权威基线。ResultVersion是否已经处理过。- 该 Result 的
Stage是否依赖另一个尚未到达的阶段。 RequestId是否已进入终态。
旧 revision 的状态不能覆盖新 revision。若一个 Impact Result 依赖的 Activation Result 尚未到达,可以暂存有限时间;超过窗口后,使用 Snapshot 或请求服务器补发,而不是永远等待。
8.6.2 过期 Request
客户端时钟不可直接作为服务器时钟使用。ClientTick 只能作为“输入大概发生在何时”的提示,服务器需要结合同步过的时间基准和规则窗口判断:
- 该请求是否超过可接受延迟;
- 是否允许在有限回溯窗口内验证目标;
- 角色状态是否已经变化到不再允许施法;
- 请求是否来自旧场景、旧角色或旧能力版本。
请求过期后要返回稳定的错误码,例如 RequestExpired、StaleAbilityVersion 或 InvalidState。不要让客户端根据本地等待时间直接显示“服务器拒绝”;在没有明确 Result 前,它仍然可能处于未知状态。
8.7 延迟、拒绝和重连
8.7.1 延迟状态不是拒绝状态
客户端发出请求后,UI 至少要能区分三个状态:
PendingUnknown 已发送,尚未知道服务器结论Confirmed 服务器已提交对应阶段Rejected 服务器明确拒绝PendingUnknown 期间可以降低投机表现的强度,例如让预测火球使用待确认材质,或者把资源数字标记为待确认,但不能把未知状态当作失败立即退款。否则服务器其实已经扣除 Mana,客户端又会在本地错误地恢复一次。
超时重试时复用原 RequestId 和 PredictionKey。若协议需要客户端主动查询,可以发送一个独立的状态查询消息。
8.7.2 拒绝必须是可解释的稳定结果
拒绝不是异常分支,而是正常的业务结果。常见拒绝原因包括:
MissingCostOnCooldownInvalidTargetOutOfRangeInvalidStateRequestExpiredDuplicateConflict
错误码应该稳定,显示文本可以由客户端本地化。服务器应当返回足够的权威字段,让客户端能准确回滚本地预测,例如当前 Mana、当前冷却结束时间和相关状态标签。
8.7.3 重连先建立基线,再决定旧请求
重连后,客户端首先需要获得新的身份上下文和 Snapshot。Snapshot 应带有 AuthorityRevision,并至少覆盖能力预测依赖的资源、冷却、状态标签、能力实例或可见投射物信息。
重连流程可以是:
建立新连接 │ ├─ 完成身份恢复 ├─ 接收 Snapshot(revision = R) ├─ 丢弃或收束无法证明有效的旧 Prediction Journal ├─ 以 R 作为新的本地权威基线 └─ 仅对明确允许恢复的输入重新提交不要默认重放断线前的所有输入。客户端可能不知道哪些请求已经提交,也可能已经错过了目标和时间窗口。只有协议明确支持、且服务器能以原 RequestId 幂等恢复的请求,才适合继续查询或补发。
NGO 的 ClientId 是连接上下文中的网络身份标识,不能自动替代游戏自己的持久会话身份。跨连接恢复请求记录时,需要使用由登录或会话系统提供的稳定身份,并结合服务器端的权限校验。
8.8 在 Unity NGO 中放置这套协议
在 Unity 项目中,NGO 可以提供两类基础能力:
- 使用 RPC 或 Custom Message 携带
AbilityRequest、AbilityResult、阶段通知和查询消息。 - 使用
NetworkVariable或快照消息同步持续存在的权威状态,例如 Mana、冷却、状态标签和对象引用。
一个概念上的映射如下:
AbilityRequest -> ServerRpc 或 Custom MessageAbilityResult -> ClientRpc 或 Custom MessageSnapshot -> 初始同步消息 / NetworkVariable 组合 / 自定义快照Mana/Cooldown -> NetworkVariable 或权威状态快照这只是 Transport 和状态同步的选型示例。NGO 不会自动知道哪个 RequestId 已经执行,不会自动检查 Mana 30 和冷却 2 秒,也不会自动判断 120 点伤害是否能由客户端宣称。它同样不会自动提供 Prediction Journal、Confirm/Reject/Correct、回滚和重放。
因此不要写出这样的伪代码:
[ServerRpc]private void CastFireballServerRpc(int damage){ target.Health -= damage;}这里的 damage 来自客户端,服务器没有验证施法者、能力版本、资源、冷却、目标和命中事件。即使把方法名改成 ServerRpc,它也只是传输入口,不会因此变成可信的能力系统。
更合理的边界是让 RPC 只接收 Request,服务器内部根据服务器持有的 AbilitySpec 和状态构造 Command:
[ServerRpc]private void SubmitAbilityRequestServerRpc(AbilityRequest request){ // 1. 校验连接、版本、RequestId 和输入窗口。 // 2. 从服务器数据构造可信的 Command。 // 3. 在服务器提交资源、冷却和能力阶段。 // 4. 返回 AbilityResult,而不是接受客户端伤害结论。}注释中的类型和流程仍然是本文自定义的协议示例;具体 NGO 方法签名应以项目使用的包版本和序列化方式为准。
8.9 从 Intent 到 Result 的完整时序
假设角色当前 Mana 为 100,火球术规则固定。 客户端按下技能键后,先产生本地 Intent:
Intent: AbilityId = Fireball TargetData = 当前瞄准位置随后客户端生成一次请求:
Request: RequestId = "session-a:000184" PredictionKey = 901 ClientTick = 48210 ClientSequence = 184 AbilityId = Fireball TargetData = ...客户端可以立即把本地表现放入 Prediction Journal:
PredictionJournal[901]: RequestId = "session-a:000184" Scope = Activation BaseAuthorityRevision = 77 预测变化 = Mana 显示 -30、冷却 UI 2 秒、起手动画、待确认投射物 状态 = PendingUnknown服务器收到 Request 后,使用服务器自己的状态检查 Mana 是否足够、冷却是否结束、施法状态和目标数据是否有效。成功时,服务器提交激活:Mana 从 100 变为 70,技能进入 2 秒冷却,生成服务器拥有的火球实例,并返回 Activation + Confirm。
火球命中后,服务器再产生 Impact + Confirm 或 Impact + Correct,具体取决于客户端预测表现与权威事实是否一致。权威命中逻辑应用 120 点直接伤害,并施加持续 5 秒、每 1 秒结算一次的 Burning。
拒绝时,例如服务器判断技能仍在冷却,返回:
Result: RequestId = "session-a:000184" PredictionKey = 901 Kind = Reject Stage = Activation Code = OnCooldown AuthorityRevision = 78 AuthoritativeState = 当前冷却结束时间、当前 Mana、相关标签客户端据此回滚预测的 Mana 显示、冷却 UI、Casting 标签和待确认投射物。若请求已经在服务器成功提交但 Result 丢失,客户端重发相同 RequestId,服务器会返回原来的结果,不会再次扣除 Mana。