团本并发锁、故障边界与排错 private

团本并发锁、故障边界与排错

这一篇不是流程复述,而是对当前实现做工程审计:哪些一致性已经处理,哪些只是“看起来有快照”,哪些并发窗口会在压力下出现。

返回栏目首页

1. 当前用了哪几把锁

场景Redis keyTTL目的
每日初始化轮次/qmzs/lobby/initpvebossinfo_{guildId}15 秒避免多个 Lobby 重复抽取 Boss
创建团本/qmzs/lobby/createbattlepveboss_{guildId}20 秒避免同公会同时创建多个 Actor
胜利加公会分{guildId}_{roundId}1 天多玩家结算只给公会加一次分
Actor 内清理isClearPveBossActor 生命周期避免同一 Actor 重复清 Redis

这里同时存在分布式幂等与 Actor 内幂等。两者解决的问题不同:

  • Redis 锁处理多个 Lobby/玩家并发。
  • isClearPveBoss 只处理同一个 Battle Actor 内多条结束路径重复触发。

2. 创建锁目前不是完整的原子提交

当前成功回调顺序:

await database.LockReleaseAsync(lockKey, lockVal);

batch.HashSetAsync(guildKey, "battleId", battleId);
batch.HashSetAsync(guildKey, "curRoundInfo", json);
batch.Execute();

存在窗口:

sequenceDiagram
    participant A as 创建者 A
    participant R as Redis
    participant B as 创建者 B

    A->>R: 释放创建锁
    B->>R: 获得创建锁
    B->>R: 读取 battleId
    R-->>B: 空
    A->>R: 写入 battleId=A
    B->>B: 继续创建第二个 Actor

正确的不变量应该是:

只有在 BattleId 已经对所有 Lobby 可见后,才能释放创建权。

建议至少改为:

  1. 在仍持锁时写 battleId + curRoundInfo。
  2. 等待写入完成。
  3. 最后用唯一 token 释放锁。

更稳妥的是 Lua/事务式 compare-and-set,把“验证锁归属、确认 battleId 仍为空、写两个字段、释放锁”做成一次原子操作。

3. 锁值不应该使用 guildId

当前:

lockKey = createbattlepveboss_{guildId};
lockVal = guildId;

同一公会所有竞争者使用完全相同的 lockVal。如果旧请求卡住超过 20 秒,锁过期后新请求重新取得同一个 key,旧请求恢复时仍可能用相同 value 释放新请求的锁。

锁 token 应该是每次尝试唯一值,例如:

{lobbyNodeId}:{playerId}:{requestId}:{randomNonce}

释放时必须 compare token,不能只凭 guildId。

4. 清理阶段检查了错误的创建锁 key

创建时的 key 后缀是公会 ID:

createbattlepveboss_{GameGuildData.Id}

ClearBattlePveBoss 检查时却使用 Redis 读出的 BattleId:

var ggBattleId = await database.HashGetAsync(
    guildKey, "battleId");

string lockKey =
    $"/qmzs/lobby/createbattlepveboss_{ggBattleId}";

这两个 key 通常完全不同。因此注释“锁定中不用处理”在实际运行中大概率没有保护到创建锁。

这里应使用 battleGameGuildId,并且最好把“BattleId 相等才清空”做成 Redis 原子 compare-and-delete,避免读取与写入之间又被新 Actor 更新。

5. Redis Batch 不是业务事务

多处代码:

_ = batch.HashSetAsync(...);
_ = batch.HashSetAsync(...);
batch.Execute();

问题不只是“是否批量发送”。业务需要的是:

battleId 与 curRoundInfo 同时可见
或者二者都不变

普通 batch 主要减少网络往返,不自动等价于事务,也没有在调用点等待两个 Task 的最终结果。观察者可能短暂看到:

  • 有 battleId,没有 curRoundInfo。
  • 已清 battleId,但仍有旧 curRoundInfo。
  • 一个写成功,另一个因连接问题失败。

入口校验应以一个结构化值存储,或使用 Redis transaction/Lua 保证原子更新。

6. async void 让完成顺序不可观测

EndCreateBattlePveBoss、ClearBattlePveBoss、PveBossSettle 等是 async void。

虽然内部有 try/catch,但调用方仍然:

  • 不能 await 完成。
  • 不能根据失败决定是否重试。
  • 不能保证 Actor 关闭前清理已经完成。
  • 无法在测试中稳定等待结果。

