ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot任务模块设计实战:从定时调度到分布式锁

Spring Boot任务模块设计实战:从定时调度到分布式锁 任务模块这章我在好几个项目里都栽过跟头。先说个结论多数任务模块的问题不是代码写不出来而是设计的时候没想清楚这个模块到底要管哪些事。你翻开大部分后台管理系统任务模块无非就这几摊事定时跑批、异步处理、失败重试、执行记录。但真到了上线那天你会发现还有一个隐藏需求——产品经理随时可能跑过来跟你说这个任务要支持手动触发那个任务得能暂停。所以这一章我们不急着写代码先把任务模块的边界、选型、表结构、调度策略这几件事捋清楚再动手实现你会省掉后面大量返工的功夫。适合谁来读呢正在做前后端分离项目、用的是Spring Boot体系的Java后端同学或者刚接触任务调度、想系统梳理定时任务和异步任务设计的初中级开发。里面有可落地的表结构设计、接口定义、防重复执行方案也包括我在生产环境里踩过的坑。1. 任务模块的定位与技术选型1.1 核心需求解析任务模块不只是定时任务很多人一听任务模块第一反应就是Quartz、XXL-Job这类定时任务框架。但任务模块在真实业务里形态比你想的宽得多。我在设计时习惯先把任务分成四类每一类的技术方案不一样定时任务固定周期或固定时间点执行比如每天凌晨同步数据、每周一生成报表。延迟任务触发后等一段时间再执行比如下单后30分钟未支付自动关单。异步任务请求进来后不阻塞主流程在后台慢慢处理比如导入Excel后异步解析入库。手动任务管理界面上点一下立刻执行很多运维场景需要这种东西。这四类业务有的用Spring自带的Scheduled就能解决有的需要引入消息队列或任务调度框架。难点在于它们通常会混在同一个任务管理页面里产品经理眼里都是任务但技术实现路径完全不同。我之前见过一个项目把所有任务都往Quartz里塞结果就是Quartz的线程池被异步任务占满定时任务全部延迟。所以任务模块设计的第一步不是选框架而是把任务分类明确每一类适合的技术手段。我通常的做法是异步任务走线程池或MQ延迟任务用延迟队列或Redis过期监听只有真正的定时任务才交给调度框架管理。1.2 基于Spring Boot 3的选型思路在我自己的项目里后端基础是Spring Boot 3.x任务模块的选型是这样考虑的轻量级场景单个应用、任务量不大直接ScheduledThreadPoolTaskExecutor不引额外中间件部署运维成本最低。中型场景任务类型多、需要动态调整执行计划引入Quartz。Quartz对触发器的管理很成熟支持CRON表达式的动态修改而且和Spring集成很顺。分布式场景多个应用实例同时部署需要任务只在某一台机器上执行这时候就要引入分布式调度平台像XXL-Job。它有自带的调度中心和控制台可视化管理任务比较方便。还要说一句题外话最近FastAPI这类的Python后端也很火如果你的团队是Python技术栈任务模块的思路完全一样只是工具换成APScheduler或Celery。设计模式是通用的我不建议你被语言绑死。我这里主要按Spring Boot 3 Quartz这条路往下讲。选Quartz而不是纯Scheduled核心原因有两个第一任务执行计划要存在数据库里支持运行时通过接口修改第二Quartz自带了持久化和集群能力虽然我们单机部署也用不到但至少不会因为部署形态变化就重写代码。2. 数据模型设计任务模块的基石2.1 任务表结构设计任务模块必须有一张任务表这是所有功能的地基。表结构设计得不好后面动态创建、修改任务会写出一堆烂代码。我用的核心表是这样的CREATE TABLE task_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_code VARCHAR(64) NOT NULL COMMENT 任务编码唯一, task_name VARCHAR(128) NOT NULL COMMENT 任务名称, task_type VARCHAR(16) NOT NULL COMMENT 任务类型cron/delay/async, task_handler VARCHAR(128) NOT NULL COMMENT 处理器Bean名称, cron_expression VARCHAR(64) COMMENT CRON表达式定时任务使用, task_status VARCHAR(16) NOT NULL DEFAULT ENABLED COMMENT 状态ENABLED/DISABLED, execute_param TEXT COMMENT 执行参数JSON格式, timeout_seconds INT DEFAULT 300 COMMENT 超时时间, fail_strategy VARCHAR(16) DEFAULT RETRY COMMENT 失败策略RETRY/ALERT/STOP, retry_count INT DEFAULT 3 COMMENT 失败重试次数, version INT DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_task_code (task_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT任务信息表;这张表有几点值得说明。task_handler存的是Spring容器里的Bean名称执行任务的时候通过ApplicationContext.getBean(handlerName)拿到处理器而不是用反射硬编码类路径。这样做的好处是新增任务类型不需要改调度代码只要在Spring容器里注册一个Bean就行。execute_param用JSON格式存业务参数这样任务内容变更时不需要改表结构。fail_strategy字段是血的教训换来的没有这个字段之前任务失败后的处理逻辑写死在代码里后来产品要求部分任务失败要自动重试部分任务只想告警只能改表加字段。所以这个字段现在是我设计任务模块的标配。2.2 执行日志与锁状态的设计有任务表还不够执行日志表才是排查问题的关键。任务出了故障第一件事就是查日志表CREATE TABLE task_execution_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_id BIGINT NOT NULL COMMENT 任务ID, task_code VARCHAR(64) NOT NULL COMMENT 任务编码, execute_time DATETIME NOT NULL COMMENT 执行时间开始, finish_time DATETIME COMMENT 完成时间, execute_status VARCHAR(16) NOT NULL COMMENT 状态SUCCESS/FAILED/RUNNING/TIMEOUT, error_message TEXT COMMENT 错误信息, execute_result TEXT COMMENT 执行结果摘要, cost_ms BIGINT COMMENT 耗时毫秒数, create_time DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT任务执行日志表;日志表的索引特别关键。task_code execute_time的联合索引必须建否则查某任务最近100次执行记录这种高频操作会把数据库拖垮。我见过线上系统日志表数据量到了千万级查询直接超时最后只能加索引。另外任务状态管理上有个容易出问题的点。如果你的任务模块需要支持暂停恢复不要在任务表里用一个布尔字段is_running要设计成状态机。任务状态至少要有ENABLED启用、DISABLED停用、RUNNING执行中。这个执行中状态很重要它是防重入判断的基础。如果任务正在跑又到了下一个触发时间有了RUNNING状态就能在上层直接拦截。2.3 分布式锁与多实例部署的坑任务模块在单机部署时很省心一旦多实例部署就有个大坑同一个定时任务会在每台机器上都执行一遍。比如你有两台服务器凌晨的数据同步任务会跑两次如果是给用户发短信这种任务后果就是灾难。解决思路我知道的无非三种分布式锁、Quartz集群模式、任务只在一台机器上部署。Quartz集群模式配置比较繁琐而且和Spring Boot 3整合的资料相对少我实际项目中更常用Redis分布式锁String lockKey task:lock: taskCode; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofMinutes(5)); if (Boolean.TRUE.equals(locked)) { try { // 执行任务 executeTask(task); } finally { redisTemplate.delete(lockKey); } } else { log.warn(任务 {} 已在其他节点执行本次跳过, taskCode); }这段代码看着简单有几个隐藏细节。锁的过期时间必须大于任务最大执行时间否则任务没跑完锁就过期了别的节点就会进来重复执行。还有delete锁的时候要校验value防止误删了别的线程刚设置的锁。Redis分布式锁的坑不少但中小规模项目够用等真要上大规模分布式调度再考虑XXL-Job这类专业工具不迟。3. 任务调度的核心实现3.1 调度器与任务注册机制Quartz在Spring Boot里的使用关键是把任务定义和任务执行解耦。我通常会做一个统一的任务分发处理器所有任务都走同一个入口然后根据task_handler字段分发到具体的业务处理器Component public class TaskDispatcher implements Job { Autowired private ApplicationContext applicationContext; Autowired private TaskExecutionLogMapper logMapper; Override public void execute(JobExecutionContext context) throws JobExecutionException { JobDataMap dataMap context.getMergedJobDataMap(); String taskCode dataMap.getString(taskCode); String handlerName dataMap.getString(handlerName); TaskHandler handler (TaskHandler) applicationContext.getBean(handlerName); // 记录开始日志 Long jobLogId saveLog(taskCode, RUNNING, null); long startTime System.currentTimeMillis(); try { handler.handle(dataMap.getString(executeParam)); // 更新日志为成功 updateLog(jobLogId, SUCCESS, System.currentTimeMillis() - startTime, null); } catch (Exception e) { log.error(任务执行失败: {}, taskCode, e); updateLog(jobLogId, FAILED, System.currentTimeMillis() - startTime, e.getMessage()); // 判断失败策略是否需要重试 handleFailStrategy(taskCode, handler, dataMap, e); } } }业务处理器统一实现一个TaskHandler接口接口里就一个方法handle(String param)。新增任务时开发者只需写一个新的Handler类然后在task_info表里加一条记录调度器不需要改一行代码。这个模式不是我想出来的很多开源项目都这么干但它确实是用Quartz做任务模块比较优雅的方案。3.2 CRON表达式校验与动态调度任务模块的管理界面一定会有新增任务功能前端会传来一个CRON表达式。这个表达式不能直接信服务端必须校验。Quartz自带CronExpression.isValidExpression()方法它很好用但有一个坑它校验的是Quartz的CRON格式七位包含秒。而Linux的crontab是五位很多后端开发习惯写五位的表达式传进来就会校验失败。我给前端传参的接口里会明确告诉前端用六位或七位格式秒 分 时 日 月 周并且在后端做一次格式转换兜底。动态调度用Quartz的Scheduler接口实现核心代码如下public void addCronJob(String taskCode, String handlerName, String cronExpression, String param) { JobKey jobKey JobKey.jobKey(taskCode); if (scheduler.checkExists(jobKey)) { // 已存在则更新触发器 TriggerKey triggerKey TriggerKey.triggerKey(taskCode _trigger); CronTrigger newTrigger TriggerBuilder.newTrigger() .withIdentity(triggerKey) .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)) .usingJobData(initDataMap(taskCode, handlerName, param)) .build(); scheduler.rescheduleJob(triggerKey, newTrigger); } else { JobDetail jobDetail JobBuilder.newJob(TaskDispatcher.class) .withIdentity(jobKey) .usingJobData(initDataMap(taskCode, handlerName, param)) .build(); CronTrigger trigger TriggerBuilder.newTrigger() .withIdentity(TriggerKey.triggerKey(taskCode _trigger)) .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)) .build(); scheduler.scheduleJob(jobDetail, trigger); } }这段代码里我把TaskDispatcher作为固定入口所有Quartz的Job都指到它然后通过JobDataMap把任务信息传进去。有人会问为什么不用DisallowConcurrentExecution注解这里我说一下这个注解是Quartz用来防止同一个Job并发执行的但它只在Quartz层面生效如果你的任务是通过分布式锁控制的那个注解加了不加都行。等到了分布式场景更推荐在业务代码里用Redis锁控制不只是依赖Quartz的并发限制。3.3 异步任务与失败重试策略异步任务这块Spring Boot的Async注解虽然方便但生产环境用起来有几个问题。第一默认线程池的队列无界任务量大时内存直接飙升。第二异步方法内部异常默认只会打日志没有回调无法做重试。所以我的项目里会自定义线程池Bean(taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(async-task-); // 重点拒绝策略。任务满了之后做补偿而不是直接丢弃 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }CallerRunsPolicy这个策略很多人容易忽略它表示线程池满了之后任务不会被扔掉而是回到调用线程里执行。这个策略在任务模块里意义重大至少能保证任务一定被执行只是变成同步执行而已。用AbortPolicy默认策略的话任务一满就直接抛异常很多任务就这么无声无息丢了。失败重试的逻辑我放在执行日志记录的后面。简单说一下思路执行失败后先判断fail_strategy如果是RETRY就查最多重试次数把当前失败次数加一如果还没到上限就重新提交到线程池。提交前要判断任务类型定时任务直接交给Quartz下一轮异步任务提交给线程池不能一刀切。4. 前后端交互与状态管理细节4.1 任务列表与详情接口设计任务模块的前后端交互核心接口无非这几个任务列表查询、新增任务、修改任务、启停任务、手动触发一次、查看执行日志。这些接口本身不复杂但有几个细节值得说说。任务列表查询接口建议支持分页、状态筛选、类型筛选、模糊搜索。一个常见的坑是前端要根据任务状态显示不同的操作按钮比如已禁用的任务才能点启用启用中的才能点停用。如果后端接口不返回状态枚举的完整语义前端就要硬编码状态判断代码里到处是魔法数字。所以我在列表接口的返回结构里除了taskStatus字段还会额外返回一个allowedOperations数组告诉前端这个任务当前允许哪些操作。这样前端拿到数据渲染就好不需要知道状态机内部逻辑。4.2 按钮重复提交的校验方案这个点让我多说几句。任务模块的操作按钮都是重操作新增任务还好点两次最多创建两条记录数据库有唯一索引也能防住但手动触发任务点两次意味着业务会跑两遍。前端会做按钮disabled处理但后端必须有兜底因为总有请求绕过前端直接打接口。我在项目里的做法是两层校验第一层幂等令牌。前端每次进入任务详情页时向后端要一个requestId操作时把requestId一起提交后端用Redis存了这个requestId的消费状态如果重复提交直接返回操作已受理。public void manualTrigger(String taskCode, String requestId) { Boolean success redisTemplate.opsForValue() .setIfAbsent(idempotent: requestId, 1, Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(success)) { throw new BusinessException(请勿重复提交); } // 后续触发逻辑 triggerTask(taskCode); }第二层任务状态锁。手动触发前检查任务当前状态如果处于RUNNING状态直接拒绝触发。这一层的意义在于即便第一层被绕过了任务正在执行中也不会被再次触发。这两层配合才算是把重复提交的漏洞堵严实了。我在踩坑之后以后凡是涉及任务触发、资金操作、状态变更这类接口都默认套上这两层已经成为我的习惯了。4.3 跨域与接口联调的问题前端本地开发时访问后端接口一定会遇到跨域问题。Spring Boot解决跨域常规做法是写WebMvcConfigurerConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有个细节allowedOriginPatterns和allowedOrigins不一样。当allowCredentials(true)时allowedOrigins不能设为*否则浏览器会拦截。要用allowedOriginPatterns(*)才能配合凭证一起用。不过跨域配置只是开发环境友好生产环境还是建议前端通过Nginx把/api路径转发到后端完全避免跨域问题。我见过不少项目生产环境跨域配置没删最后带来安全隐患和奇怪的CORS报错排查半天其实根因就是跨域配置和网关配置叠在一起起了冲突。5. 实战中的问题排查与踩坑实录5.1 任务不执行的排查路径任务模块上线之后最常见的问题就是任务到点了没跑。遇到这类问题我建议按照下面这个顺序排查效率最高先查task_info表里任务状态是不是ENABLED。这一步看着低级但有一次我们把状态字段值从1改成了ENABLED一些老接口还在用数字判断任务一直不执行排查了整整一个下午。再查Quartz的qrtz_triggers表看触发器的TRIGGER_STATE是不是WAITING。如果状态是PAUSED或者ERROR说明调度器层面出问题了。查看日志TaskDispatcher的入口日志有没有打印。如果Quartz触发了但没进到execute方法大概率是DisallowConcurrentExecution导致上一次任务还没执行完下一次触发被阻断。查执行日志表最近有没有RUNNING状态一直没结束的记录。如果有说明线程被占死了看线程栈。还有一种隐蔽情况服务器时钟不准。Quartz的CRON表达式依赖系统时钟如果服务器时间漂移了所有定时任务都会错位。Docker容器里经常出现这个问题后来我在代码里加了启动时的时间校准检查对比NTP时间偏差超过30秒就告警。5.2 任务重复执行的典型场景还有一种很头疼的现象任务重复执行。我有一次排查发现任务每次执行三遍最后定位到是三个不同实例上的应用都在加载任务注册表。为什么三个实例都注册了任务因为代码里把启动时自动注册任务的逻辑写在ApplicationRunner里而任务数据是从数据库读取的所以每个应用实例启动时都把数据库里的任务注册到了本地的Quartz调度器。解决办法是注册任务前先获取分布式锁只有拿到锁的实例才执行注册逻辑。这是多实例部署最容易踩的坑而且非常隐蔽只有你部署多个副本时才会暴露出来。任务的执行时间偏移是另一个常见问题。CRON表达式每5分钟执行一次实际上可能会延迟几十秒甚至几分钟。原因通常是任务队列里有长任务占住了线程后面的任务排队等待。解决方案有两类一类是给不同任务分配独立的线程池另一个是设定超时时间任务执行超过阈值直接中断这个要在Handler层做配合。我比较推荐第一种不同重要级的任务分开线程池互不干扰。5.3 日志清理与任务堆积的长期维护任务执行日志表的数据会越来越大。我见过没做日志清理的项目跑了半年日志表占了50GB查询效率严重下降。我的做法是写一个清理任务每天凌晨删除30天前的日志这个清理任务本身也注册到任务模块里。但要注意清理任务执行时间要安排在业务低峰期而且不要把清理任务和业务任务放在同一个线程池里不然业务高峰期的定时任务会被清理任务拖慢。另外如果任务的execute_param里塞了大JSON日志表会膨胀得很快。存执行结果时我只截取前500个字符作为摘要完整结果如果业务需要单独存文件或者对象存储数据库里不放大文本。这些细节不做等到日志表数据量上来系统慢的是整个库不只是任务模块。6. 任务模块的扩展思考任务模块写到这个程度能应付大多数中小型项目的需求了。但有几个方向可以再延伸。任务编排是个大趋势比如任务A执行成功后才执行任务B失败则执行任务C这种依赖关系Quartz原生不支持。可以引入工作流引擎比如Flowable但那是另一个大话题一般项目用不上我建议有这需求时再研究不要提前引入复杂度。任务监控告警也是必需的。任务失败不能只写在日志里要能通过邮件、短信、企业微信机器人推送给负责人。我在项目里做了一个简单的告警规则表每个任务可以绑定不同的告警渠道和接收人执行失败即触发告警。最后提醒一个很多人栽过跟头的点任务处理器的代码质量比调度框架更重要。Quartz再稳定任务处理器里出现内存泄漏、数据库连接不释放整个应用都会被拖垮。所以任务模块设计好后还要配套代码规范任务处理器里禁止手动开线程禁止长时间持有数据库连接禁止出现未捕获的RuntimeException。写到这里差不多把任务模块的完整设计思路讲清楚了。如果你正在做任务模块我的建议是先把数据模型和锁机制设计好再去纠结具体的框架选择。定时任务框架可以换表结构和幂等方案的基本思路是通用的。项目遇到问题时多花点时间看执行日志比在网上搜各种完美解决方案要靠谱得多。
返回列表