从零构建 Cron 表达式解析器与任务调度器:TypeScript 实战指南

深入解析 Cron 表达式语法,手把手用 TypeScript 从零构建高性能 Cron 解析器和分布式任务调度器,涵盖字段解析、时区处理、容错机制等生产级实践。

开发者效率 2026-06-07 12 分钟

2026 年,超过 78% 的后端服务依赖定时任务(Scheduled Task)来处理数据同步、报表生成、缓存预热等关键业务。然而,大多数开发者对 Cron 表达式的理解停留在 * * * * * 的表面,真正能从零构建一个生产级 Cron 解析器的人凤毛麟角。本文将用 TypeScript 手把手带你实现一个完整的 Cron 解析器和任务调度器,让你彻底掌握定时任务的底层原理。

🔍 一、Cron 表达式深度解析

Cron 表达式(Cron Expression)是 Unix 系统中最经典的时间调度语法,诞生于 1975 年的 AT&T Unix V7。一个标准 Cron 表达式由 5 个时间字段和 1 个命令字段组成,每个字段用空格分隔。

📋 字段结构与取值范围

字段 含义 取值范围 特殊字符
分钟(Minute) 0-59 0-59 * , - /
小时(Hour) 0-23 0-23 * , - /
日期(Day of Month) 1-31 1-31 * , - / ? L W
月份(Month) 1-12 1-12 或 JAN-DEC * , - /
星期(Day of Week) 0-7 0-7(0 和 7 都是周日) * , - / L #

💡 提示:? 字符仅用于日期和星期字段,表示"不指定值"。它用于解决日期和星期字段之间的冲突——当你指定其中一个时,另一个应该用 ?

常见 Cron 表达式示例:

表达式 含义
0 * * * * 每小时整点执行
0 0 * * * 每天凌晨 0 点执行
0 0 * * 1 每周一凌晨 0 点执行
0 0 1 * * 每月 1 号凌晨 0 点执行
*/5 * * * * 每 5 分钟执行一次
0 9-18 * * 1-5 工作日 9 点到 18 点每小时执行
0 0 1 1 * 每年 1 月 1 日执行

🧩 特殊字符详解

Cron 表达式的强大之处在于其特殊字符组合:

  • *(星号)— 匹配该字段的所有值
  • ,(逗号)— 列举多个值,如 1,3,5
  • -(连字符)— 定义范围,如 1-5 表示 1 到 5
  • /(斜杠)— 定义步长,如 */5 表示每隔 5 个单位
  • L — 仅在日期字段中表示"最后一天"(不同实现行为不一致)
  • W — 表示最近的工作日(非标准,部分实现支持)

⚠️ **警告:**不同 Cron 实现(Vixie cron、Spring cron、Quartz)的语法存在差异。Quartz 支持 6-7 个字段(包含秒和年),而标准 Unix cron 只有 5 个字段。本文聚焦标准 5 字段格式。

🔧 二、TypeScript 实现 Cron 解析器

接下来我们用 TypeScript 从零实现一个类型安全的 Cron 解析器。核心思路是将每个字段解析为一个 Set<number>,存储所有合法的触发时间点。

🏗️ 数据结构设计

// Cron 表达式解析结果的数据结构
interface CronFields {
  minutes: Set<number>      // 0-59
  hours: Set<number>        // 0-23
  daysOfMonth: Set<number>  // 1-31
  months: Set<number>       // 1-12
  daysOfWeek: Set<number>   // 0-6 (0 = Sunday)
}

// 解析配置选项
interface CronParserOptions {
  /** 是否允许 L (last day) 语法 */
  supportL?: boolean
  /** 是否允许 W (nearest weekday) 语法 */
  supportW?: boolean
  /** 是否允许 ? (no specific value) 语法 */
  supportQuestionMark?: boolean
}

// 字段定义:用于限制解析范围
interface FieldDef {
  name: string
  min: number
  max: number
  aliases?: Record<string, number>
}

🔨 字段解析核心逻辑

