Saas系统中,怎样设计一个通用报表导出中心

76 阅读10分钟

欢迎关注我的公众号:Java开发者笔记

扫码_搜索联合传播样式-白色版.png

概要

在Saas系统中,报表导出是一个很常见的业务,例如:ERP的库存报表,财务软件的资产负债表,千牛商家后台的月度营销报表。

上述业务虽然简单,但是涉及到一个复杂的业务问题;以下内容出自去年遇到的一个真实场景,经过我对导出的系统改造,降低了线上的事故发生率,希望这篇文章对你有所帮助;

业务背景

在我们的财务软件中,每到季度末或者年末,需要导出财务报表线下手动核算对比本年利润和资产负债用于税务局报税,尤其是在下午两点到四点之间,导出报表的高峰期,QPS经常会飙升,最高一次的QPS到1500+;

随之而来的就是无休止的钉钉告警,运维团队对接口紧急限流,客户工单的猛烈打击过来询问,为什么系统这么卡,我的报表数据就几百条都这么卡,你们系统真难用!!!xxx,退钱!!!

在拿到这个问题后,首先我们对系统接口进行了分析,QPS这么高,那多半是后台的查询卡住了,然后去定位具体的慢查询SQL。

在SQL优化后,线上的问题降低了一些,但时不时的还是会出现接口查询卡顿的问题,并且这个卡顿也不是大数据量用户的卡顿,小用户依旧会卡顿,再仔细分析,最终定位到问题,是同步导出带来的服务器卡顿问题;

技术思考

被pass的方案

当我们定位到是导出的问题之后,马上就想到了一种临时解决方案:加缓存!

但是听到加缓存,其实并不是Redis的缓存,而是一种以空间换时间的MySQL缓存,将数据查询的结果集缓存到一张表中,后续的导出直接去查询这张表,不需要占用接口的查询。

但是这样会带来另外的问题:缓存的数据他是需要更新的,并且你的数据也不能一直存着。如果你采用Redis的缓存,那这样很明显有点大材小用了,Redis内存那么贵,肯定不能用来存报表这样只看一眼的数据。MySQL的缓存你又得写一个定时器来做定时清除。

但是定时清除同样也会导致一种问题,那就是缓存雪崩的问题(不仅仅是Redis的维度,只要是缓存的概念,都存在)那么这个缓存加和不加没什么区别,并不能完全的解决问题;

还有一个问题,缓存的数据更新,因为报表的数据来源于多个层面,例如你的商品发生改变,销量,采购数量都发生了改变,这时候你得在每一个地方都去做埋点,然后更新数据,这样对代码的侵入性实在是太强。

经过以上的考虑,缓存这个方案直接就被pass掉。

另一种解决办法

既然缓存没法解决,那么就从同步上入手。

首先,从前端体验感上来说,让用户一直看着页面转圈圈导出报表就是一种极其xx的设计。等待转圈圈什么也做不了,最终导致的就是用户的流失;假如你的接口超过了http的响应时间,最终你也没办法给用户呈现导出的实际报表数据;

改异步?

异步也有两种方案:

  • 进度条轮询方式做异步:前端点击查询后,后端收到一个导出请求,返回给前端一个状态码,然后开线程执行导出业务,前端一直轮询查询进度接口,伪代码如下:
@PostMapping("asyncExport")
public Result asyncExport(@RequestBody ExportRequest request){
    // 检查乐观锁,当前用户数据只允许一个职员导出
    int status = mapper.updateExportVersion(request);
    if (status = 0) {
        return Result.fail("账套内已有导出任务,不允许重复执行")
    }
    
    // 生成唯一key 用来读取进度
    Stringn progressId = UidUtil.createUid();
    request.setProgressId(progressId);
    new Thread(){
        exportService.export(request);
    }.start();
    return Result.ok(progressId);
}

