十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Spring Boot夜间定时任务工程化实践:从分布式锁到告警排查

Spring Boot夜间定时任务工程化实践:从分布式锁到告警排查 一条“今晚见吗”出现在团队群里经常不是约饭而是在问凌晨的定时任务到底还跑不跑如果把这句话补完整其实就是“今晚执行这个批处理是个好主意吗”很多后端工程师都经历过类似场景白天不敢跑批量任务于是把数据归档、报表计算、缓存预热排到凌晨两点。深夜执行看起来避开了业务高峰但随之而来的是一系列更麻烦的问题时区怎么统一、多实例会不会重复执行、失败之后有没有人能马上看到、日志能不能定位到是哪一批数据出了问题。这篇文章围绕“夜间定时任务”这一场景展开讲清楚为什么深夜执行容易变成坏主意以及如何用 Spring Boot 和常见调度手段把任务设计成可执行、可验证、可排查的工程能力。适合后端开发、运维工程师和负责批处理任务的同学阅读。读完可以形成一套自己的夜间任务评价清单也能实际写出一个带锁、带日志、带告警的最小任务。1. “今晚见吗”背后的真实问题夜间任务为什么容易变成坏主意1.1 夜间执行不等于风险低很多团队把批量任务排在凌晨理由是白天业务高峰资源紧张数据库压力大用户操作频繁跑全量更新容易拖垮在线接口。这个判断本身没有错但它忽略了一个关键变量夜间执行是在人最少、监控最弱的窗口运行。白天一条告警出现五分钟内就会有人跟进。凌晨两点出现告警大多数时候只能进入值班群值班同学可能同时在处理多个问题。如果日志没有结构化任务失败后没有自动重试负责人第二天早上才看到失败提醒那么“凌晨执行”并没有降低风险只是把故障发现时间推迟了。更常见的情况是夜间任务失败后处于“半成功”状态一部分数据已经处理完另一部分没有处理。到了第二天业务人员看到数据不完整又不确定该不该重新跑最终只能人工核对。这种局面比白天执行更消耗成本。所以判断一个夜间任务是不是坏主意不能只看“对在线系统有没有影响”还要看“失败后能不能被发现、能不能被恢复”。1.2 适合放夜间和不适合放夜间的任务不是所有任务都适合放到凌晨。下面这组判断可以帮助快速过滤。任务类型是否适合放夜间原因全量数据归档、历史数据清理适合对在线库影响大夜间用户负载低离线报表、指标计算适合可延迟执行失败后可重跑缓存批量预热适合但需要验证预热数据不准确时会直接影响线上批量发送短信、邮件、站内信谨慎用户感知强短时高峰容易触发限流支付、退款、结算类任务不适合资金敏感需要审计和人工确认实时补偿、订单状态流转不适合延迟敏感需要事件驱动实时处理“适合”不等于“可以直接跑”。即使是数据清理任务也要先确认清理规则是否正确、是否有备份、失败后能否回滚。真正的判断标准是如果这个任务在凌晨出错团队需要多久能发现又需要多久能恢复到正确状态。1.3 夜间任务真正的风险点夜间任务不是只有“跑不跑”的问题常见的风险点可以归纳为六类。第一时区问题。服务器时区是 UTC应用配置时区是 Asia/Shanghaicron 表达式按哪个时区解释直接决定任务会不会在错误的时间执行。第二任务重叠。上一次任务因为数据量大还没跑完下一次触发时间已经到了两个任务同时处理同一批数据。第三多实例重复执行。应用部署了两个节点同一个 cron 表达式会在两台机器上同时触发如果没有分布式锁或幂等机制数据会被处理两次。第四时钟漂移。服务器系统时间不准导致任务提前或延后执行。单机部署时影响不大多机部署时会造成执行窗口不一致。第五依赖未就绪。凌晨外部接口可能正在维护数据库可能在切换文件源可能还没有上传任务没有做依赖检查就直接开始。第六不可观测。没有日志、没有执行记录、没有监控指标任务失败后根本不知道从哪里查起。后面的内容会围绕这六类风险逐步给出可落地的处理方式。2. 环境准备先跑通一个最小的 Spring Boot 夜间任务2.1 依赖与项目结构建议使用 Spring Boot 搭建一个最小工程。定时任务本身只需要spring-boot-starter但为了能手动触发验证通常再加上spring-boot-starter-web。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version使用你项目匹配的 Spring Boot 版本/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies这里没有写死 Spring Boot 版本因为不同团队的基础工程版本差别很大。落地前要先确认项目当前的 Spring Boot 版本避免依赖冲突。最小目录结构如下demo-night-job/ pom.xml src/main/java/com/example/nightjob/ NightJobApplication.java job/CleanDataJob.java job/JobRunLogService.java src/main/resources/ application.ymlNightJobApplication是启动类负责开启调度能力。CleanDataJob是具体的夜间任务。JobRunLogService负责记录任务执行状态后面会用到。2.2 最小可执行代码启动类需要加上EnableScheduling否则Scheduled注解不会生效。package com.example.nightjob; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; SpringBootApplication EnableScheduling public class NightJobApplication { public static void main(String[] args) { SpringApplication.run(NightJobApplication.class, args); } }任务类先写一个最简单的版本每天凌晨两点执行一次清理任务。当前阶段只打印日志不处理真实业务。package com.example.nightjob.job; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; Component public class CleanDataJob { private static final Logger log LoggerFactory.getLogger(CleanDataJob.class); Scheduled(cron 0 0 2 * * ?, zone Asia/Shanghai) public void cleanExpiredData() { log.info(clean expired data task start); // 这里先只记录日志实际项目中替换为清理逻辑 log.info(clean expired data task end); } }这里要特别说明 cron 表达式。Spring 的Scheduled使用六位 cron秒、分、时、日、月、周。0 0 2 * * ?表示每天 02:00:00 执行。最后一位使用?表示“不指定”避免和周字段冲突。zone参数用来指定 cron 按哪个时区解析。这里固定为Asia/Shanghai表示无论服务器是 UTC 还是其他时区都按北京时间凌晨两点触发。为什么要单独设置因为很多服务器默认时区是 UTC如果 cron 写0 0 2 * * ?而不指定时区实际执行时间可能变成北京时间早上八点或晚上十点完全偏离预期。2.3 学习环境如何快速验证启动应用后确认日志里没有报错。但凌晨两点的 cron 不方便调试所以推荐把 cron 表达式放到配置文件中通过配置覆盖。spring: task: scheduling: time-zone: Asia/Shanghai pool: size: 4同时在任务注解中使用配置占位符Scheduled(cron ${night.job.clean.cron:0 0 2 * * ?}, zone ${night.job.clean.zone:Asia/Shanghai}) public void cleanExpiredData() { // 执行逻辑 }测试环境可以在application.yml或环境变量里临时覆盖night: job: clean: cron: 0 */1 * * * ?这样每整分钟都会触发一次方便验证。注意测试环境临时改成每分钟执行没问题生产环境不要用这种覆盖方式。夜间任务一旦跑错影响的是真实数据。3. 把“坏主意”改造成“好主意”可靠性设计3.1 cron 表达式、时区和线程池要一起确认cron 表达式看起来很直观实际最容易出错。字段取值说明秒0-59第几秒触发分0-59第几分钟触发时0-23第几小时触发日1-31月中第几天月1-12几月周0-7 或 SUN-SAT星期几0 和 7 都是周日初学者常犯的错误是把0 0 2 * * *当成“每天两点”。在 Spring 六位表达式中最后一位是周*表示每天都会匹配而日的*也表示每天匹配日和周同时为*时规则不明确所以推荐使用?表示不指定。时区方面生产环境最稳妥的做法是服务器操作系统统一使用 UTC。应用代码中手动指定 cron 时区为Asia/Shanghai。日志输出时加入业务时区信息。数据库时间字段统一使用DATETIME并按业务时区写入。不要把时区判断散落在各处。只要有一个地方用了服务器默认时区另一个地方用Asia/Shanghai就会出现“昨天”“今天”边界混乱的问题。线程池也需要注意。Spring Boot 默认的调度线程数只有 1。如果项目里有多个Scheduled任务其中一个任务长时间阻塞其他任务会被卡住。可以配置调度线程池大小。spring: task: scheduling: pool: size: 4线程池大小不是越多越好。需要评估任务是否允许并行。如果两个任务都操作同一张表并行执行可能会造成锁等待。调度线程池只解决“多个任务排队”的问题不解决“业务资源争抢”的问题。3.2 多实例部署时必须加分布式锁应用一旦部署多个节点同一个 cron 表达式会在每个节点上触发。比如两个实例都跑cleanExpiredData同一批过期数据会被处理两次。如果任务只是重新计算结果幂等性还好。如果任务是清理数据、累加金额、发送消息重复执行就是事故。最简单的保护方式是使用 Redis 分布式锁。import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import java.time.Duration; import java.util.Collections; Component public class CleanDataJobWithLock { private static final Logger log LoggerFactory.getLogger(CleanDataJobWithLock.class); private static final String LOCK_KEY job:clean:lock; private static final Duration LOCK_TIMEOUT Duration.ofMinutes(30); private final StringRedisTemplate redisTemplate; public CleanDataJobWithLock(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Scheduled(cron ${night.job.clean.cron:0 0 2 * * ?}, zone ${night.job.clean.zone:Asia/Shanghai}) public void cleanExpiredData() { String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(LOCK_KEY, requestId, LOCK_TIMEOUT); if (!Boolean.TRUE.equals(locked)) { log.info(获取分布式锁失败其他节点正在执行); return; } try { log.info(clean expired data task start); // 执行业务逻辑 log.info(clean expired data task end); } finally { releaseLock(LOCK_KEY, requestId); } } private void releaseLock(String key, String requestId) { String scriptText if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; RedisScriptLong script new DefaultRedisScript(scriptText, Long.class); redisTemplate.execute(script, Collections.singletonList(key), requestId); } }这里有几个关键点。锁的过期时间必须大于任务最大执行时间。如果任务正常要跑 20 分钟锁只设置 10 分钟任务还没结束锁就过期了另一个节点可能再次进入造成重复执行。释放锁时要校验requestId。不要直接redisTemplate.delete(key)否则可能把其他节点刚获取的锁删掉。上面用 Lua 脚本完成“判断后删除”保证原子性。分布式锁能防止多实例重复执行但不能防止任务内部的数据错误。锁只是第一道防线后面的执行记录和幂等判断仍然需要。3.3 执行记录、幂等与重试即使有锁任务也可能在业务执行一半时报错。为了能定位问题也为了支持手动重跑需要为每个任务维护一张执行记录表。CREATE TABLE job_run_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_code VARCHAR(64) NOT NULL COMMENT 任务编码, biz_date VARCHAR(20) NOT NULL COMMENT 业务日期, executor_ip VARCHAR(64) COMMENT 执行节点 IP, trigger_time DATETIME COMMENT 触发时间, start_time DATETIME COMMENT 开始时间, end_time DATETIME COMMENT 结束时间, status VARCHAR(16) COMMENT RUNNING/SUCCESS/FAILED, error_msg TEXT COMMENT 失败信息, retry_count INT DEFAULT 0 COMMENT 重试次数, UNIQUE KEY uk_job_biz (job_code, biz_date) ) COMMENT 定时任务执行记录;核心是唯一键(job_code, biz_date)。同一个任务同一天只能有一条执行记录。第一次执行时插入一条RUNNING记录成功或失败后更新状态。这样即使手动重跑和自动触发同时到达数据库也会拒绝第二条记录。任务代码大致结构如下public void cleanExpiredData(String bizDate) { if (jobRunLogService.existsSuccess(cleanData, bizDate)) { log.info(任务当天已成功跳过执行); return; } jobRunLogService.markRunning(cleanData, bizDate, currentIp()); try { doClean(bizDate); jobRunLogService.markSuccess(cleanData, bizDate); } catch (Exception e) { jobRunLogService.markFailed(cleanData, bizDate, e.getMessage()); throw e; } }这里有很多团队会忽略一个细节任务失败后是否自动重试。自动重试只适合网络抖动、依赖临时不可用这类错误。如果是数据本身有问题或者业务规则不允许自动重试只会反复失败甚至把问题扩大。建议的做法是可重试错误延迟一段时间后重试最多重试 2 到 3 次。不可重试错误标记失败进入人工处理列表。重试必须有上限避免无限循环。3.4 告警与日志要能回答三个问题告警不是“发出一条消息”就结束了。一条合格的告警必须能回答三个问题哪个任务失败了、影响哪一天的数据、执行节点在哪里。最简单的告警方式是在任务失败后调用告警平台接口。比如使用 Webhook 发送到值班群。if [ $? -ne 0 ]; then curl -s -X POST $ALERT_URL \ -H Content-Type: application/json \ -d {\title\:\night job failed\, \job\:\cleanData\, \bizDate\:\2026-04-25\} fi日志方面不要只打印start和end还要打印业务日期、处理数量、执行耗时、节点 IP。推荐格式jobcleanData bizDate2026-04-25 nodenode-1 start jobcleanData bizDate2026-04-25 deleted156 costMs2333 end只要日志里有这些信息第二天排查时就能直接定位是哪个节点、哪一天、处理了多少数据。如果日志里只有“success”一旦数据有问题所有节点都要重新查一遍。4. 运行验证从启动到看到结果的完整闭环4.1 手动触发与业务日期构造定时任务不能只靠 cron 触发。生产环境经常需要手动补跑某一天的数据因此任务方法最好设计成接收bizDate参数而不是每次都取“当前日期”。Controller 可以这样写RestController RequestMapping(/job) public class JobDebugController { private final CleanDataJob cleanDataJob; public JobDebugController(CleanDataJob cleanDataJob) { this.cleanDataJob cleanDataJob; } PostMapping(/clean/run) public String run(RequestParam String bizDate) { cleanDataJob.cleanExpiredData(bizDate); return ok; } }手动触发时的操作目标是确认任务能处理指定业务日期的数据并且执行状态正确写入job_run_log。操作内容在测试库中插入一批过期数据和一批正常数据。调用POST /job/clean/run?bizDate2026-04-25。检查过期数据是否被清理正常数据是否保留。检查job_run_log中状态是否为SUCCESS。这里要注意手动触发接口不能在生产环境随意暴露。至少需要加权限控制或者只允许从内网访问。否则任何人都可以触发一个凌晨任务对数据库产生影响。4.2 日志与指标检查运行完成后日志应该包含以下信息2026-04-26 02:00:00.123 INFO node-1 jobcleanData bizDate2026-04-25 start 2026-04-26 02:00:02.456 INFO node-1 jobcleanData bizDate2026-04-25 deleted156 costMs2333 end这里deleted156就是影响行数。没有这个数字日志就只是“跑完了”无法判断数据量是否符合预期。监控指标方面建议至少接入以下指标指标名类型含义job_execute_totalCounter任务总执行次数job_execute_success_totalCounter任务成功次数job_execute_failed_totalCounter任务失败次数job_execute_duration_secondsHistogram任务耗时分布job_execute_runningGauge当前正在执行的任务数job_latest_success_timeGauge最近一次成功执行时间戳其中job_latest_success_time很关键。它用来回答“任务到底有没有跑”。很多团队只监控失败告警但任务因为调度器故障根本没有触发这时候不会有失败告警只有“最近成功时间越来越旧”。加上这个指标后可以通过类似“超过 24 小时没有新成功记录”的规则发现漏跑任务。4.3 模拟故障验证恢复能力验证不只是跑通一次正常链路。至少要模拟三种故障场景。第一让任务失败。可以故意传入一个不存在的业务日期或者让任务里的 SQL 报错。确认job_run_log的状态变成FAILED日志里出现异常堆栈告警能发出来。第二模拟两个节点同时触发。在两个实例上同时调用手动触发接口确认只有其中一个执行。另一个实例应该输出“获取分布式锁失败”或“任务当天已成功”而不是继续跑业务。第三失败后手动重跑。修复问题后再次调用手动触发接口确认job_run_log中同一条记录被更新为SUCCESS而不是插入一条新记录。这样才能保证补数和自动任务不冲突。这些验证必须在测试环境完成不能直接在生产环境演练。5. 常见问题排查凌晨被电话叫醒的几类原因5.1 任务没有执行现象到了预定时间没有任何日志job_run_log里也没有新记录。可能原因cron 表达式写错尤其是日和周的配置冲突。时区配置不统一导致执行时间偏移。应用虽然启动了但调度线程池被某个长时间任务占满。机器时钟漂移提前或延后执行。如果使用系统 croncrond服务未运行。检查方式date -R timedatectl crontab -l在应用日志中搜索Initializing ExecutorService或调度相关的注册日志。同时查询job_run_log当天的记录SELECT * FROM job_run_log WHERE job_code cleanData AND biz_date 2026-04-25;处理建议统一时区cron 表达式用在线工具校验调度线程池留出足够大小。如果单机调度不可靠可以引入外部调度平台作为兜底。5.2 任务重复执行现象job_run_log中同一job_code和biz_date出现多条记录或者数据被处理两次。可能原因多实例部署但没有加分布式锁。自动调度和手动重跑同时触发。Redis 锁过期时间太短任务还没结束锁就释放了。多个节点使用的锁 key 不一致比如带上了节点 IP。检查方式SELECT * FROM job_run_log WHERE job_code cleanData AND biz_date 2026-04-25;同时查看 Redis 中锁 key 是否存在redis-cli EXISTS job:clean:lock处理建议使用带随机值且带过期时间的分布式锁释放时用 Lua 校验。执行记录表增加唯一键(job_code, biz_date)让数据库层面拒绝重复。5.3 任务卡死现象日志停在start一直没有end任务执行时间远超预期。可能原因外部 HTTP 调用没有设置超时时间。数据库连接池耗尽任务在等待连接。慢 SQL 造成锁等待。内存不足触发频繁 Full GC业务线程长时间暂停。循环处理数据时出现死循环。检查方式先看线程栈jstack pid | grep -A 20 cleanData再看数据库当前事务和锁SELECT * FROM information_schema.innodb_trx; SHOW PROCESSLIST;处理建议所有外部调用必须设置超时时间和熔断降级。大批量任务要分批处理每批提交一次事务避免一个超长事务锁住大量数据。同时设置任务最大执行时间监控超过阈值直接告警。5.4 数据不对但状态显示成功现象任务日志显示SUCCESS但业务数据不符合预期比如清理的数据多了、少了或者汇总金额不对。可能原因时区导致业务日期窗口错误处理了不该处理的数据。事务边界不对部分成功但整体没有回滚。读取的是缓存或从库主从延迟导致结果不一致。任务处理的数据被其他任务并发修改。检查方式看日志里deleted或处理数量是否符合预期。在任务末尾增加对账查询比如统计清理前后的数据量如果差值不等于预期就标记失败。处理建议不要只监控“是否执行成功”还要监控“执行结果是否符合预期”。在任务末尾增加结果校验函数金额、数量、状态分布都可以作为校验项。5.5 排查顺序凌晨被叫醒时不要先翻代码。按顺序来查job_run_log确认任务有没有触发、状态是什么。查应用日志确认开始时间和结束时间以及异常堆栈。查监控指标确认失败次数、耗时、最近成功时间。查线程栈和数据库锁确认是不是卡死。确认数据结果判断是不是执行成功但数据错误。这个顺序的目的是先确定“任务到底有没有跑”和“结果对不对”再往代码层面走。很多人一上来就改代码结果发现任务是没触发或只是外部接口超时白白浪费大量时间。6. 工程保障发布前和执行后的检查清单6.1 变更评估先回答六个问题上线一个夜间任务前不要只问“cron 写对没有”。更重要的六个问题是这个任务能不能不在夜间跑如果任务失败会造成什么影响任务最晚必须几点完成失败后能不能自动恢复恢复是否需要人工介入这个任务的负责人是谁如果六个问题都回答清楚写代码时就有明确边界。比如“最晚必须 02:30 完成”那么 30 分钟就是任务超时阈值超过就要告警。如果“失败后不能自动恢复”就不能把任务设计成无限重试而是应该跳过并等待人工处理。6.2 可复用检查清单下面这份清单适用于绝大多数夜间批处理任务。类别检查项通过标准时间cron 表达式通过工具校验执行时间符合预期时间时区统一配置cron 按业务时区触发并发有分布式锁或幂等键多实例不会重复执行日志开始、结束、数量、耗时都有记录失败后能定位问题监控有失败告警和最近成功时间监控失败后 5 分钟内能感知补偿有手动重跑入口能按业务日期补跑依赖外部接口有超时和熔断不会无限等待数据任务末尾有结果校验数据正确性可验证权限手动触发接口有权限控制生产环境不会乱触发回滚有备份或可逆方案数据出错能恢复这份清单不复杂但可以有效避免“任务上线后只能靠人盯”的局面。6.3 运维响应机制夜间任务不能只靠开发同学盯。需要有一条明确的响应链路。任务失败告警发出后值班人员应该先查看 job
返回列表