原子锁

本指南介绍 AdonisJS 应用中的原子锁。你将学习如何:

  • 安装并配置 @adonisjs/lock
  • 创建并释放锁以保护临界区
  • 针对不同场景使用不同的获取方法
  • 为长时间运行的操作延长锁的过期时间
  • 在不同进程之间共享锁

概述

当多个进程或代码库的不同部分可能对同一资源执行并发操作时,原子锁可以防止竞态条件。考虑一个支付处理场景:由于网络重试,某个队列任务可能会被入队两次。如果没有适当的锁定机制,系统可能会向用户重复扣款。原子锁确保同一时间只有一个进程能够执行临界区。

@adonisjs/lock 包是对 Verrou 的封装,后者是由 AdonisJS 核心团队创建并维护的、与框架无关的锁定库。它支持三种存储后端:Redis、数据库和内存。

安装

使用以下命令安装并配置该包。

node ace add @adonisjs/lock
查看 add 命令执行的步骤
  1. 使用检测到的包管理器安装 @adonisjs/lock 包。

  2. adonisrc.ts 文件中注册以下服务提供者。

    adonisrc.ts
    {
      providers: [
        // ...other providers
        () => import('@adonisjs/lock/lock_provider')
      ]
    }
  3. 创建 config/lock.ts 文件。

  4. start/env.ts 文件中定义 LOCK_STORE 环境变量及其校验。

  5. 为锁表创建数据库迁移(如果使用数据库存储)。

配置

配置存储在 config/lock.ts 文件中。

config/lock.ts
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/lock'

const lockConfig = defineConfig({
  default: env.get('LOCK_STORE'),
  stores: {
    /**
     * 用于管理锁的 Redis 存储。
     * 需要 @adonisjs/redis 包。
     */
    redis: stores.redis({}),

    /**
     * 用于管理锁的数据库存储。
     * 需要 @adonisjs/lucid 包。
     */
    database: stores.database({
      tableName: 'locks'
    }),

    /**
     * 内存存储可用于测试期间。
     */
    memory: stores.memory()
  },
})

export default lockConfig

declare module '@adonisjs/lock/types' {
  export interface LockStoresList extends InferLockStores<typeof lockConfig> {}
}

Redis 存储

redis 存储对 @adonisjs/redis 包有对等依赖。在使用 Redis 存储之前,你必须 配置 Redis 包

connectionName 是对 config/redis.ts 文件中定义的连接的引用。如果未定义,则使用默认的 Redis 连接。

config/lock.ts
{
  redis: stores.redis({
    connectionName: 'main',
  }),
}

数据库存储

database 存储对 @adonisjs/lucid 包有对等依赖。在使用数据库存储之前,你必须 配置 Lucid

connectionName 是对 config/database.ts 文件中定义的数据库连接的引用。如果未定义,则使用默认的数据库连接。

config/lock.ts
{
  database: stores.database({
    connectionName: 'postgres',
    tableName: 'my_locks',
  }),
}

数据存储在 locks 表中。安装时会自动为该表创建迁移。不过,如有需要,你也可以手动创建包含以下内容的迁移。

迁移文件内容
database/migrations/xxxx_create_locks_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'locks'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.string('key', 255).notNullable().primary()
      table.string('owner').notNullable()
      table.bigint('expiration').unsigned().nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

环境变量

默认存储通过 LOCK_STORE 环境变量进行配置。

.env
LOCK_STORE=redis

创建锁

使用锁管理器服务提供的 createLock 方法创建锁。该方法接受一个唯一标识被锁定资源的键,以及一个定义锁有效时长的 TTL(time-to-live,生存时间)。

import lockManager from '@adonisjs/lock/services/main'

const lock = lockManager.createLock('processing_payment:order:42', '30s')

锁键应当唯一标识被保护的资源。一种常见模式是使用描述性前缀后跟一个标识符,例如 processing_payment:order:${orderId}sending_email:user:${userId}

TTL 接受时间表达式字符串(如 '10s''5m''1h')或毫秒为单位的数字。TTL 充当安全机制。如果某个进程在持有锁时发生崩溃,锁会在 TTL 到期后自动过期,从而防止死锁。

获取锁

该包提供了多种获取锁的方法,各自适用于不同的场景。

在锁内运行代码

run 方法是推荐的在锁内执行代码的方式。它会获取锁、执行你的回调,并在回调完成(或抛出错误)时自动释放锁。

app/services/payment_service.ts
import lockManager from '@adonisjs/lock/services/main'

