Unix 时间戳在定时任务和 Cron 作业中的应用

Cron 作业表面上很简单:在某个时间运行某个命令。但生产环境中的调度很快就会变得复杂。你需要追踪作业上次运行的时间、检测它是否逾期、处理重叠运行,并用足够的精度记录日志以便事后调试失败。Unix 时间戳是处理所有这些问题的实用工具。

使用 Unix 时间戳转换器 可以将任何时间戳转换为人类可读的日期,或获取当前的 Unix 时间。本文介绍时间戳在调度系统中的实际应用——从基础 cron 到更复杂的作业队列。

为什么定时任务使用 Unix 时间戳

Cron 表达式如 0 2 告诉调度器何时*运行作业。但它无法告诉你运行过程中发生了什么。为此,你需要在运行时记录时间戳:作业何时启动、何时结束、是否成功。

Unix 时间戳是自然的选择,因为它们:

  • 无歧义 — 没有时区混乱,没有与地区相关的格式问题
  • 可比较 — 你可以用两个时间戳相减来计算经过的秒数
  • 紧凑 — 单个整数可以存储在任何数据库字段或日志行中
  • 可排序 — 整数顺序等同于时间顺序

一个记录 started_at: 1712534400 到数据库的定时任务正在记录一些精确且可查询的东西。而一个记录 started_at: "2026年4月8日 上午2:00" 的任务则记录了需要经过解析才有用的东西。

存储上次运行的时间戳

在定时任务中最常见的时间戳用法是追踪上次成功运行。一个简单的模式:

1. 作业启动,从存储中读取 last_run 时间戳 2. 作业执行其工作 3. 成功时,将当前 Unix 时间戳写入 last_run 4. 失败时,保持 last_run 不变(或写入单独的 last_failed_at 字段)

# Shell 示例
LAST_RUN=$(cat /var/run/myjob.timestamp 2>/dev/null || echo 0)
NOW=$(date +%s)

# 在这里执行工作...
if [ $? -eq 0 ]; then
    echo $NOW > /var/run/myjob.timestamp
fi

这个模式让你可以轻松回答"这个作业上次成功运行是什么时候?"——只需读取文件并转换时间戳。它还让你很容易检测是否陈旧:如果 now - last_run > expected_interval,说明作业已逾期。

检测逾期或失败的作业

Cron 无法检测自己的失败。如果服务器在计划运行时间下线,cron 不会重试也不会告警。如果作业运行但以错误退出,cron 不会将其标记为失败。检测这些情况需要外部监控来检查时间戳。

一个简单的逾期检查:如果当前时间减去 last_run 超过阈值,说明出了问题。

import time

EXPECTED_INTERVAL_SECONDS = 3600  # 作业应该每小时运行一次
TOLERANCE_SECONDS = 300           # 允许 5 分钟的偏差

last_run = get_last_run_timestamp()  # 从数据库或文件读取
now = int(time.time())

if now - last_run > EXPECTED_INTERVAL_SECONDS + TOLERANCE_SECONDS:
    alert("Job overdue — last ran at {}".format(last_run))

容差值很重要。Cron 作业不总是完全按计划启动——系统负载、时钟漂移和启动时间意味着计划在 02:00:00 运行的作业可能实际在 02:00:04 启动。没有容差的话,在 02:59:56 运行的监控检查可能会把在 02:00:04 完美运行、4 秒后再次到期的作业视为逾期。

对于小时级作业,5 分钟是合理的容差。对于日级作业,30-60 分钟是典型值。

使用时间戳防止重叠运行

如果下一个计划运行在当前运行完成前启动,长时间运行的作业会重叠。每天备份作业耗时 2 小时没问题。但如果耗时 26 小时,就会开始重叠,最终导致级联故障。

时间戳用简单的锁模式解决这个问题:

1. 作业启动,从锁记录读取 started_at 2. 如果 started_at 存在且 now - started_at < timeout,说明另一个运行进行中——退出 3. 如果没有锁或锁已过期,将当前时间戳写入 started_at 4. 执行工作 5. 完成时清除锁

LOCK_TIMEOUT = 7200  # 2 小时——如果作业运行更长,假定它卡住了

lock_time = get_lock_timestamp()
now = int(time.time())

if lock_time and (now - lock_time) < LOCK_TIMEOUT:
    print("Job already running, started at {}".format(lock_time))
    exit(0)

set_lock_timestamp(now)
# ... 执行工作 ...
clear_lock()

超时机制处理作业在清除锁之前崩溃的情况。没有超时,崩溃的作业会无限期阻止所有后续运行。有基于时间戳的锁,崩溃运行的锁在超时后过期,下一个计划运行可以继续。

使用时间戳安排未来的作业

