如何让Firebase Database双人游戏匹配场景具备多用户安全性?
问题:Firebase Realtime Database 双人手游匹配时避免重复获取 lobby 记录
我打算用 Firebase Database 给我的简易双人手游实现匹配功能,流程分为两种情况:
无待处理游戏请求时
- 用户选择寻找游戏
- 对
lobby引用执行limitToFirst(1)查询 - 未找到记录
- 使用
push将用户 UID 写入lobby - 等待其他游戏请求发现该记录
有待处理游戏请求时
- 用户选择寻找游戏
- 对数据库的
lobby引用执行limitToFirst(1)查询 - 找到含 UID 的记录
- 删除
lobby中的该记录 - 创建包含两名玩家的游戏
现在遇到的问题是:如果多名用户同时发起找游戏请求,可能出现该记录被删除前,多个用户的 limitToFirst 查询获取到同一个 UID 的情况。我想确保 lobby 中的 UID 仅被获取一次用于创建游戏。我查阅了事务相关文档,但似乎不适用此场景,我不想让读写操作持续重试,而是希望在同一调用中完成读取和删除,避免该值被重复使用。补充:该逻辑将在 Firebase Function 中实现。
解决方案:用 Firebase 事务实现原子性读删,解决匹配冲突问题
其实你担心的事务不适用是误解啦——事务恰恰是解决这种并发冲突的最佳方案,而且在 Firebase Functions 里执行的话,我们可以把逻辑写得很高效,不会出现不必要的重试,还能保证同一时间只有一个请求能成功读取并删除那条 lobby 记录。
核心思路
事务的本质是让你在一个原子操作里完成「读取数据→修改/删除数据」的流程,Firebase 会自动锁定目标节点,在事务执行期间,其他任何请求都没法修改这个节点。如果事务执行过程中数据被其他操作改动了,Firebase 会重试事务,但我们可以通过逻辑控制,让重试只在必要时发生,或者直接终止。
具体实现步骤(Firebase Functions 版本)
- 查询 lobby 第一条记录:先获取
lobby下的第一个节点(要拿到节点的 key,因为后续需要精准删除它)。 - 对目标节点执行事务:在事务里检查节点是否存在(也就是还没被其他请求删掉),如果存在就把它设为
null(即删除),同时返回原 UID;如果不存在,直接让事务失败。 - 根据事务结果处理匹配:
- 事务成功:说明成功抢到了这条记录,用该 UID 和当前用户 UID 创建游戏。
- 事务失败:说明这条记录已经被其他请求处理了,把当前用户 UID 推入
lobby等待匹配。
示例代码
const functions = require("firebase-functions"); const admin = require("firebase-admin"); admin.initializeApp(); exports.findMatch = functions.https.onCall(async (data, context) => { // 先校验用户是否登录 if (!context.auth) { throw new functions.https.HttpsError("unauthenticated", "请先登录"); } const currentUserUid = context.auth.uid; const lobbyRef = admin.database().ref("lobby"); // 第一步:查询lobby里的第一条待匹配记录 const snapshot = await lobbyRef.limitToFirst(1).once("value"); if (!snapshot.exists()) { // 没有待匹配记录,把当前用户加入队列 await lobbyRef.push(currentUserUid); return { status: "waiting", message: "已加入匹配队列,等待其他玩家" }; } // 拿到待匹配记录的key和对应的UID const [lobbyKey, matchedUserUid] = Object.entries(snapshot.val())[0]; const targetRef = lobbyRef.child(lobbyKey); // 第二步:原子性执行读删操作的事务 const transactionResult = await targetRef.runTransaction((currentData) => { if (currentData === null) { // 记录已经被其他请求删了,返回null让事务失败 return null; } // 记录还在,把它设为null(删除),返回原数据 return null; }); if (!transactionResult.committed) { // 事务没提交,说明记录被抢了,重新加入队列 await lobbyRef.push(currentUserUid); return { status: "waiting", message: "匹配失败,已重新加入队列" }; } // 事务成功,创建新游戏 const gameRef = admin.database().ref("games").push(); await gameRef.set({ players: [currentUserUid, matchedUserUid], status: "active" }); return { status: "matched", message: "匹配成功", gameId: gameRef.key, opponentUid: matchedUserUid }; });
为什么这个方案能解决问题?
- 原子性保障:
runTransaction会锁定目标节点,在事务完成前,其他请求根本碰不到这条记录。只有第一个成功执行事务的请求能删掉它,后续请求的事务会发现节点已经是null,直接失败。 - 避免重复获取:哪怕多个请求同时查到了同一条 lobby 记录,事务的排他性也能确保只有一个请求能完成匹配,其他请求会自动回到等待队列。
- 可控的重试:这个逻辑里,事务只会在节点被其他事务修改时重试,但我们一旦发现节点为空就直接返回,不会出现无限重试的情况。
额外优化建议
- 给
lobby里的记录加过期时间:用 Firebase Functions 定时任务清理超过一定时间的待匹配记录,避免用户退出后记录一直占着队列。 - 匹配成功后发通知:结合 Firebase Cloud Messaging 给两位玩家推送实时通知,让客户端第一时间知晓匹配结果。
内容的提问来源于stack exchange,提问作者Tristan




