前提

这篇文章不是标题党,下文会通过一个仿真例子分析如何优化百万级别数据Excel导出。

笔者负责维护的一个数据查询和数据导出服务是一个相对远古的单点应用,在上一次云迁移之后扩展为双节点部署,但是发现了服务经常因为大数据量的数据导出频繁Full GC,导致应用假死无法响应外部的请求。因为某些原因,该服务只能够分配2GB的最大堆内存,下面的优化都是以这个堆内存极限为前提。通过查看服务配置、日志和APM定位到两个问题:

  1. 启动脚本中添加了CMS参数,采用了CMS收集器,该收集算法对内存的敏感度比较高,大批量数据导出容易瞬间打满老年代导致Full GC频繁发生。
  2. 数据导出的时候采用了一次性把目标数据全部查询出来再写到流中的方式,大量被查询的对象驻留在堆内存中,直接打满整个堆。

对于问题1咨询过身边的大牛朋友,直接把所有CMS相关的所有参数去掉,由于生产环境使用了JDK1.8,相当于直接使用默认的GC收集器参数-XX:+UseParallelGC,也就是Parallel Scavenge + Parallel Old的组合然后重启服务。观察APM工具发现Full GC的频率是有所下降,但是一旦某个时刻导出的数据量十分巨大(例如查询的结果超过一百万个对象,超越可用的最大堆内存),还是会陷入无尽的Full GC,也就是修改了JVM参数只起到了治标不治本的作用。所以下文会针对这个问题(也就是问题2),通过一个仿真案例来分析一下如何进行优化。

一些基本原理

如果使用Java(或者说依赖于JVM的语言)开发数据导出的模块,下面的伪代码是通用的:

  1. 数据导出方法(参数,输出流[OutputStream]){
  2. 1. 通过参数查询需要导出的结果集
  3. 2. 把结果集序列化为字节序列
  4. 3. 通过输出流写入结果集字节序列
  5. 4. 关闭输出流
  6. }

一个例子如下:

  1. @Data
  2. public static class Parameter{
  3. private OffsetDateTime paymentDateTimeStart;
  4. private OffsetDateTime paymentDateTimeEnd;
  5. }
  6. public void export(Parameter parameter, OutputStream os) throws IOException {
  7. List<OrderDTO> result =
  8. orderDao.query(parameter.getPaymentDateTimeStart(), parameter.getPaymentDateTimeEnd()).stream()
  9. .map(order -> {
  10. OrderDTO dto = new OrderDTO();
  11. ......
  12. return dto;
  13. }).collect(Collectors.toList());
  14. byte[] bytes = toBytes(result);
  15. os.write(bytes);
  16. os.close();
  17. }

针对不同的OutputStream实现,最终可以把数据导出到不同类型的目标中,例如对于FileOutputStream而言相当于把数据导出到文件中,而对于SocketOutputStream而言相当于把数据导出到网络流中(客户端可以读取该流实现文件下载)。目前B端应用比较常见的文件导出都是使用后一种实现,基本的交互流程如下:

为了节省服务器的内存,这里的返回数据和数据传输部分可以设计为分段处理,也就是查询的时候考虑把查询全量的结果这个思路改变为每次只查询部分数据,直到得到全量的数据,每批次查询的结果数据都写进去OutputStream中。

这里以MySQL为例,可以使用类似于分页查询的思路,但是鉴于LIMIT offset,size的效率太低,结合之前的一些实践,采用了一种改良的"滚动翻页"的实现方式(这个方式是前公司的某个架构小组给出来的思路,后面广泛应用于各种批量查询、数据同步、数据导出以及数据迁移等等场景,这个思路肯定不是首创的,但是实用性十分高),注意这个方案要求表中包含一个有自增趋势的主键,单条查询SQL如下:

  1. SELECT * FROM tableX WHERE id > #{lastBatchMaxId} [其他条件] ORDER BY id [ASC|DESC](这里一般选用ASC排序) LIMIT ${size}

把上面的SQL放进去前一个例子中,并且假设订单表使用了自增长整型主键id,那么上面的代码改造如下:

  1. public void export(Parameter parameter, OutputStream os) throws IOException {
  2. long lastBatchMaxId = 0L;
  3. for (;;){
  4. List<Order> orders = orderDao.query([SELECT * FROM t_order WHERE id > #{lastBatchMaxId}
  5. AND payment_time >= #{parameter.paymentDateTimeStart} AND payment_time <= #{parameter.paymentDateTimeEnd} ORDER BY id ASC LIMIT ${LIMIT}]);
  6. if (orders.isEmpty()){
  7. break;
  8. }
  9. List<OrderDTO> result =
  10. orderDao.query([SELECT * FROM t_order]).stream()
  11. .map(order -> {
  12. OrderDTO dto = new OrderDTO();
  13. ......
  14. return dto;
  15. }).collect(Collectors.toList());
  16. byte[] bytes = toBytes(result);
  17. os.write(bytes);
  18. os.flush();
  19. lastBatchMaxId = orders.stream().map(Order::getId).max(Long::compareTo).orElse(Long.MAX_VALUE);
  20. }
  21. os.close();
  22. }

上面这个示例就是百万级别数据Excel导出优化的核心思路。查询和写入输出流的逻辑编写在一个死循环中,因为查询结果是使用了自增主键排序的,而属性lastBatchMaxId则存放了本次查询结果集中的最大id,同时它也是下一批查询的起始id,这样相当于基于id和查询条件向前滚动,直到查询条件不命中任何记录返回了空列表就会退出死循环。而limit字段则用于控制每批查询的记录数,可以按照应用实际分配的内存和每批次查询的数据量考量设计一个合理的值,这样就能让单个请求下常驻内存的对象数量控制在limit个从而使应用的内存使用更加可控,避免因为并发导出导致堆内存瞬间被打满。

这里的滚动翻页方案远比LIMIT offset,size效率高,因为此方案每次查询都是最终的结果集,而一般的分页方案使用的LIMIT offset,size需要先查询,后截断。

仿真案例

某个应用提供了查询订单和导出记录的功能,表设计如下:

  1. DROP TABLE IF EXISTS `t_order`;
  2. CREATE TABLE `t_order`
  3. (
  4. `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
  5. `creator` VARCHAR(16) NOT NULL DEFAULT 'admin' COMMENT '创建人',
  6. `editor` VARCHAR(16) NOT NULL DEFAULT 'admin' COMMENT '修改人',
  7. `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  8. `edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  9. `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号',
  10. `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识',
  11. `order_id` VARCHAR(32) NOT NULL COMMENT '订单ID',
  12. `amount` DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '订单金额',
  13. `payment_time` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '支付时间',
  14. `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态,0:处理中,1:支付成功,2:支付失败',
  15. UNIQUE uniq_order_id (`order_id`),
  16. INDEX idx_payment_time (`payment_time`)
  17. ) COMMENT '订单表';

现在要基于支付时间段导出一批订单数据,先基于此需求编写一个简单的SpringBoot应用,这里的Excel处理工具选用Alibaba出品的EsayExcel,主要依赖如下:

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-web</artifactId>
  4. </dependency>
  5. <dependency>
  6. <groupId>org.springframework.boot</groupId>
  7. <artifactId>spring-boot-starter-jdbc</artifactId>
  8. </dependency>
  9. <dependency>
  10. <groupId>mysql</groupId>
  11. <artifactId>mysql-connector-java</artifactId>
  12. <version>8.0.18</version>
  13. </dependency>
  14. <dependency>
  15. <groupId>com.alibaba</groupId>
  16. <artifactId>easyexcel</artifactId>
  17. <version>2.2.6</version>
  18. </dependency>

模拟写入200W条数据,生成数据的测试类如下:

  1. public class OrderServiceTest {
  2. private static final Random OR = new Random();
  3. private static final Random AR = new Random();
  4. private static final Random DR = new Random();
  5. @Test
  6. public void testGenerateTestOrderSql() throws Exception {
  7. HikariConfig config = new HikariConfig();
  8. config.setUsername("root");
  9. config.setPassword("root");
  10. config.setJdbcUrl("jdbc:mysql://localhost:3306/local?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=false");
  11. config.setDriverClassName(Driver.class.getName());
  12. HikariDataSource hikariDataSource = new HikariDataSource(config);
  13. JdbcTemplate jdbcTemplate = new JdbcTemplate(hikariDataSource);
  14. for (int d = 0; d < 100; d++) {
  15. String item = "('%s','%d','2020-07-%d 00:00:00','%d')";
  16. StringBuilder sql = new StringBuilder("INSERT INTO t_order(order_id,amount,payment_time,order_status) VALUES ");
  17. for (int i = 0; i < 20_000; i++) {
  18. sql.append(String.format(item, UUID.randomUUID().toString().replace("-", ""),
  19. AR.nextInt(100000) + 1, DR.nextInt(31) + 1, OR.nextInt(3))).append(",");
  20. }
  21. jdbcTemplate.update(sql.substring(0, sql.lastIndexOf(",")));
  22. }
  23. hikariDataSource.close();
  24. }
  25. }

基于JdbcTemplate编写DAOOrderDao

  1. @RequiredArgsConstructor
  2. @Repository
  3. public class OrderDao {
  4. private final JdbcTemplate jdbcTemplate;
  5. public List<Order> queryByScrollingPagination(long lastBatchMaxId,
  6. int limit,
  7. LocalDateTime paymentDateTimeStart,
  8. LocalDateTime paymentDateTimeEnd) {
  9. return jdbcTemplate.query("SELECT * FROM t_order WHERE id > ? AND payment_time >= ? AND payment_time <= ? " +
  10. "ORDER BY id ASC LIMIT ?",
  11. p -> {
  12. p.setLong(1, lastBatchMaxId);
  13. p.setTimestamp(2, Timestamp.valueOf(paymentDateTimeStart));
  14. p.setTimestamp(3, Timestamp.valueOf(paymentDateTimeEnd));
  15. p.setInt(4, limit);
  16. },
  17. rs -> {
  18. List<Order> orders = new ArrayList<>();
  19. while (rs.next()) {
  20. Order order = new Order();
  21. order.setId(rs.getLong("id"));
  22. order.setCreator(rs.getString("creator"));
  23. order.setEditor(rs.getString("editor"));
  24. order.setCreateTime(OffsetDateTime.ofInstant(rs.getTimestamp("create_time").toInstant(), ZoneId.systemDefault()));
  25. order.setEditTime(OffsetDateTime.ofInstant(rs.getTimestamp("edit_time").toInstant(), ZoneId.systemDefault()));
  26. order.setVersion(rs.getLong("version"));
  27. order.setDeleted(rs.getInt("deleted"));
  28. order.setOrderId(rs.getString("order_id"));
  29. order.setAmount(rs.getBigDecimal("amount"));
  30. order.setPaymentTime(OffsetDateTime.ofInstant(rs.getTimestamp("payment_time").toInstant(), ZoneId.systemDefault()));
  31. order.setOrderStatus(rs.getInt("order_status"));
  32. orders.add(order);
  33. }
  34. return orders;
  35. });
  36. }
  37. }

编写服务类OrderService

  1. @Data
  2. public class OrderDTO {
  3. @ExcelIgnore
  4. private Long id;
  5. @ExcelProperty(value = "订单号", order = 1)
  6. private String orderId;
  7. @ExcelProperty(value = "金额", order = 2)
  8. private BigDecimal amount;
  9. @ExcelProperty(value = "支付时间", order = 3)
  10. private String paymentTime;
  11. @ExcelProperty(value = "订单状态", order = 4)
  12. private String orderStatus;
  13. }
  14. @Service
  15. @RequiredArgsConstructor
  16. public class OrderService {
  17. private final OrderDao orderDao;
  18. private static final DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
  19. public List<OrderDTO> queryByScrollingPagination(String paymentDateTimeStart,
  20. String paymentDateTimeEnd,
  21. long lastBatchMaxId,
  22. int limit) {
  23. LocalDateTime start = LocalDateTime.parse(paymentDateTimeStart, F);
  24. LocalDateTime end = LocalDateTime.parse(paymentDateTimeEnd, F);
  25. return orderDao.queryByScrollingPagination(lastBatchMaxId, limit, start, end).stream().map(order -> {
  26. OrderDTO dto = new OrderDTO();
  27. dto.setId(order.getId());
  28. dto.setAmount(order.getAmount());
  29. dto.setOrderId(order.getOrderId());
  30. dto.setPaymentTime(order.getPaymentTime().format(F));
  31. dto.setOrderStatus(OrderStatus.fromStatus(order.getOrderStatus()).getDescription());
  32. return dto;
  33. }).collect(Collectors.toList());
  34. }
  35. }

最后编写控制器OrderController

  1. @RequiredArgsConstructor
  2. @RestController
  3. @RequestMapping(path = "/order")
  4. public class OrderController {
  5. private final OrderService orderService;
  6. @GetMapping(path = "/export")
  7. public void export(@RequestParam(name = "paymentDateTimeStart") String paymentDateTimeStart,
  8. @RequestParam(name = "paymentDateTimeEnd") String paymentDateTimeEnd,
  9. HttpServletResponse response) throws Exception {
  10. String fileName = URLEncoder.encode(String.format("%s-(%s).xlsx", "订单支付数据", UUID.randomUUID().toString()),
  11. StandardCharsets.UTF_8.toString());
  12. response.setContentType("application/force-download");
  13. response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
  14. ExcelWriter writer = new ExcelWriterBuilder()
  15. .autoCloseStream(true)
  16. .file(response.getOutputStream())
  17. .head(OrderDTO.class)
  18. .build();
  19. // xlsx文件上上限是104W行左右,这里如果超过104W需要分Sheet
  20. WriteSheet writeSheet = new WriteSheet();
  21. writeSheet.setSheetName("target");
  22. long lastBatchMaxId = 0L;
  23. int limit = 500;
  24. for (; ; ) {
  25. List<OrderDTO> list = orderService.queryByScrollingPagination(paymentDateTimeStart, paymentDateTimeEnd, lastBatchMaxId, limit);
  26. if (list.isEmpty()) {
  27. writer.finish();
  28. break;
  29. } else {
  30. lastBatchMaxId = list.stream().map(OrderDTO::getId).max(Long::compareTo).orElse(Long.MAX_VALUE);
  31. writer.write(list, writeSheet);
  32. }
  33. }
  34. }
  35. }

这里为了方便,把一部分业务逻辑代码放在控制器层编写,实际上这是不规范的编码习惯,这一点不要效仿。添加配置和启动类之后,通过请求http://localhost:10086/order/export?paymentDateTimeStart=2020-07-01 00:00:00&paymentDateTimeEnd=2020-07-16 00:00:00测试导出接口,某次导出操作后台输出日志如下:

  1. 导出数据耗时:29733 ms,start:2020-07-01 00:00:00,end:2020-07-16 00:00:00

导出成功后得到一个文件(连同表头一共1031540行):

小结

这篇文章详细地分析大数据量导出的性能优化,最要侧重于内存优化。该方案实现了在尽可能少占用内存的前提下,在效率可以接受的范围内进行大批量的数据导出。这是一个可复用的方案,类似的设计思路也可以应用于其他领域或者场景,不局限于数据导出。

文中demo项目的仓库地址是:

(本文完 c-2-d e-a-20200711 20:27 PM)

技术公众号《Throwable文摘》(id:throwable-doge),不定期推送笔者原创技术文章(绝不抄袭或者转载):

百万级别数据Excel导出优化的更多相关文章

  1. JAVA使用POI如何导出百万级别数据(转)

    https://blog.csdn.net/happyljw/article/details/52809244   用过POI的人都知道,在POI以前的版本中并不支持大数据量的处理,如果数据量过多还会 ...

  2. JAVA使用POI如何导出百万级别数据

    用过POI的人都知道,在POI以前的版本中并不支持大数据量的处理,如果数据量过多还会常报OOM错误,这时候调整JVM的配置参数也不是一个好对策(注:jdk在32位系统中支持的内存不能超过2个G,而在6 ...

  3. JAVA使用POI如何导出百万级别数据(转载)

    用过POI的人都知道,在POI以前的版本中并不支持大数据量的处理,如果数据量过多还会常报OOM错误,这时候调整JVM的配置参数也不是一个好对策(注:jdk在32位系统中支持的内存不能超过2个G,而在6 ...

  4. MYSQL百万级数据,如何优化

    MYSQL百万级数据,如何优化     首先,数据量大的时候,应尽量避免全表扫描,应考虑在 where 及 order by 涉及的列上建立索引,建索引可以大大加快数据的检索速度.但是,有些情况索引是 ...

  5. SpringBoot图文教程10—模板导出|百万数据Excel导出|图片导出「easypoi」

    有天上飞的概念,就要有落地的实现 概念十遍不如代码一遍,朋友,希望你把文中所有的代码案例都敲一遍 先赞后看,养成习惯 SpringBoot 图文教程系列文章目录 SpringBoot图文教程1「概念+ ...

  6. Redis 单节点百万级别数据 读取 性能测试.

    个人博客网:https://wushaopei.github.io/    (你想要这里多有) 这里先进行造数据,向redis中写入五百万条数据,具体方式有如下三种: 方法一:(Lua 脚本) vim ...

  7. mysql中百万级别分页查询性能优化

    前提条件: 1.表的唯一索引 2.百万级数据 SQL语句: select c.* FROM ( SELECT a.logid FROM tableA a where 1 = 1 <#if pho ...

  8. HSSF、XSSF和SXSSF区别以及Excel导出优化

    之前有写过运用POI的HSSF方式导出数据到Excel(见:springMVC中使用POI方式导出excel至客户端.服务器实例),但这种方式当数据量大到一定程度时容易出现内存溢出等问题. 首先,PO ...

  9. 使用Laravel将数据Excel导出的方法

    1.copmposer下载maatwebsite/excel 2.在控制器引入:use Excel; 3.将要导出的数据处理成数组,第一组数据为表的字段名,如图 4.导出成表格 Excel::crea ...

随机推荐

  1. 如何在本地搭建微信小程序服务器

    现在开发需要购买服务器,价格还是有点贵的,可以花费小代价就可以搭建一个服务器,可以用来开发小程序,博客等. 1.域名(备案过的) 2.阿里云注册免费的https证书 3.配置本地的nginx 4.内网 ...

  2. react中使用decorator 封装context

    2020-03-27 react中使用decorator 封装context 在传统的react context中,子组件使用context十分繁琐,如果需要使用context的子组件多的话 每个组件 ...

  3. 数据库连接池 Druid和C3p0

    datasource.properties数据源 #数据源 datasource.peoperties jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc: ...

  4. Jmeter基础003----Jmeter组件之测试计划和线程组

    一.测试计划 1.界面展示 测试计划是测试脚本的容器,主要是对测试脚本做总体设置.它定义了测试要执行什么,怎么执行(执行的).其界面如下图所示:   2.设置用户定义变量 在测试计划中定义的变量是在整 ...

  5. 龙芯团队完成CoreCLR MIPS64移植,在github开源

    国产龙芯的软件生态之中.NET不会缺席,毕竟 C# 与 .NetCore/Mono 也是全球几大主流的编程语言和运行平台之一,最近一段时间听到太多的鼓吹政务领域不支持.NET, 大家都明白这是某些人为 ...

  6. opencv 单通道合并为多通道

    int main(){ cv::Mat m1=(cv::Mat_<int>(,)<<,,,,,); cv::Mat m2=(cv::Mat_<int>(,)< ...

  7. weblogic高级进阶之查看日志

    域的日志位于 D:\Oracle\Middleware\user_projects\domains\base_domain\servers\AdminServer\logs 名字是base_domai ...

  8. Linux软件服务管理

    学习该课程之前先学习linux的软件安装管理 1.linux的运行级别有下面几种类型 在后面的服务启动管理之中会被使用到 [root@weiyuan httpd-2.4.20]# runlevel N ...

  9. caffe的python接口学习(1)生成配置文件

    ---恢复内容开始--- 看了denny的博客,写下自己觉得简短有用的部分 想用caffe训练数据首先要学会编写配置文件: (即便是用别人训练好的模型也要进行微调的,所以此关不可跨越) 代码就不粘贴了 ...

  10. Python实用笔记 (5)使用dictionary和set

    dictionary 通过键值存储,具有极快的查找速度,但占用空间比list大很多 举个例子,假设要根据同学的名字查找对应的成绩,如果用list实现,需要两个list: names = ['Micha ...