定时任务批量处理千万级订单数据完整方案(SpringCloud+XXL-Job)

6 阅读11分钟

定时任务批量处理千万级订单数据完整方案(SpringCloud+XXL-Job)

方案概述

本方案面向高并发分布式SpringCloud系统,针对凌晨定时统计千万级订单数据场景,以「分布式分片+单机线程池并行」为核心,结合「从库隔离+游标分页+ClickHouse高性能存储+全链路监控」,解决数据读取不压库、任务不丢不重、计算高效、结果高准确的核心问题,保障系统高可用、可扩展、易运维。

核心设计思路

  1. 双层并行:XXL-Job分片实现分布式粗粒度并行,单实例线程池实现单机细粒度并行,最大化利用集群和单机资源;
  2. 资源隔离:统计任务与业务服务物理/逻辑隔离,读库优先使用MySQL从库,避免影响核心业务;
  3. 安全可控:全链路幂等、线程安全计算、故障自动重试/转移,确保数据准确不丢失;
  4. 可观测:覆盖任务、系统、线程池、中间件全维度监控,异常实时告警。

一、整体架构设计

分层核心组件核心职责
任务调度层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 > #{lastOrderId}
      AND create_time BETWEEN #{startDate} AND #{endDate}
      AND MOD(id, #{shardTotal}) = #{shardIndex}  -- 按订单ID哈希分片
    ORDER BY id ASC
    LIMIT #{batchSize}
</select>

3. 分片+线程池双层并行计算(核心)

3.1 单机线程池配置(统计专用)

为避免与业务线程池资源抢占,单独配置统计专用线程池,适配8核16G服务器示例:

@Configuration
public class ThreadPoolConfig {
    /**
     * 订单统计专用线程池(计算密集型任务最优配置)
     */
    @Bean("orderStatThreadPool")
    public ExecutorService orderStatThreadPool() {
        int cpuCore = Runtime.getRuntime().availableProcessors();
        int corePoolSize = cpuCore * 2;    // 核心线程数:CPU核心数*2
        int maxPoolSize = cpuCore * 4;     // 最大线程数:CPU核心数*4
        // 有界队列:避免任务过多导致OOM
        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;

    /**
     * XXL-Job分片任务入口:千万级订单统计
     */
    @XxlJob("orderStatJobHandler")
    public void execute() throws Exception {
        // 1. 获取分片参数
        XxlJobHelper helper = XxlJobHelper.getJobHelper();
        int shardIndex = helper.getShardIndex();    // 分片索引(0-99)
        int shardTotal = helper.getShardTotal();    // 总分片数(100)
        String statDate = helper.getJobParam();     // 统计日期,如20251211

        // 2. 幂等校验:避免重复执行
        String lockKey = "order:stat:lock:" + statDate + ":" + shardIndex;
        if (!redisTemplate.opsForValue().setIfAbsent(lockKey, "EXECUTING", 4, TimeUnit.HOURS)) {
            helper.log("分片{}已执行,跳过", shardIndex);
            return;
        }

        try {
            // 3. 初始化参数
            Long lastOrderId = 0L;                  // 游标起始ID
            Date startDate = DateUtils.parseDate(statDate + " 00:00:00");
            Date endDate = DateUtils.parseDate(statDate + " 23:59:59");
            int batchReadSize = 1000;               // 单次游标读取1000条
            int threadBatchSize = 1000;             // 单线程任务处理1000条

            // 线程安全的统计结果容器
            ConcurrentHashMap<String, MerchantStat> merchantStatMap = new ConcurrentHashMap<>();
            // 异常捕获容器
            AtomicReference<Exception> exceptionRef = new AtomicReference<>();

            // 4. 游标分页读取+线程池并行计算
            while (true) {
                // 4.1 主线程游标读取当前批次订单(避免多线程读库导致连接数爆炸)
                List<OrderDO> orderList = orderMapper.listByShardAndCursor(
                        shardIndex, shardTotal, lastOrderId, startDate, endDate, batchReadSize);
                if (CollectionUtils.isEmpty(orderList)) break;

                // 4.2 拆分小批次,提交到线程池并行计算
                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();
                        }
                    });
                }

                // 4.3 等待当前批次线程任务完成(超时1分钟)
                boolean awaitResult = latch.await(1, TimeUnit.MINUTES);
                if (!awaitResult) throw new TimeoutException("分片" + shardIndex + "批次处理超时");
                if (exceptionRef.get() != null) throw exceptionRef.get();

                // 4.4 更新游标,记录进度
                lastOrderId = orderList.get(orderList.size() - 1).getId();
                helper.log("分片{}已读取至order_id={},累计统计商户数={}",
                        shardIndex, lastOrderId, merchantStatMap.size());
            }

            // 5. 批量写入ClickHouse(高性能存储统计结果)
            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);
            }

            // 6. 标记任务成功
            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());
        }
    }

    /**
     * 拆分List为小批次,适配线程池处理
     */
    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 前置准备

  1. 压测验证:模拟千万级订单数据,验证分片数(建议100)、线程池参数(核心线程数=CPU*2)、读库QPS(≤1000)的最优配置;
  2. 灰度上线:先跑近7天历史数据,验证准确性和性能,无问题后全量上线;
  3. 资源隔离:统计执行器与业务微服务物理隔离,避免资源竞争。

4.2 性能优化方向

  1. 数据倾斜优化:监控分片数据量,对数据量过大的分片进一步拆分子分片;
  2. JVM优化:执行器JVM参数配置-Xms8G -Xmx8G -XX:+UseG1GC -XX:MaxGCPauseMillis=200,避免OOM和GC停顿过长;
  3. 预计算优化:对高频统计维度(如商户日订单量),可在业务写入时异步预计算,降低凌晨统计压力;
  4. 冷热数据分离:历史订单数据迁移至ClickHouse,MySQL仅保留近3个月数据,提升读取效率。

4.3 运维建议

  1. 定期巡检:每周检查分片数据倾斜、索引有效性、线程池状态、中间件集群健康度;
  2. 备份策略:MySQL从库每日0点全量备份,ClickHouse统计结果保留30天备份;
  3. 文档沉淀:梳理任务流程、告警规则、应急方案,便于运维交接。

五、方案核心优势

  1. 高性能:分布式分片+单机线程池双层并行,千万级订单统计耗时可控制在15-30分钟(传统单线程需60+分钟);
  2. 高可用:集群容灾+故障自动转移+重试机制,任务不丢不重,数据库/中间件故障不影响核心流程;
  3. 高准确:幂等控制+数据对账+线程安全计算,统计准确率100%;
  4. 易扩展:分片数/执行器节点可动态扩容,适配订单量从千万级到亿级的增长;
  5. 易落地:基于SpringCloud+XXL-Job成熟生态,代码复用性高,学习成本低。