风控指标平台实战:大数据量下如何设计分批处理

01 问题回顾:为什么需要分批处理?

上一篇文章里提到过一个线上事故:

某银行风控指标平台的一个计算任务,需要从用户行为流水表中,按用户ID分组,计算过去90天内每个用户的"贷款申请次数"指标。

测试环境里这张表只有100万行数据,代码跑得飞快。

但生产环境里,这张表有2.7亿行。

代码里用的方式是:一次性把整个表的数据加载到内存里,再按用户ID分组聚合。

2.7亿行 × 每行几百字节 ≈ 几十个G的数据。

物理内存只有16G,结果就是OOM,任务卡死,风控评分全挂。

这次事故之后,我做的第一个改进就是:把一次性加载改成按批次处理。

今天这篇文章,就把分批处理的设计思路、代码实现、踩过的坑完整总结一下。

02 分批处理的三种常见方案

在Java后端做大数据量处理,分批处理通常有三种方案:

方案 原理 适用场景 优缺点

按时间分批 按日期/小时拆分 数据有时间字段,且分布均匀 ✅ 简单直观

❌ 数据倾斜时某些批次很大

按ID取模分批 对用户ID取模(mod) 没有时间字段,或需要均匀拆分 ✅ 分布均匀

❌ 需要扫全表多次

游标/分页分批 每次取N条,记录上次位置 通用场景 ✅ 实现简单

❌ 深分页有性能问题

我们项目最终采用的是方案一:按时间分批,因为用户行为流水表有明确的时间字段(create_time),且业务上按天统计天然合理。

03 方案一:按时间分批(我们最终的选择)

核心思路:

把数据按日期拆分成多个小批次,每次只处理一天的数据,处理完再处理下一天。

代码示例:

java 复制代码
@Service
public class FeatureBatchService {
    
    @Autowired
    private UserBehaviorMapper behaviorMapper;
    
    @Autowired
    private FeatureCalculator calculator;
    
    /**
     * 按天分批计算指标
     * @param startDate 开始日期
     * @param endDate 结束日期
     */
    public void batchCalculateByDate(LocalDate startDate, LocalDate endDate) {
        LocalDate current = startDate;
        
        while (!current.isAfter(endDate)) {
            log.info("开始处理日期:{}", current);
            
            // 1. 查询当天的数据
            List<UserBehavior> behaviors = behaviorMapper.selectByDate(current);
            
            if (CollectionUtils.isEmpty(behaviors)) {
                log.warn("日期{}无数据,跳过", current);
                current = current.plusDays(1);
                continue;
            }
            
            // 2. 计算当天的指标
            Map<String, Integer> dailyResult = calculator.calculate(behaviors);
            
            // 3. 合并到总结果(这里用Redis或临时表存储)
            mergeToTotalResult(current, dailyResult);
            
            // 4. 手动GC提示(可选)
            System.gc();
            
            log.info("日期{}处理完成,数据量:{}", current, behaviors.size());
            current = current.plusDays(1);
        }
        
        // 5. 所有批次处理完后,输出最终结果
        outputFinalResult();
    }
}

关键点

每次只处理一天的数据,内存占用可控

每天的数据量通常不会超过几百万行(在银行场景下)

处理完一天后可以手动GC,释放内存

实际效果:

优化前:一次性加载2.7亿行 → OOM

优化后:按天分批,每天约50-100万行 → 稳定运行30分钟

04 方案二:按ID取模分批(备选方案)

如果你的数据没有时间字段,或者时间字段分布不均匀(比如某一天数据量特别大),可以考虑按ID取模分批。

核心思路:

对用户ID进行取模运算,分成N个批次,每次只处理模值等于某个数的数据。

代码示例:

java 复制代码
@Service
public class FeatureModBatchService {
    
    @Autowired
    private UserBehaviorMapper behaviorMapper;
    
    /**
     * 按ID取模分批计算
     * @param modValue 取模值(0到mod-1)
     * @param mod 模数(分批数)
     */
    public void batchCalculateByMod(int modValue, int mod) {
        log.info("开始处理模值:{}/{}", modValue, mod);
        
        // 1. 分批查询
        int offset = 0;
        int batchSize = 50000;
        
        while (true) {
            List<UserBehavior> behaviors = behaviorMapper.selectByMod(
                modValue, mod, offset, batchSize
            );
            
            if (CollectionUtils.isEmpty(behaviors)) {
                break;
            }
            
            // 2. 计算当前批次
            calculator.calculate(behaviors);
            
            // 3. 更新offset
            offset += batchSize;
            log.info("已处理offset:{},当前批次量:{}", offset, behaviors.size());
        }
        
        log.info("模值{}/{}处理完成", modValue, mod);
    }
}

对应的SQL:

