一提”定时任务”,很多人第一反应是”起一个调度服务”或者”上 Celery / 独立 cron”。”独立”听起来更干净:机器坏了不影响主程序,还能横向扩展。但有些场景,把定时器放进宿主进程反而更合适。

我在做一个 DSH 定时任务插件(dsh-timer-agent),它就是在宿主进程内做 cron 调度,到点驱动真实的 Agent 会话。这篇讲讲这个取舍。

独立调度的代价,往往被低估

独立调度器看起来美好,实际会带来一堆和核心无关的复杂度:

  • 部署多一个组件:要单独起服务、开机自启、配置;
  • 跨进程通信:调度器在 A 进程,Agent 运行时在 B 进程,两者之间要序列化传递任务、结果、上下文;这一层通信很容易出 bug,也很难调;
  • 状态同步:调度的”有没有跑过”“跑到哪了”要跨进程同步;
  • 环境不一致:调度器能拿到的东西,和运行时拿到的不一定一样,踩环境坑。

如果你的定时任务就是要驱动同一个运行时里的 Agent 会话,那把这层通信拆到进程外,是把简单的事做复杂了。

宿主内 cron 的好处

在宿主进程里做 cron,最直接的好处是上下文共享:定时器到点,直接能拿到来驱动会话所需的一切——运行时、配置、会话对象。不需要序列化、不需要网络调用、不需要额外部署。代码更短,也更不容易坏。

而且它天然贴近真实场景:你调度的是这个运行时的 Agent,那就该在它身边调度,而不是隔着进程猜。

那”崩了怎么办”?——这才是关键

有人会问:宿主进程一崩,定时任务不就没了吗?对。这正是”宿主内 vs 独立”的核心权衡,也是你必须想清楚的点。

我的取舍是:接受”宿主进程存续期间“这个前置条件——定时任务的生命周期跟随宿主进程。够用,因为要驱动的是宿主内的会话;进程不在,会话本来也没了。

所以重点不是”怎么让它跨进程存活”,而是怎么让丢失可被感知。这里有个很实在的原则:“丢一次”和”重跑一遍”,要选一个并明确说明。

  • 选丢一次:就要有可观测——任务失败、没跑、跑挂了,得能看到。我用了 Trace / 回执这类记录,进程内留下痕迹。
  • 选重跑一遍:就要保证幂等——重跑不会造成重复副作用,否则删了两次订单就麻烦了。

不声明、不观测的”丢了就丢”,是最危险的。

踩过一个很”脏”的坑

这个项目里有个挺典型的问题:中文任务名被错误地带进了跨平台的会话标识,导致请求头序列化失败、被误判成 TRANSPORT 错误。绕过去?不,要修根因——把展示名和用 ASCII 标识的会话 ID 解耦,并补回归测试(”中文标题 → ASCII session id”)。

这类问题很能说明”宿主内调度”的一个特质:它是和运行时长在一起的,所以运行时里任何一点脏数据,都可能被定时器放大成故障。定时器虽然是”宿主内”的,但它的健壮性和运行时是绑定的。

最后几条

  • 调度器放在宿主进程内,能省掉跨进程通信 + 额外部署,适合”就是要驱动本进程会话”的场景。
  • 接受”生命周期跟随宿主进程”这个前提,别硬做跨进程存活。
  • “丢一次”和”重跑一遍”必须二选一并声明;不声明的丢失最可怕。
  • 无论选哪个,都要有可观测(Trace/回执)和幂等(重跑无副作用)。
  • 中文任务名这类脏数据,会被定时器放大成故障——要修根因,加回归测试。

架构取舍没有标准答案,但你得知道自己选了哪条路、放弃了什么、以及怎么兜底。把这几句话说清楚,比”哪个方案更高大上”重要得多。