// 字段定义映射表
const FIELD_DEFS: FieldDef[] = [
  { name: 'minute', min: 0, max: 59 },
  { name: 'hour', min: 0, max: 23 },
  { name: 'dayOfMonth', min: 1, max: 31 },
  { name: 'month', min: 1, max: 12, aliases: {
    JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
    JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
  }},
  { name: 'dayOfWeek', min: 0, max: 7, aliases: {
    SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
  }},
]

// 解析单个字段值,支持 *,逗号,范围,步长
function parseField(field: string, def: FieldDef): Set<number> {
  const result = new Set<number>()

  // 处理 ? 通配符(仅日期和星期字段)
  if (field === '?') {
    // ? 表示不指定,返回空集(由调用方处理冲突逻辑)
    return result
  }

  // 处理 * 通配符和步长
  if (field.startsWith('*/')) {
    const step = parseInt(field.slice(2), 10)
    if (step <= 0 || step > def.max - def.min + 1) {
      throw new Error(`Invalid step value: ${step} for field ${def.name}`)
    }
    for (let i = def.min; i <= def.max; i += step) {
      result.add(i)
    }
    return result
  }

  // 处理逗号分隔的多个值
  const parts = field.split(',')
  for (const part of parts) {
    parsePart(part.trim(), def, result)
  }
  return result
}

// 解析单个部分(可能是范围、步长或单一值)
function parsePart(part: string, def: FieldDef, result: Set<number>): void {
  // 替换别名(如 JAN -> 1)
  let processed = part.toUpperCase()
  if (def.aliases) {
    for (const [alias, value] of Object.entries(def.aliases)) {
      processed = processed.replace(alias, String(value))
    }
  }

  // 处理步长表达式:如 1-30/5
  if (processed.includes('/')) {
    const [rangePart, stepStr] = processed.split('/')
    const step = parseInt(stepStr, 10)
    const [start, end] = rangePart === '*'
      ? [def.min, def.max]
      : parseRange(rangePart, def)

    for (let i = start; i <= end; i += step) {
      result.add(i)
    }
    return
  }

  // 处理范围表达式:如 1-5
  if (processed.includes('-')) {
    const [start, end] = parseRange(processed, def)
    for (let i = start; i <= end; i++) {
      result.add(i)
    }
    return
  }

  // 单一值
  const value = parseInt(processed, 10)
  if (isNaN(value) || value < def.min || value > def.max) {
    throw new Error(`Invalid value: ${part} for field ${def.name}`)
  }
  result.add(value)
}

// 解析范围表达式
function parseRange(range: string, def: FieldDef): [number, number] {
  const [startStr, endStr] = range.split('-')
  const start = parseInt(startStr, 10)
  const end = parseInt(endStr, 10)
  if (start < def.min || end > def.max || start > end) {
    throw new Error(`Invalid range: ${range} for field ${def.name}`)
  }
  return [start, end]
}

📌 记住:解析器的核心思路是将语法规则转化为集合运算。每个字段最终变成一个 Set<number>,匹配时间点时只需检查 set.has(value) 即可,时间复杂度为 O(1)。

🎯 完整解析器实现

// 完整的 Cron 表达式解析器
class CronParser {
  private fields: CronFields

  constructor(expression: string, options: CronParserOptions = {}) {
    this.fields = this.parse(expression, options)
  }

  private parse(expression: string, options: CronParserOptions): CronFields {
    const parts = expression.trim().split(/\s+/)
    if (parts.length !== 5) {
      throw new Error(
        `Invalid cron expression: expected 5 fields, got ${parts.length}`
      )
    }

    const fields: CronFields = {
      minutes: parseField(parts[0], FIELD_DEFS[0]),
      hours: parseField(parts[1], FIELD_DEFS[1]),
      daysOfMonth: parseField(parts[2], FIELD_DEFS[2]),
      months: parseField(parts[3], FIELD_DEFS[3]),
      daysOfWeek: parseField(parts[4], FIELD_DEFS[4]),
    }

    // 处理日期和星期字段的冲突
    this.resolveDayConflict(fields, options)
    return fields
  }

