首页 / 资讯中心 / 文章详情

XXL-Job分片广播实战:会员服务百万级定时任务提速与幂等保障

XXL-Job分片广播实战:会员服务百万级定时任务提速与幂等保障 ★ FEATURED ARTICLE
1. 会员服务定时任务到底难在哪会员服务里有一类活儿看起来不起眼但一旦量上来就特别容易翻车每天凌晨要给昨天新增的会员补发成长值、每隔十分钟要扫描一批即将过期的积分并做提醒、每小时要同步一次会员等级状态、大促期间还要临时跑一批优惠券到账任务。这些活儿有几个共同特征——它们不在用户请求链路上但一旦挂了业务方第二天就会找上门它们往往要处理几十万甚至上百万条数据单机跑一次要几十分钟它们还特别怕重复执行比如补发成长值跑了两遍用户直接多拿一份客诉就来了。我见过不少团队一开始的做法很朴素在会员服务里写个Scheduled注解或者用ScheduledExecutorService起个线程池能跑就行。单机、单实例、数据量小的时候确实没问题可一旦会员服务为了扛住流量做了多实例部署问题立刻暴露——三个实例同时触发同一个任务同一批数据被处理三遍。有人会说那我加个分布式锁不就行了加锁确实能解决重复执行但解决不了另一个更头疼的问题单机处理百万级数据太慢锁一持有就是半小时其他实例干等着资源全浪费了。这就是为什么我们需要把定时任务从应用内嵌升级成独立调度 分片执行的架构。标题里提到的 XXL-Job 就是干这个的它把调度和执行拆开调度中心负责什么时候触发、触发到哪几个执行器执行器负责真正干活。而分片是它最值钱的能力之一——一个任务可以拆成 N 片分给 N 个执行器并行跑每片只处理自己那份数据。集群模式下执行器可以水平扩容任务处理能力跟着涨这才是会员服务这种数据密集型场景该有的姿势。这篇文章我会把整套东西拆开讲为什么选 XXL-Job 而不是 Quartz、为什么分片比加锁更优雅、分片参数怎么算、路由策略怎么选、任务幂等怎么做、集群部署时有哪些坑。内容基于我在实际项目里的落地经验代码和配置都可以直接抄作业但每个参数背后的为什么我会讲清楚你换到自己的业务里也能举一反三。2. 为什么是 XXL-Job而不是 Quartz 或自研2.1 三种定时任务方案的横向对比在动手之前先把选型这件事说透。定时任务这个领域常见方案就那么几种我把它们放在一张表里对比你一眼就能看出差距。维度Spring ScheduledQuartzXXL-Job调度与执行耦合在应用内可分离但配置重彻底分离调度中心独立部署集群支持无多实例重复执行有数据库锁但性能一般原生支持路由策略丰富分片能力无无原生支持分片广播失败重试无有限支持可配置重试次数任务治理无弱有可视化后台日志、统计齐全动态修改改代码重启改配置重启后台直接改实时生效阻塞策略无有有串行、丢弃、覆盖三种学习成本极低中中低Scheduled适合什么适合那种跑不跑无所谓、跑错了也不影响业务的辅助任务比如清理本地临时文件。会员服务的核心任务绝对不能用它因为多实例部署下它没有任何协调机制。Quartz 是老牌选手功能全但它的集群模式依赖数据库行锁任务量大时数据库压力不小而且它没有分片这个概念——一个任务在集群里同一时刻只有一个实例能跑其他实例闲着。对于百万级数据的会员任务Quartz 只能做到不重复做不到更快。XXL-Job 的核心优势在于它把调度和执行彻底解耦调度中心是个独立的 Web 应用执行器是嵌入在业务服务里的轻量组件。调度中心决定任务什么时候触发、发给哪些执行器、用什么路由策略执行器收到指令后干活干完把结果回传。这个架构天然支持水平扩展而且分片广播这个路由策略直接解决了单机跑得慢的问题。2.2 分片广播会员任务提速的关键分片广播是 XXL-Job 里我最喜欢的一个路由策略没有之一。它的逻辑是这样的调度中心把一次触发拆成 N 个分片任务同时发给所有在线的执行器每个执行器拿到自己的分片序号从 0 到 N-1和总分片数 N然后各干各的。举个具体例子。假设会员服务部署了 3 个实例注册到 XXL-Job 后形成一个执行器集群。有个每日成长值补发任务数据表里有 90 万条待处理记录。如果用普通路由策略比如轮询调度中心只会把任务发给其中 1 个实例这个实例要独自处理 90 万条假设每条耗时 1 毫秒那就是 900 秒15 分钟。如果用分片广播3 个实例同时收到任务分片参数分别是 (0,3)、(1,3)、(2,3)每个实例只处理 30 万条耗时降到 5 分钟。如果会员服务扩容到 6 个实例每个实例处理 15 万条2.5 分钟搞定。这就是分片的威力——处理能力随实例数线性增长。而且它不需要你改任何调度逻辑只要在任务代码里根据分片参数做数据过滤就行。注意分片广播的前提是执行器集群里所有实例都注册到了同一个调度中心并且任务的路由策略配置成了分片广播。如果配成其他策略分片参数不会生效。2.3 会员服务引入 XXL-Job 的整体架构在动手写代码前先把架构在脑子里过一遍。会员服务引入 XXL-Job 后整体结构是这样的调度中心xxl-job-admin独立部署的一个 Web 应用负责管理任务、触发调度、收集日志。它有自己的数据库存任务配置和调度记录。执行器会员服务会员服务里引入xxl-job-core依赖配置调度中心地址和执行器信息启动后自动注册到调度中心。任务处理器JobHandler会员服务里用XxlJob注解标记的方法就是具体干活的逻辑。业务数据库会员数据、积分数据、成长值数据都在这里任务处理器直接操作。调度中心和执行器之间通过 HTTP 通信执行器主动注册、调度中心主动触发。这个方向很重要——执行器不需要暴露公网端口只要它能访问调度中心就行内网部署非常友好。3. 环境搭建与依赖配置的实操细节3.1 调度中心的部署与初始化调度中心是 XXL-Job 的大脑必须先跑起来。它的部署方式很灵活可以下源码编译也可以用官方提供的镜像。我一般推荐在测试环境用 Docker 快速起一个生产环境用源码编译后打成 jar 包部署方便定制。数据库初始化是第一步。调度中心的源码里有个doc/db/tables_xxl_job.sql文件里面建了十几张表核心的有xxl_job_info任务配置、xxl_job_log调度日志、xxl_job_group执行器分组、xxl_job_registry执行器注册表。这些表必须提前建好否则调度中心启动会报错。建完表后修改调度中心的application.properties重点是数据库连接和访问令牌server.port8080 server.servlet.context-path/xxl-job-admin spring.datasource.urljdbc:mysql://127.0.0.1:3306/xxl_job?useUnicodetruecharacterEncodingUTF-8autoReconnecttrueserverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password你的密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver xxl.job.accessToken你的自定义令牌 xxl.job.i18nzh_CNaccessToken这个参数很多人会忽略但它很重要。调度中心和执行器通信时会带上这个令牌做鉴权如果两边不一致执行器注册不上任务触发会失败。生产环境一定要改成一个复杂的随机字符串别用默认值。启动调度中心后浏览器访问http://localhost:8080/xxl-job-admin默认账号是admin/123456。登录后第一件事是改密码第二件事是去执行器管理里新建一个执行器分组。3.2 会员服务引入执行器依赖调度中心跑起来后轮到会员服务这边。在pom.xml里加依赖dependency groupIdcom.xuxueli/groupId artifactIdxxl-job-core/artifactId version2.4.0/version /dependency版本选择上2.4.0 是比较稳定的一个版本支持 Spring Boot 2.x 和 3.x。如果你用的是 Spring Boot 3.x注意 JDK 版本要 17 以上XXL-Job 2.4.0 已经适配了。然后在application.yml里配置执行器信息xxl: job: admin: addresses: http://127.0.0.1:8080/xxl-job-admin accessToken: 你的自定义令牌 executor: appname: member-service-executor address: ip: port: 9999 logpath: ./logs/xxl-job logretentiondays: 30这里有几个参数值得展开说。appname是执行器的唯一标识必须和调度中心里配置的执行器 AppName 一致否则注册不上。address和ip留空的话执行器会自动获取本机 IP但在容器化环境里自动获取的 IP 往往是容器内网 IP调度中心可能访问不到这时候需要手动指定宿主机 IP。port是执行器监听的端口默认 9999如果同一台机器部署多个执行器这个端口要错开。logpath是执行器本地日志路径XXL-Job 会把每次任务执行的日志写到这个目录调度中心通过接口拉取展示。logretentiondays控制日志保留天数默认 30 天生产环境可以根据磁盘空间调整。3.3 执行器配置类的编写配置类的作用是把上面的配置项组装成一个XxlJobSpringExecutorBean交给 Spring 管理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; Value(${xxl.job.executor.logretentiondays}) private int logRetentionDays; Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor executor new XxlJobSpringExecutor(); executor.setAdminAddresses(adminAddresses); executor.setAppname(appName); executor.setPort(port); executor.setAccessToken(accessToken); executor.setLogPath(logPath); executor.setLogRetentionDays(logRetentionDays); return executor; } }这个类看起来简单但有个坑要提醒XxlJobSpringExecutor在启动时会去注册到调度中心如果调度中心还没起来或者网络不通执行器会重试注册但不会阻塞应用启动。所以会员服务启动顺序上调度中心最好先于执行器启动否则要等执行器自动重连。实操心得在容器化环境里执行器的ip和port一定要显式配置。我踩过一次坑容器内自动获取的 IP 是 172.17.0.x调度中心在另一个网段根本连不上任务一直显示调度失败排查了半天才发现是 IP 问题。4. 分片定时任务的核心实现4.1 分片参数是怎么传进来的XXL-Job 的分片参数通过XxlJobHelper这个工具类获取核心是两个方法int shardIndex XxlJobHelper.getShardIndex(); int shardTotal XxlJobHelper.getShardTotal();shardIndex是当前执行器的分片序号从 0 开始shardTotal是总分片数等于执行器集群里在线实例的数量。这两个值在任务触发时由调度中心计算好随任务参数一起下发。这里有个关键点要理解分片总数不是你在后台配的而是调度中心根据当前在线的执行器数量动态算出来的。假设你配了 3 个实例但其中一个挂了调度中心发现只有 2 个在线那shardTotal就是 2分片序号是 0 和 1。等挂掉的实例恢复上线下次触发时shardTotal又变回 3。这个动态特性很实用扩容缩容都不用改配置。但动态也有代价——如果任务执行到一半某个实例挂了它负责的那片数据就没人处理了。所以分片任务的设计要保证每片数据独立、可重跑不能有跨片的依赖。4.2 数据分片的三种常见策略拿到分片参数后怎么把数据分给各个分片常见的有三种策略我逐个说。第一种按主键取模。这是最直观的方式SQL 里加个条件SELECT * FROM member_growth_record WHERE status 0 AND MOD(id, #{shardTotal}) #{shardIndex}MOD(id, shardTotal) shardIndex保证每条记录只被一个分片处理。这种方式的优点是均匀缺点是MOD函数用不上索引数据量大时全表扫描会慢。优化办法是先用范围条件缩小数据集再用取模过滤。第二种按时间范围切分。如果任务处理的是某一天的数据可以按小时或按 ID 区间切SELECT * FROM member_growth_record WHERE status 0 AND create_time #{startTime} AND create_time #{endTime} AND id BETWEEN #{minId} AND #{maxId}每个分片负责一个 ID 区间区间边界由分片参数算出来。这种方式能用上索引性能更好但要求数据分布相对均匀否则会出现某个分片数据特别多的情况。第三种按业务维度哈希。比如按会员 ID 的哈希值分片SELECT * FROM member_growth_record WHERE status 0 AND MOD(CRC32(member_id), #{shardTotal}) #{shardIndex}这种方式适合需要同一会员的数据落在同一分片的场景比如要按会员聚合计算。缺点是CRC32也是函数计算索引利用不上。我一般推荐第二种性能和均匀性平衡得最好。具体选哪种取决于你的数据特征和任务语义。4.3 一个完整的会员成长值补发任务光说理论没意思直接上一个能跑的完整例子。需求是这样的每天凌晨 2 点给昨天新增的会员补发初始成长值每个会员补 100 分处理完后把记录状态改成已处理。先定义任务处理器Component public class MemberGrowthJobHandler { Autowired private MemberGrowthService memberGrowthService; XxlJob(memberGrowthShardingJob) public void execute() { int shardIndex XxlJobHelper.getShardIndex(); int shardTotal XxlJobHelper.getShardTotal(); XxlJobHelper.log(分片任务开始当前分片{}/{}, shardIndex, shardTotal); long startTime System.currentTimeMillis(); int processedCount 0; try { processedCount memberGrowthService.processGrowthByShard(shardIndex, shardTotal); XxlJobHelper.log(分片 {} 处理完成共处理 {} 条记录, shardIndex, processedCount); XxlJobHelper.handleSuccess(处理成功数量 processedCount); } catch (Exception e) { XxlJobHelper.log(分片 {} 处理失败{}, shardIndex, e.getMessage()); XxlJobHelper.handleFail(处理失败 e.getMessage()); } long cost System.currentTimeMillis() - startTime; XxlJobHelper.log(分片 {} 耗时 {} ms, shardIndex, cost); } }XxlJob注解里的值就是任务在调度中心里的 JobHandler 名称必须和后台配置的一致。XxlJobHelper.log写的日志会回传到调度中心在后台能看到排查问题非常方便。handleSuccess和handleFail用来标记任务执行结果调度中心根据这个决定是否重试。Service 层的分片处理逻辑Service public class MemberGrowthService { Autowired private MemberGrowthRecordMapper growthRecordMapper; private static final int BATCH_SIZE 500; public int processGrowthByShard(int shardIndex, int shardTotal) { int totalProcessed 0; Long lastId 0L; while (true) { ListMemberGrowthRecord records growthRecordMapper .selectPendingByShard(lastId, shardIndex, shardTotal, BATCH_SIZE); if (records.isEmpty()) { break; } for (MemberGrowthRecord record : records) { grantGrowth(record); lastId record.getId(); } totalProcessed records.size(); XxlJobHelper.log(分片 {} 已处理 {} 条, shardIndex, totalProcessed); } return totalProcessed; } private void grantGrowth(MemberGrowthRecord record) { // 幂等校验如果已经处理过直接跳过 if (record.getStatus() 1) { return; } // 更新会员成长值 memberMapper.addGrowth(record.getMemberId(), 100); // 标记记录已处理 growthRecordMapper.markProcessed(record.getId()); } }Mapper 层的分片查询 SQLselect idselectPendingByShard resultTypeMemberGrowthRecord SELECT id, member_id, growth_value, status, create_time FROM member_growth_record WHERE status 0 AND id gt; #{lastId} AND MOD(id, #{shardTotal}) #{shardIndex} ORDER BY id ASC LIMIT #{batchSize} /select这段代码有几个设计点值得说。第一用lastId做游标分页而不是LIMIT offset, size因为深分页时offset越大越慢游标分页性能稳定。第二MOD(id, shardTotal) shardIndex保证分片不重叠。第三每批 500 条处理完再查下一批避免一次性加载太多数据到内存。第四grantGrowth里有幂等校验即使任务重跑也不会重复发成长值。4.4 调度中心的任务配置代码写完了还得在调度中心后台配任务。登录后台进入任务管理点新增关键配置项如下配置项值说明执行器member-service-executor选你注册的执行器任务描述会员成长值分片补发见名知意路由策略分片广播关键必须选这个Cron0 0 2 * * ?每天凌晨 2 点运行模式BEAN用 XxlJob 注解的方式JobHandlermemberGrowthShardingJob和注解值一致阻塞处理策略丢弃后续调度防止任务堆积任务超时时间180030 分钟超时自动失败失败重试次数2失败后重试 2 次路由策略选分片广播是核心选错了分片参数就不生效。阻塞处理策略我一般选丢弃后续调度意思是如果上一次任务还没跑完下一次触发直接丢弃避免任务堆积把系统压垮。对于会员成长值这种任务晚跑一次问题不大但堆积起来就麻烦了。5. 集群模式下的坑与排查实录5.1 执行器注册不上的排查思路这是新手最容易遇到的问题会员服务启动了但调度中心后台的执行器管理里看不到实例或者显示离线。排查思路按这个顺序走。先看执行器日志搜索xxl-job关键字正常注册成功会打印类似 xxl-job register jobhandler success的日志。如果看到registry fail或者连接超时说明网络不通。检查执行器配置的admin.addresses是否能 ping 通端口是否开放。如果日志显示注册成功但后台看不到检查appname是否和后台配置的执行器 AppName 完全一致。这个大小写敏感member-service-executor和Member-Service-Executor是两个不同的执行器。还有一种情况是accessToken不一致。执行器注册时会带上令牌调度中心校验失败会拒绝注册但日志可能只显示注册失败不显示具体原因。这时候把两边的令牌都改成一样的再试。踩坑记录有一次在容器环境里执行器注册一直失败日志显示连接127.0.0.1:8080超时。查了半天发现是admin.addresses配成了localhost容器内的localhost指向容器自己不是宿主机。改成宿主机的内网 IP 就好了。5.2 分片数据不均匀怎么办分片广播最理想的情况是每个分片处理的数据量差不多但现实中经常出现倾斜。比如按MOD(id, shardTotal)分片如果 ID 不是连续自增的比如用了雪花算法分布可能不均匀。更常见的是按时间范围分片某些时间段数据特别多。排查方法很简单在任务日志里打印每个分片处理的数量对比一下就知道是否倾斜。如果倾斜严重有几个调整方向。一是换分片键。如果按 ID 分片不均匀试试按member_id的哈希分片前提是业务允许同一会员的数据分散。二是调整分片粒度。如果按天分片不均匀改成按小时分片粒度更细倾斜程度会降低。三是引入二级分片。比如先按 ID 范围分成 10 个大块每个大块内部再按分片参数细分这样能兼顾均匀性和索引效率。还有一种情况是执行器实例性能不一致。比如 3 个实例其中 1 个配置低处理同样数据量耗时是其他实例的 3 倍整体任务时间就被它拖长了。这种只能靠运维层面统一实例规格或者用故障转移路由策略把慢实例踢出去。5.3 任务重复执行的幂等保障分片广播本身不会导致重复执行因为每个分片处理的数据是互斥的。但有两种情况会导致重复一是任务失败重试二是人工在后台手动触发。如果任务逻辑不幂等重复执行就会出问题。幂等保障有几个层次。最外层是数据库唯一约束比如成长值流水表对(member_id, biz_type, biz_date)建唯一索引重复插入直接报错。中间层是状态机处理前先查状态已处理的直接跳过就像前面代码里的if (record.getStatus() 1) return;。最内层是分布式锁用 Redis 对member_id加锁防止并发处理同一会员。我一般推荐状态机 唯一约束的组合性能好且可靠。分布式锁是最后手段因为它有锁超时、锁误删等问题能不用就不用。实操心得状态机的更新一定要用UPDATE ... WHERE status 0这种带条件的语句靠数据库的行锁保证原子性。如果先SELECT再UPDATE中间有时间窗口并发下还是会重复。5.4 常见问题速查表把上面这些坑整理成一张表方便你排查时对照现象可能原因排查方向解决方式执行器后台不显示appname 不一致对比配置和后台改成一致执行器显示离线网络不通或令牌错检查 admin 地址和令牌修正配置任务触发但无日志JobHandler 名不匹配对比注解值和后台配置改成一致分片参数为 0/1路由策略不是分片广播检查任务路由策略改为分片广播数据被重复处理任务不幂等检查状态更新逻辑加条件更新或唯一约束任务执行超时单批数据量太大看日志处理进度减小批次或加索引分片数据倾斜分片键分布不均统计各分片处理量换分片键或调粒度任务堆积阻塞策略配置不当看调度记录改为丢弃后续调度6. 性能调优与生产环境建议6.1 批次大小与线程池的权衡前面代码里批次大小设的是 500这个值不是拍脑袋定的。批次太小数据库交互次数多网络开销大批次太大单次查询内存占用高而且事务时间长容易锁表。500 到 1000 是比较稳妥的区间具体要看单条记录的大小和数据库配置。如果单分片处理能力还是不够可以在分片内部再开线程池并行处理。比如一个分片拿到 10 万条数据用 4 个线程各处理 2.5 万条。但要注意线程池并行会带来新的并发问题幂等保障必须做到位。而且线程数不是越多越好数据库连接池大小、CPU 核数都是瓶颈一般设成 CPU 核数的 2 倍就够了。我个人的经验是优先靠分片水平扩展而不是单机多线程。分片扩展是线性的、可控的多线程并发的复杂度高得多出了问题也难排查。6.2 调度中心的高可用部署调度中心是单点它挂了所有任务都停摆。生产环境必须做高可用。XXL-Job 的调度中心支持集群部署多个实例连同一个数据库通过数据库锁保证同一时刻只有一个实例在调度。部署两个实例前面挂个负载均衡基本就够用了。调度中心的数据库也要做高可用主从复制加自动切换。调度日志表xxl_job_log增长很快要定期清理XXL-Job 自带清理任务在后台配置日志保留天数即可。执行器这边会员服务本身就是多实例部署天然就是集群。只要保证每个实例都正确注册到调度中心分片广播就能正常工作。6.3 监控与告警的落地任务跑没跑、跑成功没有不能靠人盯。XXL-Job 后台有调度记录和日志但需要主动去看。生产环境要接告警。最简单的做法是配置任务失败告警XXL-Job 支持邮件告警在后台配置收件人即可。更完善的做法是把调度结果接入公司的监控系统比如通过执行器的handleFail上报到 Prometheus再配 Grafana 面板和告警规则。我还会在任务里加一些业务指标埋点比如处理数量、耗时、失败率这些指标比成功/失败更有价值。比如某天处理数量突然只有平时的一半可能是上游数据没准备好虽然任务显示成功但业务上是有问题的。6.4 灰度与回滚的考虑新任务上线或者老任务改逻辑不要一上来就全量跑。先在测试环境验证再在生产环境用一个小分片试跑确认没问题再放开。XXL-Job 支持执行一次功能可以在后台手动触发指定参数。我一般会先用一个只处理 100 条数据的参数试跑观察日志和数据库变化确认逻辑正确后再改成正式参数。如果任务跑出问题了要回滚分片任务的好处是每片独立可以单独重跑某一片。在后台的调度记录里找到失败的那次点执行一次指定分片参数重跑即可。前提是任务幂等重跑不会产生副作用。7. 写在最后的一点个人体会这套东西我在几个项目里落地过最大的感受是分片定时任务的价值不在于技术多复杂而在于它把数据量增长这个变量从架构里解耦了。以前会员量涨到百万级定时任务就要重构现在只要加实例分片数自动跟着涨任务处理能力线性提升业务代码一行不用改。另一个体会是幂等这件事怎么强调都不为过。分片、重试、手动触发任何一个环节都可能让任务跑两遍。我现在的习惯是任何写数据的定时任务第一行代码就是幂等校验宁可多查一次数据库也不要冒重复处理的风险。最后分享一个小技巧在任务日志里打印分片参数和处理数量看起来是小事但排查问题时特别有用。有一次线上任务变慢我看日志发现某个分片处理数量是其他分片的 5 倍立刻定位到是数据倾斜换了分片键就解决了。如果日志里没这些信息可能就要花几个小时去猜。
阅读完成 · 觉得有帮助?
咨询建站