1. 会员服务定时任务到底难在哪
会员服务里有一类活儿,看起来不起眼,但一旦量上来就特别容易翻车:每天凌晨要给昨天新增的会员补发成长值、每隔十分钟要扫描一批即将过期的积分并做提醒、每小时要同步一次会员等级状态、大促期间还要临时跑一批优惠券到账任务。这些活儿有几个共同特征——它们不在用户请求链路上,但一旦挂了,业务方第二天就会找上门;它们往往要处理几十万甚至上百万条数据,单机跑一次要几十分钟;它们还特别怕重复执行,比如补发成长值跑了两遍,用户直接多拿一份,客诉就来了。
我见过不少团队一开始的做法很朴素:在会员服务里写个@Scheduled注解,或者用ScheduledExecutorService起个线程池,能跑就行。单机、单实例、数据量小的时候确实没问题,可一旦会员服务为了扛住流量做了多实例部署,问题立刻暴露——三个实例同时触发同一个任务,同一批数据被处理三遍。有人会说,那我加个分布式锁不就行了?加锁确实能解决重复执行,但解决不了另一个更头疼的问题:单机处理百万级数据太慢,锁一持有就是半小时,其他实例干等着,资源全浪费了。
这就是为什么我们需要把定时任务从"应用内嵌"升级成"独立调度 + 分片执行"的架构。标题里提到的 XXL-Job 就是干这个的,它把调度和执行拆开,调度中心负责"什么时候触发、触发到哪几个执行器",执行器负责"真正干活"。而"分片"是它最值钱的能力之一——一个任务可以拆成 N 片,分给 N 个执行器并行跑,每片只处理自己那份数据。集群模式下,执行器可以水平扩容,任务处理能力跟着涨,这才是会员服务这种数据密集型场景该有的姿势。
这篇文章我会把整套东西拆开讲:为什么选 XXL-Job 而不是 Quartz、为什么分片比加锁更优雅、分片参数怎么算、路由策略怎么选、任务幂等怎么做、集群部署时有哪些坑。内容基于我在实际项目里的落地经验,代码和配置都可以直接抄作业,但每个参数背后的"为什么"我会讲清楚,你换到自己的业务里也能举一反三。
2. 为什么是 XXL-Job,而不是 Quartz 或自研
2.1 三种定时任务方案的横向对比
在动手之前,先把选型这件事说透。定时任务这个领域,常见方案就那么几种,我把它们放在一张表里对比,你一眼就能看出差距。
| 维度 | Spring @Scheduled | Quartz | XXL-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.port=8080 server.servlet.context-path=/xxl-job-admin spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=你的密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver xxl.job.accessToken=你的自定义令牌 xxl.job.i18n=zh_CNaccessToken这个参数很多人会忽略,但它很重要。调度中心和执行器通信时会带上这个令牌做鉴权,如果两边不一致,执行器注册不上,任务触发会失败。生产环境一定要改成一个复杂的随机字符串,别用默认值。
启动调度中心后,浏览器访问http://localhost:8080/xxl-job-admin,默认账号是admin/123456。登录后第一件事是改密码,第二件事是去"执行器管理"里新建一个执行器分组。
3.2 会员服务引入执行器依赖
调度中心跑起来后,轮到会员服务这边。在pom.xml里加依赖:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.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) { List<MemberGrowthRecord> 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 层的分片查询 SQL:
<select id="selectPendingByShard" resultType="MemberGrowthRecord"> SELECT id, member_id, growth_value, status, create_time FROM member_growth_record WHERE status = 0 AND id > #{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 | 选你注册的执行器 |
| 任务描述 | 会员成长值分片补发 | 见名知意 |
| 路由策略 | 分片广播 | 关键,必须选这个 |
| Cron | 0 0 2 * * ? | 每天凌晨 2 点 |
| 运行模式 | BEAN | 用 @XxlJob 注解的方式 |
| JobHandler | memberGrowthShardingJob | 和注解值一致 |
| 阻塞处理策略 | 丢弃后续调度 | 防止任务堆积 |
| 任务超时时间 | 1800 | 30 分钟,超时自动失败 |
| 失败重试次数 | 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 倍,立刻定位到是数据倾斜,换了分片键就解决了。如果日志里没这些信息,可能就要花几个小时去猜。