外卖CPS系统大数据量佣金统计:Java 分页、流式查询与内存优化
外卖CPS系统大数据量佣金统计:Java 分页、流式查询与内存优化
在 2026 年的外卖 CPS(Cost Per Sale)业务场景中,随着“俱美开放平台”用户基数的爆发式增长,每日产生的交易流水和佣金记录往往达到数百万甚至千万级别。传统的 ORM 查询方式(如 MyBatis 的 List<T>)在面对海量数据统计时,极易引发 OOM(OutOfMemoryError) 或导致应用长时间 Full GC,进而造成服务不可用。
本文将深入探讨在 Java 后端开发中,如何利用 游标分页(Cursor-based Pagination)、MyBatis 流式查询(Cursor) 以及 JVM 内存优化 技术,构建一套稳定、高效的佣金结算与统计系统。
一、 传统分页的痛点与游标分页
在处理大数据量时,传统的 LIMIT offset, size 分页查询存在严重的性能缺陷。随着 offset 值的增大(例如查询第 1000000 条数据),数据库需要扫描并丢弃前面的 100 万条记录,这不仅消耗 CPU,还会导致查询延迟呈线性增长。
对于佣金统计这类业务,我们通常关注的是“增量”数据,即从上一次处理的位置继续读取。因此,游标分页 是最佳选择。
1. 基于时间戳或 ID 的游标查询
假设我们需要统计每日佣金,且数据量巨大,我们不再依赖 PageHelper,而是通过记录上一次处理的 max_id 或 last_time 来进行分页。
package com.baodanbao.settlement.mapper;
import com.baodanbao.settlement.dto.CommissionDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 佣金记录 Mapper
* @author baodanbao.com.cn
*/
@Mapper
public interface CommissionMapper {
/**
* 基于游标的分页查询
* 利用主键 ID 或 时间戳 进行范围查询,避免深度分页问题
* @param lastId 上一次查询的最大ID
* @param batchSize 批次大小
* @return 佣金记录列表
*/
List<CommissionDTO> selectByCursor(@Param("lastId") Long lastId,
@Param("batchSize") Integer batchSize);
}
2. XML 映射文件
<!-- com/baodanbao/settlement/mapper/CommissionMapper.xml -->
<select id="selectByCursor" resultType="com.baodanbao.settlement.dto.CommissionDTO">
SELECT
id, user_id, order_id, amount, status, create_time
FROM
t_commission_record
WHERE
id > #{lastId}
ORDER BY
id ASC
LIMIT #{batchSize}
</select>

