如何利用Firebase Firestore实现悲观锁处理并发更新?
Firestore实现悲观锁方案
核心思路
以Firestore文档作为锁的载体,每个目标记录的唯一ID对应一个锁文档,通过事务+乐观锁模拟悲观锁效果,结合轮询重试机制,确保同一记录的函数调用串行执行,避免第三方数据库的更新覆盖问题。
锁文档结构
在Firestore中创建locks集合,每个锁文档ID与目标记录ID一一对应,文档包含以下字段:
locked: boolean类型,标记锁是否被占用expiresAt: 时间戳,设置锁的过期时间(防止函数崩溃导致锁永久无法释放)owner: 字符串(可选),标记持有锁的函数实例ID,用于问题排查
具体实现步骤
1. 获取锁(步骤0)
编写获取锁的函数,利用Firestore事务实现原子性的锁抢占逻辑,抢占失败则进入轮询重试:
- 事务内先读取对应锁文档,若文档不存在则直接创建并标记为锁定状态
- 若文档已存在,检查锁是否处于未占用状态,或是否已过期
- 满足抢占条件则更新锁状态为已占用,并设置新的过期时间(建议根据函数执行耗时调整,比如5分钟)
- 事务执行成功则返回锁获取成功;失败则等待指定间隔后重试,直到超时
示例代码(Node.js):
const { initializeApp } = require('firebase-admin/app'); const { getFirestore } = require('firebase-admin/firestore'); initializeApp(); const db = getFirestore(); async function acquireLock(recordId, timeoutMs = 30000, retryIntervalMs = 1000) { const lockRef = db.collection('locks').doc(recordId); const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { try { const success = await db.runTransaction(async (tx) => { const lockDoc = await tx.get(lockRef); const now = new Date(); // 锁不存在、已解锁或已过期时,抢占锁 if (!lockDoc.exists || !lockDoc.data().locked || lockDoc.data().expiresAt.toDate() < now) { tx.set(lockRef, { locked: true, expiresAt: new Date(now.getTime() + 5 * 60 * 1000), // 5分钟过期 owner: process.env.FUNCTION_INSTANCE_ID || 'unknown' }, { merge: true }); return true; } return false; }); if (success) { console.log(`锁获取成功:${recordId}`); return true; } } catch (err) { console.error(`锁获取失败,重试中:${err.message}`); } // 等待后重试 await new Promise(resolve => setTimeout(resolve, retryIntervalMs)); } console.error(`锁获取超时:${recordId}`); return false; }
2. 释放锁(步骤4)
函数执行完成(无论成功或失败),必须释放锁,同样通过事务确保原子性操作:
- 事务内读取锁文档,将
locked字段设为false;也可直接删除锁文档(无需保留锁历史时使用)
示例代码:
async function releaseLock(recordId) { const lockRef = db.collection('locks').doc(recordId); try { await db.runTransaction(async (tx) => { const lockDoc = await tx.get(lockRef); if (lockDoc.exists) { tx.update(lockRef, { locked: false }); // 若无需保留锁记录,可替换为:tx.delete(lockRef); } }); console.log(`锁释放成功:${recordId}`); } catch (err) { console.error(`锁释放失败:${err.message}`); } }
3. 主函数整合
将锁逻辑嵌入原有业务流程,确保无论执行结果如何都能释放锁:
exports.processRecord = async (req, res) => { const { recordId, partialPayload } = req.body; // 步骤0:获取锁 const lockAcquired = await acquireLock(recordId); if (!lockAcquired) { return res.status(503).send('无法获取锁,请稍后重试'); } try { // 步骤2:从第三方读取记录 const thirdPartyRecord = await fetchThirdPartyRecord(recordId); // 步骤3:修补记录并存储回第三方 const updatedRecord = { ...thirdPartyRecord, ...partialPayload }; await updateThirdPartyRecord(recordId, updatedRecord); res.status(200).send('记录更新成功'); } catch (err) { console.error(`记录处理失败:${err.message}`); res.status(500).send('记录处理失败'); } finally { // 步骤4:释放锁,无论成功失败都执行 await releaseLock(recordId); } };
关键注意事项
- 过期时间配置:需根据函数实际执行耗时设置,确保锁有效期覆盖整个处理流程,同时避免过期时间过长导致死锁
- 事务原子性:Firestore事务确保锁的读取和更新操作原子化,防止多个函数实例同时修改锁状态
- 轮询策略:并发量不超10次的场景下,1秒间隔重试足够,超时时间可根据业务需求调整(比如30秒)
- 异常兜底:必须在
finally块中执行锁释放逻辑,避免函数崩溃导致锁永久占用 - 锁文档清理:可定期清理过期且未锁定的锁文档,或在释放锁时直接删除文档,减少无用数据占用
内容的提问来源于stack exchange,提问作者david_adler
相关产品推荐
相关产品推荐

