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-cron 或 cron 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)是定时任务设计的第一原则。任何不幂等的定时任务,都是一颗定时炸弹。