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次
内存占用 可控 可控 可控
适用场景 有时间字段且分布均匀 无时间字段或倾斜严重 有自增主键
我们项目的最终选择:按时间分批,因为:
- 业务上天然要按天聚合
- 代码简单,易维护
- 可以在分批过程中加监控(每天处理完记录日志)
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亿行。一次性加载全表的代码,换一个数据量就可能崩。
如果你的项目也需要处理大数据量,希望这篇文章能帮你少踩一些坑。
下一篇我会写:《风控平台的"指标血缘"怎么设计?从一次事故说起》
欢迎关注,不走丢。