Node.js 定时发布总差一小时:我用 UTC 存储、时区转换和任务校准修复排期
博客定时发布不是提前就是延后一小时,最后发现是前端本地时间、服务器时区和扫描任务边界混在一起。这里记录我用 UTC 存储、展示层转换、幂等扫描和漏发补偿稳住排期的做法。

作者
ZHOUYI
前阵子博客后台定时发布总出怪事:约好 22:00 上线,有时 21:00 就发出去,有时又晚一小时。第一反应以为是 cron 配错,后来发现是时间在不同地方各说各话:前端按本地时间选,MongoDB 存了 Date,服务器又是另一个时区,扫描任务还拿当前时间直接比较。
存储统一用 UTC,展示再转时区
我把规则改成:用户选择的是墙钟时间,提交时必须带上时区或明确换算成带偏移的时间字符串,服务端统一转成 UTC 存入 publishAt。后台列表和小程序展示时,再按访问者时区或站点默认时区格式化。这样数据库里只有一个绝对时间,不会因为容器时区变化而漂移。
const publishAt = new Date('2026-06-20T22:00:00+08:00');
await Post.create({ title, status: 'scheduled', publishAt });
const show = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short'
}).format(publishAt);扫描任务别只信 cron,要看边界
定时扫描也不能只依赖 cron 准点。服务重启、任务延迟、实例重复,都可能让某一分钟被跳过或重复执行。我把扫描逻辑改成到期即发:只要状态还是 scheduled,并且 publishAt 小于等于当前时间,就进入处理。这样即使晚跑几分钟,也能把到期文章补发出来。
- 查询条件用 publishAt <= now 且 status = 'scheduled',避免错过整分钟边界。
- 发布动作加文章 ID 幂等锁,防止多实例同时扫到同一篇。
- Redis 记录 lastScanAt,服务重启后从一个短窗口补扫。
- 失败任务进入重试队列,超过阈值就告警,不静默吞掉。
一段扫描补偿代码
const now = new Date();
const due = await Post.find({ status: 'scheduled', publishAt: { $lte: now } }).limit(100);
for (const post of due) {
const ok = await redis.set(`publish:lock:${post._id}`, '1', 'EX', 60, 'NX');
if (!ok) continue;
await publishPost(post);
await Post.updateOne({ _id: post._id }, { $set: { status: 'published', publishedAt: now } });
}
await redis.set('publish:lastScan', now.toISOString());“定时发布看似只是时间比较,实际是存储、展示、调度和补偿四层一致性问题。先把 UTC 定死,再把扫描做成到期补偿,比到处修补 cron 更稳。”
修完后我还补了一个小面板:显示文章原始提交时区、UTC 时间、预计发布时间和实际发布时间。这样下次再有人说时间不对,至少能一眼看出是输入问题、展示问题,还是任务真的漏跑了。