前言
在分布式系统中,多个服务实例同时操作共享资源时,如何保证数据一致性是一个经典问题。传统的单机锁(如 synchronized、ReentrantLock)在分布式环境下失效,我们需要分布式锁来解决。
本文将介绍如何在 NestJS 中设计并实现一个声明式分布式锁组件,通过 @RedLock 装饰器实现无侵入式的并发控制。
一、为什么需要分布式锁?
1.1 问题场景
┌─────────────────────────────────────────────────────────────────┐
│ 典型场景:秒杀抢购 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 用户 A ────┐ │
│ │ │
│ 用户 B ────┼───► 服务实例 1 ────┐ │
│ │ │ │
│ 用户 C ────┼───► 服务实例 2 ────┼───► 数据库(库存 = 1) │
│ │ │ │
│ 用户 D ────┼───► 服务实例 3 ────┘ │
│ │ │
│ └─── 问题:三个实例同时查询库存,都认为有库存 │
│ 导致超卖! │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 分布式锁的核心诉求
| 特性 | 说明 |
|---|---|
| 互斥性 | 同一时刻只有一个客户端持有锁 |
| 防死锁 | 锁必须有过期时间,防止持有者崩溃后无法释放 |
| 高可用 | 锁服务不能成为单点故障 |
| 可重入 | 同一线程可多次获取同一把锁 |
二、Redlock 算法简介
Redlock(Redis Distributed Lock)是 Redis 作者 Antirez 提出的分布式锁算法,核心思想是:
向多个独立的 Redis 节点请求加锁,只有当在大多数节点上都成功获取锁时,才算加锁成功。
┌─────────────────────────────────────────────────────────────────┐
│ Redlock 算法流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 客户端 │
│ │ │
│ ├─── 1. 获取当前时间戳 │
│ │ │
│ ├─── 2. 依次向 N 个 Redis 节点请求加锁 │
│ │ ┌─────────────────────────────────────┐ │
│ │ │ Redis-1 │ Redis-2 │ Redis-3 │ │
│ │ │ ✓ │ ✓ │ ✗ │ │
│ │ └─────────────────────────────────────┘ │
│ │ │
│ ├─── 3. 计算获取锁消耗的时间 │
│ │ │
│ ├─── 4. 有效锁 = 加锁成功数 > N/2 且 消耗时间 < 锁TTL │
│ │ │
│ └─── 5. 加锁成功 / 失败则向所有节点发释放请求 │
│ │
└─────────────────────────────────────────────────────────────────┘
三、模块设计概览
我们设计的 Redlock 模块包含以下核心组件:
libs/redlock/src/
├── redlock.interface.ts # 类型定义
├── redlock.module-definition.ts # 动态模块构建器
├── redlock.service.ts # 核心服务(继承 Redlock)
├── redlock.decorator.ts # @RedLock 声明式装饰器
├── redlock.module.ts # 模块定义
└── index.ts # 导出
四、核心实现解析
4.1 类型定义
首先定义模块配置的接口:
// redlock.interface.ts
import { RedisOptions } from "ioredis";
import { Settings } from "redlock";
export interface RedlockModuleOptions {
// 支持单节点或多节点 Redis 配置
redisClient: RedisOptions | RedisOptions[]
// Redlock 高级配置
settings?: Partial<Settings>
}
4.2 动态模块构建
使用 NestJS 的 ConfigurableModuleBuilder 实现动态模块配置:
// redlock.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { RedlockModuleOptions } from './redlock.interface';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<RedlockModuleOptions>()
.setClassMethodName('forRoot')
.setFactoryMethodName('createRedlockOptions')
.setExtras({
isGlobal: true, // 默认全局模块
}, (definition, extras) => ({
...definition,
isGlobal: extras.isGlobal,
}))
.build();
设计亮点:
- 使用
ConfigurableModuleBuilder简化动态模块创建 - 默认设置为全局模块,避免重复导入
- 通过
MODULE_OPTIONS_TOKEN实现配置注入
4.3 服务层实现
RedlockService 继承 Redlock,封装 Redis 客户端初始化:
// redlock.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { MODULE_OPTIONS_TOKEN } from './redlock.module-definition';
import { RedlockModuleOptions } from './redlock.interface';
import Redlock from 'redlock';
import Client from "ioredis";
@Injectable()
export class RedlockService extends Redlock {
constructor(@Inject(MODULE_OPTIONS_TOKEN) options: RedlockModuleOptions) {
// 将单个或多个配置统一为数组,然后 map 创建客户端
const clients = [options.redisClient]
.flat()
.map(config => new Client(config));
super(clients, options.settings);
}
}
代码解析:
[options.redisClient].flat()巧妙处理单节点/多节点配置- 继承
Redlock使服务具备完整的锁操作能力 - 通过依赖注入获取配置,符合 NestJS 设计原则
4.4 模块定义
// redlock.module.ts
import { Global, Module } from '@nestjs/common';
import { RedlockService } from './redlock.service';
import { ConfigurableModuleClass } from './redlock.module-definition';
@Module({
providers: [RedlockService],
exports: [RedlockService],
})
export class RedlockModule extends ConfigurableModuleClass {}
使用方式:
// app.module.ts
import { RedlockModule } from '@app/redlock';
@Module({
imports: [
RedlockModule.forRoot({
redisClient: {
host: 'localhost',
port: 6379,
},
settings: {
// 锁默认过期时间
driftFactor: 0.01,
retryCount: 3,
retryDelay: 200,
},
}),
],
})
export class AppModule {}
五、声明式装饰器实现(核心亮点)
这是整个模块最精妙的部分,通过装饰器 + Proxy 实现声明式锁控制。
除此之外当然还有很多种实现方式,如Interceptor、Injector等等方案 这里降低代码耦合度我决定使用ModuleRef的特性在运行时获取到RedlockService去获取锁(不过对比手动注入RedlockService方案使用ModuleRef会略微增加一点性能消耗可忽略不计)。
5.1 装饰器设计
// redlock.decorator.ts
import { HttpException, HttpStatus } from "@nestjs/common";
import { RedlockService } from "./redlock.service";
import { ExecutionError, Settings, Lock } from "redlock";
import { ModuleRef } from "@nestjs/core";
export const RedLock = (
key: string | string[], // 锁的 key,支持多个
ttl: number, // 锁过期时间(毫秒)
settings?: Partial<Settings> // 可选的高级配置
) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
// 校验装饰目标必须是方法
if (!descriptor || typeof descriptor.value !== 'function') {
throw new Error(
`@RedLock 装饰器只能应用于方法。属性 ${String(propertyKey)} 不是一个方法。`,
);
}
// 注入 ModuleRef 用于运行时获取 RedlockService
Inject(ModuleRef)(target, ModuleRef.name);
//Inject(RedlockService)(target,ModuleRef.name) 同时也可以手动注入 ,但无法throw异常提示RedlockService 未注入或者模块未配置等
// 使用 Proxy 代理方法调用
descriptor.value = new Proxy(originalMethod, {
apply: async (target, thisArg, argumentsList) => {
// 运行时获取 RedlockService 实例
const moduleRef = thisArg[ModuleRef.name] as ModuleRef;
const redlockService = moduleRef.get(RedlockService, {
strict: false
});
if (!redlockService) {
throw new Error(
'@RedLock 装饰器需要 RedlockService 但未注入,' +
'请检查 RedLockModule 是否正确配置'
);
}
let lock: Lock | undefined;
try {
// 获取锁
lock = await redlockService.acquire(
Array.isArray(key) ? key : [key],
ttl,
settings
);
// 执行原始方法
return await Reflect.apply(target, thisArg, argumentsList);
} catch (error) {
// 锁获取失败处理
if (error instanceof ExecutionError) {
throw new HttpException(
'业务繁忙,请稍后再试!',
HttpStatus.CONFLICT
);
}
throw error;
} finally {
// 确保锁被释放
if (lock) {
await lock.release().catch(console.error);
}
}
},
});
return descriptor;
};
};
5.2 工作流程图
┌─────────────────────────────────────────────────────────────────┐
│ @RedLock 装饰器执行流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 方法调用 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Proxy.apply 拦截 │ │
│ │ │ │
│ │ 1. 通过 ModuleRef 获取 RedlockService │ │
│ │ 2. 调用 acquire() 获取分布式锁 │ │
│ │ │ │ │
│ │ ├─── 成功 ──► 执行原始方法 │ │
│ │ │ │ │
│ │ └─── 失败 ──► 抛出业务异常 │ │
│ │ │ │
│ │ 3. finally 释放锁 │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 返回结果 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.3 为什么使用 ModuleRef 而不是直接注入?
// 方案一:直接注入(不推荐)
// 问题:需要提前在目标类中注入 RedlockService,侵入性强
// 方案二:通过 ModuleRef 动态获取(推荐)
// 优点:无需提前注入,运行时按需获取,减少耦合
六、实际使用示例
6.1 基础用法
// user.service.ts
import { RedLock } from '@app/redlock';
import { Injectable } from '@nestjs/common';
@Injectable()
export class UserService {
// 单个锁 key
@RedLock('user:deduct:balance', 5000) // 5秒过期
async deductBalance(userId: number, amount: number) {
// 业务逻辑,此时已持有分布式锁
// 不会出现并发扣款问题
}
// 多个锁 key(同时锁多个资源)
@RedLock(['order:create', 'inventory:check'], 10000)
async createOrder(productId: number, userId: number) {
// 同时锁定订单创建和库存检查
// 防止超卖
}
}
6.2 与事务结合
// withdraw.service.ts
@Injectable()
export class WithdrawService {
constructor(private prisma: PrismaService) {}
@RedLock('withdraw:process', 10000)
async processWithdraw(userId: number, amount: number) {
return this.prisma.$transaction(async (tx) => {
// 1. 查询用户余额
const user = await tx.user.findUnique({ where: { id: userId } });
// 2. 检查余额是否足够
if (user.balance < amount) {
throw new BadRequestException('余额不足');
}
// 3. 扣除余额
await tx.user.update({
where: { id: userId },
data: { balance: { decrement: amount } }
});
// 4. 创建提现记录
return tx.withdrawRecord.create({
data: { userId, amount, status: 'pending' }
});
});
}
}
七、与传统方案对比
7.1 传统手动加锁方式
// 传统方式:手动管理锁生命周期
async deductBalance(userId: number, amount: number) {
let lock;
try {
// 手动获取锁
lock = await this.redlockService.acquire(['user:balance'], 5000);
// 业务逻辑
await this.doSomething();
} catch (error) {
if (error instanceof ExecutionError) {
throw new HttpException('请稍后重试', HttpStatus.CONFLICT);
}
throw error;
} finally {
// 手动释放锁
if (lock) await lock.release();
}
}
问题:
- 代码冗余,每个需要锁的方法都要重复 try-catch-finally
- 容易遗漏释放锁,导致死锁
- 锁 key 管理分散
7.2 声明式装饰器方式
// 声明式:一行注解搞定
@RedLock('user:balance', 5000)
async deductBalance(userId: number, amount: number) {
// 纯粹的业务逻辑
await this.doSomething();
}
优势:
| 对比项 | 传统方式 | 装饰器方式 |
|---|---|---|
| 代码量 | 多 | 少 |
| 可读性 | 业务逻辑被锁代码包围 | 清晰直观 |
| 维护性 | 容易遗漏释放 | 自动释放 |
| 复用性 | 每次都要写 | 一处定义处处可用 |
八、总结
8.1 核心设计思想
┌─────────────────────────────────────────────────────────────────┐
│ 设计思想总结 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 动态模块配置 │
│ └── ConfigurableModuleBuilder 支持灵活配置 │
│ │
│ 2. 继承优于组合 │
│ └── RedlockService 继承 Redlock,保留完整功能 │
│ │
│ 3. 装饰器 + Proxy │
│ └── 声明式编程,无侵入式增强 │
│ │
│ 4. ModuleRef 动态依赖 │
│ └── 运行时获取,减少耦合 │
│ │
│ 5. 统一异常处理 │
│ └── 将技术异常转换为业务异常 │
│ │
└─────────────────────────────────────────────────────────────────┘
8.3 注意事项
- 锁的粒度:锁 key 设计要合理,粒度过大会影响并发性能
- TTL 设置:过期时间要大于业务执行时间,但要合理控制
- 异常处理:获取锁失败要有降级策略
- Redis 集群:生产环境建议使用多节点提高可用性
参考资料: