《TypeScript编程实战》9.3 定时任务与并发限流

定时任务看似最简单,却最容易在多实例部署下出错。本节先对比系统 crontab、node-cron 与 BullMQ Job Scheduler 三条路径,解释前两者为何在容器多副本下会重复执行;再用 Job Scheduler 与确定性 jobId 解决重复投递;最后讲透并发控制的两层含义——单实例的 concurrency 与跨实例的全局闸,以及 limiter 如何把配额变成硬限制。

本节目标:掌握在容器化、多副本环境下正确运行周期性任务的方法。你会知道该选系统 crontab 还是队列驱动的调度器、如何保证同一时刻只有一个实例在跑、并发数该配在哪些参数上,以及如何把「下游每秒最多 20 次」这样的口头约束变成代码里的硬限制。

9.3 定时任务与并发限流

前两节的任务都由用户行为触发。这一节的任务由时钟触发:每天凌晨对账、每 5 分钟拉一次汇率、每小时清理过期缓存。这类任务在开发机上永远正常,一上生产就出问题——因为生产是三个副本。

9.3.1 三条实现路径

方案触发方多副本表现适合
系统 crontab宿主机 cron每个副本各跑一遍单机脚本、运维任务
node-cron / cron进程内定时器每个副本各跑一遍单实例应用、本地开发
BullMQ Job SchedulerRedis + worker全集群只产生一条 job需要可靠性与去重的业务任务

关键差别在第三列。系统 crontab 与进程内定时器都是本地时钟,它们不知道集群里还有几个兄弟。三个副本意味着同一分钟发三遍对账邮件。这不是配置问题,是模型问题——除非你在应用层再加分布式锁,否则无法解决。

BullMQ 的调度器把「何时触发」这件事从进程搬到了 Redis:所有副本共用一份时间表,触发时只投递一条 job,由任意一个空闲 worker 消费。这正是我们想要的性质。

9.3.2 Job Scheduler 的用法

BullMQ v5 用 upsertJobScheduler 替代了早期的 repeat 选项:

import { Queue } from 'bullmq';

const reports = new Queue('reports', { connection });

// 每天凌晨 2:00 生成日报(注意时区)
await reports.upsertJobScheduler(
  'daily-report', // 调度器 id,重复调用是「更新」而非「新增」
  { pattern: '0 2 * * *', tz: 'Asia/Shanghai' },
  {
    name: 'report:generate',
    data: { scope: 'daily' },
    opts: { attempts: 3, removeOnComplete: { count: 30 } },
  },
);
// 每 5 分钟一次,用 every 而不是 pattern
await reports.upsertJobScheduler('fx-sync', { every: 5 * 60 * 1000 }, { name: 'fx:sync' });

调度器一旦创建就落在 Redis 里,不受进程重启影响,所以必须提供配套的管理手段,否则「任务下线」会变成一件很容易做错的事:

// 查看当前所有调度器及其下次触发时间
const schedulers = await reports.getJobSchedulers(0, 100);
console.table(
  schedulers.map((s) => ({ key: s.key, pattern: s.pattern, next: s.next })),
);

// 下线一个任务:只删代码是不够的,必须显式移除调度器
await reports.removeJobScheduler('daily-report');
$ node scripts/list-schedulers.ts
┌─────────┬──────────────────┬─────────────┬──────────────────────────┐
│ (index) │ key              │ pattern     │ next                     │
├─────────┼──────────────────┼─────────────┼──────────────────────────┤
│ 0       │ daily-report     │ 0 2 * * *   │ 2026-09-28T02:00:00.000Z │
│ 1       │ fx-sync          │ (every 5m)  │ 2026-09-27T10:05:00.000Z │
└─────────┴──────────────────┴─────────────┴──────────────────────────┘

这一步经常被忽略:任务下线时如果只删代码,Redis 里的调度器还在,它会继续按时间表往队列投递 job,而消费者已经不存在,于是 wait 集合不断堆积,直到有人发现队列长度告警。

pattern 是标准 cron 表达式(五段:分 时 日 月 周),every 是毫秒间隔。两者的取舍:

字段语义注意
pattern按日历时刻触发夏令时切换时会跳过或重复
every固定间隔不受时区影响,但重启后重新计时
tz指定 IANA 时区不写就按服务器时区,容器里通常是 UTC

tz 是最容易踩的坑:容器默认 TZ=UTC,你写的 0 2 * * * 会在北京时间上午 10 点执行。要么显式写 tz: 'Asia/Shanghai',要么在 Dockerfile 里固定 TZ 并保证与业务预期一致——两处不一致比一处不写更难排查。