  // 解决日期与星期字段的冲突
  private resolveDayConflict(
    fields: CronFields,
    options: CronParserOptions
  ): void {
    const domSpecified = fields.daysOfMonth.size > 0
    const dowSpecified = fields.daysOfWeek.size > 0
    const questionMarkSupported = options.supportQuestionMark ?? false

    // 标准 Unix cron:两者都指定时,取并集(OR 关系)
    // Quartz cron:必须有一个是 ?
    if (questionMarkSupported && domSpecified && dowSpecified) {
      throw new Error(
        'Day-of-month and day-of-week cannot both be specified ' +
        '(one must be ?)'
      )
    }
  }

  // 检查给定时间是否匹配此 cron 表达式
  matches(date: Date): boolean {
    const minute = date.getMinutes()
    const hour = date.getHours()
    const dayOfMonth = date.getDate()
    const month = date.getMonth() + 1
    const dayOfWeek = date.getDay()

    const minuteMatch = this.fields.minutes.has(minute)
    const hourMatch = this.fields.hours.has(hour)
    const monthMatch = this.fields.months.has(month)

    // 日期匹配逻辑:日期 OR 星期(任一匹配即可)
    const domMatch = this.fields.daysOfMonth.size === 0 ||
      this.fields.daysOfMonth.has(dayOfMonth)
    const dowMatch = this.fields.daysOfWeek.size === 0 ||
      this.fields.daysOfWeek.has(dayOfWeek)

    return minuteMatch && hourMatch && monthMatch && domMatch && dowMatch
  }

  // 获取字段信息(用于调试)
  toString(): string {
    return [
      `Minutes: [${[...this.fields.minutes].join(', ')}]`,
      `Hours: [${[...this.fields.hours].join(', ')}]`,
      `Days: [${[...this.fields.daysOfMonth].join(', ')}]`,
      `Months: [${[...this.fields.months].join(', ')}]`,
      `Weekdays: [${[...this.fields.daysOfWeek].join(', ')}]`,
    ].join('\n')
  }
}

使用示例:

// 解析每 5 分钟执行一次的 cron 表达式
const parser = new CronParser('*/5 * * * *')
console.log(parser.toString())
// Minutes: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
// Hours: [0, 1, 2, ..., 23]
// ...

// 检查当前时间是否匹配
const now = new Date()
console.log(parser.matches(now))  // true 或 false

// 解析工作日 9 点到 18 点每 30 分钟执行
const workdayParser = new CronParser('*/30 9-17 * * 1-5')
console.log(workdayParser.matches(new Date('2026-06-08T10:30:00')))
// true(周一 10:30)

🚀 三、构建任务调度器

有了 Cron 解析器,下一步是构建任务调度器。一个生产级调度器需要处理时区、并发控制、错误重试等核心问题。

⏰ 核心调度循环

// 任务调度器核心实现
interface ScheduledTask {
  id: string
  cron: CronParser
  handler: () => Promise<void>
  /** 最大重试次数 */
  maxRetries: number
  /** 重试间隔(毫秒) */
  retryDelayMs: number
  /** 是否正在执行 */
  running: boolean
  /** 上次执行时间 */
  lastRun?: Date
  /** 上次执行错误 */
  lastError?: Error
}

class CronScheduler {
  private tasks = new Map<string, ScheduledTask>()
  private timer: ReturnType<typeof setInterval> | null = null
  private timezone: string

  constructor(options: { timezone?: string } = {}) {
    this.timezone = options.timezone ?? 'UTC'
  }

  // 注册定时任务
  addTask(config: {
    id: string
    expression: string
    handler: () => Promise<void>
    maxRetries?: number
    retryDelayMs?: number
  }): void {
    const task: ScheduledTask = {
      id: config.id,
      cron: new CronParser(config.expression),
      handler: config.handler,
      maxRetries: config.maxRetries ?? 3,
      retryDelayMs: config.retryDelayMs ?? 5000,
      running: false,
    }
    this.tasks.set(config.id, task)
    console.log(`✅ Task "${config.id}" registered: ${config.expression}`)
  }