上述业务代码,将同步的导出变成异步的导出,同时前端通过返回的progressId轮询查询导出进度,就可避免同步导出带来的问题(乐观锁请根据自行业务需要添加)。

  • 引入中间件,例如RocketMQ,导出任务先发送到任务记录表,只负责记录,然后返回结果,后续将记录表的数据读取出来交给MQ,由MQ负责执行导出业务;
@PostMapping("asyncExport")
public Result asyncExport(@RequestBody ExportRequest request){
    // 检查是否存在导出任务
    int status = mapper.checkExistsExportTask();
    if (status = 1) return Result.fail("任务已存在,请在导出记录面板下载报表") 
    TaskProgress tp = TaskProgress.Builder.builder()
                                    .id(UidUtil.createUid())
                                    .status(0)
                                    .reportContext(JsonUtil.toJson(request))
                                    .build();
    // 新增导出任务
    mapper.insertExportTask(tp);
    
    // 导出任务给mq,后续由mq执行
    SysMQ.send(tp);
    
    return Result.ok("导出任务创建成功,请稍后在导出面板下载报表");
}

中间件的选型并不是一定不变的,例如我认为这里使用RabbitMQ比RocketMQ更合适,可以用来做多版本路由和多服务整合;选型使用RocketMQ只是因为公司有自建MQ和阿里云商业版,无需重复造轮子了;

回归正题,上述两种异步方案哪一种合适?

首先,理解需求;我们需要做的是一个统一的导出中心,比如系统中的商品服务,财务服务,经营服务,订单服务都有导出需求,那么如果每个报表的导出都自己开线程去导出,QPS的问题就转变成了一个线程池溢出的问题;

假如你的报表只有一两个需要做导出,那么建议你还是用线程池+前端状态轮询解决问题

技术实现

明白了上述的需求后,就有了如下的设计:

image.png

图虽然画的比较歪,但实际逻辑很好理解:

  1. 前端所有导出请求后端的接口都请求导出中心:axios.post("export/createTask",params)
  2. 导出中心收到导出请求,生成一个Message,写入export_task表
  3. 同时导出中心提供抽象基类:BaseExport<Q,R> 各个服务需要导出的报表引入jar包后实现该类
  4. 导出中心写入任务表后,将Message发送给MQ
  5. 导出中心内置的消费者,消费该消息,执行BaseExport下的子类,根据各个报表的唯一名称找到具体的Service
  6. 拿到各个服务Service返回的List,根据泛型参数生成Excel
  7. 生成的Excel上传到Minio,获取下载链接
  8. 将下载链接更新到export_task表,同时使用websocket推送消息给前端,前端也可以根据面板来查询导出任务表对应的下载链接

关键代码

根据上述逻辑,我们有了如下的伪代码:

首先定义注解:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExportJob {
    String jobName(); // 报表唯一标识(如:"product_stock_export"、"order_sales_export")
}

接着定义导出基类:


public abstract class BaseExport<Q, R> {

    // 最大导出数据量(可通过配置文件读取,默认10万条)
    private static final int MAX_EXPORT_SIZE = 100000;

    /**
     * 导出核心方法(模板方法,不可重写)
     * @param queryParam 查询参数(Q泛型)
     * @return 导出数据列表(R泛型)
     */
    public final List<R> export(Q queryParam) {
        // 1. 前置校验(数据量参数合法性)
        validateQueryParam(queryParam);
        
        // 2. 服务自定义查询数据
        List<R> dataList = queryData(queryParam);
        
        // 3. 数据量控制(超过最大限制抛出异常)
        if (dataList.size() > MAX_EXPORT_SIZE) {
            throw new ExportException(String.format("导出数据量超过最大限制[%d]条,请缩小查询范围", MAX_EXPORT_SIZE));
        }
        
        return dataList;
    }

    /**
     * 自定义查询数据(子类必须实现)
     */
    protected abstract List<R> queryData(Q queryParam);