9.3.3 重复投递与确定性 jobId

调度器本身已经保证「同一时刻只投一条」,但还有一种重复来自手动触发与定时触发的撞车:运维在 2:00 手动跑了一次日报,2:00 的定时又跑了一次。

BullMQ 允许在 opts.jobId 里指定 id,相同 id 的 job 在同一队列中不会被重复添加:

const day = new Date().toISOString().slice(0, 10); // 2026-09-27

await reports.add(
  'report:generate',
  { scope: 'daily', day },
  { jobId: `daily-report:${day}`, attempts: 3 },
);

这样无论手动还是定时触发,同一天的日报只会产生一条 job。用业务日期而非时间戳做 id,是让它具备幂等性的关键;用 Date.now() 就退化成随机值了。

在调度器上也可以指定固定的 jobId 模板,让每次触发都覆盖同一 id——但要注意这会让「当天的第二次触发被静默忽略」,通常不是期望行为。更常见的组合是:调度器只负责触发,幂等性交给 processor 里的业务键(见 《TypeScript编程实战》9.2 重试、幂等与死信 )。

9.3.4 并发控制的两层含义

concurrency 是 Worker 上最常见的参数,但它常常被误解:

new Worker('tasks', processor, { connection, concurrency: 10 });

这行代码的意思是「这个 worker 进程同时处理 10 个 job」。如果你起了 4 个 worker 副本,全局并发是 40,不是 10。这是排查「下游被压垮」时第一个要确认的数字。

参数作用范围多副本时的实际值
Worker.concurrency单个 worker 进程concurrency × 副本数
limiter单个 worker 进程同上,速率也会成倍
Redis 全局闸整个集群你设定的值

所以当约束是「下游只允许 20 QPS」这类全局限制时,光调 concurrency 是不够的——副本数一变,限制就失效了。需要的是跨进程的全局闸:

// 基于 Redis 的全局并发闸
import { redis } from './redis';

async function acquireSlot(key: string, limit: number, ttlSec = 60): Promise<boolean> {
  const current = await redis.incr(key);
  if (current === 1) await redis.expire(key, ttlSec);
  if (current > limit) {
    await redis.decr(key);
    return false;
  }
  return true;
}

async function releaseSlot(key: string) {
  await redis.decr(key);
}
new Worker(
  'tasks',
  async (job) => {
    const ok = await acquireSlot('global:downstream', 20);
    if (!ok) {
      // 拿不到名额,抛错让 BullMQ 退避重试,而不是硬闯
      throw new Error('global concurrency limit reached');
    }
    try {
      return await downstream.call(job.data);
    } finally {
      await releaseSlot('global:downstream');
    }
  },
  { connection, concurrency: 10 },
);

更严谨的做法是直接用成熟的分布式信号量库,或者用 Redlock 风格的分布式锁保证「同一时刻只有一个实例在跑某个任务」,参见站内 分布式锁:Redis、ZooKeeper 与 etcd 。

9.3.5 用 limiter 表达速率配额

如果下游给的是「每分钟最多 600 次」这类速率约束,BullMQ 内置了 limiter:

new Worker('tasks', processor, {
  connection,
  concurrency: 20,
  limiter: { max: 10, duration: 1000 }, // 每 1000ms 最多处理 10 个 job
});

concurrency 与 limiter 的分工是:

参数约束的是触发超限时
concurrency同时在处理的 job 数job 排队等待
limiter单位时间处理的 job 数job 进入 delayed,稍后重试

两者是「与」的关系,都会生效。注意 limiter 是每个 worker 进程的,多副本时同样要按副本数折算——4 个副本配 max: 10,实际是 40/秒。

9.3.6 长任务与周期重叠

最隐蔽的一类 bug 是「任务执行时间超过触发周期」。设一个任务每 5 分钟触发、实际要跑 8 分钟,那么第 5 分钟时第二个实例会启动,两者操作同一批数据。

三种处理方式,按推荐度排序:

// 方式一:串行化 —— 同一队列的定时任务只用 concurrency: 1 的专用 worker
new Worker('reports', processor, { connection, concurrency: 1 });
// 方式二:加锁跳过 —— 拿不到锁就放弃本次执行(而不是排队)
const locked = await redis.set('lock:daily-report', instanceId, 'EX', 600, 'NX');
if (!locked) {
  logger.warn('previous run still in progress, skipping');
  return { skipped: true };
}
// 方式三:动态计算下次触发时间(自调度),让间隔从「上次结束」起算
const next = Date.now() + 5 * 60 * 1000;
await queue.add('sync', {}, { delay: 5 * 60 * 1000 });