  // 启动调度器(每分钟检查一次)
  start(): void {
    if (this.timer) return
    console.log(`🚀 Scheduler started (timezone: ${this.timezone})`)

    // 每秒检查一次(精度到秒级)
    this.timer = setInterval(() => this.tick(), 1000)
    // 立即执行一次检查
    this.tick()
  }

  // 停止调度器
  stop(): void {
    if (this.timer) {
      clearInterval(this.timer)
      this.timer = null
      console.log('⏹️ Scheduler stopped')
    }
  }

  // 核心跳动:检查所有任务
  private async tick(): Promise<void> {
    const now = new Date()

    for (const task of this.tasks.values()) {
      // 跳过正在执行的任务(防止重叠执行)
      if (task.running) continue

      // 检查是否匹配 cron 表达式
      if (!task.cron.matches(now)) continue

      // 防止同一分钟内重复触发
      if (task.lastRun && this.isSameMinute(task.lastRun, now)) continue

      // 异步执行任务(不阻塞调度循环)
      this.executeTask(task, now)
    }
  }

  // 执行任务(带重试逻辑)
  private async executeTask(task: ScheduledTask, triggeredAt: Date): Promise<void> {
    task.running = true
    task.lastRun = triggeredAt

    for (let attempt = 1; attempt <= task.maxRetries; attempt++) {
      try {
        const startTime = Date.now()
        await task.handler()
        const duration = Date.now() - startTime
        console.log(
          `✅ Task "${task.id}" completed in ${duration}ms ` +
          `(attempt ${attempt}/${task.maxRetries})`
        )
        task.lastError = undefined
        break
      } catch (error) {
        task.lastError = error as Error
        console.error(
          `❌ Task "${task.id}" failed (attempt ${attempt}/${task.maxRetries}):`,
          (error as Error).message
        )
        if (attempt < task.maxRetries) {
          await this.sleep(task.retryDelayMs)
        }
      }
    }

    task.running = false
  }

  private isSameMinute(a: Date, b: Date): boolean {
    return a.getFullYear() === b.getFullYear() &&
      a.getMonth() === b.getMonth() &&
      a.getDate() === b.getDate() &&
      a.getHours() === b.getHours() &&
      a.getMinutes() === b.getMinutes()
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms))
  }
}

⚠️ **警告:**上述实现是单进程调度器。在多实例部署(如 Kubernetes Pod 水平扩展)时,必须引入分布式锁(如 Redis SETNX 或 RedLock)来防止同一任务被多个实例同时执行。

🌍 时区处理

Cron 表达式在不同时区的语义完全不同。0 9 * * * 在 UTC 和 Asia/Shanghai 下对应的绝对时间相差 8 小时。生产环境必须正确处理时区。

// 使用 Intl API 处理时区(无需额外依赖)
function getDateInTimezone(timezone: string): Date {
  const now = new Date()
  // 获取目标时区的本地时间组件
  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone: timezone,
    year: 'numeric',
    month: 'numeric',
    day: 'numeric',
    hour: 'numeric',
    minute: 'numeric',
    second: 'numeric',
    hour12: false,
  })
  const parts = formatter.formatToParts(now)
  const get = (type: string) =>
    parseInt(parts.find(p => p.type === type)!.value, 10)

  return new Date(
    get('year'),
    get('month') - 1,
    get('day'),
    get('hour'),
    get('minute'),
    get('second')
  )
}

// 使用示例
const shanghaiTime = getDateInTimezone('Asia/Shanghai')
console.log('上海当前时间:', shanghaiTime.toLocaleString())

const tokyoTime = getDateInTimezone('Asia/Tokyo')
console.log('东京当前时间:', tokyoTime.toLocaleString())

📊 任务调度器性能对比

不同规模下调度器的选择策略:

场景 单机 Cron Node Cron 库 Redis + BullMQ Temporal
任务数量 < 50 < 200 < 10,000 无上限
精度 分钟级 秒级 毫秒级 秒级
持久化
分布式
可视化 ✅ (Bull Board) ✅ (Web UI)
复杂度
推荐场景 脚本 小型服务 中型项目 企业级

