Firestore多端并发更新房间文档导致玩家数据丢失问题咨询
你现在碰到的并发覆盖是Firestore非原子写的典型问题,根源是「读全量players数组→本地追加条目→回写整个数组」的流程没有并发保护,多个客户端同时基于旧版本数组快照修改后回写,后发起的请求必然覆盖前面的写入结果。完全不需要拆分玩家为独立子文档,单房间单文档的模型下有三种成熟方案可以彻底解决该问题,按改造成本和场景适配性排序如下:
方案1:使用Firestore内置arrayUnion原子操作(改动成本最低)
Firestore原生提供数组原子更新指令,不需要提前读取全量房间数据,服务端会自动完成元素追加、去重逻辑,全程不会和其他并发写产生冲突。
实现代码非常简单:
// 用户加入房间时无需预读数据,直接执行原子更新 const roomRef = doc(db, 'rooms', targetRoomId); await updateDoc(roomRef, { players: arrayUnion({ id: currentUserId, state: 0 // 对应玩家初始状态 }) });
移除玩家时对应使用arrayRemove指令即可,同样是原子操作,不存在并发覆盖风险。
局限性:arrayUnion/arrayRemove依赖完整元素匹配做增删,如果后续需要频繁修改单个玩家的state字段,还是需要读取全量数组修改后回写,依然会遇到并发覆盖问题,这种场景优先选下面的键值对结构方案。
方案2:将players从数组改为键值对结构(游戏场景最优解)
完全放弃数组存储结构,把players改成以玩家ID为键的对象,每个玩家的信息都是房间文档下的独立子字段,更新时可以直接定位到对应玩家的字段路径做原子写入,从数据结构层面彻底杜绝并发覆盖问题,完全符合你「单文档读取、不额外加载用户子文档」的性能要求。
首先调整数据模型定义:
export class Room { roomName: string; // 原Player[] 改为以玩家ID为key的键值对结构 players: Record<string, Player>; } export class Player { state: number; // id直接作为对象key使用,value中无需重复存储,保留也无影响 }
用户加入房间时,直接更新对应ID的字段即可,不需要提前读取任何房间数据:
const roomRef = doc(db, 'rooms', targetRoomId); // 直接指定players下对应用户ID的字段赋值,并发写入互不干扰 await updateDoc(roomRef, { [`players.${currentUserId}`]: { state: 0 } });
后续更新玩家状态、移除玩家都是操作独立字段,完全不会影响其他玩家的已存数据:
// 更新单个玩家状态,不会覆盖其他玩家数据 await updateDoc(roomRef, { [`players.${currentUserId}.state`]: 1 }); // 移除指定玩家 await updateDoc(roomRef, { [`players.${kickUserId}`]: deleteField() });
优势:
- 所有写入操作都是原子执行,用户并发加入、并发更新状态完全不会出现数据丢失
- 读取房间时依然是单次请求拿到全量玩家数据,性能和原数组方案完全一致,前端只要用
Object.values(room.players)就能得到和之前完全相同的玩家数组结构,业务渲染逻辑几乎不需要改动 - 定位单个玩家数据不需要遍历全数组,直接通过key就能取值,查询修改效率更高
方案3:事务机制(适合有加入校验逻辑的场景)
如果加入房间的流程有额外校验规则,比如需要判断房间人数上限、房间是否已经开局、玩家是否在黑名单中,没法直接用上面两种无预读的原子更新,可以用Firestore的事务做并发控制:事务执行时会先读取最新版本的房间文档,校验通过后再基于最新数据写入,遇到并发冲突时Firestore会自动重试整个事务流程,从机制上避免基于旧快照写入的问题。
示例代码:
const roomRef = doc(db, 'rooms', targetRoomId); await runTransaction(db, async (transaction) => { const roomSnap = await transaction.get(roomRef); const roomData = roomSnap.data() as Room; // 自定义校验逻辑 if (roomData.gameStatus === 'started') { throw new Error('房间已开局,无法加入'); } if (Object.keys(roomData.players).length >= roomData.maxPlayer) { throw new Error('房间人数已满'); } // 基于最新的players数据追加当前用户 transaction.update(roomRef, { [`players.${currentUserId}`]: {state: 0} }); });
注意:事务遇到并发冲突会自动重试,写入性能比直接原子更新差,没有特殊校验逻辑的话优先选方案2的键值对结构,是多人实时游戏房间的通用最佳实践。
内容的提问来源于stack exchange,提问作者Schmank

