原子锁
本指南介绍 AdonisJS 应用中的原子锁。你将学习如何:
- 安装并配置
@adonisjs/lock包 - 创建并释放锁以保护临界区
- 针对不同场景使用不同的获取方法
- 为长时间运行的操作延长锁的过期时间
- 在不同进程之间共享锁
概述
当多个进程或代码库的不同部分可能对同一资源执行并发操作时,原子锁可以防止竞态条件。考虑一个支付处理场景:由于网络重试,某个队列任务可能会被入队两次。如果没有适当的锁定机制,系统可能会向用户重复扣款。原子锁确保同一时间只有一个进程能够执行临界区。
@adonisjs/lock 包是对
Verrou
的封装,后者是由 AdonisJS 核心团队创建并维护的、与框架无关的锁定库。它支持三种存储后端:Redis、数据库和内存。
安装
使用以下命令安装并配置该包。
node ace add @adonisjs/lock
查看 add 命令执行的步骤
-
使用检测到的包管理器安装
@adonisjs/lock包。 -
在
adonisrc.ts文件中注册以下服务提供者。adonisrc.ts{ providers: [ // ...other providers () => import('@adonisjs/lock/lock_provider') ] } -
创建
config/lock.ts文件。 -
在
start/env.ts文件中定义LOCK_STORE环境变量及其校验。 -
为锁表创建数据库迁移(如果使用数据库存储)。
配置
配置存储在 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 连接。
{
redis: stores.redis({
connectionName: 'main',
}),
}
数据库存储
database 存储对 @adonisjs/lucid 包有对等依赖。在使用数据库存储之前,你必须
配置 Lucid
。
connectionName 是对 config/database.ts 文件中定义的数据库连接的引用。如果未定义,则使用默认的数据库连接。
{
database: stores.database({
connectionName: 'postgres',
tableName: 'my_locks',
}),
}
数据存储在 locks 表中。安装时会自动为该表创建迁移。不过,如有需要,你也可以手动创建包含以下内容的迁移。
迁移文件内容
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 环境变量进行配置。
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 方法是推荐的在锁内执行代码的方式。它会获取锁、执行你的回调,并在回调完成(或抛出错误)时自动释放锁。
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) {
// 锁不可用
}
手动管理锁
若要对锁的生命周期进行更精细的控制,请直接使用 acquire 和 release 方法。在使用手动获取时,你必须确保锁被释放,即使发生错误也要如此。
const acquired = await lock.acquire()
if (acquired) {
try {
// 执行受保护的操作
} finally {
await lock.release()
}
}
acquireImmediately 方法尝试在不等待的情况下获取锁,并在成功时返回 true。
const acquired = await lock.acquireImmediately()
if (!acquired) {
// 锁不可用,据此进行处理
}
尽可能优先使用 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 方法会将锁转换为一个字符串,该字符串可以存储并在其他地方恢复。
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 恢复锁。
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()
}