在单机环境下,NestJS官方提供的@nestjs/schedule模块开箱即用,一个@Cron装饰器就能把定时任务跑起来。可一旦服务以多副本形式部署在Kubernetes或者PM2集群里,问题就来了:每个实例都会按同样的时间表达式触发任务,同一个任务被重复执行N次。轻则浪费资源,重则造成数据重复写入、消息重复推送等线上事故。解决这个问题的通用思路是给任务加锁,谁抢到锁谁执行。这篇文章就来聊聊如何用TypeScript为这套锁机制设计严谨的类型系统,并封装成一个可复用的模块。

一、先理解Scheduler的执行模型与锁的切入点
在动手写代码之前,需要先弄清楚@nestjs/schedule的工作方式。这个模块在应用启动时通过SchedulerRegistry扫描所有使用了@Cron、@Interval、@Timeout装饰器的方法,把它们注册到底层调度器上。到了触发时间点,调度器直接调用被装饰的方法,中间没有任何"是否该执行"的判断逻辑。
这意味着锁机制有两种切入点:一种是直接改造任务方法本身,在方法体开头尝试获取锁,拿不到就直接return;另一种是在装饰器层面做拦截,把锁的获取与释放封装到方法之外,业务代码保持纯净。第一种方式实现简单但侵入性强,每个任务都要写一遍重复样板代码;第二种方式才是工程上更优雅的做法,也是本文要重点展开的方案。
另外一个容易被忽视的细节是:加锁的粒度应该是任务级别,而不是实例级别。锁的key应该由任务名唯一确定,这样无论有多少个实例,同一时刻全局只有一个实例能拿到这个key对应的锁。理解了这一点,类型设计的目标就清晰了。
二、用TypeScript定义锁的核心类型
类型设计的第一步是抽象出锁的通用接口。一个好的锁抽象应该至少包含获取、释放两个动作,同时考虑锁的过期时间,避免某个实例崩溃后锁永远无法释放的死锁问题。
/**
* 分布式锁的统一抽象
*/
export interface DistributedLock {
/** 尝试获取锁,成功返回解锁函数,失败返回 null */
acquire(key: string, ttlMs: number): Promise<(() => Promise<void>) | null>;
}
/**
* 锁获取失败时的处理策略
*/
export enum LockFailStrategy {
/** 静默跳过本次执行 */
SKIP = 'skip',
/** 抛出异常并记录日志 */
THROW = 'throw',
}
/**
* 带锁任务装饰器的配置项
*/
export interface LockedTaskOptions {
/** 锁的唯一标识,缺省时使用 类名.方法名 */
lockKey?: string;
/** 锁的存活时间,单位毫秒,默认 60 秒 */
ttl?: number;
/** 抢锁失败策略 */
onFail?: LockFailStrategy;
}这里有几个类型设计的考量值得说明。首先acquire方法返回的是(() => Promise<void>) | null而不是布尔值,这样解锁函数天然和锁绑定,调用方不需要再传递key去释放,能有效避免释放错锁的bug。其次lockKey设计成可选属性,配合缺省生成策略,既支持显式指定也支持自动推导。最后LockFailStrategy用枚举而不是布尔值,是为了后续扩展"等待重试"等策略时不破坏已有类型签名。
有了这些基础类型,我们还可以进一步定义一个LockProvider的抽象类约束,要求具体实现必须同时实现acquire和release两个能力,并且通过OnModuleDestroy生命周期钩子保证应用优雅停机时释放持有的锁资源,这一点在生产环境非常重要。
三、实现Redis锁提供者并封装装饰器
类型定义好之后,接下来实现一个基于Redis的锁提供者。Redis的SET key value NX PX ttl命令是原子操作,天然适合做分布式锁。借助ioredis客户端,配合NestJS的Provider机制,实现如下:
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisLockProvider implements DistributedLock, OnModuleDestroy {
private readonly client: Redis;
/** 记录当前实例持有的锁token,用于安全释放 */
private readonly heldTokens = new Map<string, string>();
constructor() {
this.client = new Redis({ host: '127.0.0.1', port: 6379 });
}
async acquire(key: string, ttlMs: number) {
// 生成随机token,保证只能释放自己持有的锁
const token = Math.random().toString(36).slice(2);
const ok = await this.client.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
if (!ok) return null;
this.heldTokens.set(key, token);
return async () => {
const current = await this.client.get(`lock:${key}`);
// token一致才释放,防止误删他人持有的锁
if (current === token) {
await this.client.del(`lock:${key}`);
this.heldTokens.delete(key);
}
};
}
async onModuleDestroy() {
await Promise.all(
[...this.heldTokens].map(([key, token]) =>
this.client.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
1, `lock:${key}`, token,
),
),
);
this.client.disconnect();
}
}注意释放锁时用了Lua脚本来保证"先比较token再删除"的原子性,如果拆成先get再del两步,存在检查后锁恰好过期、别的实例拿到新锁又被误删的竞态窗口。这个细节是很多手写Redis锁最容易踩的坑。
有了锁提供者,装饰器的封装就水到渠成了。装饰器内部需要拿到NestJS的依赖注入容器,所以这里采用Interval装饰器加包装函数的方式,把原方法替换为一个先抢锁再执行的代理:
import { Interval } from '@nestjs/schedule';
import { LOCK_PROVIDER } from './lock.constants';
export function LockedInterval(options: LockedTaskOptions, ms: number): MethodDecorator {
return (target, propertyKey, descriptor: PropertyDescriptor) => {
const original = descriptor.value;
const lockKey = options.lockKey ?? `${target.constructor.name}.${String(propertyKey)}`;
const ttl = options.ttl ?? 60_000;
descriptor.value = async function (...args: unknown[]) {
const provider: DistributedLock = Reflect.getMetadata(LOCK_PROVIDER, target) ??
globalThis.__defaultLockProvider;
const unlock = await provider.acquire(lockKey, ttl);
if (!unlock) {
if (options.onFail === LockFailStrategy.THROW) {
throw new Error(`抢锁失败: ${lockKey}`);
}
return; // 静默跳过
}
try {
await original.apply(this, args);
} finally {
await unlock();
}
};
// 复用官方装饰器完成调度注册
return Interval(ms)(target, propertyKey, descriptor);
};
}使用时体验和原生装饰器几乎一致,业务代码完全不感知锁的存在:
@Injectable()
export class ReportService {
@LockedInterval({ lockKey: 'nightly-report', ttl: 300_000 }, 60_000)
async generateReport() {
// 只有抢到锁的实例会进入这里
console.log('开始生成报表...');
}
}四、锁超时与续租的工程细节
上面的实现还有一个隐患:如果任务实际执行时间超过了TTL,锁会在任务还没结束时自动过期,此时另一个实例就能抢到锁,重复执行的问题又回来了。解决方案有两种思路,分别是悲观设置和主动续租。
悲观设置是指把TTL设置得远大于任务的最坏执行时间,比如任务通常跑30秒,就把TTL设成10分钟。这种方式实现零成本,但代价是一旦持锁实例崩溃,其他实例要等10分钟才能接手。主动续租则是在任务执行期间启动一个定时器,每隔TTL的三分之一时间执行一次EXPIRE命令延长锁的存活期,任务结束后取消续租并释放锁。续租模式下TTL可以设得很短(比如15秒),实例崩溃后锁很快失效,可用性更高。
还有一种兜底手段是给任务加幂等保护,即使锁偶尔失效导致重复执行,任务本身的业务逻辑(比如按日期做upsert)也能保证最终结果正确。锁机制负责尽量不重复,幂等设计负责重复了也没事,两层防护叠加才能在生产环境高枕无忧。至于选Redis还是数据库行锁、etcd或者ZooKeeper,取决于团队现有的基础设施,只要实现了DistributedLock接口,上面的装饰器和类型体系可以无缝切换具体实现,这正是类型抽象带来的价值。
NestJS定时任务TypeScript类型分布式锁修改时间:2026-09-05 10:00:50