副标题(备选标题,按平台选用):
- 掘金风:
2.8 万行代码重构定时任务模块:从 Cron 劝退到 SSRF 防护,我踩过的坑都在这了- 头条风:
一个定时任务模块,为什么值得写 10 个版本?聊聊企业级调度系统的可靠性设计- 悬念风:
你的定时任务,可能正在裸奔:聊聊调度系统里那些被忽视的坑
几乎每个中后台系统都有定时任务模块。Forge Admin(一个 Vue3 + Spring Boot 3 的企业级中后台框架)也不例外——基于 Quartz,能配 Cron,能跑 Bean,看起来一切正常。
直到我认真盘了一遍代码,发现它只是"能跑",离"敢用"还很远:
http://169.254.169.254/latest/meta-data/,恭喜你,云服务器的元数据 credentials 可能就泄了(经典 SSRF);于是我用 10 个可独立上线的小版本(V1~V10)、294 个文件、约 2.8 万行代码,把这个模块重做了一遍。这篇文章挑出其中最有价值的几个设计点聊一聊,代码全部开源,欢迎拷打。
定时任务系统最大的隐患不是跑挂了,而是静默漂移:数据库里任务已经停了,Quartz 里还在跑;你完全不知道。
原来的代码是这样的:
// 简化示意:先写库,再注册 Quartz,事务注解还被注释掉了
jobConfigMapper.insert(config); // 写库成功
scheduler.scheduleJob(...); // Quartz 注册失败 → 抛异常?不,吞掉返回 false
数据库和调度器之间没有任何一致性保障,失败后只能人肉登服务器排查。
sync_status=FAILED,前端直接标红,并提供一个 POST /job/config/:id/sync 手动重试入口;WHERE id=? AND version=? 条件更新——配置并发修改时,旧请求无法把已删除/已修改的任务"复活";(job_name, job_group) 创建后不可编辑。改名 = 新任务,杜绝遗留旧 JobKey 双跑的问题。一句话总结:不要相信任何一次"写库 + 注册调度器"能同时成功,要让不一致的状态可观测、可修复。
旧表单长这样:一个输入框,上面写着"请输入 Cron 表达式",旁边一个输入框写着"Handler 编码"。这对开发都不友好,更别说产品和运营。
这次我做了个双模式的 CronBuilder 组件:
POST /job/config/cron/preview 做服务端权威校验,返回自然语言描述("每周一上午 9:00")+ 未来 5 次触发时间。配错了当场就能发现,不用等明天早上看日志。执行器也做成了目录化:后端扫描显式注解的任务处理器生成下拉列表,用户从"背编码"变成"选菜单"。
整个配置页重构成了全屏工作台:基本信息 / 执行内容 / 执行计划 / 高级设置四个分区,右侧固定显示配置摘要,改完没保存就切 Tab 会弹确认。
【截图 1:任务配置工作台全貌,重点展示右侧配置摘要 + 未来 5 次触发时间预览】
另外还顺手补了两个呼声很高的能力:
生产环境定时任务的事故,一半来自这三个问题。我的原则是:默认安全,危险操作需要显式声明。
长任务跨周期重入(上一轮没跑完,下一轮又开始了)是脏数据的重灾区。开启 SKIP_IF_RUNNING 后,用任务级 Redisson 分布式 tryLock:
非幂等任务自动重试 = 自动制造脏数据(比如重复扣款、重复发消息)。所以重试开关做了硬约束:只有 idempotent_flag=1 的任务才允许开启重试,保存时校验直接拦截。重试是顶层有限重试 + 固定退避,且重试不会新建执行记录——日志里能清楚看到"第几次重试"。
宕机恢复后是立刻补跑一次(FIRE_ONCE_NOW)还是干脆跳过等下轮(DO_NOTHING)?交给用户按业务选,而不是框架替你猜。
这是个经典难题:任务执行到一半,进程被 kill 了。数据库里那条 RUNNING 状态的日志,会永远 RUNNING 下去。
我的方案是三件套:
ACCEPTED → RUNNING → 终态,所有状态流转收口到 JobExecutionLifecycleService;heartbeat_time 字段;还有一个隐蔽的坑:并行任务乱序完成会污染"连续失败次数"统计——老结果晚到,会把新结果覆盖掉。解法是引入 last_completion_time + last_completion_execution_id 定序,只接受更新的结果来推进统计。
配合这些能力,日志模块也重做了一遍:组合筛选、右侧抽屉详情(时间线/结果摘要/技术字段折叠)、近 24 小时监控摘要(执行量/成功率/跳过量/连续失败任务 TOP)。
【截图 2:日志列表 + 右侧抽屉详情,或近 24 小时监控摘要面板】
对了,还有一条安全红线:任务参数、执行结果、异常信息全部经过结构化脱敏再入库(JobLogSanitizer),日志详情和导出不返回完整参数和堆栈——很多系统在这里明文存了手机号甚至密码。
这是我觉得这次优化里技术含量最高、也最容易被忽视的一块。
前面说过,告警 Webhook 地址可以由用户配置。类似的还有流程 API 节点、RPC 远程执行——这些都是"任意外发 HTTP 请求"的入口,每一个都是 SSRF 旁路。攻击者把 Webhook 配成内网地址或 169.254.169.254(云厂商元数据服务),就能借你的服务器当跳板。
我新建了一个独立的技术启动器,规定业务插件禁止自建 URL 校验逻辑,所有出站 HTTP 必须走它。核心设计:
1. IP 分类器(IpAddressClassifier)
把解析出的每个 IP 分为 PUBLIC / PRIVATE / BLOCKED,阻断:
::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(/openapi/v1/jobs、/openapi/v1/executions),安全设计拉满:
Token 管理
fja_<keyId>_<secret>,SecureRandom 生成;双重授权
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 调用示例】
定时任务只能跑单个 Bean/Handler,但实际业务经常需要"定时驱动一串流程"——比如每天凌晨自动发起一批审批流。
这次的方案是:任务绑定流程时,保存那一刻就把 modelKey + version + deploymentId + processDefinitionId 快照固定。之后流程发布了新版本,已有任务不受影响——杜绝"流程升级导致线上任务行为悄悄变化"这类事故。
幂等设计也值得说:businessKey 固定为 job:<jobConfigId>:<executionId>,流程端按租户 + businessKey 幂等。远程调用独立 Flow 服务时,5xx 或传输错误会先按 businessKey 查回原实例,绝不重复启动。当然,这个远程调用走的也是上面那套 SecureOutboundClient + 白名单。
最后聊聊过程。最初这事的评估是一个 28~42 人日的大方案。直接梭哈的风险谁都懂,所以我把它拆成了 V1~V10 十个版本,每个版本:
最终 job 模块 178 个测试全绿,前端相关 Vitest 累计数百例,新增的每张表都有 Flyway 迁移脚本(带防重复保护,遵循逻辑删除规范)。
【截图 4(可选):版本路线图或测试通过截图,1 张即可】
回顾整个改造,有几条原则是通用的,跟具体框架无关: