拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从@Scheduled到XXL-JOB:分布式定时任务实战指南

1. 从Scheduled到XXL-JOB我为什么放弃Spring自带的定时任务先聊个实际场景。前两年我做了一个B端商城系统用户下单后需要自动关闭超时未支付订单、定时推送营销短信、凌晨同步ERP库存。一开始整个服务是单机部署我图省事全用Spring Boot自带的Scheduled注解搞定几个Scheduled(cron 0 */5 * * * ?)挂在Service方法上跑得挺欢。后面业务量上来了服务要水平扩展从1台变成3台噩梦就开始了。你会发现同一套代码在3台机器上同时部署Scheduled任务会在3台机器上各自执行。关闭超时订单的任务理论上只需要一个实例跑一遍结果3个实例同时跑如果没有做好幂等就会重复发短信、重复扣库存、重复关单轻则数据错乱重则用户投诉。有人会说加个分布式锁不就行了可以但事情远比你想的复杂等你把锁加上、把失败重试加上、把监控告警加上你会发现你其实是在活活重造一个调度平台。Scheduled只能说解决了有没有定时任务的问题但没解决定时任务能不能被可靠管理的问题。它没有管理界面没有执行日志没有失败重试没有动态调整执行时间的能力。改一个Cron表达式要重新发版任务失败了只能翻日志找原因凌晨4点任务挂了你根本不知道。XXL-JOB这个开源项目解决的就是这一整套问题。它是一个轻量级分布式任务调度平台核心功能包括任务统一管理、动态Cron配置、执行日志可视化、失败重试、告警通知、分片广播、路由策略。我把它接入Spring Boot工程后定时任务这块的运维成本降了至少七八成这个系列博客我就用实操的方式带大家从零到一搞一套生产可用的分布式定时任务。这篇博客适合谁看第一工程里正在用Scheduled且服务是多实例部署的第二需要给团队搭一套统一的任务调度平台但不太清楚怎么入手的第三面Java后端岗位被问到分布式定时任务怎么设计实际心里没底的。看完你不仅能动手把XXL-JOB跑起来还能理解它的调度原理和生产落地的细节。2. 整体设计与方案选型拆解2.1 原生Scheduled的四个真正痛点先说清楚Scheduled在分布式环境下的具体痛点只有理解了痛点你才知道XXL-JOB的价值在哪。第一个痛点是重复执行。多实例部署时同一个任务在每个节点都会跑这是最致命的。你说可以用ShedLock加数据库锁确实能解决但ShedLock本身也是一个外部依赖而且锁失效、锁超时这些边界问题一样要处理。第二个痛点是任务没有完整生命周期管理。Scheduled任务从Spring容器启动就开始跑你没法在运行中暂停它、恢复它也没法临时让它立刻执行一次。运维说这个任务先停一下你只能改代码、重新发版。一次发版涉及构建、测试、审批、回滚方案为了一个定时任务搞这么重不值当。第三个痛点是失败不可见、不可追。任务抛异常了日志分散在各个节点上你得一台一台查。更麻烦的是如果任务执行到一半进程被kill掉没有重试机制这个任务就永久丢失了。对业务来说库存没同步可能只是脏数据但如果是资金类的对账任务漏跑一次问题就严重了。第四个痛点是Cron表达式不灵活。Scheduled的Cron是编译期写死的想从每5分钟跑一次改成每10分钟跑一次必须改代码。你想想业务方很可能今天说5分钟明天说10分钟后天说每天凌晨跑每个需求都要排期发版开发资源就被这种零碎需求吃掉了。2.2 分布式定时任务的三个核心挑战把视角拉高一点任何一套分布式定时任务方案本质都要解决三个问题。第一个问题是只执行一次。在集群环境下一个任务在同一时刻只能被一个节点执行。这要求调度系统具备分布式协调能力能选出一个节点来跑选不出来就出现重复执行。第二个问题是大数据量怎么分片。有些任务天然适合并行处理比如扫描订单表给未评价订单发提醒如果订单有500万条一台机器扫到天荒地老。如果能把任务按某个维度比如订单ID取模分成多个分片每台机器处理自己的分片整体效率就是成倍的提升。第三个问题是失败可恢复、可追踪。任务漏跑、失败、超时这些是必然会发生的事情。调度平台要能把失败的任务捞回来重试同时把每个任务的执行状态记录下来出问题能快速定位到是哪一批数据、哪个实例、什么异常。Scheduled严格来说只解决了到点触发这一个动作后面这些问题它全部甩给你了。XXL-JOB的优秀之处就是在调度层把这些能力都做成了默认能力你只需要关心业务逻辑本身。2.3 主流方案对比XXL-JOB、Quartz、Elastic-Job怎么选做技术选型的时候我调研过Quartz和Elastic-Job简单说下对比结果维度QuartzElastic-JobXXL-JOB调度触发方式纯Java库内嵌于应用基于ZooKeeper实现分布式协调调度中心独立部署触发执行器管理界面无需要自研有轻量界面需另外部署功能完善任务管理、日志、报表都有失败重试需自己实现自带自带支持重试次数配置分片能力不支持强项分片机制完善支持分片广播实现简单动态修改Cron需重启支持支持改完秒级生效运维成本低接入成本但后续维护靠人工依赖ZK组件较重调度中心独立部署但不依赖外部组件社区活跃度老牌稳定目前维护节奏一般活跃迭代快中文文档全Quartz的问题是太裸了它就是个调度库给了你一把刀但切菜配菜全得自己来。Elastic-Job在分片和弹性扩容这块确实很强但要引入ZooKeeper小团队运维成本偏高。XXL-JOB在功能完整度和部署复杂度之间取得了很好的平衡而且它的设计思路非常适合Spring Boot技术栈的团队——本身就提供了和Spring整合的starter性质组件接入成本极低。2.4 为什么推荐XXL-JOB管理后台和生产可观测性你说Quartz也能用XXL-JOB和Quartz本质都是Cron触发区别在哪区别在于可观测性和可操作性。XXL-JOB自带一个Web管理后台。你登录后台能看到所有任务、每个任务最近几次的执行状态、执行耗时、失败原因、调度日志。想手动触发一个任务后台点一下执行一次就行。想调整执行频率改Cron保存立即生效。这些操作在Quartz体系里要么你自己写代码实现要么手工连数据库改运维体验完全不是一个量级。我在生产环境用了半年多最大的感受是定时任务终于变得可控了。之前被业务方问这个任务怎么没跑的时候我要登服务器看日志现在看一眼后台就知道是调度没触发还是执行报错效率翻倍。这也是我为什么愿意花一整篇博客来安利它的原因。3. Spring Boot集成XXL-JOB核心细节3.1 调度中心部署先让大脑跑起来XXL-JOB的架构分两部分调度中心xxl-job-admin和执行器xxl-job-executor。调度中心是独立部署的Web应用负责任务调度、日志管理、执行器管理执行器是嵌入在业务服务里的组件接收调度中心的指令并执行具体业务逻辑。部署调度中心的第一步是下载源码。到XXL-JOB的Gitee仓库或者GitHub仓库拉取代码项目里有xxl-job-admin这个子模块。你需要本地环境有JDK 8和Maven 3直接将xxl-job-admin打包成可执行Jar包即可。要注意的是调度中心依赖数据库。XXL-JOB使用MySQL存储任务信息、执行日志、调度记录等数据。项目源码的/doc/db/目录下有一个tables_xxl_job.sql文件你先建好一个空数据库比如xxl_job然后执行这个SQL脚本它会自动建好全部所需的数据表。我第一次部署的时候没细看文档直接启动xxl-job-admin结果报数据库连接错误。后来才发现要先建库导表这种小细节遇到的多了整理成经验就是启动前先看配置文件# xxl-job-admin的application.properties里需要改的核心配置 server.port8080 spring.datasource.urljdbc:mysql://127.0.0.1:3306/xxl_job?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password123456 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver ### 调度中心通讯TOKEN执行器配置的时候要填同一个值 xxl.job.accessTokendefault_tokenaccessToken这个参数容易被忽略它的作用类似密钥执行器需要配置一个相同的Token才能与调度中心通信。生产环境一定要改成复杂的随机字符串不要用默认值不然存在被恶意节点注册的风险。启动方式很简单编译打包后mvn clean package -DskipTests java -jar xxl-job-admin/target/xxl-job-admin-2.4.0.jar启动成功后访问http://localhost:8080/xxl-job-admin默认账号密码是admin/123456登录进去你就看到了调度中心的全貌。3.2 Spring Boot工程接入执行器引入依赖和配置调度中心有了接下来就是把业务服务变成执行器。我这里用的是Spring Boot 2.7版本XXL-JOB 2.4.0版本组合比较稳定。第一步在Spring Boot工程里引入xxl-job-core依赖dependency groupIdcom.xuxueli/groupId artifactIdxxl-job-core/artifactId version2.4.0/version /dependency第二步在application.yml里加上执行器相关配置。我习惯把调度中心的地址、执行器的名称、端口这些参数放到配置中心或者application-xxljob.yml单独管理方便不同环境切换xxl: job: admin: # 调度中心地址多个地址用逗号分隔 addresses: http://localhost:8080/xxl-job-admin # 执行器通讯TOKEN需与调度中心配置一致 accessToken: default_token executor: # 执行器名称调度中心注册时会用到需与后台添加的执行器AppName一致 appname: xxl-job-executor-demo # 执行器注册地址不配则自动获取 address: # 执行器IP不配则自动获取 ip: # 执行器端口调度中心通过该端口回调执行器 port: 9999 # 执行器运行日志文件存储路径 logpath: /data/applogs/xxl-job/jobhandler # 执行器日志保存天数大于0时生效 logretentiondays: 30第三步创建执行器配置类。这一步本质上是把XxlJobSpringExecutor注册为一个Spring Bean让它去扫描带XxlJob注解的方法Configuration public class XxlJobConfig { Value(${xxl.job.admin.addresses}) private String adminAddresses; Value(${xxl.job.accessToken}) private String accessToken; Value(${xxl.job.executor.appname}) private String appname; Value(${xxl.job.executor.port}) private int port; Value(${xxl.job.executor.logpath}) private String logPath; Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor xxlJobSpringExecutor new XxlJobSpringExecutor(); xxlJobSpringExecutor.setAdminAddresses(adminAddresses); xxlJobSpringExecutor.setAppname(appname); xxlJobSpringExecutor.setPort(port); xxlJobSpringExecutor.setAccessToken(accessToken); xxlJobSpringExecutor.setLogPath(logPath); return xxlJobSpringExecutor; } }写好之后启动Spring Boot服务然后回到调度中心后台在执行器管理页面手动新增一个执行器AppName填xxl-job-executor-demo名称随意注册方式选自动注册。保存后稍等几秒你会看到该执行器的在线机器地址列出现了一个地址类似http://192.168.1.10:9999/这就代表执行器注册成功了。这块比较常见的坑是端口冲突。执行器的port是调度中心回调执行器的端口它会在该端口上启动一个内嵌的Jetty服务器所以一定不能被其他进程占用。我遇到过几次服务启动报Address already in use查一下就是端口被占用了。3.3 第一个XxlJob Handler三分钟跑通第一个任务接入成功之后我们来写第一个任务。在Spring Boot工程里新建一个类方法上打上XxlJob注解即可被调度中心识别为一个可执行的任务HandlerComponent public class SimpleJobHandler { private static final Logger logger LoggerFactory.getLogger(SimpleJobHandler.class); XxlJob(demoJobHandler) public void demoJobHandler() throws Exception { String now LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); System.out.println(XXL-JOB 任务执行成功当前时间 now); logger.info(XXL-JOB demo task executed at {}, now); } }注意XxlJob注解里的字符串demoJobHandler这个就是任务Handler的名称调度中心创建任务时会用到。一个类里可以定义多个XxlJob方法只要Handler名不重复就行。接下来去调度中心后台配置任务触发。在任务管理页面点击新增任务执行器选择刚刚注册的执行器xxl-job-executor-demo任务描述随便填比如演示任务调度类型选Cron填写0 0/1 * * * ?表示每分钟执行一次运行模式选BeanJobHandler填demoJobHandler阻塞处理策略选单机串行路由策略选第一个保存之后任务默认是停止状态点击操作列里的启动按钮等到下一个Cron时间点你就会在调度日志里看到一条记录状态显示成功。如果你不想等Cron时间可以直接点击任务的执行一次按钮调度中心会立即触发一次调度。这个功能调试的时候太方便了以前用Scheduled改个时间得发版现在改任务配置、手动触发都是秒级操作。3.4 关键配置项解析路由策略、阻塞处理、失败重试XXL-JOB的配置项虽然多但你真正需要搞明白的是下面这三个它们直接决定任务的执行行为。路由策略解决的是调度中心把任务派给哪个执行器的问题。常见的有第一个/最后一个固定把任务派给注册的第一个或最后一个实例适合只需要一个实例执行的场景轮询/随机轮流派发或随机派发适合多个实例都可以执行且互不影响的场景一致性HASH按任务的JobId做哈希分配同一个任务每次都会分配到同一个实例适合希望任务稳定绑定在某台机器上的场景分片广播把任务同时派发给所有执行器但会给每个执行器传递不同的分片参数分片序号、分片总数这是处理大数据量的利器阻塞处理策略解决的是上一个任务还没跑完下一个触发时间又到了的问题单机串行排队执行上个跑完下个才开始适合耗时波动大的任务丢弃后续调度直接丢弃这次触发适合对实时性要求不高、跑完一次就行的任务覆盖之前调度强制终止上一次执行开始新的执行适合对数据时效性要求高的任务失败重试次数指的是任务执行失败后调度中心自动重新调度的次数。我一般会设置成2次或3次但要小心如果你的业务逻辑不是幂等的重试可能会造成重复处理。重试机制配合幂等设计才是生产级配置。4. 实操过程三个真实业务场景的完整落地4.1 场景一订单超时自动关闭单机串行 路由策略电商系统里最常见的定时任务就是关闭超时未支付订单。这个任务的业务逻辑简单但敏感性高操作错了就是用户投诉。任务逻辑如下Component public class OrderTimeoutJobHandler { Resource private OrderService orderService; XxlJob(closeTimeoutOrderHandler) public void closeTimeoutOrderHandler() throws Exception { // 查询创建时间超过30分钟且状态为待支付的订单ID列表 ListLong timeoutOrderIds orderService.listTimeoutOrderIds(30, 1000); if (CollectionUtils.isEmpty(timeoutOrderIds)) { return; } for (Long orderId : timeoutOrderIds) { try { orderService.closeOrder(orderId, 系统超时自动关闭); } catch (Exception e) { // 单条失败不能影响整批记录日志继续处理 XxlJobHelper.log(关闭订单失败, orderId{}, error{}, orderId, e.getMessage()); } } } }生产上配置建议是路由策略选第一个阻塞处理策略选丢弃后续调度Cron设置成每分钟执行一次。为什么用第一个因为关单操作本身只需要一个实例跑而且这个实例挂了调度中心会自动把任务派给其他存活的实例不影响整体可用性。为什么用丢弃后续调度因为上一个任务一般几秒就跑完如果真出现了任务积压说明数据有问题这时候不应该继续堆任务而应该人工介入排查。必须提醒的是批量任务里单条失败不要直接抛出异常中断整个循环。我写的这段代码里用了XxlJobHelper.log来记录业务日志这个日志会作为该次任务执行的上下文日志展示在调度日志里方便你查看某次执行到底处理了哪些数据、哪些失败。4.2 场景二海量用户数据分片处理分片广播第二个场景更能体现XXL-JOB的价值——给500万未活跃用户推送营销短信。如果一台机器跑假设每秒处理100条500万条需要接近14个小时短信发完活动都结束了。分片广播就是解决这类问题的标配方案。调度中心会把任务通知给所有执行器每个执行器拿到一个分片序号和总分片数各自处理自己的那部分数据Component public class UserMarketingPushHandler { Resource private UserService userService; XxlJob(userMarketingPushHandler) public void userMarketingPushHandler() throws Exception { // 获取当前执行器的分片参数 int shardIndex XxlJobHelper.getShardIndex(); int shardTotal XxlJobHelper.getShardTotal(); // 按用户ID取模过滤出属于当前分片的数据 ListLong userIds userService.listInactiveUserIds(shardIndex, shardTotal, 10000); for (Long userId : userIds) { try { String phone userService.getPhoneByUserId(userId); smsClient.send(phone, 您有一张优惠券即将过期...); } catch (Exception e) { XxlJobHelper.log(推送失败, userId{}, error{}, userId, e.getMessage()); } } } }这里的核心思路是假设你有3台执行器实例分片总数就是3每台机器的分片序号分别是0、1、2。业务上按user_id % 3取模分为0、1、2三组数据每台机器只处理自己序号对应的那组数据三条线程并行跑处理时间直接压缩到原来的三分之一。服务扩缩容的时候分片广播的特性就会显现出来。你新加一台机器调度中心自动识别到4台执行器下次任务开始分片总数就变成4数据自动重新分布无需人工干预。这种弹性扩容能力是Scheduled无论如何也做不到的。生产建议是即使你现在只有一台机器也可以配置分片广播因为后续扩容时代码完全不用改。分片参数通过XxlJobHelper.getShardIndex()获取这个类会从调度请求头里解析参数用起来非常方便。4.3 场景三定时拉取外部接口数据失败重试 告警第三个场景更偏向业务侧——每个小时从第三方ERP系统拉取一次库存数据更新到本地。这类任务的特点是依赖外部系统外部接口不稳定经常超时或者返回异常。Component public class ErpStockSyncHandler { Resource private StockSyncService stockSyncService; XxlJob(erpStockSyncHandler) public void erpStockSyncHandler() throws Exception { // 拉取接口数据 ListStockDTO stockList stockSyncService.fetchFromErp(); if (CollectionUtils.isEmpty(stockList)) { XxlJobHelper.log(ERP返回数据为空请检查ERP系统状态); throw new RuntimeException(ERP库存同步失败返回数据为空); } // 批量更新本地库存 int updateCount stockSyncService.batchUpdateStock(stockList); XxlJobHelper.log(库存同步完成共更新{}条数据, updateCount); } }这个任务的配置要点在调度中心后台调度类型Cron设置为0 0 * * * ?每小时整点执行路由策略选轮询失败重试次数设置为3次失败处理策略选失败告警。需要特别注意的是重试间隔。XXL-JOB默认重试是立即重试如果外部接口本身在故障中连续3次重试可能全部失败挂在同一个接口上。更稳妥的做法是任务本身不做过多重试而是配合失败告警机制让运维第一时间感知到任务失败再人工决定是否补跑数据。XXL-JOB的告警方式目前内置了邮件告警。在调度中心后台的系统管理-用户管理里配置收件人邮箱以及邮件服务器信息任务执行失败就会自动发送告警邮件。你可以把告警邮箱配置成团队运维组的公共邮箱避免告警只发到某个人邮箱里没人看的尴尬。4.4 多环境部署的实践经验生产实践里还有个容易被忽略的点多套环境下的执行器管理。我有两套环境测试环境和生产环境各自部署了一套调度中心。执行器服务里通过xxl.job.admin.addresses这个配置来区分连哪个调度中心。测试环境的执行器AppName我命名为xxl-job-executor-test生产环境命名为xxl-job-executor-prod配置分开管理。这里有一个必须强调的坑如果测试环境执行器误连了生产环境的调度中心而且AppName和生产环境相同会导致生产执行器出现串数据的问题。所以多环境必须用不同的AppName并且最好在生产环境初始化时就把默认Token换掉从源头避免误连。5. 常见问题与排查技巧实录5.1 执行器注册不上先查这三处执行器注册不上是刚开始接入时最常遇到的问题。你在执行器管理页面看不到在线机器地址按下面顺序排查基本能定位第一检查appname是否匹配。调度中心后台添加执行器时填写的AppName必须和Spring Boot配置文件里的xxl.job.executor.appname完全一致大小写和字符都得一致。这俩不一致调度中心根本认不出这个执行器自然也不会注册。第二检查accessToken是否一致。调度中心配置文件application.properties里的xxl.job.accessToken和业务服务application.yml里的xxl.job.accessToken要一模一样。Token不一致时调度中心不会报明显错误但执行器注册会被静默拒绝非常坑。第三检查网络连通性。调度中心访问执行器的端口默认9999需要能从调度中心所在机器连通。如果执行器部署在云服务器上记得检查安全组规则是否放行了9999端口。我在本地测试一切正常部署到云服务器后注册不上排查了一圈发现是安全组没放开教训深刻。5.2 任务不触发或者触发但日志不完整任务创建好了状态也启动了但到了Cron时间就是没有执行记录。这种情况先看任务配置的Cron表达式对不对XXL-JOB的Cron表达式是6位和7位都支持但千万注意?和*的语义。不熟悉Cron的人容易把0 0 0 * * ?每天零点写成0 0 0 * * *虽然只有一位之差但*表示匹配任意值在星期位上*和?在Quartz里行为不同可能导致任务在完全错误的时间触发。另一个可能性是任务没有启动。XXL-JOB新建的任务默认状态是停止你需要手动点启动按钮。这个设计是为了防止新增任务时配置不完整导致误执行但很多新手会漏掉这一步。触发后日志不完整的情况多半是执行日志路径权限问题。检查一下xxl.job.executor.logpath配置的目录是否存在并且运行用户有写权限。我遇到过部署到Docker容器里容器内没有这个目录任务执行报错但看不出原因创建目录后就好了。5.3 任务重复执行不只是代码的问题很多人担心XXL-JOB会不会重复执行任务。实际上XXL-JOB本身的调度记录是唯一的同一个任务同一时刻只会被调度一次。重复执行更多是出现在路由策略和手动触发同时使用的场景——比如你配了轮询同时业务方又手动点了一次执行一次那确实会有两个实例同时跑同一类任务。要避免重复执行核心思路还是业务侧做好幂等。我在秒杀系统的库存同步任务里更新数据时都会加上update stock set count count - #{num} where count #{num}这类条件更新的SQL天然幂等。不管被调度几次最终数据一致性都能保证。另外分片广播任务要注意一个容易忽视的细节如果你在业务代码里对分片参数做了日志输出需要确认日志的shardTotal确实是当前在线实例数。如果某个执行器实例因为网络波动掉线了调度中心感知到之后分片总数才会变化。在这期间掉线实例可能会漏处理自己那部分数据。这种情况下任务设计时需要把分片任务设计成可重复执行的增量任务或者配合定时补漏任务把缺失的数据捞回来。5.4 问题排查速查表现象可能原因排查手段执行器列表为空AppName不一致或Token不一致检查执行器配置和调度中心配置是否匹配任务不触发任务未启动或Cron表达式错误确认任务状态为启动用Cron工具验证表达式任务执行失败业务代码异常或外部依赖故障查看调度日志里的完整堆栈和XxlJobHelper.log输出调度日志显示成功但业务数据未变代码逻辑问题或连错数据库检查日志路径确认执行的是哪套环境任务偶发重复路由策略与手动触发叠加确认业务幂等避免强依赖调度侧保证执行器注册后又掉线网络抖动或执行器服务重启查看执行器服务日志确认端口稳定占用6. 最后的实操心得XXL-JOB这套东西看起来就是一个管理后台加一个SDK但真正把它用得顺手需要你有运维视角。我踩过几次坑之后有一个体会定时任务不能只当代码来写要当生产数据流来设计。任务失败会怎样、任务重复了会怎样、任务积压了会怎样这些都要提前想清楚。给大家几个实用建议。第一所有任务的Handler方法里尽量用XxlJobHelper.log来输出业务日志而不是直接用System.out或者业务Logger因为前者的日志会跟着调度记录走排查问题时全部集中在一个界面里效率完全不同。第二任务里一定要做参数校验和空值处理调度中心支持传参但传过来的参数可能为空或者非法你代码里要兜底。第三生产环境一定要把调度中心和执行器的版本对齐我遇到过调度中心升级业务执行器没升级的情况新旧版本之间的通讯协议不兼容任务一直调度失败排查了很久才发现是版本问题。如果你现在的项目还是多实例部署加Scheduled强烈建议抽个半天时间把XXL-JOB接进去。刚开始会有点繁琐但等你在后台看到可视化的执行日志、一秒钟改好Cron、分片任务在多台机器上并行跑起来的时候你会发现这套轮子造得值。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门