forge-admin-job-optimization-blog.md 14 KB

我给开源项目的定时任务模块做了一次「从能用到敢用」的重构:SSRF 防护、崩溃恢复、开放 API 全安排上了

副标题(备选标题,按平台选用):

  • 掘金风:2.8 万行代码重构定时任务模块:从 Cron 劝退到 SSRF 防护,我踩过的坑都在这了
  • 头条风:一个定时任务模块,为什么值得写 10 个版本?聊聊企业级调度系统的可靠性设计
  • 悬念风:你的定时任务,可能正在裸奔:聊聊调度系统里那些被忽视的坑

开篇:一个"能用"的定时任务模块,藏着多少坑?

几乎每个中后台系统都有定时任务模块。Forge Admin(一个 Vue3 + Spring Boot 3 的企业级中后台框架)也不例外——基于 Quartz,能配 Cron,能跑 Bean,看起来一切正常。

直到我认真盘了一遍代码,发现它只是"能跑",离"敢用"还很远:

  • 改个任务名,旧的 Quartz JobKey 还留在调度器里,新旧任务一起跑,数据直接双写;
  • 服务器半夜重启,RUNNING 状态的执行日志永远悬挂,你以为任务还在跑,其实进程早就死了;
  • 配置表单要求用户手写 Cron 表达式、背 Handler 编码,运营同学看一眼就关页面了;
  • 失败告警的 Webhook 地址随便填——填个 http://169.254.169.254/latest/meta-data/,恭喜你,云服务器的元数据 credentials 可能就泄了(经典 SSRF);
  • 外部系统想触发任务?要么给个登录账号,要么直接调内部接口,没有最小授权、没有幂等、没有限流。

于是我用 10 个可独立上线的小版本(V1~V10)、294 个文件、约 2.8 万行代码,把这个模块重做了一遍。这篇文章挑出其中最有价值的几个设计点聊一聊,代码全部开源,欢迎拷打。


一、先修地基:DB 期望态 vs Quartz 运行态的"对账"架构

定时任务系统最大的隐患不是跑挂了,而是静默漂移:数据库里任务已经停了,Quartz 里还在跑;你完全不知道。

原来的代码是这样的:

// 简化示意:先写库,再注册 Quartz,事务注解还被注释掉了
jobConfigMapper.insert(config);   // 写库成功
scheduler.scheduleJob(...);        // Quartz 注册失败 → 抛异常?不,吞掉返回 false

数据库和调度器之间没有任何一致性保障,失败后只能人肉登服务器排查。

我的解法:让"不同步"变得可见、可重试

  1. 写链路先落库再同步 Quartz,同步失败不再是静默的——任务标记 sync_status=FAILED,前端直接标红,并提供一个 POST /job/config/:id/sync 手动重试入口;
  2. 启动对账(JobStartupReconciler):应用启动时做一次幂等对账,只恢复 Forge 自己管理的任务,绝不动 Quartz 框架或其他模块注册的 Job;
  3. 乐观锁防复活:DB→Quartz 同步按任务 ID 串行化,更新用 WHERE id=? AND version=? 条件更新——配置并发修改时,旧请求无法把已删除/已修改的任务"复活";
  4. 调度身份冻结(job_name, job_group) 创建后不可编辑。改名 = 新任务,杜绝遗留旧 JobKey 双跑的问题。

一句话总结:不要相信任何一次"写库 + 注册调度器"能同时成功,要让不一致的状态可观测、可修复。


二、CronBuilder:让不会写 Cron 的人也能配任务

旧表单长这样:一个输入框,上面写着"请输入 Cron 表达式",旁边一个输入框写着"Handler 编码"。这对开发都不友好,更别说产品和运营。

这次我做了个双模式的 CronBuilder 组件:

  • 简单模式:每隔几分钟 / 每小时 / 每天 / 每周 / 每月,五类图形化选项,覆盖 90% 的场景,选完自动生成 Cron;
  • 专家模式:直接编辑 Quartz 6 段表达式。有个细节值得说:复杂表达式反解析不回简单模式时,原值不丢失——很多开源实现在这里会把用户的表达式悄悄改掉;
  • 未来 5 次触发时间预览:后端 POST /job/config/cron/preview 做服务端权威校验,返回自然语言描述("每周一上午 9:00")+ 未来 5 次触发时间。配错了当场就能发现,不用等明天早上看日志。

执行器也做成了目录化:后端扫描显式注解的任务处理器生成下拉列表,用户从"背编码"变成"选菜单"。

整个配置页重构成了全屏工作台:基本信息 / 执行内容 / 执行计划 / 高级设置四个分区,右侧固定显示配置摘要,改完没保存就切 Tab 会弹确认。

【截图 1:任务配置工作台全貌,重点展示右侧配置摘要 + 未来 5 次触发时间预览】