export default class PaymentService {
  async processPayment(order: Order) {
    const lock = lockManager.createLock(
      `processing_payment:order:${order.id}`,
      '30s'
    )

    const [acquired, result] = await lock.run(async () => {
      /**
       * 此回调仅在获取锁之后才会执行。
       * 当回调完成或抛出错误时,锁会被自动释放。
       */
      const charge = await this.chargeCustomer(order)
      await order.merge({ status: 'paid', chargeId: charge.id }).save()
      return charge
    })

    if (!acquired) {
      return { success: false, message: 'Payment already in progress' }
    }

    return { success: true, charge: result }
  }
}

默认情况下,run 会无限期等待,直到锁变为可用。你可以使用选项来配置这一行为。

立即运行,否则不运行

runImmediately 方法尝试在不等待的情况下获取锁。如果锁已被另一个进程持有,回调将不会执行。

const [acquired, result] = await lock.runImmediately(async () => {
  // 仅当锁被立即获取时才会运行
})

if (!acquired) {
  // 锁不可用
}

手动管理锁

若要对锁的生命周期进行更精细的控制,请直接使用 acquirerelease 方法。在使用手动获取时,你必须确保锁被释放,即使发生错误也要如此。

const acquired = await lock.acquire()

if (acquired) {
  try {
    // 执行受保护的操作
  } finally {
    await lock.release()
  }
}

acquireImmediately 方法尝试在不等待的情况下获取锁,并在成功时返回 true

const acquired = await lock.acquireImmediately()

if (!acquired) {
  // 锁不可用,据此进行处理
}
Tip

尽可能优先使用 run 方法而非手动获取。它会自动处理锁的释放,包括在发生异常时,从而避免意外的锁泄漏。

锁选项

在获取锁时,你可以配置重试行为与超时时间。

timeout

在放弃获取锁之前等待的最长时间。接受时间表达式字符串或毫秒值。

await lock.acquire({ timeout: '5s' })
attempts

在抛出错误之前的最大重试次数。

await lock.acquire({ attempts: 3 })
delay

两次重试之间的延迟。接受时间表达式字符串或毫秒值。

await lock.acquire({ delay: '100ms' })

你可以组合这些选项以实现精细控制。

const acquired = await lock.acquire({
  timeout: '10s',
  attempts: 5,
  delay: '500ms'
})

检查锁状态

你可以使用以下方法检查锁的当前状态。

const lock = lockManager.createLock('my_resource', '30s')

/**
 * 检查是否有任何进程当前持有该锁。
 */
const locked = await lock.isLocked()

/**
 * 检查锁是否已过期。
 */
const expired = await lock.isExpired()

/**
 * 获取锁过期前的剩余时间(毫秒)。
 * 如果锁未被持有,则返回 null。
 */
const remaining = await lock.getRemainingTime()

延长锁

对于可能超过初始 TTL 的长时间运行操作,你可以延长锁的持续时间。当你无法准确预测某个操作将耗时多久时,这会很有用。

const lock = lockManager.createLock('long_running_task', '10s')

await lock.acquire()

try {
  for (const item of largeDataset) {
    await processItem(item)

    /**
     * 再延长 10 秒锁,以防止
     * 在处理期间过期。
     */
    await lock.extend('10s')
  }
} finally {
  await lock.release()
}

在进程之间共享锁

在分布式系统中,你可能需要在某个进程中获取锁,并在另一个进程中释放它。serialize 方法会将锁转换为一个字符串,该字符串可以存储并在其他地方恢复。

app/jobs/start_processing.ts
import lockManager from '@adonisjs/lock/services/main'

const lock = lockManager.createLock('batch_job:123', '5m')
await lock.acquire()

/**
 * 序列化锁以便存储。该字符串包含
 * 恢复锁所需的全部信息。
 */
const serialized = lock.serialize()

/**
 * 存储序列化后的锁(例如在数据库或缓存中),
 * 以便另一个进程取回。
 */
await redis.set('batch_job:123:lock', serialized)

在另一个进程中,使用 restoreLock 恢复锁。

app/jobs/finish_processing.ts
import lockManager from '@adonisjs/lock/services/main'

const serialized = await redis.get('batch_job:123:lock')

/**
 * 从序列化形式恢复锁。
 * 这会创建一个具有相同 owner 的锁实例,
 * 从而允许此进程释放它。
 */
const lock = lockManager.restoreLock(serialized)

try {
  // 完成处理
} finally {
  await lock.release()
}

另见