ARTICLE DETAIL

资讯详情

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

微服务定时任务的分布式唯一执行:ARQ轻量级调度组件实践

微服务定时任务的分布式唯一执行:ARQ轻量级调度组件实践 做微服务做了一段时间之后基本都会碰到一个绕不开的难题定时任务。Spring Boot自带的Scheduled入手很快但一旦把它放到多个节点上跑问题就接踵而来——每个副本都执行一遍数据重复处理、下游接口被反复调用半夜报警能把人吵醒。今天我们聊的ARQ定时任务不是什么神秘框架它是我们团队在Spring Cloud环境下自研的一个轻量级定时任务调度组件核心目标只有三个任务在多节点下保证只跑一次、可以随时手动单独执行某个任务、每次执行都有完整的记录和日志可查。这篇文章会从需求拆解、技术选型、核心实现到实战踩坑把ARQ的完整落地过程讲透。适合正在折腾微服务定时任务的开发者也适合那些在后台管理系统中被添加的定时任务怎么单独执行这类问题卡住的人。不管你是准备自研一套还是只想把你现有的定时任务治理得更稳一些这篇文章里应该都有值得参考的东西。1. 为什么要自研ARQ先把需求拆清楚1.1 从能跑到可控Scheduled的边界在哪里讲ARQ之前先回头看最原始的方案。Scheduled加一个cron表达式一行注解任务就定时跑起来了。单体时代这个方案完全够用但服务一上容器编排平台副本数调成3问题马上出现一个订单数据同步任务每天凌晨跑一次三个副本同时触发数据库里瞬间产生一堆重复数据。有人这时候会想到Quartz。Quartz确实能做集群调度但它的job_store配置、集群模式、数据库表结构对一个业务团队来说多少有点重。而且Quartz解决的是调度问题任务执行成功还是失败、耗时多久、上次跑完是什么时候这些信息你依然看不到还是得自己额外做一套记录。在微服务架构里定时任务的诉求其实是多维度的。它不只是到点触发这么简单还牵扯到分布式环境下的唯一执行、执行结果的审计、异常出现时的补偿机制。ARQ正是基于这些诉求做出来的。它的定位不是要去替代Quartz这类完整的调度引擎而是解决业务侧使用定时任务时最常碰到的几个痛点让定时任务从能跑变成可控。1.2 四个核心痛点决定ARQ的骨架我最初整理需求时把定时任务的诉求分成四类后面ARQ的所有设计都围绕这四类展开分布式环境下的唯一执行。多个服务实例同时存在但一个任务在同一个时间点只能有一个实例真正执行。手动单独执行的能力。后台录了一个定时任务测试阶段不可能死等cron到点必须能立即点一下执行一次。执行结果可观测。至少要知道每个任务上次执行时间、执行结果、失败原因、耗时情况。执行异常可收敛。任务抛异常不能静默吞掉要有失败标记、重试机制严重的还要触发告警。第一点是分布式环境里最核心的问题。很多人第一反应是加个分布式锁但锁的粒度怎么设计、超时时间怎么算、拿到锁之后任务卡死了怎么办这些都是细节。后面我会单独用一整节来讲。第二点手动单独执行。这个话题看着不起眼却是后台系统里使用频率最高的功能。现在很多快速开发框架都自带定时任务管理但添加的定时任务怎么单独执行依然是高频搜索问题。ARQ从早期版本就把手动触发作为一等公民来设计每个任务注册之后天然支持通过接口触发一次。第三点和第四点本质上要求每个任务都有一次执行的元数据记录。ARQ最简单的一版就是一张任务执行日志表网上很多方案也类似区别在于ARQ把执行记录和业务日志打通了后面排查问题非常省事。2. 方案选型为什么不自研调度引擎而是做轻量组件2.1 主流方案的成本先算清楚Java生态里做定时任务大致可以分为三类方案。第一类是纯自研基于ScheduledExecutorService自己管理适合任务数量很少、逻辑很简单的场景。第二类是Quartz这种老牌调度框架功能全面但集群模式要维护额外的表结构部署时还得留意线程模型。第三类是XXL-JOB这类分布式任务调度平台功能确实强控制台、分片、动态配置全都有但它要求你独立部署一个调度中心任务代码要按它的规范打包接入对很多中小团队来说相当于引入了一个额外的系统。我当时做了一张简单的评估表在这里可以给大家参考方案落地成本分布式支持运维负担适用规模Scheduled极低不支持无单实例、低频场景Quartz集群中需额外表中集群能接受配置复杂度XXL-JOB高完善高需部署调度中心大规模任务集群ARQ自研中低基于Redis低中小微服务团队这里说的成本不只是开发时间还包括团队长期维护的心智负担。XXL-JOB确实成熟但对我们当时几十个服务的规模来说专门养一个调度中心有点杀鸡用牛刀。ARQ最后选了一条中间路线利用Spring Boot Starter的机制把任务调度能力内嵌在业务服务里不额外部署不引入外部依赖Redis负责分布式锁数据库记录执行日志。2.2 ARQ的整体结构一个Starter搞定ARQ最终被打包成一个spring-boot-starter业务服务引入依赖之后自动装配。整体上分成三层arq-core定义注解、任务注册表、调度执行器等核心模型不依赖Spring容器。arq-spring-boot-starter负责自动配置扫描带注解的任务拉起调度线程暴露管理接口。arq-log模块负责把任务执行日志写入数据库或者独立日志文件。分层的意义在于如果某个老项目没办法引入Spring Bootarq-core也能脱离容器单独复用只是少了自动装配的便利。调度触发模型我选了ScheduledThreadPoolExecutor。这里需要说明为什么不上Quartz我们业务系统里的任务量没有那么夸张不需要一个复杂的调度状态机真正需要的是一个到点了把任务丢给执行线程池的触发器。想清楚这个定位之后实现就变得非常简单也更容易排查问题。2.3 关键选型的一些细节执行线程池用的是ThreadPoolExecutor因为任务里经常涉及IO操作、远程调用线程数量不固定核心线程数会根据任务数量和平均耗时动态调整。拒绝策略选的是CallerRunsPolicy宁可让调度线程自己等一下也不要静默丢弃任务。这一点在任务量突然上来的时候非常关键至少不会出现任务到点了根本没执行的诡异现场。分布式锁选了Redis。理由很简单项目里本来就有Redis不需要额外引入中间件。锁的实现直接基于Spring Data Redis的setIfAbsent指令配合过期时间避免进程崩溃之后锁变成僵尸锁。手动触发的HTTP接口用Spring MVC实现没有引入额外框架。接口只允许内网访问并加了一层token校验避免管理接口暴露到公网。这里多说一句管理类接口的安全校验一定要做哪怕只是内网不校验的话一旦某个服务被SSRF打穿定时任务管理接口就是下一个突破口。3. 核心实现从注解到手动触发一次讲透3.1 注解设计与任务注册机制ARQ的使用方式非常像简化版的Scheduled。业务方法上标一个注解ArqTask( name syncOrder, cron 0 0 2 * * ?, desc 每天凌晨2点同步昨日订单, timeout 1800, retry 2 ) public void syncOrderTask() { // 业务逻辑 }注意这里的cron我统一用6段式。Spring从4.x开始使用的就是6段式最大的坑在于第1位到底是秒还是分。不同框架、不同在线生成网站对cron的解释不完全一致稍不留神就把每天2点执行写成了每小时2分执行一次。ARQ在注册阶段就做了cron合法性校验非法表达式直接启动失败。宁可启动报错也不能让一个配置错误的任务半夜悄悄跑起来。注册机制用的是Spring的Bean后置处理。任务类实例化之后扫描所有带ArqTask注解的方法组装成ArqTaskDefinition对象然后放进一个ConcurrentHashMap注册表。这里有一个很容易忽略的点注册表的key是任务名所以任务名必须全局唯一。我见过有同事直接拿方法名做任务名后来重构方法名任务执行记录全对不上了排查了整整一个下午。3.2 分布式锁保证只在集群中执行一次这是ARQ最核心的部分。实现思路本身不复杂任务触发时先尝试加锁Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(lockTimeout)); if (!Boolean.TRUE.equals(locked)) { // 本实例拿不到锁跳过本次执行 return; }有三个细节必须展开说。第一个锁key的粒度。我建议锁key包含任务名和调度周期比如arq:lock:syncOrder:20250101。如果你的任务是短周期执行比如每5分钟一次按时间点加锁可以避免上一次还没结束、下一次调度又触发时的混乱。但这里有个需要注意的地方如果任务执行时间跨了零点锁key按日期分就会失效。处理方法是如果任务是跑批类型的锁key干脆不要带日期只带任务名用lockTimeout来控制锁的存活。第二个锁内的requestId。requestId是一个UUID任务是哪个实例抢到的、什么时候开始执行的都能根据它追溯。更重要的是释放锁的时候要用requestId做校验防止线程A执行超时、锁自动过期之后线程B拿到锁结果A结束后把B的锁释放掉。这种误删问题在生产环境非常常见网上大量Redis分布式锁到底怎么写的文章核心就在讲这个。第三个锁的超时时间怎么算。不能拍脑袋。我的经验公式是正常情况下任务最坏执行时间加上30秒缓冲区。如果任务本身是长时间跑批最坏执行时间估不准那就把锁的超时时间调大或者引入看门狗自动续期。ARQ第一版用的是固定超时时间后来发现一个凌晨跑的报表任务偶尔会超过锁超时导致另一个节点在同一时刻又开始跑同样的报表。后来我把锁改成执行结束后主动释放带requestId校验锁的过期时间只作为兜底。执行过程中由看门狗线程负责自动续期。这里有个小细节续期逻辑要放在finally块的前面执行避免任务结束了、锁也释放了看门狗还在续期把锁活活续到了几十分钟之后。3.3 单独执行任务后台管理最需要的能力任务注册之后除了cron自动触发ARQ还暴露了一个手动触发接口。这也是后台管理系统集成时最常用到的功能curl -X POST http://arq-service/arq/task/syncOrder/trigger \ -H X-ARQ-TOKEN: xxx接口内部做的事情是根据任务名从注册表里找到ArqTaskDefinition不经过调度器直接把任务提交给执行线程池。这样一来无论后台管理系统里的立即执行按钮还是运维同学手动补跑数据都复用了同一套执行逻辑。这里有一个设计细节值得记录手动触发到底要不要走分布式锁我的答案是走而且必须走。在集群环境里如果你连续点两次立即执行实际只会有一个实例真正执行成功。任务日志里会记录trigger_type字段AUTO还是MANUAL事后排查谁动了我的任务就非常清晰。单独执行还有一个容易被忽视的场景任务参数。ARQ支持在trigger接口上带JSON参数参数会随任务上下文传给执行方法。做数据补偿的时候尤其好用比如夹带一个日期参数2024-12-01就能临时补跑那天的数据不用为了补数专门改代码重新发版。3.4 执行记录、超时控制与重试策略每次执行ARQ都会生成一条任务执行记录。核心字段如下字段说明id执行记录IDtask_name任务名称trigger_typeAUTO-自动调度MANUAL-手动触发start_time开始时间end_time结束时间statusSUCCESSFAILEDTIMEOUTcost_ms执行耗时单位毫秒trace_id日志追踪IDerror_msg失败异常信息失败重试的逻辑放在执行器里。默认不重试因为很多定时任务的幂等性做得并不好盲目重试反而可能造成数据问题。需要重试的任务在注解上显式声明retry2而且ARQ只对任务方法抛出的异常做重试业务代码内部自己捕获并处理的异常不应该被重试机制接管否则会出现业务逻辑已经感知到失败并做了补偿外层又傻乎乎跑了一次的尴尬情况。4. 实战排查ARQ落地过程中踩过的那些坑4.1 Cron表达式的版本差异ARQ内部的cron解析器最早直接用了Quartz的CronExpression但业务侧的同事习惯拿在线cron网站生成表达式好多生成出来是Spring风格。最后统一成6段式并在注册阶段用校验器拦截。这个设计在事后看非常值得它把错误拦截在部署阶段而不是等任务跑错之后再去翻日志。举一个真实案例。有一次任务配置成0 0 2 * * ?同事以为是每天2点执行后来发现线上的实际行为是每天2分0秒执行一次。第一分钟执行了60次下游接口直接被压垮。从那之后ARQ的cron校验器里多了一条规则如果配置的cron在24小时内触发次数超过100次就给出告警提示。秒级任务不是不能用但大部分业务场景根本不需要秒级调度。4.2 Redis分布式锁的三次教训第一次教训是没有设置过期时间。setIfAbsent成功之后进程崩溃了锁就永远存在Redis里其他节点再也抢不到锁。修复方式就是加过期时间。第二次教训是锁超时时间固定为30秒。结果某个任务跑了40秒还没结束锁提前过期另一个实例又抢到了锁两个节点同时跑同一个任务。修复方式是引入看门狗自动续期。第三次教训是释放锁时不校验requestId。A线程的锁过期之后B线程拿到锁A结束时把B的锁释放了导致B的后续执行不受保护。修复方式是使用Lua脚本先校验requestId再删除保证原子性。if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end这三步走完之后分布式锁这块基本就稳定了。需要说明的是Redis分布式锁不是银弹它在极端情况下依然存在脑裂问题但对定时任务这种场景来说可接受的失败概率已经足够低。4.3 线程池耗尽引发的假死现场有一次生产环境某个任务执行特别慢数据库连接池被打满结果所有任务都阻塞在等待数据库连接上。执行线程池的线程全部被占住新任务进队列排队所有定时任务看起来都停摆了。排查时我先用jps -l找到了对应的Java进程接着用jstack打印线程栈发现大量线程卡在数据库连接获取的位置。定位到问题之后团队做了一个重要决定给每个任务加独立的超时控制。ARQ执行器里给任务包了一层FutureTask支持timeout参数超过指定时间直接中断标记为TIMEOUT状态。这个功能看起来简单但它真的能避免一个慢任务拖垮整个定时任务体系。任务慢不可怕可怕的是慢任务没有边界把整个线程池都拖进去。4.4 日志与系统管理工具的组合使用排查定时任务问题最常用的一套组合是这样的jps -l确认服务进程还活着top -Hp pid查看进程内线程CPU占用情况jstack pid抓线程栈重点看执行器线程的状态日志系统ARQ每条执行记录带trace_id按trace_id过滤一次执行从头到尾的全部链路日志都能拉出来这套组合实测下来非常有效。有一次根据线程栈发现某个任务的线程状态停在WAITING顺着日志一路查才发现是阻塞队列的消费线程没有被唤醒。任务本身没有报错但数据就是不更新如果没有线程栈和日志配合这种问题排查起来会非常痛苦。还有一点关于进程信号处理。定时任务在容器里被kill -15优雅停机时如果任务正在执行jstack里能看到线程状态但任务的业务代码不一定有机会做收尾。ARQ在停机事件里做了处理Spring容器关闭时发出的ContextClosedEvent会触发执行线程池的shutdown()等待正在执行的任务完成超时则强制中断。这个细节在K8s滚动发布时特别重要否则每次发版都可能打断正在跑的任务。5. 后续可以做的一些扩展5.1 从组件到任务中心的演进ARQ做到第二版时我感觉组件的单点能力到顶了于是抽了一个简单的管理页面把任务列表、执行记录、手动触发按钮做了出来。后台管理系统集成时定期任务那块可以直接对接任务在页面上新增保存后写入配置表再动态注册到ARQ。这种模式类似很多快速开发框架自带的管理功能但底层管理的是真正的分布式定时任务而不是单机执行。想做这个扩展的话核心要新增几个东西一张任务配置表、一个任务操作日志表、一个后台API。任务配置表里需要维护任务名、cron表达式、状态、创建人、更新人等字段。手动触发时复用前面第三节提到的trigger接口权限校验通过后调用。5.2 监控告警的补齐监控方面建议做两件事。第一是任务执行耗时上报到Prometheus在Grafana里建一个看板把慢任务、失败任务分开展示。第二是失败任务的告警用Webhook推到群里任务晚上失败第二天早上才发现这种被动局面必须扭转。ARQ在实现上定义了一个TaskExecutionMetric对象任务执行完成后发送Spring事件由监控模块异步消费上报指标不阻塞任务主流程。这里有个建议监控上报一定要异步否则监控系统出问题反而会拖累任务本身。我在实际使用中还有一个体会定时任务相关的告警信息一定要包含任务名、执行记录ID、失败摘要这三样。告警信息不清楚收到的人还是得自己去翻日志效率极低。目前我们仍然在继续完善ARQ后面还计划支持任务编排让一些有依赖关系的任务能够按DAG方式执行。如果你也在做类似的后台管理系统或者微服务改造希望这篇文章能帮你少踩几个坑。定时任务这个看似基础的能力真的不要等到出了问题才去重视。手动触发、分布式锁的细节、执行记录的留存这三件事在项目初期就考虑进去后面会省非常多的事。
返回列表