另外还顺手补了两个呼声很高的能力:

  • 真正的一次性任务(ONCE):以前只能用"每天执行的 Cron"模拟单次触发。现在 ONCE 用 SimpleTrigger 实现,执行完进入"已结束"状态,重启也不会复活;
  • 每任务独立时区:CronTrigger 显式携带任务的 IANA ZoneId,多时区业务不再全平台共享 JVM 时区。夏令时不存在的时间直接拒绝保存,重复时间采用固定偏移策略。

三、并发、重试、Misfire:防御式设计三件套

生产环境定时任务的事故,一半来自这三个问题。我的原则是:默认安全,危险操作需要显式声明。

禁止并发:跳过,而不是排队

长任务跨周期重入(上一轮没跑完,下一轮又开始了)是脏数据的重灾区。开启 SKIP_IF_RUNNING 后,用任务级 Redisson 分布式 tryLock:

  • 抢到锁 → 正常执行;
  • 没抢到 → 本轮记为"已跳过"(不排队、不伪装成功);
  • Redis 挂了 → 失败关闭(fail-closed),宁可不跑,也不冒着并发的风险跑。

重试:只有声明了"幂等"的任务才配重试

非幂等任务自动重试 = 自动制造脏数据(比如重复扣款、重复发消息)。所以重试开关做了硬约束:只有 idempotent_flag=1 的任务才允许开启重试,保存时校验直接拦截。重试是顶层有限重试 + 固定退避,且重试不会新建执行记录——日志里能清楚看到"第几次重试"。

Misfire:宕机补跑,行为必须可控

宕机恢复后是立刻补跑一次(FIRE_ONCE_NOW)还是干脆跳过等下轮(DO_NOTHING)?交给用户按业务选,而不是框架替你猜。


四、执行生命周期 + 心跳:服务器重启后,悬挂任务怎么办?

这是个经典难题:任务执行到一半,进程被 kill 了。数据库里那条 RUNNING 状态的日志,会永远 RUNNING 下去。

我的方案是三件套:

  1. 统一生命周期:执行记录从 ACCEPTED → RUNNING → 终态,所有状态流转收口到 JobExecutionLifecycleService
  2. 数据库心跳:运行中的任务周期性更新 heartbeat_time 字段;
  3. 启动恢复:应用启动时扫描失去心跳且超过阈值(默认 15 分钟可配)的悬挂记录,统一终结为失败。恢复过程幂等,重复执行不产生副作用。

还有一个隐蔽的坑:并行任务乱序完成会污染"连续失败次数"统计——老结果晚到,会把新结果覆盖掉。解法是引入 last_completion_time + last_completion_execution_id 定序,只接受更新的结果来推进统计。

配合这些能力,日志模块也重做了一遍:组合筛选、右侧抽屉详情(时间线/结果摘要/技术字段折叠)、近 24 小时监控摘要(执行量/成功率/跳过量/连续失败任务 TOP)。

【截图 2:日志列表 + 右侧抽屉详情,或近 24 小时监控摘要面板】

对了,还有一条安全红线:任务参数、执行结果、异常信息全部经过结构化脱敏再入库(JobLogSanitizer),日志详情和导出不返回完整参数和堆栈——很多系统在这里明文存了手机号甚至密码。


五、SSRF 防护:一个 Starter 统一全平台的出站 HTTP

这是我觉得这次优化里技术含量最高、也最容易被忽视的一块。

前面说过,告警 Webhook 地址可以由用户配置。类似的还有流程 API 节点、RPC 远程执行——这些都是"任意外发 HTTP 请求"的入口,每一个都是 SSRF 旁路。攻击者把 Webhook 配成内网地址或 169.254.169.254(云厂商元数据服务),就能借你的服务器当跳板。

方案:forge-starter-outbound

我新建了一个独立的技术启动器,规定业务插件禁止自建 URL 校验逻辑,所有出站 HTTP 必须走它。核心设计:

1. IP 分类器(IpAddressClassifier)

把解析出的每个 IP 分为 PUBLIC / PRIVATE / BLOCKED,阻断:

  • 环回地址(127.x、::1)
  • 链路本地地址——包括 169.254.169.254 这个云元数据地址
  • RFC1918 私网段(10/8、172.16/12、192.168/16)
  • IPv4-mapped IPv6(::ffff:127.0.0.1 这种绕过手法)
  • 保留段、组播段

2. 白名单按场景隔离

JOB_WEBHOOK / FLOW_API / JOB_RPC 三个场景各自独立维护白名单,只支持精确主机 + 端口范围匹配——明确禁止通配符和后缀匹配(*.example.com 这种看似方便、实则一开一大片的设计)。更狠的是:Webhook 场景在数据库层加了 CHECK 约束,永不允许配置私网地址,代码出 bug 都绕不过去。