    /**
     * 校验查询参数(子类可重写扩展)
     */
    protected void validateQueryParam(Q queryParam) {
        if (queryParam == null) {
            throw new ExportException("查询参数不能为空");
        }
    }
}

对应的导出任务实体类和MySQL脚本:

@Data
@TableName("export_task")
public class ExportTask {
    @TableId(type = IdType.AUTO)
    private Long id;                // 任务ID
    private String jobName;         // 导出Job名称(匹配@ExportJob)
    private String queryParam;      // 查询参数(JSON字符串存储)
    private String taskStatus;      // 任务状态(WAITING/RUNNING/SUCCESS/FAIL)
    private String downloadUrl;     // MinIO下载链接
    private String errorMsg;        // 失败原因
    private String userId;          // 操作人ID(用于WebSocket定向通知)
    private Date createTime;
    private Date updateTime;

    // 状态常量(静态内部类)
    public static class Status {
        public static final String WAITING = "WAITING";
        public static final String RUNNING = "RUNNING";
        public static final String SUCCESS = "SUCCESS";
        public static final String FAIL = "FAIL";
    }
}

CREATE TABLE export_task (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '任务ID',
    job_name VARCHAR(64) NOT NULL COMMENT '导出Job名称',
    query_param TEXT NOT NULL COMMENT '查询参数(JSON)',
    task_status VARCHAR(32) NOT NULL DEFAULT 'WAITING' COMMENT '任务状态',
    download_url VARCHAR(255) COMMENT '下载链接',
    error_msg VARCHAR(512) COMMENT '失败原因',
    user_id VARCHAR(64) NOT NULL COMMENT '操作人ID',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    KEY idx_job_name (job_name),
    KEY idx_user_id (user_id),
    KEY idx_task_status (task_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导出任务表';

核心方法,提供初始化和具体导出执行逻辑:

@Service
@Transactional
public class ExportService {

    @Autowired
    private ExportTaskMapper exportTaskMapper;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    @Autowired
    private ApplicationContext applicationContext;

    // 缓存:jobName → BaseExport实现类(启动时初始化)
    private Map<String, BaseExport<?, ?>> exportJobCache;

    // 初始化缓存(从Spring容器中扫描所有@ExportJob注解的实现类)
    @PostConstruct
    public void initExportJobCache() {
        exportJobCache = new HashMap<>();
        Map<String, Object> exportBeans = applicationContext.getBeansWithAnnotation(ExportJob.class);
        for (Object bean : exportBeans.values()) {
            ExportJob annotation = bean.getClass().getAnnotation(ExportJob.class);
            String jobName = annotation.jobName();
            if (exportJobCache.containsKey(jobName)) {
                throw new ExportException("存在重复的导出JobName:" + jobName);
            }
            exportJobCache.put(jobName, (BaseExport<?, ?>) bean);
        }
    }

    /**
     * 接收前端导出请求
     */
    public ExportResponse submitExportTask(ExportRequest request) {
        // 1. 校验JobName是否存在
        String jobName = request.getJobName();
        if (!exportJobCache.containsKey(jobName)) {
            throw new ExportException("不存在的导出任务:" + jobName);
        }

        // 2. 保存任务到MySQL
        ExportTask task = new ExportTask();
        task.setJobName(jobName);
        task.setQueryParam(JSON.toJSONString(request.getQueryParam())); // 查询参数转JSON
        task.setUserId(request.getUserId());
        task.setTaskStatus(ExportTask.Status.WAITING);
        exportTaskMapper.insert(task);

        // 3. 发送任务到RocketMQ(主题:EXPORT_TASK_TOPIC)
        rocketMQTemplate.send("EXPORT_TASK_TOPIC", MessageBuilder.withPayload(task.getId()).build());

        // 4. 返回任务ID给前端
        return new ExportResponse(true, "导出任务已提交", task.getId());
    }

    /**
     * 根据JobName获取导出实现类(供消费端调用)
     */
    public BaseExport<?, ?> getExportByJobName(String jobName) {
        return exportJobCache.get(jobName);
    }
}

消费端,负责消费具体的导出任务逻辑:

@Component
@RocketMQMessageListener(topic = "EXPORT_TASK_TOPIC", consumerGroup = "EXPORT_TASK_CONSUMER_GROUP")
public class ExportTaskConsumer implements RocketMQListener<Long> {

    @Autowired
    private ExportService exportService;
    @Autowired
    private ExportTaskMapper exportTaskMapper;
    @Autowired
    private MinIOUtil minIOUtil;
    @Autowired
    private WebSocketUtil webSocketUtil;

    @Override
    @Transactional
    public void onMessage(Long taskId) {
        ExportTask task = null;
        try {
            // 1. 查询任务信息
            task = exportTaskMapper.selectById(taskId);
            if (task == null) {
                throw new ExportException("任务不存在:" + taskId);
            }

            // 2. 更新任务状态为运行中
            task.setTaskStatus(ExportTask.Status.RUNNING);
            exportTaskMapper.updateById(task);

            // 3. 获取导出实现类和查询参数
            String jobName = task.getJobName();
            BaseExport<?, ?> baseExport = exportService.getExportByJobName(jobName);
            Class<?> queryClazz = getQueryClass(baseExport); // 获取Q泛型类型
            Object queryParam = JSON.parseObject(task.getQueryParam(), queryClazz);

            // 4. 调用服务查询数据
            List<?> dataList = baseExport.export(queryParam);

            // 5. 生成Excel文件(临时文件)
            String fileName = String.format("%s_%s.xlsx", jobName, System.currentTimeMillis());
            File excelFile = ExcelUtil.generateExcel(dataList, getResultClass(baseExport), fileName);

            // 6. 上传Excel到MinIO
            String bucketName = "export-files"; // MinIO桶名(提前创建)
            String minIOUrl = minIOUtil.uploadFile(bucketName, fileName, excelFile);

            // 7. 更新任务状态和下载链接
            task.setTaskStatus(ExportTask.Status.SUCCESS);
            task.setDownloadUrl(minIOUrl);
            exportTaskMapper.updateById(task);

            // 8. WebSocket通知前端(定向推送给操作人)
            webSocketUtil.sendToUser(task.getUserId(), 
                JSON.toJSONString(new ExportNotifyDTO(taskId, minIOUrl, "导出成功")));

            // 9. 删除临时文件
            excelFile.delete();
        } catch (Exception e) {
            // 异常处理:更新任务状态为失败
            if (task != null) {
                task.setTaskStatus(ExportTask.Status.FAIL);
                task.setErrorMsg(e.getMessage());
                exportTaskMapper.updateById(task);
                // 通知前端导出失败
                webSocketUtil.sendToUser(task.getUserId(), 
                    JSON.toJSONString(new ExportNotifyDTO(taskId, null, "导出失败:" + e.getMessage())));
            }
            e.printStackTrace();
        }
    }

    // 反射获取BaseExport的Q泛型类型(查询参数类型)
    private Class<?> getQueryClass(BaseExport<?, ?> baseExport) {
        Type[] genericInterfaces = baseExport.getClass().getGenericInterfaces();
        ParameterizedType parameterizedType = (ParameterizedType) genericInterfaces[0];
        return (Class<?>) parameterizedType.getActualTypeArguments()[0];
    }

    // 反射获取BaseExport的R泛型类型(结果实体类型)
    private Class<?> getResultClass(BaseExport<?, ?> baseExport) {
        Type[] genericInterfaces = baseExport.getClass().getGenericInterfaces();
        ParameterizedType parameterizedType = (ParameterizedType) genericInterfaces[0];
        return (Class<?>) parameterizedType.getActualTypeArguments()[1];
    }
}