作业队列(Sidekiq、Celery、BullMQ、RQ)通常通过存储作业应该执行时的 Unix 时间戳来安排未来的任务。队列工作进程轮询查找 run_at <= current_timestamp 的作业。

-- 查找准备运行的作业
SELECT * FROM scheduled_jobs
WHERE run_at <= EXTRACT(EPOCH FROM NOW())::int
  AND status = 'pending'
ORDER BY run_at ASC;

这比 cron 更灵活,用于动态调度。与其说"每小时运行",你可以说"用户注册后 24 小时运行"或"支付失败后 15 分钟运行"。作业被插入时设置 run_at = NOW_UNIX + delay_seconds,工作进程在时间到达时拾取它。

重试逻辑也是这样工作的。失败后,用指数退避重新安排:

attempt = job.attempt_count
delay = min(2 ** attempt * 60, 3600)  # 1分钟、2分钟、4分钟...最多1小时
job.run_at = int(time.time()) + delay
job.save()

第 1 次尝试后:60 秒后重试。第 2 次:120 秒。第 3 次:240 秒。3600 的上限防止无限延迟。

使用 Unix 时间戳记录作业运行日志

作业运行日志最有用的是包含精确的时间信息。像这样的日志行:

[1712534400] backup-job started
[1712534447] backup-job completed in 47s, 3.2GB written

立即有用于调试。你可以使用 Unix 时间戳转换器1712534400 转换为人类可读的日期,与其他系统日志关联,并计算确切的持续时间,而无需解析日期字符串。

另一种方式——记录格式化字符串如 "2026-04-08 02:00:00 UTC"——对人类可读但对机器脆弱。不同的日志传送器以不同方式格式化日期、时区转换引入错误,而且字符串比较对时间范围查询的速度不如整数比较快。

常见的做法是两者都记录:原始时间戳用于机器可读性,格式化日期用于人类可读性。

started_at=1712534400 started_at_human="2026-04-08T02:00:00Z" duration_s=47

测量作业持续时间和随时间推移的性能

时间戳能够跨运行追踪性能。为每次运行记录 started_atcompleted_at,你可以查询:

  • 最近 30 次运行的平均持续时间
  • 过去一周的最长运行时间
  • 持续时间是否趋势上升(可能存在性能回归)
SELECT
    AVG(completed_at - started_at) AS avg_duration_seconds,
    MAX(completed_at - started_at) AS max_duration_seconds,
    COUNT(*) AS run_count
FROM job_runs
WHERE job_name = 'nightly-report'
  AND started_at > EXTRACT(EPOCH FROM NOW())::int - (30 * 86400);

这类查询之所以成为可能,是因为持续时间存储为简单的整数减法。如果你存储的是格式化日期,你在进行算术运算之前需要字符串解析。

时间戳精度:秒级与毫秒级在调度中的应用

对于大多数定时任务,秒级精度就足够了。计划在 1712534400 运行的作业不需要亚秒级精度。

但对于高频任务队列——可能每秒运行数百次作业的队列——毫秒级时间戳很重要。如果两个作业在同一秒内插入,你按时间戳排序以确定处理顺序,秒级时间戳会产生平局。毫秒级时间戳保留每秒内的插入顺序。

JavaScript 的 Date.now() 返回毫秒。Python 的 time.time() 返回浮点数,精度为微秒。大多数数据库支持微秒精度的时间戳。精度的选择应该匹配你的调度频率——高容量队列用毫秒级,标准 cron 型作业用秒级。

在定时任务中处理夏令时

很多调度 bug 就隐藏在这里。配置为 0 2 * 的 cron 作业在上午 2:00 本地时间运行。当时钟向前推进时,上午 2:00 不存在——时钟从 1:59 AM 跳到 3:00 AM。作业要么被跳过,要么在 3:00 AM 运行,取决于 cron 的实现。

当时钟向后退时,上午 2:00 出现两次。作业可能运行两次。

解决方案:用 UTC 运行 cron。UTC 中的 0 2 * 始终是 2:00 UTC——没有歧义,没有夏令时惊喜。作业时间相对于本地时间每年移动两次,但它总是恰好运行一次。

Unix 时间戳根据定义使用 UTC,这就是为什么它们对这个问题免疫。计划在时间戳 1712534400 运行的作业在那个确切时刻运行,无论服务器处于什么时区或该时刻适用什么夏令时偏移。这是对任何需要精确时序的场景使用基于时间戳的调度器而非 cron 的核心论证。

对于本地时间语义重要的作业——"在营业时间上午 9 点运行"——你需要显式的时区处理,而不仅仅是 UTC。在时间表旁存储目标时区,在时间表创建时转换为 UTC,如果时区的夏令时规则变更则重新计算。

相关文章