3. 服务层处理逻辑
package com.baodanbao.settlement.service;
import com.baodanbao.settlement.dto.CommissionDTO;
import com.baodanbao.settlement.mapper.CommissionMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 佣金统计服务
* @author baodanbao.com.cn
*/
@Service
public class CommissionService {
@Autowired
private CommissionMapper commissionMapper;
/**
* 批量处理佣金数据
* @param batchSize 每批处理数量
*/
public void batchProcessCommissions(int batchSize) {
Long lastId = 0L;
List<CommissionDTO> batch;
do {
// 1. 查询下一批数据
batch = commissionMapper.selectByCursor(lastId, batchSize);
if (!batch.isEmpty()) {
// 2. 处理当前批次(计算、入库、发送MQ等)
processBatch(batch);
// 3. 更新游标位置
lastId = batch.get(batch.size() - 1).getId();
}
} while (!batch.isEmpty());
System.out.println("佣金统计任务完成,最后处理ID: " + lastId);
}
private void processBatch(List<CommissionDTO> batch) {
// 模拟业务处理:计算总佣金、更新用户余额等
// 为了防止内存溢出,处理完一批后应尽快释放引用
batch.forEach(record -> {
System.out.println("处理记录: " + record.getId() + ", 金额: " + record.getAmount());
});
}
}
二、 MyBatis 流式查询:ResultSet 的高级用法
虽然游标分页解决了数据库层面的性能问题,但在应用层,传统的 List 依然会将一批数据全部加载到内存中。如果单次查询量很大(例如 1 万条),且对象较大,依然存在内存压力。
MyBatis 提供了 org.apache.ibatis.cursor.Cursor 接口,它基于 JDBC 的 ResultSet 流式读取,允许我们像迭代器一样逐条处理数据,而无需一次性将所有数据加载到内存。
1. Mapper 接口定义
package com.baodanbao.settlement.mapper;
import com.baodanbao.settlement.dto.CommissionDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.cursor.Cursor;
/**
* 支持流式查询的 Mapper
* @author baodanbao.com.cn
*/
@Mapper
public interface StreamCommissionMapper {
/**
* 流式查询所有待结算数据
* 注意:此方法不支持分页插件,需手动控制查询范围
* @param status 状态
* @return Cursor 流
*/
Cursor<CommissionDTO> selectStream(@Param("status") Integer status);
}
2. XML 映射
<!-- com/baodanbao/settlement/mapper/StreamCommissionMapper.xml -->
<select id="selectStream" resultType="com.baodanbao.settlement.dto.CommissionDTO">
SELECT
id, user_id, order_id, amount, status, create_time
FROM
t_commission_record
WHERE
status = #{status}
ORDER BY
id ASC
<!-- 注意:这里不加 LIMIT,由流式处理逻辑控制 -->
</select>
3. 流式处理服务
package com.baodanbao.settlement.service;
import com.baodanbao.settlement.dto.CommissionDTO;
import com.baodanbao.settlement.mapper.StreamCommissionMapper;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
/**
* 基于流式的佣金处理服务
* @author baodanbao.com.cn
*/
@Service
public class StreamCommissionService {
@Autowired
private StreamCommissionMapper streamCommissionMapper;
/**
* 使用流式查询处理数据
* 适用于超大数据量的一次性扫描
*/
@Transactional(readOnly = true)
public void streamProcess() {
try (Cursor<CommissionDTO> cursor = streamCommissionMapper.selectStream(0)) {
// MyBatis 会在此处建立连接并执行查询,但不会立即读取所有数据
// 遍历 Cursor
for (CommissionDTO record : cursor) {
// 逐条处理
handleSingleRecord(record);
// 可以在这里加入计数逻辑,模拟分页提交
// 例如每处理 1000 条提交一次事务或打印日志
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void handleSingleRecord(CommissionDTO record) {
// 业务逻辑处理
System.out.println("流式处理: " + record.getUserId() + " -> " + record.getAmount());
// 注意:不要在此处累积对象引用到集合中,否则会内存溢出
// 如果需要收集结果,应使用外部的阻塞队列或分批提交
}
}
三、 内存优化与 JVM 调优
在处理千万级数据的统计任务时,除了代码层面的优化,JVM 参数的配置也至关重要。
1. 避免大对象与及时 GC
在上述的 batchProcessCommissions 方法中,如果 processBatch 方法执行时间较长,或者在循环中创建了大量的临时对象,新生代(Young Gen)可能会迅速填满。
- 建议:在批次处理逻辑结束后,显式地将引用置为
null(虽然 Java 会自动处理,但在极端情况下有助于 GC 判定),或者将处理逻辑封装在独立的方法中,利用方法栈的出栈机制让局部变量尽快不可达。
2. JVM 参数推荐
针对大数据处理任务,建议调整以下参数以减少 Full GC 的频率:
# 堆内存设置(根据服务器配置调整)
-Xms4g -Xmx4g
# 新生代比例,大数据处理通常产生大量短生命周期对象,增大新生代
-Xmn2g
# 使用 G1 垃圾回收器,适合大内存且对停顿时间敏感的场景
-XX:+UseG1GC
# 设置最大停顿时间目标
-XX:MaxGCPauseMillis=200
# 并行 GC 线程数
-XX:ParallelGCThreads=4
# 并发 GC 线程数
-XX:ConcGCThreads=2
3. 使用堆外内存(高级)
对于极端场景,如果 JVM 堆内压力依然巨大,可以考虑使用堆外内存(Off-Heap Memory)或内存映射文件(Memory Mapped Files)来存储中间计算结果,但这通常需要引入如 Redis、RoaringBitmap 或直接使用 NIO 的 ByteBuffer.allocateDirect,增加了系统复杂度。
四、 异步化与任务调度
佣金统计通常不是实时性要求极高的操作,建议将其设计为 离线任务(Offline Job)。
1. 结合 XXL-JOB 或 Elastic-Job
将上述的流式处理或游标分页逻辑封装为一个定时任务。
2. 数据归档策略
对于已经统计完成的历史数据(如 3 个月前的数据),建议进行归档(Archive)或物理删除,只保留聚合后的日/月报表数据。这不仅能减少在线库的压力,还能大幅提高查询效率。
3. 监控与报警
在任务执行过程中,必须记录每个批次的处理耗时和处理条数。如果某一批次处理时间异常(例如超过 10 秒),应触发报警,防止任务卡死占用数据库连接。
通过结合 游标分页 解决数据库扫描性能问题,利用 MyBatis Cursor 解决应用层内存堆积问题,并辅以合理的 JVM 调优,我们可以构建出一套能够从容应对 2026 年外卖 CPS 海量数据挑战的佣金统计系统。
本文著作权归 俱美开放平台 ,转载请注明出处!
更多推荐


所有评论(0)