# 我给开源项目的定时任务模块做了一次「从能用到敢用」的重构: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 里还在跑;你完全不知道。 原来的代码是这样的: ```java // 简化示意:先写库,再注册 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__`,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::`,流程端按租户 + 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 - 在线演示:http://www.dlforgelab.com:8084/forge/login - 默认账号:admin / 123456 - Gitee:https://gitee.com/ForgeLab/forge-admin