sql 复制代码
SELECT * FROM user_behavior 
WHERE MOD(user_id, #{mod}) = #{modValue}
LIMIT #{offset}, #{batchSize}

优缺点:

✅ 分布均匀,不会出现某批次数据量过大

❌ 需要扫描全表多次(模数是多少次就要扫多少次)

❌ 深分页(offset越来越大)性能会下降

05 方案三:游标/分页分批(最简单)

如果你的表有自增主键,可以用游标方式分批。

代码示例:

java 复制代码
@Service
public class FeatureCursorBatchService {
    
    @Autowired
    private UserBehaviorMapper behaviorMapper;
    
    public void batchCalculateByCursor() {
        Long lastId = 0L;
        int batchSize = 100000;
        
        while (true) {
            List<UserBehavior> behaviors = behaviorMapper.selectByCursor(lastId, batchSize);
            
            if (CollectionUtils.isEmpty(behaviors)) {
                break;
            }
            
            // 计算当前批次
            calculator.calculate(behaviors);
            
            // 更新游标
            lastId = behaviors.get(behaviors.size() - 1).getId();
            log.info("已处理到ID:{},本批次量:{}", lastId, behaviors.size());
        }
    }
}

对应的SQL:

sql 复制代码
SELECT * FROM user_behavior 
WHERE id > #{lastId} 
ORDER BY id ASC 
LIMIT #{batchSize}

优缺点:

✅ 实现最简单

✅ 利用主键索引,性能好

❌ 需要表有自增主键且连续(或索引可用)

06 三种方案对比总结

对比维度 按时间分批 按ID取模分批 游标分批

实现复杂度 低 中 最低

是否需要特殊字段 时间字段 无 自增主键

数据均匀性 可能倾斜 均匀 均匀

扫描全表次数 1次 N次(模数) 1次

内存占用 可控 可控 可控

适用场景 有时间字段且分布均匀 无时间字段或倾斜严重 有自增主键

我们项目的最终选择:按时间分批,因为:

  1. 业务上天然要按天聚合
  2. 代码简单,易维护
  3. 可以在分批过程中加监控(每天处理完记录日志)

07 分批处理还需要注意什么?

除了分批策略本身,还有几个坑值得注意:

坑1:批次大小怎么定?

不是越小越好,也不是越大越好。

  • 太小:批次数量多,总耗时长(每批都有查询开销)
  • 太大:内存压力大

我们项目最终定的:每批10-50万行(根据单行数据大小调整)

坑2:事务怎么处理?

如果整个任务需要在一个事务里完成,分批处理会导致事务时间过长。

解决方案:

  • 如果允许部分失败,每批单独提交事务
  • 如果需要强一致性,考虑用TCC或分布式事务

坑3:中断恢复怎么办?

如果任务跑到第10天时挂了,重新跑要从头开始吗?

解决方案:

  • 记录每批的处理状态(Redis或任务表)
  • 重启时跳过已完成的批次
sql 复制代码
// 示例:记录处理进度
CREATE TABLE batch_progress (
    batch_key VARCHAR(64) PRIMARY KEY,
    processed_date DATE,
    status VARCHAR(16),
    create_time DATETIME
);

08 写在最后

分批处理看起来是一个很简单的思路,但真正落地时需要考虑数据分布、事务边界、中断恢复等多个问题。

我们从OOM事故中学到的最重要的东西是:

处理大数据量时,永远不要假设"这次数据不大"。

测试环境的100万行,到生产环境可能是2.7亿行。一次性加载全表的代码,换一个数据量就可能崩。

如果你的项目也需要处理大数据量,希望这篇文章能帮你少踩一些坑。

下一篇我会写:《风控平台的"指标血缘"怎么设计?从一次事故说起》

欢迎关注,不走丢。

相关推荐
2301_782040451 小时前
JavaScript中丢失的this:回调函数中指向改变的对策
jvm·数据库·python
2301_818008441 小时前
MySQL从库出现数据同步异常中断_重新获取binlog坐标同步
jvm·数据库·python
四维迁跃2 小时前
MySQL如何优雅处理数据库连接池耗尽_HikariCP与连接数调优
jvm·数据库·python
ch.ju2 小时前
Java programming(The third edition) Chapter Two——Null return value
java·开发语言
X56612 小时前
Go语言如何做Helm Chart_Go语言Helm打包部署教程【收藏】
jvm·数据库·python
szccyw02 小时前
如何阻止 HTML 页面在 JavaScript 执行完成前渲染
jvm·数据库·python
1.14(java)2 小时前
Spring事务和事务传播机制
java·数据库·spring
forEverPlume2 小时前
Go语言怎么做链路追踪_Go语言分布式链路追踪教程【精选】
jvm·数据库·python
折哥的程序人生 · 物流技术专研2 小时前
第3篇:为何要配置环境变量?
java·开发语言·后端·面试