【NestJs】基于Redlock装饰器分布式锁设计与实现

前言

在分布式系统中,多个服务实例同时操作共享资源时,如何保证数据一致性是一个经典问题。传统的单机锁(如 synchronizedReentrantLock)在分布式环境下失效,我们需要分布式锁来解决。

本文将介绍如何在 NestJS 中设计并实现一个声明式分布式锁 组件,通过 @RedLock 装饰器实现无侵入式的并发控制。

一、为什么需要分布式锁?

1.1 问题场景

css 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    典型场景:秒杀抢购                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  用户 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 模块包含以下核心组件:

ruby 复制代码
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 类型定义

首先定义模块配置的接口:

typescript 复制代码
// 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 实现动态模块配置:

typescript 复制代码
// 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 客户端初始化:

typescript 复制代码
// 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 模块定义

typescript 复制代码
// 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 {}

使用方式:

typescript 复制代码
// 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 装饰器设计

typescript 复制代码
// 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 工作流程图

typescript 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    @RedLock 装饰器执行流程                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  方法调用                                                        │
│    │                                                            │
│    ▼                                                            │
│  ┌─────────────────────────────────────────┐                   │
│  │ Proxy.apply 拦截                         │                   │
│  │                                          │                   │
│  │  1. 通过 ModuleRef 获取 RedlockService   │                   │
│  │  2. 调用 acquire() 获取分布式锁          │                   │
│  │     │                                    │                   │
│  │     ├─── 成功 ──► 执行原始方法           │                   │
│  │     │                                    │                   │
│  │     └─── 失败 ──► 抛出业务异常           │                   │
│  │                                          │                   │
│  │  3. finally 释放锁                       │                   │
│  └─────────────────────────────────────────┘                   │
│    │                                                            │
│    ▼                                                            │
│  返回结果                                                        │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

5.3 为什么使用 ModuleRef 而不是直接注入?

typescript 复制代码
// 方案一:直接注入(不推荐)
// 问题:需要提前在目标类中注入 RedlockService,侵入性强

// 方案二:通过 ModuleRef 动态获取(推荐)
// 优点:无需提前注入,运行时按需获取,减少耦合

六、实际使用示例

6.1 基础用法

typescript 复制代码
// 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 与事务结合

typescript 复制代码
// 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 传统手动加锁方式

typescript 复制代码
// 传统方式:手动管理锁生命周期
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 声明式装饰器方式

typescript 复制代码
// 声明式:一行注解搞定
@RedLock('user:balance', 5000)
async deductBalance(userId: number, amount: number) {
    // 纯粹的业务逻辑
    await this.doSomething();
}

优势:

对比项 传统方式 装饰器方式
代码量
可读性 业务逻辑被锁代码包围 清晰直观
维护性 容易遗漏释放 自动释放
复用性 每次都要写 一处定义处处可用

八、总结

8.1 核心设计思想

javascript 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    设计思想总结                                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 动态模块配置                                                 │
│     └── ConfigurableModuleBuilder 支持灵活配置                   │
│                                                                 │
│  2. 继承优于组合                                                 │
│     └── RedlockService 继承 Redlock,保留完整功能                │
│                                                                 │
│  3. 装饰器 + Proxy                                               │
│     └── 声明式编程,无侵入式增强                                  │
│                                                                 │
│  4. ModuleRef 动态依赖                                           │
│     └── 运行时获取,减少耦合                                      │
│                                                                 │
│  5. 统一异常处理                                                 │
│     └── 将技术异常转换为业务异常                                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

8.3 注意事项

  1. 锁的粒度:锁 key 设计要合理,粒度过大会影响并发性能
  2. TTL 设置:过期时间要大于业务执行时间,但要合理控制
  3. 异常处理:获取锁失败要有降级策略
  4. Redis 集群:生产环境建议使用多节点提高可用性

参考资料:

相关推荐
初次攀爬者2 小时前
Kafka + KRaft模式架构基础介绍
后端·kafka
洛森唛2 小时前
Elasticsearch DSL 查询语法大全:从入门到精通
后端·elasticsearch
拳打南山敬老院3 小时前
Context 不是压缩出来的,而是设计出来的
前端·后端·aigc
初次攀爬者3 小时前
Kafka + ZooKeeper架构基础介绍
后端·zookeeper·kafka
LucianaiB3 小时前
Openclaw 安装使用保姆级教程(最新版)
后端
华仔啊3 小时前
Stream 代码越写越难看?JDFrame 让 Java 逻辑回归优雅
java·后端
哈密瓜的眉毛美3 小时前
零基础学Java|第五篇:进制转换与位运算、原码反码补码
后端
开心就好20254 小时前
免 Xcode 的 iOS 开发新选择?聊聊一款更轻量的 iOS 开发 IDE kxapp 快蝎
后端·ios
Java编程爱好者4 小时前
为什么国内大厂纷纷”弃坑”MySQL,转投PostgreSQL阵营?
后端