背景痛点:报告生成为何总“卡死”?
智能客服每天产生海量对话记录,运营同学想看一份“昨日热点问题分布”报告,结果系统一跑查询就飙红:
- 高并发:早高峰 10 k QPS,一条慢 SQL 就把数据库打挂
- 大数据量:30 天日志 5 亿条,聚合一次 3 GB 内存起步
- 实时性:老板希望“5 分钟内看到结果”,同步跑脚本直接超时
早期我们直接把 SQL 丢进报表引擎,结果平均响应 28 s,失败率 18 %,客服主管直接在群里发“”。痛定思痛,决定把“报告生成”拆成独立战场,用异步+微服务重新设计。
技术选型:同步 vs 异步,为什么选后者?
同步方案
- 优点:代码直来直去,一次 HTTP 返回文件流
- 缺点:接口超时风险高,数据库连接被长时间占用,无法水平扩展
异步方案
- 优点:请求立即返回“任务编号”,后台慢慢跑;可横向加 worker;失败可重试
- 缺点:链路变长,需要消息队列、缓存、状态机,开发量 +30 %
我们评估了吞吐量与业务容忍度:运营可以接受“分钟级”延迟,但绝不能接受“系统挂掉”。于是拍板:Spring Boot + RabbitMQ 做任务队列,Redis 做缓存与分布式锁,MySQL 只存“最终结果”。一句话——用最终一致性换可用性。
核心实现:代码说话最管用
1. 整体架构
前端点击[生成报告] ↓ ReportGateway(返回任务ID) ↓ 投递 RabbitMQ(topic: report.generate) ↓ 竞争消费 ReportWorker(多实例) ↓ 写MySQL & OSS 前端轮询 /report/status/{taskId}2. 关键代码片段
以下均基于 Spring Boot 2.7,RabbitMQ 3.11,JDK 17。
(1) 发送任务——确保幂等
@Service public class ReportTaskPublisher { @Autowired private RabbitTemplate rabbit; // 使用业务编号做唯一键,防止用户重复点击 public String submitTask(ReportRequest req) { String taskId = DigestUtils.md5DigestAsHex( (req.getTenantId() + req.getReportType() + req.getQueryDate()).getBytes()); req.setTaskId(taskId); // 1. 先写缓存,利用 SET NX 防重 Boolean absent = RedisUtils.setIfAbsent("lock:report:" + taskId, "1", Duration.ofMinutes(5)); if (Boolean.FALSE.equals(absent)) { throw new BizException("任务已提交,请勿重复点击"); } // 2. 发送队列 rabbit.convertAndSend("report.generate", req, m -> { m.getMessageProperties().setMessageId(taskId); // 消息ID用于去重 return m; }); return taskId; } }(2) 消费端——背压 + 批量聚合
@RabbitListener(queues = "report.generate", concurrency = "3-6") // 动态扩容 public class ReportWorker { @Autowired private ReportBuilder builder; @RabbitHandler public void handle(ReportRequest req, Channel channel, @Header(name = "deliveryTag") long tag) throws IOException { try { // 1. 分布式锁二次校验,防止 MQ 重试 try (RedisLock lock = RedisUtils.acquire("report:lock:" + req.getTaskId(), Duration.ofSeconds(30))) { if (lock == null) { // 没抢到锁,直接 ack,下次重试 channel.basicNack(tag, false, false); return; } // 2. 真正干活 builder.build(req); } // 3. 手动 ack,确保消息不丢失 channel.basicAck(tag, false); } catch (Exception e) { log.error("build report error, taskId={}", req.getTaskId(), e); // 失败进入重试队列,最多 3 次 channel.basicNack(tag, false, RedisUtils.incr("report:retry:" + req.getTaskId()) <= 3); } } }(3) 数据聚合——30 天 5 亿行怎么扛?
- 预聚合:每日凌晨跑批,把会话维度指标落到stats_daily表,数据量从 5 亿→500 万
- 缓存热点:查询结果按tenantId + date做 key,存 Redis Hash,过期 4 h
- 大文件走 OSS:>10 MB 的 Excel 直接上传阿里云 OSS,MySQL 只保留 URL
public class ReportBuilder { public void build(ReportRequest req) { // 1. 读缓存 String cacheKey = "report:cache:" + req.getTaskId(); byte[] cached = RedisUtils.getBytes(cacheKey); if (cached != null) { uploadAndFinish(req.getTaskId(), cached); return; } // 2. 聚合 List<StatsDaily> list = statsDailyDao.query(req.getTenantId(), req.getStartDate(), req.getEndDate()); Map<String, Long> agg = list.parallelStream() .collect(Collectors.groupingBy(StatsDaily::getCategory, Collectors.summingLong(StatsDaily::getCount))); // 3. 渲染 byte[] excel = ExcelRender.toBytes(agg); // 4. 写缓存 & OSS RedisUtils.set(cacheKey, excel, Duration.ofHours(4)); uploadAndFinish(req.getTaskId(), excel); } }3. 最终一致性保障
- 任务状态机:Pending → Processing → Success/Failed,状态落库
- 用户轮询拿到 Success 再去 OSS 下载,保证文件已上传完成
性能优化:把 28 s 干到 3 s
批量处理
把“小时级报告”按 500 个会话一批捞数据,一次 SQL 聚合,网络往返从 2 000 次降到 4 次,吞吐量 +5 倍内存管理
- 使用
SXSSFWorkbook写 Excel,窗口 100 行,内存峰值从 2.4 GB 降到 280 MB - 聚合完立即
list.clear(),帮助 GC 提前回收
- 使用
分布式锁粒度
早期按“日期”加锁,锁竞争严重;细化到tenantId+date+type后,锁冲突下降 90 %背压与限流
给 RabbitMQ 消费端配置prefetch = 16,防止一次性把 1 GB 消息全部拉到 JVM,造成 Full GC
避坑指南:掉进去一次就长记性
事务边界
聚合→写缓存→写 OSS 三步跨了 MySQL、Redis、OSS,不能放在一个本地事务。我们采用“补偿 + 对账”:- 成功后在 MySQL 写一条“完成”记录;
- 定时任务扫描 Pending >15 min 的任务,重新投递;
- OSS 文件使用
taskId作为文件名,重复上传直接覆盖,保证幂等
失败重试
RabbitMQ 默认重试是立即 requeue,容易“消息风暴”。我们改为:- 消费失败进入延迟队列(TTL=2^n s),指数退避;
- 最多 3 次后进入 DLQ(死信),人工干预
监控报警
- Prometheus 埋点:任务数、耗时、失败次数、Full GC
- Grafana 看板:>5 % 失败率或 P99 耗时 >10 s 就飞书机器人告警
- 链路追踪:在 MDC 里写入
taskId,Zipkin 可查端到端耗时
总结与延伸
这套异步微服务方案上线后,报告生成平均耗时从 28 s 降到 3.2 s,系统可支持 20 k 并发请求,CPU 峰值只到 35 %。核心思路就是“把慢操作赶出关键路径,用最终一致性换可用性”。
下一步还能怎么玩?
- 如果业务对实时性再敏感一点,是否考虑 Flink 流式聚合,把“预聚合”改成“秒级窗口”?
- 当租户数量翻倍,Redis 缓存淘汰策略该用 allkeys-lfu 还是带 TTL 的 LRU?
- 报告模板越来越花哨,要不要把 Excel 渲染拆成独立 Pod,用横向扩容解决 CPU 密集问题?
欢迎留言聊聊你们的客服系统是怎么生成报告的,一起交流更多“踩坑”日常。