团本并发锁、故障边界与排错 private
团本并发锁、故障边界与排错
这一篇不是流程复述,而是对当前实现做工程审计:哪些一致性已经处理,哪些只是“看起来有快照”,哪些并发窗口会在压力下出现。
1. 当前用了哪几把锁
| 场景 | Redis key | TTL | 目的 |
|---|---|---|---|
| 每日初始化轮次 | /qmzs/lobby/initpvebossinfo_{guildId} | 15 秒 | 避免多个 Lobby 重复抽取 Boss |
| 创建团本 | /qmzs/lobby/createbattlepveboss_{guildId} | 20 秒 | 避免同公会同时创建多个 Actor |
| 胜利加公会分 | {guildId}_{roundId} | 1 天 | 多玩家结算只给公会加一次分 |
| Actor 内清理 | isClearPveBoss | Actor 生命周期 | 避免同一 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 可见后,才能释放创建权。
建议至少改为:
- 在仍持锁时写
battleId + curRoundInfo。 - 等待写入完成。
- 最后用唯一 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/MaxBossHpCurPlayerCount/MaxPlayerCountroundInfocurRoundInfo
但创建新 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 不在 | 只能显示旧摘要,不能还原战斗 |
必须在设计上二选一:
- 明确“不续战”:检测 Actor 丢失,清入口,按策划规则重开/补偿。
- 实现事件日志或完整 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:防重复创建与误清理
- 创建锁改唯一 token。
- 写入口后再释放锁。
- 入口写入使用事务/Lua。
- 清理锁 key 改为 guildId。
- 清理使用 compare battleId/version 的原子脚本。
P1:明确 Battle 故障策略
CheckBattle失败后区分 Actor 不存在、节点不可达和请求超时。- 明确是重开、按 HP 重建还是补偿。
- 在实现完整 checkpoint 前,不宣称支持续战。
P1:胜利权威移到服务端
- Boss Monster 死亡事件直接进入 BattleEnd。
- 保留 SceneGuid 作为客户端 UI 请求的防旧包字段,不再作为胜利条件。
- 对 BattleEnd 做一次性 CAS,防多事件重复进入。
P2:补齐重连
- 恢复
SynPrivateData/SyncPrivateEntity或提供等价快照。 - 明确
msgIdx=-1时的全量补发范围。 - 给团本覆盖
BattleReconntctInfoSync,补发团本特有状态。 - 在
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 崩溃后可恢复”的持久化副本。 把这个边界说清楚,后续扩容、容灾和奖励设计才不会建立在错误假设上。