3. 防 DNS 重绑定

只在校验时解析一次 DNS 是不够的——攻击者可以让域名第一次解析到公网 IP(通过校验),连接时重新解析到内网 IP。OkHttpSecureOutboundClient 用自定义 DNS,把校验过的地址直接交给连接层,从根上堵死这条路。另外:重定向默认关闭,开启时每跳重新校验并限次数;超时和响应体大小有平台上限;安全日志不记录 Authorization、Cookie 和响应体。

一句话总结:SSRF 防护的关键不是"校验 URL",而是"校验的结果必须贯穿到 TCP 连接建立的那一刻"。


六、开放 API:教科书级的最小授权设计

外部系统(比如数据中台、运维平台)经常需要触发定时任务或查询执行结果。以前的做法是给个登录账号——等于把整个后台都交出去了。

这次做了一套独立的开放 API(/openapi/v1/jobs/openapi/v1/executions),安全设计拉满:

Token 管理

  • 格式:fja_<keyId>_<secret>,SecureRandom 生成;
  • 明文只在创建/轮换的响应里出现一次,数据库只存 Key ID + 前缀 + HMAC-SHA256(独立 Pepper);
  • 比对用恒定时间算法,防时序侧信道。

双重授权

Scope(jobs:read / jobs:trigger / executions:read)× 资源范围(指定任务 ID / 任务组)实时交集计算。一个只被授予"触发 A 组任务"的 Token,物理上不可能碰到 B 组。

幂等触发

触发接口必须携带 Idempotency-Key(库里只存 SHA-256)。请求先到先创建一条 ACCEPTED 状态的执行预留,24 小时内同键重试返回同一个执行 ID——网络重试、客户端重发都不会导致任务重复执行。

失败关闭 + 诚实的状态码

按 Key ID 限流;Redis 故障时限流和幂等直接失败关闭(503),绝不降级放行;统一异常处理器返回真实的 401/403/409/429/503,而不是万能的 200 + code。

前端配套做了 Token 工作台(创建/吊销/轮换,明文一次性弹窗)和一个我很喜欢的细节:调用指南组件会根据 Token 的 Scope 自动生成可直接复制的 cURL 示例——用户拿到 Token 就能跑通,不用翻文档。

【截图 3:API Token 工作台 + 自动生成的 cURL 调用示例】


七、定时任务 × Flowable:绑定即快照的流程编排

定时任务只能跑单个 Bean/Handler,但实际业务经常需要"定时驱动一串流程"——比如每天凌晨自动发起一批审批流。

这次的方案是:任务绑定流程时,保存那一刻就把 modelKey + version + deploymentId + processDefinitionId 快照固定。之后流程发布了新版本,已有任务不受影响——杜绝"流程升级导致线上任务行为悄悄变化"这类事故。

幂等设计也值得说:businessKey 固定为 job:<jobConfigId>:<executionId>,流程端按租户 + businessKey 幂等。远程调用独立 Flow 服务时,5xx 或传输错误会先按 businessKey 查回原实例,绝不重复启动。当然,这个远程调用走的也是上面那套 SecureOutboundClient + 白名单。


八、工程方法:28+ 人日的单体方案,拆成 10 个可独立上线的版本

最后聊聊过程。最初这事的评估是一个 28~42 人日的大方案。直接梭哈的风险谁都懂,所以我把它拆成了 V1~V10 十个版本,每个版本:

  • 可独立构建、验证、上线、回滚;
  • 有完整的 Spec(需求)→ Tasks(任务分解)→ Test Spec(测试规格)→ Execution Log(执行记录)文档链;
  • 先改地基(V1 可靠性),再做体验(V2 工作台),然后是能力扩展(V3 一次性/时区、V4 并发治理……),V10 做整体静态审查后的收尾修复。

最终 job 模块 178 个测试全绿,前端相关 Vitest 累计数百例,新增的每张表都有 Flyway 迁移脚本(带防重复保护,遵循逻辑删除规范)。

【截图 4(可选):版本路线图或测试通过截图,1 张即可】


结尾:几个可以抄走的设计原则

回顾整个改造,有几条原则是通用的,跟具体框架无关:

  1. 状态不一致不可怕,可怕的是不可见——让 DB 和调度器的漂移可观测、可重试;
  2. 危险能力需要显式声明——重试要先声明幂等,外发请求要先配白名单;
  3. 失败关闭优于降级放行——Redis 挂了宁可不跑任务,也不冒着并发风险跑;
  4. 校验结果要贯穿到最后一厘米——DNS 校验过的 IP 直接交给连接层,不给重绑定留缝隙;
  5. 敏感数据默认不落盘——参数脱敏入库,密钥明文只出现一次。

体验 Forge Admin