计算服务 基于 xxl-job 的跑批业务实践记录
Q1:xxl-job 和 calc 自己的跑批逻辑是怎么接起来的?
入口并不复杂。BaseJobHandler 是一个抽象基类,具体任务继承它之后只负责实现业务计算。xxl-job 那边配置的 Handler 调用基类统一的调度入口后,基类会从 xxl-job 那里拿到调度参数、当前分片序号和总分片数,然后按任务类型进入对应的执行模式。
也就是说,xxl-job 在这里只负责“到点触发”和“告诉任务现在有几个分片、你是第几个分片”。真正的时间窗口推进、运行状态、补算逻辑,都放在 calc 自己的任务自动表和任务运行表里维护。
这种拆分比较踏实:调度器不背业务状态,业务也不依赖调度器的日志来恢复。只要那两张表还在,换一套调度平台也能快速接上。
Q2:同一个任务被多个执行器同时调度,怎么防止重复算同一个业务对象?
这是跑批里最常见的问题。代码里的做法分成两层。
第一层是分片取数。基类拿到 xxl-job 的分片序号和总分片数后,会先做一次兜底处理:总分片数非法时强制按单节点执行,当前分片序号非法时强制归到 0 号分片。取业务对象或组织对象时,按“总数除以总分片数向上取整”作为每片大小,当前分片只取自己那一段。这样每个执行器只算自己那一部分,理论上不会重叠。
第二层是运行记录幂等。每个时间窗口在真正执行前会先写一条运行总记录,每个分片再写一条分片记录。插入用的都是“存在就忽略、不存在才插入”的策略。如果两个执行器因为配置或调度异常同时进来,同一组任务、窗口、分片只会留下一条记录,后续执行都基于这条已存在的记录走,避免重复跑批。
还有一个细节:当任务按单个对象触发时,基类会判断只有 0 号分片才执行,其他分片直接返回空列表。这样即使任务配置成了多分片,单个对象也不会被算多次。
Q3:任务中途失败或者发布重启了,下一次调度怎么把断点续上?
不同类别的任务续跑方式不一样,这也是代码里为什么先要把任务按语义分类的原因。
对于周期回填类任务,基类会读出任务表里记录的“最后一次计算时间”,然后循环生成时间窗口。只要当前时间已经超过下一个窗口结束时间,就会继续补。假如任务停了两天,下次调度时会一口气把两天的窗口挨个补完,不需要人工干预。
如果已经追到最新时间,代码里还会再执行一次最后一个窗口。这个设计是为了处理“迟到数据”。比如昨天快零点时的原始数据过了零点才到,今天再跑一次昨天窗口就能把新数据纳入。
增量同步类任务更简单,直接从最后一次计算时间跑到当前时间,中间不再切窗口。中台拉取类任务还会额外扣掉一段安全延迟,并且把起始时间往前推一段重叠区间,防止中台数据落地有延迟导致漏拉。
Q4:为什么有些任务按窗口循环跑,有些任务只跑当前这一刀?
代码里把任务按语义分成了四类:周期回填、快照、增量同步、中台拉取同步。它们的窗口语义本来就不同,强行用同一套循环逻辑会很难维护。
周期回填类,例如能耗统计、费用计算,需要把历史窗口一个一个补齐,所以会循环推进最后一次计算时间。快照类任务,例如推送账单、发送通知、校验开关状态,只关心“现在这一刻”的状态,不需要回溯历史窗口,所以只算当前窗口。增量同步类要的是从上一次同步点到现在的全部变更,中台拉取类则是“延迟一段安全时间后再去拉”。
如果新增一种任务,第一步先想清楚它的时间语义属于哪一类,再把它放进对应的枚举集合里,而不是在基类里再加一套分支。放错了会导致时间窗口推进方式完全不对。
Q5:分片跑完后,怎么判断整个窗口可以进入下一个周期?
这里用了一个最终一致的做法,没有单独做一个协调器。每个分片执行结束后,基类会去统计这个窗口下成功分片的数量和失败分片的数量。
只要有一个分片失败,整个运行记录就标记失败,最后一次计算时间不推进,下次调度继续处理这个窗口。只有当成功分片数达到总分片数且没有失败分片时,才会更新最后一次计算时间。
这个机制的好处是简单,不依赖分布式锁;缺点是如果某个分片一直不结束,窗口会挂在那里。实际运维时要配合 xxl-job 的执行日志和分片记录表一起看,找出卡住的执行器。
Q6:本地开发或单元测试里没有 xxl-job 调度环境,怎么跑单个任务?
基类已经做了本地兼容。刷新分片信息时对分片参数做了防御性处理,所以本地直接调用调度入口时,系统会退化成单节点全量执行。
另外还暴露了一个测试入口,可以直接把业务参数透传到子类的业务计算方法。写单元测试或者本地排查问题时,可以直接构造时间窗口调用它,不需要启动 xxl-job 调度中心。
执行器名字也有兜底:配置里有机器名就用配置值,没有就按 calc 前缀加机器编号生成。这样在本地多个测试实例同时连同一套数据库时,也能从分片记录表里区分是哪台机器跑的。
Q7:能耗统计里“小时 -> 天 -> 月 -> 年”这种逐级累加是怎么衔接的?
基类里提供了一个周期向上归一的工具:小时归到天,天归到月,月归到年,其他周期统一按年处理防止死循环。真正的累加逻辑在子类实现里按当前周期类型决定从哪张周期表取上一周期数据、往哪张周期表写结果。
比如日统计任务执行时,会去小时表里汇总当天 24 条记录;月统计任务再去日表里汇总当月记录。基类只提供周期转换的能力,不侵入具体的能耗统计口径,这样不同业务复用同一套调度框架时不会被耦合死。
Q8:日志应该写本地文件,还是写 xxl-job 的执行日志?
代码里的做法是两边都写。本地日志落到应用日志文件里,便于通过 ELK 或 grep 检索;xxl-job 执行日志写到调度中心,便于在控制台直接看到每次调度的输入、输出和异常堆栈。
尤其在子任务执行前后,基类会把任务名、分片序号、统计周期、对象编码、耗时等信息拼成一条日志,同时输出到 xxl-job 执行日志。这样排查问题时不用在两套日志之间来回切换,调度中心页面就能看到最关键的上下文。
一些写新任务时容易忽略的点
如果你要在 calc 工程里新增一个跑批任务,除了继承基类实现业务计算方法之外,还要注意几个细节:
- 先在任务类型枚举里定义好任务编码,再把任务类型放进对应的语义集合里。如果都不属于,默认走周期回填逻辑。放错了会导致时间窗口推进方式完全不对。
- 在任务自动表里初始化一条记录,设置好计算周期和初始最后计算时间,否则调度时会因为取不到任务配置直接返回。
- 子类里取数据时一定要用基类提供的按分片取业务对象或组织对象的方法,它们已经处理过分片逻辑。如果绕过它们直接查全量,分片就失去意义了。
- 不要在自己的业务计算方法里推进最后计算时间,那是基类根据整体运行结果统一做的。子类只负责把当前窗口内的业务算对、算完。
- 如果任务需要按小时、天、月多种周期复用,建议子类内部再按周期类型分支,不要为每个周期新建一个 Handler 类,否则维护配置和表记录的成本会很高。
写在最后
这套跑批框架的核心思路其实就三条:把调度平台当触发器,把业务状态放在自己表里,把分片和幂等做成基础设施。xxl-job 在这里不是业务主流程的一部分,而是“准时把人叫起来干活”的角色。真正决定任务能不能可靠补跑、能不能水平扩展、能不能扛住发布中断的,是任务自动表、任务运行表、任务分片表这三张表的设计,以及基类里对时间窗口和分片的处理。
后续如果要把任务迁到别的调度平台,理论上只要重写调度入口处的参数和分片读取逻辑,基类里这些窗口推进和状态管理逻辑基本可以原样保留。这也是最开始把调度层和业务层拆开的好处。