标题: 报表还在手动生成?定时任务来救场!
副标题: 从数据聚合到缓存优化,打造高性能报表系统
🎬 开篇:一次报表生成的性能灾难
某电商平台月度报表:
老板:每天早上9点要看昨日销售报表
开发:好的(手动查询生成)
第一天:
- 查询订单表:100万条数据
- 聚合统计:CPU飙升到100%
- 生成时间:15分钟 💀
- 老板:太慢了!
优化后:
- 凌晨2点定时生成
- 增量聚合+缓存
- 生成时间:30秒 ⚡
- 老板:这才对嘛! 😊
再优化:
- 实时预聚合
- Redis缓存
- 查询时间:50ms 🚀
- 老板:完美! 🎉
教训:报表系统要提前生成,而不是实时查询!
🤔 什么是定时报表?
想象一下:
- 日报: 每天凌晨生成昨日销售数据
- 周报: 每周一生成上周运营数据
- 月报: 每月1号生成上月财务数据
- 年报: 每年1月生成上年总结报表
定时报表 = 定时任务 + 数据聚合 + 缓存优化 + 文件存储!
📚 知识地图
定时报表系统
├── 🎯 报表类型
│ ├── 日报(销售日报、运营日报)
│ ├── 周报(周销售统计)
│ ├── 月报(财务月报)
│ └── 年报(年度总结)
├── ⚡ 核心功能
│ ├── 定时生成
│ ├── 数据聚合
│ ├── 缓存优化
│ ├── 文件导出
│ └── 推送通知
├── 🏗️ 技术方案
│ ├── Spring Schedule ⭐⭐⭐
│ ├── Quartz ⭐⭐⭐⭐
│ ├── XXL-Job ⭐⭐⭐⭐⭐
│ └── ElasticJob ⭐⭐⭐⭐
└── 📊 性能优化
├── 增量聚合
├── 预计算
├── Redis缓存
├── 异步处理
└── 分库分表
💾 数据库设计
-- 报表定义表
CREATE TABLE report_definition (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(50) NOT NULL COMMENT '报表编码',
name VARCHAR(100) NOT NULL COMMENT '报表名称',
type TINYINT NOT NULL COMMENT '报表类型:1日报 2周报 3月报 4年报',
cron_expression VARCHAR(100) NOT NULL COMMENT 'Cron表达式',
data_source VARCHAR(50) COMMENT '数据源',
query_sql TEXT COMMENT '查询SQL',
template_path VARCHAR(200) COMMENT '模板路径',
export_format VARCHAR(20) NOT NULL COMMENT '导出格式:excel/pdf',
recipients VARCHAR(500) COMMENT '接收人(邮箱,逗号分隔)',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用 1启用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报表定义表';
-- 报表生成记录表
CREATE TABLE report_generate_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT NOT NULL COMMENT '报表ID',
report_code VARCHAR(50) NOT NULL COMMENT '报表编码',
report_name VARCHAR(100) NOT NULL COMMENT '报表名称',
report_date DATE NOT NULL COMMENT '报表日期',
file_path VARCHAR(500) COMMENT '文件路径',
file_size BIGINT COMMENT '文件大小(字节)',
status TINYINT NOT NULL COMMENT '状态:1生成中 2成功 3失败',
error_msg TEXT COMMENT '错误信息',
data_count INT COMMENT '数据条数',
cost_time INT COMMENT '耗时(秒)',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_report_code_date (report_code, report_date),
INDEX idx_status (status),
INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报表生成记录表';
-- 报表数据缓存表(可选,用于缓存聚合结果)
CREATE TABLE report_data_cache (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
report_code VARCHAR(50) NOT NULL COMMENT '报表编码',
cache_key VARCHAR(100) NOT NULL COMMENT '缓存键',
cache_data TEXT NOT NULL COMMENT '缓存数据(JSON)',
report_date DATE NOT NULL COMMENT '报表日期',
expire_time DATETIME NOT NULL COMMENT '过期时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_report_key (report_code, cache_key, report_date),
INDEX idx_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报表数据缓存表';
⚡ 方案1:Spring Schedule(简单场景)
/**
* 定时报表生成任务
*/
@Component
@Slf4j
public class ReportScheduleTask {
@Autowired
private ReportGenerateService reportGenerateService;
/**
* ⚡ 每天凌晨2点生成昨日销售日报
*/
@Scheduled(cron = "0 0 2 * * ?")
public void generateDailySalesReport() {
log.info("开始生成昨日销售日报");
try {
LocalDate yesterday = LocalDate.now().minusDays(1);
reportGenerateService.generateSalesReport("DAILY_SALES", yesterday);
log.info("昨日销售日报生成成功");
} catch (Exception e) {
log.error("昨日销售日报生成失败", e);
}
}
/**
* ⚡ 每周一早上3点生成上周周报
*/
@Scheduled(cron = "0 0 3 ? * MON")
public void generateWeeklySalesReport() {
log.info("开始生成上周销售周报");
try {
LocalDate lastMonday = LocalDate.now().minusWeeks(1).with(DayOfWeek.MONDAY);
reportGenerateService.generateSalesReport("WEEKLY_SALES", lastMonday);
log.info("上周销售周报生成成功");
} catch (Exception e) {
log.error("上周销售周报生成失败", e);
}
}
/**
* ⚡ 每月1号早上4点生成上月月报
*/
@Scheduled(cron = "0 0 4 1 * ?")
public void generateMonthlySalesReport() {
log.info("开始生成上月销售月报");
try {
LocalDate lastMonth = LocalDate.now().minusMonths(1).withDayOfMonth(1);
reportGenerateService.generateSalesReport("MONTHLY_SALES", lastMonth);
log.info("上月销售月报生成成功");
} catch (Exception e) {
log.error("上月销售月报生成失败", e);
}
}
}
/**
* 启用定时任务
*/
@Configuration
@EnableScheduling
public class ScheduleConfig {
// 配置
}
🎯 方案2:XXL-Job(推荐)
/**
* XXL-Job报表生成任务
*/
@Component
@Slf4j
public class ReportXxlJobHandler {
@Autowired
private ReportGenerateService reportGenerateService;
@Autowired
private ReportDefinitionMapper reportDefinitionMapper;
/**
* ⚡ 通用报表生成任务
*
* 任务参数示例:{"reportCode":"DAILY_SALES","reportDate":"2025-01-15"}
*/
@XxlJob("reportGenerateHandler")
public void reportGenerateHandler() throws Exception {
// 1. 获取任务参数
String param = XxlJobHelper.getJobParam();
log.info("开始生成报表:param={}", param);
if (StringUtils.isBlank(param)) {
XxlJobHelper.log("任务参数为空");
return;
}
// 2. 解析参数
JSONObject paramObj = JSON.parseObject(param);
String reportCode = paramObj.getString("reportCode");
String reportDateStr = paramObj.getString("reportDate");
// 3. 查询报表定义
ReportDefinition definition = reportDefinitionMapper.selectByCode(reportCode);
if (definition == null) {
XxlJobHelper.log("报表定义不存在:" + reportCode);
XxlJobHelper.handleFail("报表定义不存在");
return;
}
// 4. 解析报表日期
LocalDate reportDate = StringUtils.isNotBlank(reportDateStr)
? LocalDate.parse(reportDateStr)
: LocalDate.now().minusDays(1);
// 5. ⚡ 生成报表
try {
ReportGenerateResult result = reportGenerateService.generateReport(
definition, reportDate);
// 6. 记录日志
XxlJobHelper.log("报表生成成功:filePath={}, dataCount={}, costTime={}秒",
result.getFilePath(), result.getDataCount(), result.getCostTime());
XxlJobHelper.handleSuccess("报表生成成功");
} catch (Exception e) {
log.error("报表生成失败:reportCode={}", reportCode, e);
XxlJobHelper.log("报表生成失败:" + e.getMessage());
XxlJobHelper.handleFail(e.getMessage());
}
}
/**
* ⚡ 分片报表生成任务(大数据量)
*/
@XxlJob("reportGenerateShardingHandler")
public void reportGenerateShardingHandler() throws Exception {
// 1. 获取分片参数
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
log.info("分片报表生成:shardIndex={}, shardTotal={}", shardIndex, shardTotal);
// 2. 获取任务参数
String param = XxlJobHelper.getJobParam();
JSONObject paramObj = JSON.parseObject(param);
String reportCode = paramObj.getString("reportCode");
// 3. ⚡ 分片查询数据
List<ReportData> data = reportGenerateService.queryDataBySharding(
reportCode, shardIndex, shardTotal);
// 4. 处理数据
reportGenerateService.processShardingData(reportCode, shardIndex, data);
XxlJobHelper.log("分片{}处理完成,数据量:{}", shardIndex, data.size());
XxlJobHelper.handleSuccess();
}
}
📊 核心服务实现
/**
* 报表生成服务
*/
@Service
@Slf4j
public class ReportGenerateService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private ReportGenerateLogMapper reportLogMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private FileStorageService fileStorageService;
@Autowired
private EmailService emailService;
/**
* ⚡ 生成报表
*/
public ReportGenerateResult generateReport(ReportDefinition definition,
LocalDate reportDate) {
long startTime = System.currentTimeMillis();
// 1. 创建生成记录
ReportGenerateLog log = createGenerateLog(definition, reportDate);
try {
// 2. ⚡ 查询并聚合数据
List<ReportData> data = queryAndAggregateData(definition, reportDate);
// 3. ⚡ 生成文件
String filePath = generateFile(definition, data, reportDate);
// 4. 上传文件
String fileUrl = fileStorageService.upload(new File(filePath));
// 5. 更新生成记录
log.setStatus(2); // 成功
log.setFilePath(fileUrl);
log.setDataCount(data.size());
log.setCostTime((int) ((System.currentTimeMillis() - startTime) / 1000));
reportLogMapper.updateById(log);
// 6. ⚡ 缓存报表数据(避免重复生成)
cacheReportData(definition.getCode(), reportDate, data);
// 7. 发送通知
sendNotification(definition, fileUrl, reportDate);
log.info("报表生成成功:code={}, date={}, costTime={}秒",
definition.getCode(), reportDate, log.getCostTime());
// 8. 返回结果
ReportGenerateResult result = new ReportGenerateResult();
result.setSuccess(true);
result.setFilePath(fileUrl);
result.setDataCount(data.size());
result.setCostTime(log.getCostTime());
return result;
} catch (Exception e) {
log.error("报表生成失败:code={}, date={}",
definition.getCode(), reportDate, e);
// 更新失败记录
log.setStatus(3); // 失败
log.setErrorMsg(e.getMessage());
log.setCostTime((int) ((System.currentTimeMillis() - startTime) / 1000));
reportLogMapper.updateById(log);
throw new RuntimeException("报表生成失败", e);
}
}
/**
* ⚡ 查询并聚合数据
*/
private List<ReportData> queryAndAggregateData(ReportDefinition definition,
LocalDate reportDate) {
// 先查缓存
List<ReportData> cachedData = getCachedReportData(definition.getCode(), reportDate);
if (cachedData != null) {
log.info("使用缓存数据:code={}, date={}", definition.getCode(), reportDate);
return cachedData;
}
// 根据报表类型查询数据
switch (definition.getType()) {
case 1: // 日报
return queryDailyData(reportDate);
case 2: // 周报
return queryWeeklyData(reportDate);
case 3: // 月报
return queryMonthlyData(reportDate);
default:
throw new IllegalArgumentException("不支持的报表类型");
}
}
/**
* 查询日报数据
*/
private List<ReportData> queryDailyData(LocalDate reportDate) {
// ⚡ 查询订单数据
LocalDateTime startTime = reportDate.atStartOfDay();
LocalDateTime endTime = reportDate.plusDays(1).atStartOfDay();
// 使用SQL聚合(性能更好)
List<DailySalesReport> salesData = orderMapper.aggregateDailySales(
startTime, endTime);
return salesData.stream()
.map(this::convertToReportData)
.collect(Collectors.toList());
}
/**
* 查询周报数据
*/
private List<ReportData> queryWeeklyData(LocalDate reportDate) {
// 获取周的起止时间
LocalDate monday = reportDate.with(DayOfWeek.MONDAY);
LocalDate sunday = reportDate.with(DayOfWeek.SUNDAY);
LocalDateTime startTime = monday.atStartOfDay();
LocalDateTime endTime = sunday.plusDays(1).atStartOfDay();
List<WeeklySalesReport> salesData = orderMapper.aggregateWeeklySales(
startTime, endTime);
return salesData.stream()
.map(this::convertToReportData)
.collect(Collectors.toList());
}
/**
* 查询月报数据
*/
private List<ReportData> queryMonthlyData(LocalDate reportDate) {
// 获取月的起止时间
LocalDate firstDay = reportDate.withDayOfMonth(1);
LocalDate lastDay = reportDate.withDayOfMonth(reportDate.lengthOfMonth());
LocalDateTime startTime = firstDay.atStartOfDay();
LocalDateTime endTime = lastDay.plusDays(1).atStartOfDay();
List<MonthlySalesReport> salesData = orderMapper.aggregateMonthlySales(
startTime, endTime);
return salesData.stream()
.map(this::convertToReportData)
.collect(Collectors.toList());
}
/**
* ⚡ 生成文件(Excel)
*/
private String generateFile(ReportDefinition definition,
List<ReportData> data,
LocalDate reportDate) throws IOException {
// 文件名
String fileName = String.format("%s_%s.xlsx",
definition.getCode(),
reportDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")));
// 临时文件
String tempDir = System.getProperty("java.io.tmpdir");
String filePath = tempDir + File.separator + fileName;
// ⚡ 使用EasyExcel生成
EasyExcel.write(filePath, ReportData.class)
.sheet(definition.getName())
.doWrite(data);
log.info("报表文件生成成功:filePath={}", filePath);
return filePath;
}
/**
* ⚡ 缓存报表数据
*/
private void cacheReportData(String reportCode, LocalDate reportDate,
List<ReportData> data) {
String key = String.format("report:data:%s:%s",
reportCode, reportDate.toString());
// 缓存1天
redisTemplate.opsForValue().set(key, JSON.toJSONString(data),
1, TimeUnit.DAYS);
}
/**
* 获取缓存的报表数据
*/
private List<ReportData> getCachedReportData(String reportCode, LocalDate reportDate) {
String key = String.format("report:data:%s:%s",
reportCode, reportDate.toString());
String cached = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(cached)) {
return JSON.parseArray(cached, ReportData.class);
}
return null;
}
/**
* 发送通知
*/
private void sendNotification(ReportDefinition definition,
String fileUrl,
LocalDate reportDate) {
if (StringUtils.isBlank(definition.getRecipients())) {
return;
}
String[] emails = definition.getRecipients().split(",");
String subject = String.format("%s(%s)",
definition.getName(),
reportDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
String content = String.format("报表已生成完成,请点击下载:%s", fileUrl);
// 发送邮件
emailService.sendEmail(emails, subject, content);
}
/**
* 创建生成记录
*/
private ReportGenerateLog createGenerateLog(ReportDefinition definition,
LocalDate reportDate) {
ReportGenerateLog log = new ReportGenerateLog();
log.setReportId(definition.getId());
log.setReportCode(definition.getCode());
log.setReportName(definition.getName());
log.setReportDate(reportDate);
log.setStatus(1); // 生成中
reportLogMapper.insert(log);
return log;
}
private ReportData convertToReportData(Object data) {
// 转换逻辑
return new ReportData();
}
}
/**
* Mapper:SQL聚合
*/
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
/**
* ⚡ 聚合日销售数据
*/
@Select("SELECT " +
" DATE_FORMAT(create_time, '%Y-%m-%d') AS date, " +
" COUNT(*) AS order_count, " +
" SUM(total_amount) AS total_amount, " +
" AVG(total_amount) AS avg_amount " +
"FROM `order` " +
"WHERE create_time >= #{startTime} AND create_time < #{endTime} " +
" AND status IN (2, 3, 4) " + // 已支付、已发货、已完成
"GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')")
List<DailySalesReport> aggregateDailySales(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* ⚡ 聚合周销售数据
*/
@Select("SELECT " +
" YEARWEEK(create_time) AS week, " +
" COUNT(*) AS order_count, " +
" SUM(total_amount) AS total_amount " +
"FROM `order` " +
"WHERE create_time >= #{startTime} AND create_time < #{endTime} " +
" AND status IN (2, 3, 4) " +
"GROUP BY YEARWEEK(create_time)")
List<WeeklySalesReport> aggregateWeeklySales(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* ⚡ 聚合月销售数据
*/
@Select("SELECT " +
" DATE_FORMAT(create_time, '%Y-%m') AS month, " +
" COUNT(*) AS order_count, " +
" SUM(total_amount) AS total_amount " +
"FROM `order` " +
"WHERE create_time >= #{startTime} AND create_time < #{endTime} " +
" AND status IN (2, 3, 4) " +
"GROUP BY DATE_FORMAT(create_time, '%Y-%m')")
List<MonthlySalesReport> aggregateMonthlySales(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
}
⚡ 性能优化
1. 增量聚合
/**
* 增量聚合优化
*/
@Service
public class IncrementalAggregationService {
/**
* ⚡ 增量聚合(只统计新增数据)
*/
public void incrementalAggregate(LocalDate reportDate) {
// 1. 获取上次聚合时间
String lastAggregateTime = getLastAggregateTime(reportDate);
// 2. 查询增量数据
List<Order> incrementalOrders = orderMapper.selectByTimeRange(
LocalDateTime.parse(lastAggregateTime),
LocalDateTime.now());
// 3. 聚合增量数据
Map<String, BigDecimal> incrementalStats = incrementalOrders.stream()
.collect(Collectors.groupingBy(
order -> order.getCreateTime().toLocalDate().toString(),
Collectors.reducing(BigDecimal.ZERO,
Order::getTotalAmount,
BigDecimal::add)));
// 4. 更新聚合结果
updateAggregateResult(reportDate, incrementalStats);
// 5. 更新最后聚合时间
updateLastAggregateTime(reportDate, LocalDateTime.now());
}
}
2. 预计算
/**
* 预计算优化
*/
@Service
public class PreCalculationService {
/**
* ⚡ 实时预聚合(每小时执行一次)
*/
@Scheduled(cron = "0 0 * * * ?")
public void preAggregate() {
LocalDate today = LocalDate.now();
// 聚合当天数据
List<ReportData> data = aggregateTodayData();
// 缓存到Redis
String key = "report:pre:today";
redisTemplate.opsForValue().set(key, JSON.toJSONString(data),
1, TimeUnit.DAYS);
}
}
✅ 最佳实践
定时报表系统最佳实践:
1️⃣ 定时任务选型:
□ 简单场景:Spring Schedule
□ 分布式场景:XXL-Job(推荐)⭐⭐⭐⭐⭐
□ 复杂场景:Quartz
2️⃣ 性能优化:
□ SQL聚合(在数据库层聚合)
□ 增量聚合(只统计新增数据)
□ 预计算(实时预聚合)
□ 缓存结果(避免重复生成)
3️⃣ 数据存储:
□ 报表数据:MySQL
□ 缓存:Redis
□ 文件:OSS/MinIO
4️⃣ 用户体验:
□ 定时生成(凌晨执行)
□ 邮件通知
□ 在线查看
□ 一键下载
5️⃣ 监控告警:
□ 生成失败告警
□ 生成耗时监控
□ 数据异常检测
□ 定时任务监控
🎉 总结
定时报表系统核心:
1️⃣ 定时生成:凌晨执行,不影响业务
2️⃣ 数据聚合:SQL聚合,性能最优
3️⃣ 缓存优化:Redis缓存,秒级响应
4️⃣ 增量处理:只统计新增,节省资源
5️⃣ 文件存储:OSS存储,永久保存
记住:报表要提前生成,而不是实时查询! 📈
文档编写时间:2025年10月24日
作者:热爱数据分析的报表工程师
版本:v1.0
愿每份报表都准确及时! ✨