秒级定时任务不能直接用 setinterval,因其在浏览器失焦、后台节流或系统休眠时严重不准,node.js 中也缺乏高并发支撑、失败重试、延迟补偿和持久化能力;真正可用的秒级调度需满足动态增删、误差超200ms跳过、超时自动重试、依赖声明与拓扑排序、内存态状态管理及时钟强同步等要求。

秒级定时任务为什么不能直接用 setInterval
浏览器环境的 setInterval 在页面失焦、系统休眠或后台 tab 被节流时会严重不准,Node.js 里虽无休眠问题,但单进程下无法支撑高并发秒级调度(比如每秒触发 100+ 个不同任务),且缺乏失败重试、延迟补偿和持久化能力。
真正可用的秒级调度必须满足:任务可动态增删、执行时间误差 setTimeout 驱动 + 时间轮预计算 + 本地队列缓冲。
- 用
performance.now()替代Date.now()获取更精确的当前毫秒偏移 - 每个任务注册时计算下次触发的绝对时间戳(如
Math.ceil(Date.now() / 1000) * 1000 + 1000),避免累积误差 - 调度器内部维护一个最小堆(按触发时间排序),每次只
setTimeout最近的一个,执行后再推下一个 - 不依赖全局
setInterval,防止多个任务互相干扰或阻塞主线程
集群选举怎么避开 ZooKeeper/Etcd 这类外部依赖
如果只是中小规模服务(≤10 节点),没必要引入强一致性存储。用 Redis 的 SET key value NX PX 30000 做租约 + 心跳续期,配合本地状态机,就能实现“谁先抢到 key 谁当 leader”的轻量选举。
关键不是选 leader,而是保证同一时刻只有一个节点执行调度逻辑。难点在租约失效边界:Redis 网络分区时可能多个节点都认为自己是 leader。
- 每个节点启动时尝试用唯一
nodeId(如 hostname + pid)争抢scheduler:leader键 - 抢到后立即启动心跳线程,每 10 秒用
GETSET更新租约时间戳,并校验返回值是否等于自己的nodeId - 若心跳失败或返回值不匹配,立刻停止本地调度器并清空待执行队列
- 所有任务触发前加一层
isLeader()检查,避免误执行
scheduleJob 接口设计要兼容 cron 和秒级表达式
用户既想写 "*/5 * * * * *"(每 5 秒),也想写 "0 0 * * *"(每天零点),底层不能拆成两套解析器。统一用 cron-parser 库解析,但对秒字段做特殊处理:当表达式含 6 字段(含秒)时启用毫秒级精度调度;5 字段则降级为分钟级,避免无意义的高频轮询。
- 秒字段允许
*、*/N、1,3,5等写法,但禁止0-59/2这类范围步进(易导致精度漂移) - 每次触发前检查
job.nextRunAt是否已过期,若延迟 >200ms 则跳过本次,防止雪崩 - 任务函数执行超时(默认 5s)自动标记失败并加入重试队列,重试间隔按
2^n指数退避 - 暴露
unschedule(jobId)接口,删除时需同步清理 Redis 中对应锁和状态键(如scheduler:job:{id}:state)
任务编排依赖关系怎么不靠 DAG 引擎也能跑起来
真要上 Airflow 或 Temporal 就脱离“轻量调度”初衷了。简单场景下,用任务 ID 显式声明前置依赖即可:{ id: "B", dependsOn: ["A"], handler: fn }。调度器启动时拓扑排序,执行完 A 后才把 B 加入待触发队列。
注意这不是真正的 DAG 执行——没有并行分支、没有 join 节点、不支持条件跳转。但它够用,且完全内存态,不依赖数据库事务或消息队列。
- 依赖环检测用 DFS,发现环时报错
"circular dependency detected: A → B → A"并拒绝注册 - 每个任务成功后广播
task:success:A事件,监听者(如 B)收到后才计算自己的下次触发时间 - 失败任务不触发下游,但提供手动
retryDependents(jobId)接口强制重试整个链路 - 所有依赖状态存在内存 Map 里,节点重启后依赖关系丢失——这是取舍,换不来强一致性
最麻烦的其实是时钟同步:集群节点间系统时间差超过 500ms,秒级任务就必然乱序。别信 NTP 自动校准,上线前得用 ntpdate -q pool.ntp.org 手动对齐,否则选举和任务触发都会出问题。











