繁体   English   中英

如何使用 Firebase Firestore 获取悲观锁?

[英]How can I use Firebase Firestore to acquire a pessimistic lock?

我有一些 firebase function 执行以下操作

  1. 接受一些记录的部分有效载荷作为输入
  2. 从第三方读取该记录
  3. 使用部分有效载荷记录并将其存储在第三方的补丁

第三方有自己的数据库,不支持任何类型的部分更新/锁定。 用户可能会同时调用此端点,因此我需要实施悲观锁/FIFO 队列以确保 function 永远不会针对该特定记录同时运行。 否则,一个用户的更新可能会破坏另一个用户。 请注意,这不是性能问题,因为并发调用不太可能超过 10 个。

以下是我想象的如何通过添加步骤 0 和 4 以高级术语实现此锁定:

0:获取具有特定 ID 的锁,如果该锁已被获取,则继续轮询直到它空闲或锁过期

...

4:现在记录补丁已经完成释放锁

我如何使用 Firebase Firstore 实现这样的锁定机制?

我找到了一个方法,你可以使用 firebase 事务来检查一行是否存在,如果不存在则可以获取锁:

import { getFirestore } from 'firebase-admin/firestore'

export type Lock = {
  lockId: string
  release: () => Promise<void>
}

export const Locks = {
  /** Use firestore to acquire a lock, by writing the expiration time to the `locks/${lockId}` document.
   * If the document already exists and the expiration time is in the future, then exit without acquiring the lock.
   * If the document does not exist or the expiration time is in the past, then the acquire the lock and return a Lock object. */
  async acquire(lockId: string, { lockDurationMs = 1000 * 60 } = {}): Promise<Lock | undefined> {
    const db = getFirestore()
    const docRef = db.collection('locks').doc(lockId)
    const maybeLock = await db.runTransaction(async transaction => {
      const doc = await transaction.get(docRef)
      const expiration = doc.data()?.expiration || 0
      if (expiration > Date.now()) {
        // Someone else has the lock
        return
      }
      const newExpiration = Date.now() + lockDurationMs
      await transaction.set(docRef, { expiration: newExpiration }, { merge: true })
      const lock: Lock = {
        lockId,
        release: async () => {
          await docRef.set({ expiration: null }, { merge: true })
        },
      }
      return lock
    })
    return maybeLock
  },
  /** Poll for a lock to be acquired, and return it when it is.
   * Will return undefined if the lock is not acquired within the timeout. */
  async pollUntilAcquired(
    lockId: string,
    { lockDurationMs = 1000 * 60, pollIntervalMs = 1000, timeoutMs = 1000 * 60 * 2 } = {}
  ) {
    const lock = await pollFor(() => this.acquire(lockId, { lockDurationMs }), {
      interval: pollIntervalMs,
      // If we don't get the lock within 2 minutes, then something is wrong
      timeout: timeoutMs,
    })
    return lock
  },
}

// #region Tests
if (process.env.TESTING) {
  async function testLock() {
    const { initFirebaseAdmin } = require('./initFirebaseAdmin')
    initFirebaseAdmin('staging')

    const db = getFirestore()
    const locks = db.collection('locks')

    async function getTestLock() {
      const doc = await locks.doc('testLock').get()
      const data = doc.data()
      return data?.expiration || 0
    }

    {
      // TEST 1: Acquire a lock, check it inserts the row, then release it and check it deletes the row
      const lock = await Locks.acquire('testLock', { lockDurationMs: 1000 })
      const expiration = await getTestLock()
      assert(expiration > Date.now(), 'Lock was not acquired')
      await lock?.release()
      const expiration2 = await getTestLock()
      assert(expiration2 === 0, 'Lock was not released')
    }

    {
      // TEST 2: Acquire a lock, then try to acquire it again and check it fails
      const lock = await Locks.acquire('testLock', { lockDurationMs: 1000 })
      const lock2 = await Locks.acquire('testLock', { lockDurationMs: 1000 })
      assert(!lock2, 'Lock was acquired twice')
      await lock?.release()
    }

    {
      // TEST 3: Acquire a lock, then try to acquire it again with polling and check it succeeds
      const lock = await Locks.acquire('testLock', { lockDurationMs: 1000 })
      const lock2 = await Locks.pollUntilAcquired('testLock', { lockDurationMs: 1000 })
      assert(lock2, 'Lock was not acquired after polling, but it should have succeeded')
      await lock?.release()
      await lock2?.release()
    }
  }
  void testLock().catch(err => {
    console.error(err)
    process.exit(1)
  })
}

// #endregion

// #region Utils: you can skip this region of code, it's only included so that the snippet is self-contained

function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

function isUndefined(x: unknown): x is undefined {
  return typeof x === 'undefined'
}

/**
 * If neither timeout nor attempts is provided, defaults to 30
 * attempts.
 * If only timeout is provided, attempts will be infinite.
 * If only attempts is provided, timeout will be infinite.
 * If both are provided, both will be used to limit the poll.
 */
const pollFor = async <T>(
  fn: () => Promise<T | undefined> | T | undefined,
  opts?: {
    /** Defaults to 0 - in milliseconds */
    interval?: number
    /** In milliseconds  */
    timeout?: number
    attempts?: number
  }
) => {
  let { interval = 0, timeout, attempts } = opts || {} // eslint-disable-line
  if (!isUndefined(timeout) && isUndefined(attempts)) attempts = Infinity
  attempts = isUndefined(attempts) ? 30 : attempts
  timeout = isUndefined(timeout) ? Infinity : timeout

  const start = Date.now()
  for (let i = 1; i < attempts + 1; i++) {
    const result = await fn()
    if (result !== undefined) return result
    if (i > attempts) return
    if (Date.now() - start > timeout) return
    await sleep(interval)
  }

  return
}

function assert(
  predicate: any,
  message: string,
  extra?: Record<string, unknown>
): asserts predicate {
  if (!predicate) {
    throw new AssertionError(message, extra)
  }
}

class AssertionError extends Error {
  extra: Record<string, unknown> | undefined
  constructor(message: string, extra: Record<string, unknown> | undefined = undefined) {
    super(message)
    this.name = 'AssertionError'
    this.extra = extra
  }
  toJSON() {
    return {
      message: this.message,
      name: this.name,
      extra: this.extra,
    }
  }
}

// #endregion

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM