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

可能出现的问题

  1. 同步导数据,接口很容易超时。
  2. 如果把所有数据一次性装载到内存,很容易引起OOM。
  3. 数据量太大sql语句慢。
  4. 如果走异步,如何通知用户导出结果
  5. 如果excel文件太大,目标用户打不开怎么办

解决方案

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

问题二 分批查询 poi 禁止使用XSSFWorkbook 使用SXSSFWorkbook 或 easy Excel

问题三 分页通过滚动翻页查询

流式查询问题:容易长时间占用数据库链接池资源。

游标查询问题:应用指定每次查询获取的条数fetchSize,MySQL服务器每次只查询指定条数的数据,由于MySQL方不知道客户端什么时候将数据消费完,MySQL需要建立一个临时空间来存放每次查询出的数据,大数据量时MySQL服务器、磁盘占用都会飙升。

故使用滚动翻页查询

问题四 通过 页面或者沟通软件通知用户导出成功 ,并将导出结果上传至oss 后续可直接下载 无需重复导出

问题五 导出可用户设置最大条数

数据准备

sql 复制代码
CREATE TABLE `t_order`
(
    `id`           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY 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 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:支付失败',
    UNIQUE uniq_order_id (`order_id`),
    INDEX idx_payment_time (`payment_time`)
) COMMENT '订单表';
java 复制代码
public class OrderServiceTest {
    private static final Random OR = new Random();
    private static final Random AR = new Random();
    private static final Random DR = new Random();
    @Test
    public void testGenerateTestOrderSql() throws Exception {
        HikariConfig config = new HikariConfig();
        config.setUsername("root");
        config.setPassword("root");
        config.setJdbcUrl("jdbc:mysql://localhost:3306/local?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=false");
        config.setDriverClassName("com.mysql.jdbc.Driver");
        HikariDataSource hikariDataSource = new HikariDataSource(config);
        JdbcTemplate jdbcTemplate = new JdbcTemplate(hikariDataSource);
        for (int d = 0; d < 100; d++) {
            String item = "('%s','%d','2020-07-%d 00:00:00','%d')";
            StringBuilder sql = new StringBuilder("INSERT INTO t_order(order_id,amount,payment_time,order_status) VALUES ");
            for (int i = 0; i < 20_000; i++) {
                sql.append(String.format(item, UUID.randomUUID().toString().replace("-", ""),
                        AR.nextInt(100000) + 1, DR.nextInt(31) + 1, OR.nextInt(3))).append(",");
            }
            jdbcTemplate.update(sql.substring(0, sql.lastIndexOf(",")));
        }
        hikariDataSource.close();
    }
}

具体实现

easy Excel通过滚动翻页

Controller

java 复制代码
@GetMapping(path = "/export")
public void export(@RequestParam(name = "paymentDateStart") String paymentDateStart,
                   @RequestParam(name = "paymentDateEnd") String paymentDateEnd,
                   ) throws Exception {
    orderService.export(paymentDateStart, paymentDateEnd);
}

Service

java 复制代码
@Async
public void export(String paymentDateStart, String paymentDateEnd) throws IOException {
    Date dateBefore = new Date();
    String fileName = URLEncoder.encode(String.format("%s-(%s).xlsx", "订单数据", UUID.randomUUID()),
            StandardCharsets.UTF_8.toString());
    BufferedOutputStream outputStream = FileUtil.getOutputStream(FileUtil.file("/Users/Documents/github/"+fileName));

    ExcelWriter writer = new ExcelWriterBuilder()
            .autoCloseStream(true)
            .excelType(ExcelTypeEnum.XLSX)
            .file(outputStream)
            .head(OrderDTO.class)
            .build();
    WriteSheet writeSheet = new WriteSheet();
    writeSheet.setSheetName("target");
    long lastBatchMaxId = 0L;
    int limit = 3000;
    for (; ; ) {
        List<OrderDTO> list = queryByScrollingPagination(paymentDateTimeStart, paymentDateTimeEnd, lastBatchMaxId, limit);
      //可以添加导出条数限制
        if (list.isEmpty()) {
            writer.finish();
            Date dateAfter = new Date();
            System.out.println("导出列表共执行" + (dateAfter.getTime() - dateBefore.getTime()) + "ms");
            //todo 上传oss 发通知
            break;
        } else {
            lastBatchMaxId = list.stream().map(OrderDTO::getId).max(Long::compareTo).orElse(Long.MAX_VALUE);
            writer.write(list, writeSheet);
        }
    }
}
java 复制代码
public List<OrderDTO> queryByScrollingPagination(String paymentDateTimeStart,
                                                 String paymentDateTimeEnd,
                                                 long lastBatchMaxId,
                                                 int limit) {
    LocalDateTime start = LocalDateTime.parse(paymentDateTimeStart, formatter);
    LocalDateTime end = LocalDateTime.parse(paymentDateTimeEnd, formatter);
    return orderDao.queryByScrollingPagination(lastBatchMaxId, limit, start, end).stream().map(order -> {
        OrderDTO dto = new OrderDTO();
        dto.setId(order.getId());
        dto.setAmount(order.getAmount());
        dto.setOrderId(order.getOrderId());
        dto.setCreator(order.getCreator());
        return dto;
    }).collect(Collectors.toList());
}

Repository

java 复制代码
@Repository
public class OrderDao {

    @Resource
    private  JdbcTemplate jdbcTemplate;

    public List<Order> queryByScrollingPagination(long lastBatchMaxId,
                                                  int limit,
                                                  LocalDateTime paymentDateTimeStart,
                                                  LocalDateTime paymentDateTimeEnd) {
        return jdbcTemplate.query("SELECT id,creator,editor ,version,deleted,order_id,amount,order_status FROM t_order WHERE id > ? AND payment_time >= ? AND payment_time <= ? " +
                        "ORDER BY id ASC LIMIT ?",
                p -> {
                    p.setLong(1, lastBatchMaxId);
                    p.setTimestamp(2, Timestamp.valueOf(paymentDateTimeStart));
                    p.setTimestamp(3, Timestamp.valueOf(paymentDateTimeEnd));
                    p.setInt(4, limit);
                },
                rs -> {
                    List<Order> orders = new ArrayList<>();
                    while (rs.next()) {
                        Order order = new Order();
                        order.setId(rs.getLong("id"));
                        order.setCreator(rs.getString("creator"));
                        order.setEditor(rs.getString("editor"));
                           order.setVersion(rs.getLong("version"));
                        order.setDeleted(rs.getInt("deleted"));
                        order.setOrderId(rs.getString("order_id"));
                        order.setAmount(rs.getBigDecimal("amount"));
                       order.setOrderStatus(rs.getInt("order_status"));
                        orders.add(order);
                    }
                    return orders;
                });
    }
}

总结

业务方面

做需求时刻先考虑是不是必须要做 、如果必须要做的情况需要考虑用户的体验和使用感受

技术方面

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

2 MySQL批量查询、数据同步、数据导出可以使用类似于分页查询的思路,但是鉴于LIMIT offset,size的效率太低,可以采用"滚动翻页"的实现方式 注意要用自增趋势的主键

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

相关推荐
杜杜的man26 分钟前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*27 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu28 分钟前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s29 分钟前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子31 分钟前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
想进大厂的小王44 分钟前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea
2402_857589361 小时前
SpringBoot框架:作业管理技术新解
java·spring boot·后端
一只爱打拳的程序猿2 小时前
【Spring】更加简单的将对象存入Spring中并使用
java·后端·spring