除事件处理器外应返回 Task,并让调用链明确决定超时、重试和告警。

7. Redis Boss HP 不是可恢复战斗存档

OnBossHpChange 每 5 秒写:

  • CurBossHp/MaxBossHp
  • CurPlayerCount/MaxPlayerCount
  • roundInfo
  • curRoundInfo

但创建新 BattleGuildPveBoss 时只使用:

  • CurRound.Difficulty 加载地图。
  • CurRound.BossId 替换 Boss。

当前 Battle 代码没有把 CurRound.CurBossHp 写回新 Boss 属性。更不用说:

  • 玩家与 Boss 坐标
  • AI 状态和仇恨
  • 技能 CD
  • Buff/Debuff
  • 子弹、召唤物、掉落
  • 随机任务
  • 玩家死亡/救援状态
  • 剩余战斗时间
  • 消息序列号

因此当前准确表述是:

故障能否恢复
客户端短断线,原 Actor 仍在大部分可恢复
客户端换网,原 Actor 仍在可按 ChangeNet 补包
Lobby 节点切换公会入口在 Redis,可继续找 Actor
Battle Actor/节点崩溃不能按现有代码无损续战
Redis 中有 HP 快照但 Actor 不在只能显示旧摘要,不能还原战斗

必须在设计上二选一:

  1. 明确“不续战”:检测 Actor 丢失,清入口,按策划规则重开/补偿。
  2. 实现事件日志或完整 checkpoint:恢复 Actor、实体图、计时器和包序号。

只恢复 Boss HP 会产生表面续战、实际状态错乱,风险更大。

8. stale BattleId 的处理并不完整

加入结果为 NotFindBattle 时,Lobby 会清:

battleId = ""
curRoundInfo = ""

但 EnterBattlePveBossResult 对 BattleSettlement、BattlePlayerIsMax、TimeOut 没有同样处理。这里有意保留和异常残留混在一起:

  • 满员时应该保留入口。
  • BattleEnd 时可能应该等待 Actor 清理。
  • 超时时不知道 Actor 是否实际加入成功。
  • Actor 已经消失则应该清入口。

建议把失败原因转成明确状态机,而不是只对 NotFindBattle 做特例。

9. Boss 面板摘要不是推送式实时同步

OnBossHpChange 写 Redis,但没有调用 NotifyBossRoundInfo。后者目前主要用于每日轮次初始化时通知在线成员。

所以:

  • 场内血条:战斗协议实时更新。
  • 大厅团本面板:读取公会信息时获得最近一次 Redis 摘要。
  • 不在战斗中的成员:不会因为每次 5 秒快照写入就自动收到一条 Boss HP 推送。

如果产品要求大厅成员实时看到进度,需要单独设计节流后的 Lobby 推送或订阅,不应复用战斗内高频属性包。

10. 胜利收口仍依赖客户端

Boss 死后 OnBossHpChange 会立即把 HP=0 写 Redis,但 Battle 胜利状态不是这里直接设置。客户端确认本地已经没有存活 Boss、展示预结算,点击继续后发送 StartNextLevel(SceneGuid),服务端才:

IsWin = true;
ChangeStatus(emBattleStatus.BattleEnd);

SceneGuid 只能防止旧场景请求,不能证明请求者有权决定 Boss 已死。更权威的设计应由服务端 Monster 死亡事件直接触发胜利;客户端按钮只控制何时关闭预结算界面。

11. 参与判定存在“活着离开也算参与”的语义

PveBossStatus 只检查:

不是 DEAD
并且倒计时已开始
并且不是战场超时

它没有检查:

  • 最低战斗时长。
  • 是否造成过伤害/治疗。
  • 是否仍在胜利时刻在线。
  • 是否主动提前退出。
  • 是否挂机。

因此玩家入场后只要团本已经启动,以非 DEAD、非超时方式结算,就可能被记录为参与。本身未必是 Bug,但文档、策划口径和反作弊必须采用这个真实语义。

12. 团灭清理与可重试语义

胜利清理会把当前轮次 Status = 0,阻止继续进入。团灭清理只清 battleId/curRoundInfo,不把轮次状态置 0。

因此当前业务语义是:

  • 胜利:本轮 Boss 完成,不能重开。
  • 团灭/失败:释放当前 Actor 入口,本轮时间窗内可再次创建。
  • 玩家个人是否还能再进:还受 BossFoughtInfo 控制。
  • BossFoughtInfo 只在 PveBossStatus == 1 时记录。

