百万Excel数据导入-带通用实现

637 阅读4分钟

可能出现的问题

  1. 同步导入数据,接口很容易超时。
  2. 怎么提高插入效率。
  3. 怎么提高文件读取效率
  4. 对于大数量导入,数据导入一半失败怎么办。
  5. 如果走异步,如何通知用户导入结果 用户想要看进度怎么办。
  6. 用户导入文件太大 怎么办

问题解决方案

  1. 问题一 异步化 调用接口立即返回任务生产成功

  2. 问题二 关闭手动提交事务 批量执行提交sql 事务 减少数据库交互次数 

  3. 问题三 普通文件可以采用mmap读取文件
    excel 可以采用easyExcel 底层采用 BufferedInputStream按行读取文件 
    easyExcel优化方式
    配置优化
    1 使用readCache走缓存处理数据
    2 自定义SimpleReadCacheSelector适当调整MaxUseMapCacheSize和 MaxCacheActivateBatchCount

    代码优化
    1 通过自定义AnalysisEventListener批量处理数据
    2 批量读取到数据后 通过线程池 或者 mq处理解析的数据

  4. 问题四 对于可观测性可以分两部分
    上传进度 前端做文件上传的进度

    处理进度 通过任务中心统一管理进度,即可以做进度监控 也可以做故障恢复
    参考:我对B端任务中心功能的设计思考 – 人人都是产品经理

  5. 问题五 前端通过定时调用后端接口或通过websocket 或者直接查询

  6. 问题六 首先需要限制文件大小和行数,(需要做成可配置的),对于大文件可以考虑将大文件拆分或者将excel改为txt

通用实现

controller

 @PostMapping(value = "/upload")
    public  void upload(MultipartFile file) throws IOException {
        CompletableFuture.runAsync(()->{
            BufferedInputStream inputStream = FileUtil.getInputStream("/Users/luweijie/Documents/github/MultithreadedExportExcelData/sql/11111.xlsx");
            EasyExcelFactory.read(inputStream, OrderDTO.class, demoDataListener).readCache().excelType(ExcelTypeEnum.XLSX).sheet().doRead();
        });

    }

DTO 

@Data
public class OrderDTO {

    @ExcelIgnore
    private Long id;

    @ExcelProperty(value = "订单号", order = 1)
    private String orderId;
    @ExcelProperty(value = "金额", order = 2)
    private BigDecimal amount;
    @ExcelProperty(value = "创建人", order = 3)
    private String creator;
}

Listener

@Slf4j
@Component
public class DemoDataListener implements ReadListener<OrderDTO> {

    private static final int BATCH_COUNT = 1000;

    private ThreadLocal<ArrayList<OrderDTO>> cachedDataList = ThreadLocal.withInitial(ArrayList::new);

    private OrderDao orderDao;

    public DemoDataListener(OrderDao demoDAO) {
        this.orderDao = demoDAO;
    }

    /**
     * 这个每一条数据解析都会来调用
     *
     * @param data    one row value. Is is same as {@link AnalysisContext#readRowHolder()}
     * @param context
     */
    @Override
    public void invoke(OrderDTO data, AnalysisContext context) {
        if (completedTaskCount ==0) {
            dateBefore = new Date();
            rowNumber = context.readSheetHolder().getApproximateTotalRowNumber();
        }
        cachedDataList.get().add(data);
        // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
        if (cachedDataList.get().size() >= BATCH_COUNT) {
            asyncSaveData();
        }
    }

    /**
     * 所有数据解析完成了 都会来调用
     *
     * @param context
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        log.info("所有数据解析完成!");
    }

    public void asyncSaveData() {
        if (!cachedDataList.get().isEmpty()) {
            ArrayList<OrderDTO> cachedData = (ArrayList<OrderDTO>) cachedDataList.get().clone();
           //此处最好使用mq处理,如果使用CompletableFuture需要传入自定义线程池
            CompletableFuture.runAsync(()->{
                saveData(cachedData);
            });
            cachedDataList.get().clear();
        }
    }
    /**
     * 存储数据库
     */
    private void saveData(List<OrderDTO> cachedDataList) {
            log.info("{}条数据,开始存储数据库!", cachedDataList.size());
            orderDao.batchSave(cachedDataList);
    }


}

Repository

 public void batchSave(List<OrderDTO> cachedDataList) {
        //结果集中数据为0时,结束方法.进行下一次调用
        if (cachedDataList.size() == 0) {
            return;
        }
        //JDBC分批插入+事务操作完成对10w数据的插入
        Connection conn = null;
        PreparedStatement ps = null;
        try {
           conn = jdbcTemplate.getDataSource().getConnection();
            //控制事务:默认不提交
            conn.setAutoCommit(false);
            String sql = "INSERT INTO `execldemo`.`t_order` (`creator`, `order_id`, `amount`) VALUES ";
            sql += "(?,?,?)";
            ps = conn.prepareStatement(sql);
            for (OrderDTO orderDTO : cachedDataList) {
                ps.setString(1, orderDTO.getCreator());
                ps.setString(2, orderDTO.getOrderId());
                ps.setBigDecimal(3, orderDTO.getAmount());
                //将一组参数添加到此 PreparedStatement 对象的批处理命令中。
                ps.addBatch();
            }

            //执行批处理
            ps.executeBatch();
            //手动提交事务
            conn.commit();

        } catch (Exception e) {

            e.printStackTrace();
        } finally {
            conn.close();

        }
    }

表结构

CREATE TABLE `t_order` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `creator` varchar(16) NOT NULL DEFAULT 'admin' COMMENT '创建人',
  `editor` varchar(16) NOT NULL DEFAULT 'admin' COMMENT '修改人',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `edit_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `version` bigint NOT NULL DEFAULT '1' COMMENT '版本号',
  `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '软删除标识',
  `order_id` varchar(32) NOT NULL COMMENT '订单ID',
  `amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
  `payment_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '支付时间',
  `order_status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态,0:处理中,1:支付成功,2:支付失败',
  PRIMARY KEY (`id`),
  KEY `idx_payment_time` (`payment_time`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单表';

总结

业务方面

做需求时刻先考虑是不是必须要做 、如果必须要做的情况需要考虑用户的体验和使用感受 否则做出的功能不好用返工不如不做

技术方面

1 不需要立马返回结果的接口可以采用异步的方式让接口立刻返回结果,可以防止接口耗时过长导致tomcat线程池打满。

2 批量是一个提高性能的好手段,对于可以批量操作的功能优先考虑批量操作,因为可以减少一些资源的消耗

3 数据导入需要注意由于大对象频繁创建导致的 full gc 和oom 如果导入较频繁可以考虑拆分单独服务专门做导出可以通过 jstat -gc pid 查看gc情况

4 微服务的情况下可以考虑将单机的压力分布到不同的机器上 提高整个系统的处理能力