外观
从2小时到12分钟:百万级报表定时任务分片优化实战
问题背景:百万级报表定时任务的性能瓶颈
在电商平台的月度账单生成场景中,业务侧要求每月1号凌晨2点前完成全平台百万级用户的消费数据报表生成,支持用户随时下载归档。最初我们采用单机Quartz定时任务实现,上线后多次出现故障:全量任务跑完耗时超过2小时,经常错过业务侧要求的交付时间;高峰期任务占满所有数据库连接,导致其他在线业务接口超时;一旦执行节点宕机,整个报表生成流程直接中断,没有自动重试机制。 经过压测统计,单机环境下单线程生成1万条报表平均耗时12秒,百万级数据理论耗时接近3.5小时,完全无法满足时效要求,必须引入分布式任务分片机制,把单节点的任务拆分到多节点并行执行,把总耗时压缩到15分钟以内。
方案对比:三类定时任务分片方案选型
我们先后对比了三类主流实现方案,最终选定基于成熟分布式调度框架的原生分片能力实现:
- 单机定时任务手动拆分:优点是无需引入额外中间件,直接把用户ID范围硬编码拆分到多个单机节点执行;缺点是扩容成本极高,节点故障后手动补任务容易出现重复生成,完全无法适配弹性伸缩的云原生环境。
- 基于数据库行锁的自定义分片:通过在数据库创建任务分片表,多个节点抢锁获取未分配的分片区间执行;优点是实现逻辑轻量,不需要额外部署调度中心;缺点是分布式一致性问题多,容易出现任务重复执行,分片逻辑需要自行维护,后续迭代成本极高。
- 基于分布式调度框架的原生分片:以XXL-Job为代表的开源调度框架原生支持分片广播能力,调度中心自动根据在线执行器数量分配分片项,动态感知节点上下线,自动做故障转移,不需要自行维护分布式锁逻辑,是生产环境的最优选择。
实操步骤与代码示例:分片任务核心实现
本次实战我们基于Spring Boot + XXL-Job 2.4.0版本实现报表生成分片逻辑,核心步骤如下:
1. 引入核心依赖与基础配置
在pom.xml中引入XXL-Job核心依赖,配置调度中心地址与执行器命名:
xml
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.4.0</version>
</dependency>配置类中初始化执行器实例,保证执行器自动注册到调度中心:
java
@Configuration
public class XxlJobConfig {
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
// 配置调度中心地址
executor.setAdminAddresses("http://scheduler-center:8080/xxl-job-admin");
// 执行器应用名
executor.setAppname("report-generator-job");
executor.setPort(9999);
return executor;
}
}2. 分片任务核心逻辑实现
通过XxlJob注解定义分片任务Handler,框架自动注入当前执行器的分片序号与总分片数量,我们基于这两个参数拆分报表生成任务:
java
@Component
public class ReportGenerateJob {
@Resource
private UserReportMapper userReportMapper;
/**
* 分布式分片报表生成任务
* 每个执行器节点仅处理自己负责的分片数据,避免重复执行
*/
@XxlJob("shardingReportJobHandler")
public void shardingReportTask() {
// 获取当前分片项:当前节点的分片序号,从0开始计数
int shardIndex = XxlJobHelper.getShardIndex();
// 获取总分片数:等于当前在线的执行器节点总数
int shardTotal = XxlJobHelper.getShardTotal();
// 先查询全量用户ID的最大最小值,做均匀范围分片,避免取模导致的负载不均
Long maxUserId = userReportMapper.getMaxUserId();
Long minUserId = userReportMapper.getMinUserId();
// 计算每个分片负责的用户ID区间步长
long step = (maxUserId - minUserId) / shardTotal + 1;
long currentStart = minUserId + shardIndex * step;
long currentEnd = Math.min(minUserId + (shardIndex + 1) * step, maxUserId);
XxlJobHelper.log("当前分片:{} / {},处理用户ID区间:{} ~ {}", shardIndex, shardTotal, currentStart, currentEnd);
// 分页查询当前分片区间的用户,批量生成报表
int pageSize = 100;
long currentOffset = 0;
while (true) {
List<UserDO> userList = userReportMapper.listUserByRange(currentStart, currentEnd, currentOffset, pageSize);
if (CollectionUtils.isEmpty(userList)) {
break;
}
// 批量生成报表文件,上传到对象存储
for (UserDO user : userList) {
generateAndUploadReport(user);
}
currentOffset += pageSize;
}
}
private void generateAndUploadReport(UserDO user) {
// 省略报表生成与上传的业务逻辑
}
}3. 调度中心配置
在XXL-Job管理后台新增分片广播任务,路由策略选择"分片广播",任务执行时间配置为每月1号凌晨1点,支持自动扩容:后续执行器节点从3台扩容到10台,分片数量自动从3变成10,任务并行度同步提升。
避坑总结:3条生产环境踩坑经验
我们在上线调试过程中遇到了多个典型问题,整理出3条可直接复用的避坑规则:
- 分片总数不要硬编码超过执行器节点数:很多新手会手动配置分片总数为20,但实际部署的执行器节点只有3台,剩下的17个分片项永远处于空闲状态,导致大量任务没有被执行,报表生成不全。修正方法是采用框架原生的分片广播模式,总分片数自动等于在线执行器数量,不需要手动配置,节点扩容后自动提升并行度。
- 禁止用用户ID取模做分片逻辑:如果用户ID是稀疏分布的,比如大量早期用户ID已经被删除,直接用
userId % shardTotal的方式拆分,会出现严重的负载不均:部分节点需要处理10万条数据,部分节点只需要处理不到1万条数据,整体性能瓶颈卡在负载最高的节点。修正方法是采用基于ID最大最小值的范围分片逻辑,均匀拆分数据区间,保证每个节点的处理数据量误差不超过5%。 - 必须给分片查询条件加联合索引:很多开发者在写分片查询SQL的时候,没有给用户ID区间字段加索引,多个节点同时执行全表扫描会直接把业务数据库的CPU打满,导致在线业务全部不可用。修正方法是给分片查询涉及的用户ID、报表生成时间字段建立联合索引,每个节点的分片查询SQL命中索引,单条查询耗时控制在10ms以内,完全不会影响在线业务。 本次优化上线后,3台4核8G的执行器节点并行处理百万级报表生成任务,总耗时从原来的2小时降到12分钟,完全满足业务侧的时效要求,后续节点扩容到6台之后总耗时可以进一步压缩到6分钟以内。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。