修改奖励或重试规则时,这四处必须一起审查。

13. 建议的原子状态模型

把公会当前团本从两个散字段升级为一个带版本的结构:

{
  "roundId": 123456,
  "battleId": "battle-node/session",
  "state": "creating|running|ending|closed",
  "ownerToken": "node:player:request:nonce",
  "version": 17,
  "updatedAt": 1789460000
}

状态转换:

stateDiagram-v2
    [*] --> Closed
    Closed --> Creating: CAS + 唯一 token
    Creating --> Running: Actor 创建成功并原子提交
    Creating --> Closed: 超时/创建失败
    Running --> Ending: Boss 死亡或团灭
    Ending --> Closed: 比对 battleId + version 后清理

这样才能准确回答:

  • 谁拥有创建权。
  • 当前 BattleId 是否已经提交。
  • 清理的是不是同一代 Actor。
  • 超时请求能否重试。
  • 旧 Actor 是否可以覆盖新 Actor。

14. 建议优先修复顺序

P0:防重复创建与误清理

  1. 创建锁改唯一 token。
  2. 写入口后再释放锁。
  3. 入口写入使用事务/Lua。
  4. 清理锁 key 改为 guildId。
  5. 清理使用 compare battleId/version 的原子脚本。

P1:明确 Battle 故障策略

  1. CheckBattle 失败后区分 Actor 不存在、节点不可达和请求超时。
  2. 明确是重开、按 HP 重建还是补偿。
  3. 在实现完整 checkpoint 前,不宣称支持续战。

P1:胜利权威移到服务端

  1. Boss Monster 死亡事件直接进入 BattleEnd。
  2. 保留 SceneGuid 作为客户端 UI 请求的防旧包字段,不再作为胜利条件。
  3. 对 BattleEnd 做一次性 CAS,防多事件重复进入。

P2:补齐重连

  1. 恢复 SynPrivateData/SyncPrivateEntity 或提供等价快照。
  2. 明确 msgIdx=-1 时的全量补发范围。
  3. 给团本覆盖 BattleReconntctInfoSync,补发团本特有状态。
  4. 在 AddEntityEnd 后让客户端做实体数量/版本校验。

15. 必测并发用例

用例预期
20 个同公会成员同时点击进入只创建一个 BattleId
创建成功回调与第二个请求同时到达第二个只加入,不再创建
创建请求超过 20 秒后旧回调返回不得释放或覆盖新一代入口
旧 Actor 延迟清理,新 Actor 已创建旧 Actor 不得清新 BattleId
Boss HP=0 时十人同时结算公会积分只增加一次
Battle 节点崩溃但 Redis 有 BattleId明确清理/重开/补偿,不无限失败
重连 msgIdx 有效缺失包按序补齐,快照后无重复实体
重连 msgIdx=-1私有与公共状态仍能达到定义的一致性
倒地时断线再重连重新看到正确的复活 UI 和读条
击杀后客户端崩溃未点继续服务端仍能完成胜利收口

16. 线上日志与指标

建议所有关键日志固定带:

guildId, roundId, battleId, battleNode
requestId, lockKey, lockToken, stateVersion
playerId, sessionId, enterType, msgIdx
battleStatus, playerCount, bossEntityId
bossHp, maxBossHp, leftTime
clearReason, settleReason, errorCode

核心指标:

  • 每个 guildId/roundId 创建出的 BattleId 数量。
  • 创建锁等待、失败、TTL 过期次数。
  • NotFindBattle 与 Redis stale BattleId 次数。
  • 重连成功率和重发包数量。
  • AddEntity 快照实体数量及耗时。
  • Boss HP Redis 写入延迟。
  • BattleEnd 到 Redis 清理完成耗时。
  • 同轮胜利结算重复请求次数。

17. 最后判断

当前团本联机的主干是清楚的:公会共享 BattleId,Battle Actor 保存实时真相,首帧快照加增量协议支持中途加入,保留 Player/Hero 支持短断线重连,Redis 锁负责创建与结算幂等。

真正需要警惕的是:它目前是“可中途加入、可短线重连”的共享 Actor,不是“Battle 崩溃后可恢复”的持久化副本。 把这个边界说清楚,后续扩容、容灾和奖励设计才不会建立在错误假设上。

上一篇:断线重连、复活与结算 · 返回栏目首页