定时任务批量处理千万级订单数据完整方案(SpringCloud+XXL-Job)
方案概述
本方案面向高并发分布式SpringCloud系统,针对凌晨定时统计千万级订单数据场景,以「分布式分片+单机线程池并行」为核心,结合「从库隔离+游标分页+ClickHouse高性能存储+全链路监控」,解决数据读取不压库、任务不丢不重、计算高效、结果高准确的核心问题,保障系统高可用、可扩展、易运维。
核心设计思路
- 双层并行:XXL-Job分片实现分布式粗粒度并行,单实例线程池实现单机细粒度并行,最大化利用集群和单机资源;
- 资源隔离:统计任务与业务服务物理/逻辑隔离,读库优先使用MySQL从库,避免影响核心业务;
- 安全可控:全链路幂等、线程安全计算、故障自动重试/转移,确保数据准确不丢失;
- 可观测:覆盖任务、系统、线程池、中间件全维度监控,异常实时告警。
一、整体架构设计
| 分层 | 核心组件 | 核心职责 |
|---|
| 任务调度层 | XXL-Job(分布式调度)+ Nacos(配置/注册) | 凌晨定时触发、分片分发、任务幂等控制、失败重试、执行器集群容灾 |
| 数据接入层 | MySQL主从集群 + 动态数据源 + Sentinel(限流/熔断) | 从库读取订单数据、游标分页、读库限流保护、故障自动切换备用从库 |
| 计算处理层 | 自定义线程池(单机并行)+ ConcurrentHashMap(线程安全汇总)+ CountDownLatch | 单分片内小批次并行计算、线程安全数据汇总、批次任务同步等待 |
| 数据存储层 | ClickHouse(统计结果)+ Redis Cluster(幂等锁/中间结果) | 高性能存储统计结果、支持多维度分析、幂等控制和中间结果缓存 |
| 监控告警层 | Prometheus+Grafana(指标)+ SkyWalking(全链路)+ ELK(日志)+ 分级告警 | 任务/线程池/数据库/中间件监控、全链路追踪、异常分级告警(钉钉/短信) |
| 容灾回滚层 | 数据备份 + 手动重跑 + 应急切换预案 | 数据恢复、任务回滚、故障应急处理 |
二、核心落地方案
1. 分布式任务调度(XXL-Job分片)
1.1 调度框架配置
选择XXL-Job作为分布式调度核心(SpringCloud集成成本低、分片/重试/监控能力完善),核心部署与配置如下:
| 配置项 | 取值/策略 |
|---|
| 部署架构 | XXL-Job Admin:2台集群(主备);执行器:3台及以上(不同可用区),开启故障转移 |
| 触发时间 | 凌晨1:00(避开业务高峰),窗口期4小时(1:00-5:00) |
| 分片策略 | 按订单ID哈希分片(shardingTotalCount=100),避免数据倾斜;支持动态调整分片数 |
| 幂等控制 | 以「任务ID+分片ID+统计日期」为Redis唯一Key,执行前标记「执行中」,完成后标记「成功」,失败标记「失败」并触发重试 |
| 重试规则 | 单分片失败自动重试3次(间隔5分钟),超过阈值触发告警,支持手动重跑 |
1.2 XXL-Job执行器集成配置
@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;
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
executor.setAdminAddresses(adminAddresses);
executor.setAppname(appname);
executor.setPort(port);
executor.setAccessToken(accessToken);
return executor;
}
}
2. 千万级订单数据读取优化
2.1 数据源隔离与保护
- 从库优先:订单数据读取仅从MySQL从库获取,主库仅用于业务写入,主从同步延迟控制在1分钟内;配置3台从库,动态数据源(Dynamic-Datasource)自动切换故障从库;
- 读库限流熔断:Sentinel限制单执行器读库QPS≤1000,从库延迟>5分钟或QPS超阈值时触发熔断,切换备用从库并告警;
- 超时控制:单次数据库查询超时≤5秒,避免长连接占用数据库资源。
2.2 高效读取策略(游标分页+索引优化)
- 禁用OFFSET大分页:采用「游标分页」,按
order_id递增读取,每次读取1000条,避免全表扫描和大OFFSET性能问题;
- 索引优化:订单表添加统计维度联合索引:
idx_create_time_order_id (create_time, order_id, merchant_id, amount);
- 分库分表适配:按订单分库分表路由规则(如user_id分库、order_id分表),分片任务对应分库分表,避免跨库全扫;
- 批量读取配置:MyBatis开启batch模式,JDBC设置
fetchSize=1000,减少数据库交互次数。
2.3 游标分页Mapper实现
<select id="listByShardAndCursor" resultType="com.example.entity.OrderDO">
SELECT id, merchant_id, amount, create_time
FROM t_order_${shardIndex % 10} -- 适配订单分表(按order_id分10表)
WHERE id >
AND create_time BETWEEN
AND MOD(id,
ORDER BY id ASC
LIMIT
</select>
3. 分片+线程池双层并行计算(核心)
3.1 单机线程池配置(统计专用)
为避免与业务线程池资源抢占,单独配置统计专用线程池,适配8核16G服务器示例:
@Configuration
public class ThreadPoolConfig {
@Bean("orderStatThreadPool")
public ExecutorService orderStatThreadPool() {
int cpuCore = Runtime.getRuntime().availableProcessors();
int corePoolSize = cpuCore * 2;
int maxPoolSize = cpuCore * 4;
BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(corePoolSize * 10);
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat("order-stat-pool-%d")
.build();
RejectedExecutionHandler rejectedHandler = (r, executor) -> {
log.error("订单统计线程池任务拒绝,队列长度={},活跃线程数={}",
executor.getQueue().size(), executor.getActiveCount());
AlertUtil.sendAlert("订单统计线程池任务拒绝,可能导致统计超时");
try {
executor.getQueue().offer(r, 5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RejectedExecutionException("任务入队失败", e);
}
};
return new ThreadPoolExecutor(
corePoolSize, maxPoolSize, 60L, TimeUnit.SECONDS,
queue, threadFactory, rejectedHandler
);
}
}
3.2 分片+线程池核心任务实现
@Component
public class OrderStatJob {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ClickHouseStatMapper clickHouseStatMapper;
@Resource(name = "orderStatThreadPool")
private ExecutorService orderStatThreadPool;
@XxlJob("orderStatJobHandler")
public void execute() throws Exception {
XxlJobHelper helper = XxlJobHelper.getJobHelper();
int shardIndex = helper.getShardIndex();
int shardTotal = helper.getShardTotal();
String statDate = helper.getJobParam();
String lockKey = "order:stat:lock:" + statDate + ":" + shardIndex;
if (!redisTemplate.opsForValue().setIfAbsent(lockKey, "EXECUTING", 4, TimeUnit.HOURS)) {
helper.log("分片{}已执行,跳过", shardIndex);
return;
}
try {
Long lastOrderId = 0L;
Date startDate = DateUtils.parseDate(statDate + " 00:00:00");
Date endDate = DateUtils.parseDate(statDate + " 23:59:59");
int batchReadSize = 1000;
int threadBatchSize = 1000;
ConcurrentHashMap<String, MerchantStat> merchantStatMap = new ConcurrentHashMap<>();
AtomicReference<Exception> exceptionRef = new AtomicReference<>();
while (true) {
List<OrderDO> orderList = orderMapper.listByShardAndCursor(
shardIndex, shardTotal, lastOrderId, startDate, endDate, batchReadSize);
if (CollectionUtils.isEmpty(orderList)) break;
List<List<OrderDO>> subBatchLists = splitList(orderList, threadBatchSize);
CountDownLatch latch = new CountDownLatch(subBatchLists.size());
for (List<OrderDO> subList : subBatchLists) {
orderStatThreadPool.submit(() -> {
try {
for (OrderDO order : subList) {
merchantStatMap.computeIfAbsent(order.getMerchantId(), k -> new MerchantStat())
.accumulate(order);
}
} catch (Exception e) {
log.error("线程池处理批次数据失败", e);
exceptionRef.set(e);
} finally {
latch.countDown();
}
});
}
boolean awaitResult = latch.await(1, TimeUnit.MINUTES);
if (!awaitResult) throw new TimeoutException("分片" + shardIndex + "批次处理超时");
if (exceptionRef.get() != null) throw exceptionRef.get();
lastOrderId = orderList.get(orderList.size() - 1).getId();
helper.log("分片{}已读取至order_id={},累计统计商户数={}",
shardIndex, lastOrderId, merchantStatMap.size());
}
if (!merchantStatMap.isEmpty()) {
List<OrderStatDO> statList = merchantStatMap.values().stream()
.map(stat -> {
OrderStatDO statDO = new OrderStatDO();
statDO.setStatDate(DateUtils.parseDate(statDate));
statDO.setMerchantId(stat.getMerchantId());
statDO.setOrderCount(stat.getOrderCount());
statDO.setTotalAmount(stat.getTotalAmount());
statDO.setAvgAmount(stat.getTotalAmount().divide(
new BigDecimal(stat.getOrderCount()), 2, BigDecimal.ROUND_HALF_UP));
statDO.setShardId(shardIndex);
return statDO;
}).collect(Collectors.toList());
clickHouseStatMapper.batchInsert(statList);
}
redisTemplate.opsForValue().set(lockKey, "SUCCESS", 24, TimeUnit.HOURS);
helper.log("分片{}统计完成,共统计{}个商户", shardIndex, merchantStatMap.size());
} catch (Exception e) {
redisTemplate.opsForValue().set(lockKey, "FAILED", 4, TimeUnit.HOURS);
helper.handleFail("分片{}执行失败:{}", shardIndex, e.getMessage());
throw e;
}
}
@Data
private static class MerchantStat {
private String merchantId;
private Long orderCount = 0L;
private BigDecimal totalAmount = BigDecimal.ZERO;
public synchronized void accumulate(OrderDO order) {
this.orderCount += 1;
this.totalAmount = this.totalAmount.add(order.getAmount());
}
}
private <T> List<List<T>> splitList(List<T> list, int batchSize) {
List<List<T>> result = new ArrayList<>();
int count = (list.size() + batchSize - 1) / batchSize;
for (int i = 0; i < count; i++) {
int from = i * batchSize;
int to = Math.min((i + 1) * batchSize, list.size());
result.add(list.subList(from, to));
}
return result;
}
}
4. 统计结果存储(ClickHouse)
4.1 表结构设计(列式存储+分区优化)
CREATE TABLE order_stat (
stat_date Date COMMENT '统计日期',
merchant_id String COMMENT '商户ID',
order_count UInt64 COMMENT '订单数',
total_amount Decimal(18,2) COMMENT '总金额',
avg_amount Decimal(18,2) COMMENT '平均金额',
shard_id UInt8 COMMENT '分片ID',
create_time DateTime DEFAULT now()
) ENGINE = MergeTree()
PARTITION BY stat_date
ORDER BY (merchant_id, shard_id)
SETTINGS index_granularity = 8192;
4.2 写入优化
- 批量写入:每次写入1000条/批,减少IO次数;
- 避开合并高峰期:ClickHouse默认凌晨合并分区,统计任务提前至1点完成,避免写入与合并冲突;
- 幂等写入:采用
INSERT ... ON DUPLICATE KEY UPDATE(ClickHouse可通过ReplacingMergeTree实现)。
5. 高可用保障
5.1 集群容灾
- 所有中间件(XXL-Job Admin、Redis Cluster、Kafka、ClickHouse、MySQL从库)均集群部署,无单点;
- 执行器节点部署在不同可用区,XXL-Job自动将故障节点的分片任务分配给健康节点;
- 动态数据源自动切换故障从库,Redis故障切换至备用集群。
5.2 数据一致性保障
- 幂等性:所有写入操作基于「任务ID+分片ID+统计日期」做幂等控制,避免重复累加;
- 数据对账:统计完成后触发对账任务,对比「MySQL原始订单总数/总金额」与「ClickHouse统计结果」,差异率>0.1%立即告警;
- 增量统计:仅读取当日订单(
create_time在统计日期范围内),避免全量扫描导致性能问题。
5.3 应急方案
| 异常场景 | 应急处理方案 |
|---|
| 任务执行超时 | 临时扩容执行器节点(增加分片并行数),暂停非核心维度统计,优先保障核心指标 |
| 从库压力过大 | 临时限流读库QPS,延长窗口期,或使用昨日备份数据临时统计,后续补正 |
| 统计结果错误 | 手动触发指定分片/日期的重跑任务,重跑前备份错误结果,重跑后覆盖 |
| 线程池任务拒绝 | 临时扩容线程池队列/最大线程数,紧急扩容执行器节点 |
三、监控告警体系
3.1 核心监控指标
| 监控维度 | 核心指标 | 告警阈值 |
|---|
| 任务层面 | 分片执行状态/时长/进度、任务失败数/重试数 | 执行失败/超时>4小时/进度<10%/小时 |
| 系统层面 | 执行器CPU/内存/磁盘、MySQL从库QPS/慢查询/主从延迟、ClickHouse写入延迟 | CPU>80%/内存>70%/主从延迟>5分钟 |
| 线程池层面 | 活跃线程数、队列长度、任务拒绝数、完成数 | 活跃线程数>核心线程数*80%/拒绝数>0 |
| 业务层面 | 读取订单数、统计金额、对账差异率 | 读取数=0/对账差异率>0.1% |
3.2 告警方式
- 分级告警:一般异常(钉钉群)、严重异常(钉钉+短信+电话);
- 全链路追踪:通过SkyWalking将
traceId贯穿「读取-计算-写入」全流程,支持按任务ID/分片ID追溯;
- 日志检索:ELK收集所有执行器日志,支持关键词(如分片ID、订单ID)快速排查问题。
四、落地与优化建议
4.1 前置准备
- 压测验证:模拟千万级订单数据,验证分片数(建议100)、线程池参数(核心线程数=CPU*2)、读库QPS(≤1000)的最优配置;
- 灰度上线:先跑近7天历史数据,验证准确性和性能,无问题后全量上线;
- 资源隔离:统计执行器与业务微服务物理隔离,避免资源竞争。
4.2 性能优化方向
- 数据倾斜优化:监控分片数据量,对数据量过大的分片进一步拆分子分片;
- JVM优化:执行器JVM参数配置
-Xms8G -Xmx8G -XX:+UseG1GC -XX:MaxGCPauseMillis=200,避免OOM和GC停顿过长;
- 预计算优化:对高频统计维度(如商户日订单量),可在业务写入时异步预计算,降低凌晨统计压力;
- 冷热数据分离:历史订单数据迁移至ClickHouse,MySQL仅保留近3个月数据,提升读取效率。
4.3 运维建议
- 定期巡检:每周检查分片数据倾斜、索引有效性、线程池状态、中间件集群健康度;
- 备份策略:MySQL从库每日0点全量备份,ClickHouse统计结果保留30天备份;
- 文档沉淀:梳理任务流程、告警规则、应急方案,便于运维交接。
五、方案核心优势
- 高性能:分布式分片+单机线程池双层并行,千万级订单统计耗时可控制在15-30分钟(传统单线程需60+分钟);
- 高可用:集群容灾+故障自动转移+重试机制,任务不丢不重,数据库/中间件故障不影响核心流程;
- 高准确:幂等控制+数据对账+线程安全计算,统计准确率100%;
- 易扩展:分片数/执行器节点可动态扩容,适配订单量从千万级到亿级的增长;
- 易落地:基于SpringCloud+XXL-Job成熟生态,代码复用性高,学习成本低。