⚡ **关键结论:**对于大多数 Node.js 项目,node-cron + Redis 分布式锁是性价比最高的方案。只有当任务编排复杂度上升到工作流级别时,才需要引入 Temporal 等专业引擎。

💡 四、生产环境注意事项

✅ 最佳实践

  • 任务幂等性— 所有定时任务必须设计为幂等的,因为网络抖动或重试可能导致重复执行
  • 执行超时控制— 每个任务设置最大执行时间,超时后自动终止,防止僵尸任务
  • 优雅关闭— 进程收到 SIGTERM 信号时,等待正在执行的任务完成后再退出
  • 监控告警— 任务执行失败时触发告警(邮件/Slack/企业微信),不要静默失败
  • 执行日志— 记录每次任务的开始时间、结束时间、耗时、结果状态

❌ 常见踩坑

  • 重叠执行— 上一次执行未完成时下一次又触发,导致数据竞争
  • 忽略时区— 服务器时区和业务时区不一致,导致任务在错误时间执行
  • 无重试机制— 临时网络故障导致任务失败后直接放弃
  • Date.now() 时间漂移— 使用 setInterval 累积误差,长时间运行后偏移越来越大

💡 **提示:**对于时间精度要求高的场景,建议使用「追赶机制」—— 记录上次执行时间,每次 tick 时检查是否有遗漏的时间窗口需要补执行。

🔐 分布式锁实现(Redis)

// 基于 Redis 的分布式锁,防止多实例重复执行
import Redis from 'ioredis'

const redis = new Redis()

async function acquireLock(
  taskId: string,
  ttlMs: number = 60_000
): Promise<boolean> {
  const lockKey = `cron:lock:${taskId}`
  const lockValue = `${process.pid}:${Date.now()}`

  // SET NX EX 原子操作:仅在 key 不存在时设置
  const result = await redis.set(
    lockKey,
    lockValue,
    'PX', ttlMs,  // 过期时间(毫秒)
    'NX'           // 仅在不存在时设置
  )

  return result === 'OK'
}

async function releaseLock(taskId: string): Promise<void> {
  const lockKey = `cron:lock:${taskId}`
  // 使用 Lua 脚本确保原子性(只删除自己的锁)
  const script = `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("DEL", KEYS[1])
    else
      return 0
    end
  `
  await redis.eval(script, 1, lockKey, `${process.pid}:${Date.now()}`)
}

// 在调度器中使用分布式锁
async function executeWithLock(task: ScheduledTask): Promise<void> {
  const acquired = await acquireLock(task.id, 120_000)
  if (!acquired) {
    console.log(`⏭️ Task "${task.id}" skipped (locked by another instance)`)
    return
  }

  try {
    await task.handler()
  } finally {
    await releaseLock(task.id)
  }
}

⚠️ **警告:**Redis 单节点的分布式锁在网络分区场景下可能出现脑裂问题。对于金融级场景,建议使用 RedLock 算法(需要至少 3 个独立 Redis 实例)或 etcd/Consul 等共识系统。

📝 总结

构建一个生产级的 Cron 调度器远不是 setInterval + cron 解析那么简单。核心挑战在于时区一致性、分布式锁、执行去重、超时控制和优雅关闭。对于小型项目,node-croncron npm 包已经足够;中型项目推荐 BullMQ + Redis 的组合;企业级场景则应考虑 Temporal 或自建调度平台。

相关工具推荐:

工具 用途 链接
croner 零依赖的 Cron 解析和调度 npmjs.com/package/croner
node-cron Node.js 最流行的 Cron 库 npmjs.com/package/node-cron
BullMQ 基于 Redis 的任务队列 bullmq.io
Temporal 企业级工作流引擎 temporal.io
cron-parser 纯 Cron 表达式解析器 npmjs.com/package/cron-parser

无论选择哪种方案,请记住:任务幂等性(Idempotency)是定时任务设计的第一原则。任何不幂等的定时任务,都是一颗定时炸弹。

📚 相关文章