方式二的关键细节是 EX 必须大于任务最长耗时,否则锁提前过期,两个实例还是会重叠。同时要在 finally 里主动释放,而不是干等 TTL。

9.3.7 定时任务的健康检查

定时任务最可怕的失败模式是「静默停摆」——调度器配置被误删、worker 全部宕机,但没人发现,因为不执行也不报错。所以必须为每个定时任务记录「上次成功时间」并监控它:

const HEARTBEAT_PREFIX = 'heartbeat:';

async function heartbeat(task: string) {
  await redis.set(`${HEARTBEAT_PREFIX}${task}`, new Date().toISOString());
}

// processor 成功后打点
new Worker('reports', async (job) => {
  const result = await run(job);
  await heartbeat(job.name);
  return result;
}, { connection });
// 独立的巡检:超过预期周期 2 倍仍未更新就告警
const EXPECTED_MS = 60 * 60 * 1000; // 每小时一次
setInterval(async () => {
  const last = await redis.get(`${HEARTBEAT_PREFIX}report:generate`);
  if (!last || Date.now() - Date.parse(last) > EXPECTED_MS * 2) {
    await alerting.notify('#oncall', `定时任务 report:generate 已停摆,上次成功:${last ?? '从未'}`);
  }
}, 5 * 60 * 1000);

这种「dead man’s switch(死人开关)」模式比监控任务失败更重要:没有消息本身就是消息。指标接入方式见 《TypeScript编程实战》17.2 指标与告警 。

9.3.8 五个常见坑

一、时区。 容器 TZ=UTC 而 cron 表达式按本地时间理解,是最高频的错误。显式写 tz 并写测试断言下一次触发时间。

二、多副本重复触发。 用系统 crontab 或 node-cron 在 Kubernetes 里跑业务定时任务,等于按副本数放大执行次数。这类任务应当交给队列调度器。

三、concurrency 当成全局上限。 它只是单进程的。全局约束要么用 Redis 闸,要么按副本数折算后配置。

四、锁的 TTL 小于任务耗时。 锁提前失效导致并发重叠,症状是「偶发重复数据」,极难复现。

五、只配了调度没有消费方。 调度器投递成功但队列无人消费,job 会在 wait 里堆积。巡检要同时看队列长度与 worker 存活数。

9.3.9 与本书其它章节的衔接

队列模型与 payload 契约见 《TypeScript编程实战》9.1 BullMQ 队列模型与 payload 泛型 ;定时任务同样需要重试与幂等,见 《TypeScript编程实战》9.2 重试、幂等与死信 。本节用到的 Redis 原子操作与键设计规范见 《TypeScript编程实战》8.2 Redis 类型安全封装 ;worker 进程的优雅关闭(避免 SIGTERM 打断在途任务)见 《TypeScript编程实战》5.3 优雅关闭与健康检查 。

站内延伸阅读:Linux cron 定时任务 、Shell 与 cron 调度作业 、定时任务工具选型 、API 网关的限流与熔断实践 、游戏服务端异步任务队列设计 。

小结

本节把「定时 + 并发」拆成了两个独立的正确性问题。

定时要解决的是「谁来触发、触发几次」。系统 crontab 与进程内定时器在多副本下会成倍执行,只有把时间表放到 Redis 上(Job Scheduler)才能做到全集群一条 job;tz 必须显式声明,否则容器时区会默默把你的凌晨任务挪到上午。并发要解决的是「同时跑几个、每秒跑几个」。concurrency 与 limiter 都只作用于单个 worker 进程,副本数一变约束就失效,因此全局约束必须用跨进程的机制(Redis 闸或分布式锁)来表达。

还有两个容易被忽略的收尾动作:长任务要用锁或 concurrency: 1 防止周期重叠,锁的 TTL 必须大于任务最长耗时;定时任务必须有心跳打点与停摆告警,因为「不执行」这种失败不会自己报错。

至此第九章结束。这一章从「为什么需要队列」讲到 payload 类型、重试与幂等、定时与限流,覆盖了一个后台任务系统从建模到运维的完整链路。下一章会换一个方向:当通信从「请求—响应」变成「长连接推送」时,类型系统要面对的是一组持续流动的消息,判别联合会在那里再次登场,而这次的对手是顺序、重连与广播。

阅读导航:上一节:9.2 重试、幂等与死信 · 下一节:10.1 WebSocket 消息协议判别联合 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes