easyexcel导出百万级数据_百万级别数据Excel导出优化

26ba89ca71a5b653e4d994af3755f506.png

这篇文章不是标题党,下文会通过一个仿真例子分析如何优化百万级别数据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的语言)开发数据导出的模块,下面的伪代码是通用的:

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

一个例子如下:

@Data
public static class Parameter{private OffsetDateTime paymentDateTimeStart;private OffsetDateTime paymentDateTimeEnd;
}public void export(Parameter parameter, OutputStream os) throws IOException {List<OrderDTO> result = orderDao.query(parameter.getPaymentDateTimeStart(), parameter.getPaymentDateTimeEnd()).stream().map(order -> {OrderDTO dto = new OrderDTO();......return dto;}).collect(Collectors.toList());byte[] bytes = toBytes(result);os.write(bytes);os.close();
}

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

f6a28954f1f97dd887fdcbd3992156c7.png

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

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

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

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

public void export(Parameter parameter, OutputStream os) throws IOException {long lastBatchMaxId = 0L;for (;;){List<Order> orders =  orderDao.query([SELECT * FROM t_order WHERE id > #{lastBatchMaxId} AND payment_time >= #{parameter.paymentDateTimeStart} AND payment_time <= #{parameter.paymentDateTimeEnd} ORDER BY id ASC LIMIT ${LIMIT}]);if (orders.isEmpty()){break;}List<OrderDTO> result =orderDao.query([SELECT * FROM t_order]).stream().map(order -> {OrderDTO dto = new OrderDTO();......return dto;}).collect(Collectors.toList());byte[] bytes = toBytes(result);os.write(bytes);os.flush();lastBatchMaxId = orders.stream().map(Order::getId).max(Long::compareTo).orElse(Long.MAX_VALUE);}os.close();
}

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


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

仿真案例

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

DROP TABLE IF EXISTS `t_order`;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 '订单表';

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

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.18</version>
</dependency>
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>2.2.6</version>
</dependency>

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

public class OrderServiceTest {private static final Random OR = new Random();private static final Random AR = new Random();private static final Random DR = new Random();@Testpublic 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(Driver.class.getName());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();}
}

基于JdbcTemplate编写DAO类OrderDao:

@RequiredArgsConstructor
@Repository
public class OrderDao {private final JdbcTemplate jdbcTemplate;public List<Order> queryByScrollingPagination(long lastBatchMaxId,int limit,LocalDateTime paymentDateTimeStart,LocalDateTime paymentDateTimeEnd) {return jdbcTemplate.query("SELECT * 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.setCreateTime(OffsetDateTime.ofInstant(rs.getTimestamp("create_time").toInstant(), ZoneId.systemDefault()));order.setEditTime(OffsetDateTime.ofInstant(rs.getTimestamp("edit_time").toInstant(), ZoneId.systemDefault()));order.setVersion(rs.getLong("version"));order.setDeleted(rs.getInt("deleted"));order.setOrderId(rs.getString("order_id"));order.setAmount(rs.getBigDecimal("amount"));order.setPaymentTime(OffsetDateTime.ofInstant(rs.getTimestamp("payment_time").toInstant(), ZoneId.systemDefault()));order.setOrderStatus(rs.getInt("order_status"));orders.add(order);}return orders;});}
}

编写服务类OrderService:

@Data
public class OrderDTO {@ExcelIgnoreprivate Long id;@ExcelProperty(value = "订单号", order = 1)private String orderId;@ExcelProperty(value = "金额", order = 2)private BigDecimal amount;@ExcelProperty(value = "支付时间", order = 3)private String paymentTime;@ExcelProperty(value = "订单状态", order = 4)private String orderStatus;
}@Service
@RequiredArgsConstructor
public class OrderService {private final OrderDao orderDao;private static final DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");public List<OrderDTO> queryByScrollingPagination(String paymentDateTimeStart,String paymentDateTimeEnd,long lastBatchMaxId,int limit) {LocalDateTime start = LocalDateTime.parse(paymentDateTimeStart, F);LocalDateTime end = LocalDateTime.parse(paymentDateTimeEnd, F);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.setPaymentTime(order.getPaymentTime().format(F));dto.setOrderStatus(OrderStatus.fromStatus(order.getOrderStatus()).getDescription());return dto;}).collect(Collectors.toList());}
}

最后编写控制器OrderController:

@RequiredArgsConstructor
@RestController
@RequestMapping(path = "/order")
public class OrderController {private final OrderService orderService;@GetMapping(path = "/export")public void export(@RequestParam(name = "paymentDateTimeStart") String paymentDateTimeStart,@RequestParam(name = "paymentDateTimeEnd") String paymentDateTimeEnd,HttpServletResponse response) throws Exception {String fileName = URLEncoder.encode(String.format("%s-(%s).xlsx", "订单支付数据", UUID.randomUUID().toString()),StandardCharsets.UTF_8.toString());response.setContentType("application/force-download");response.setHeader("Content-Disposition", "attachment;filename=" + fileName);ExcelWriter writer = new ExcelWriterBuilder().autoCloseStream(true).excelType(ExcelTypeEnum.XLSX).file(response.getOutputStream()).head(OrderDTO.class).build();// xlsx文件上上限是104W行左右,这里如果超过104W需要分SheetWriteSheet writeSheet = new WriteSheet();writeSheet.setSheetName("target");long lastBatchMaxId = 0L;int limit = 500;for (; ; ) {List<OrderDTO> list = orderService.queryByScrollingPagination(paymentDateTimeStart, paymentDateTimeEnd, lastBatchMaxId, limit);if (list.isEmpty()) {writer.finish();break;} else {lastBatchMaxId = list.stream().map(OrderDTO::getId).max(Long::compareTo).orElse(Long.MAX_VALUE);writer.write(list, writeSheet);}}}
}

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

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

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

2bec9ac88b511c429cb07cb846b3a012.png

小结

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

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

  • Github:https://github.com/zjcscut/spring-boot-guide/tree/master/ch10086-excel-export

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/504021.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

运行catia_CATIA清除错误目录及防串链接

清除错误目录大家好今天我来教大家如何清除CATIA错误目录&#xff0c;由于我们在日常工作中经常会互相发送数据&#xff0c;而CATIA的缓存功能通常会记录这些文件的路径&#xff0c;所以我们打开别人发的数据时在桌面的右下角通常会有警告的提醒&#xff0c;那我们如何来清除缓…

python 键盘输入数字_九宫格键盘输入

九宫格键盘输入 Letter Combinations of a Phone Number 给定一个数字字符串&#xff0c;返回数字可能代表的所有可能的字母组合。 数字到字母的映射&#xff08;就像九宫格电话按钮一样&#xff09;如下图。 Given a digit string, return all possible letter combinations t…

java2组随机数的共通数_java随机数产生-指数分布 正态分布 等

1 指数分布指数分布的概率密度函数&#xff1a;ylamda*exp(-lamda*x)x>0由此可以计算概率分布函数&#xff1a;y1-exp(-lamda*x)x>0y是 X首先&#xff0c;把y当作是在(0&#xff0c;1)区间的均匀分布的随机变量。然后&#xff0c;求y1-exp(-lamda*x)的逆函数&#xff0c…

c# combobox集合数据不显示_excel打开数据时显示乱码/问号amp;看起来一样却v不出来怎么办...

1、乱码问题&#xff1a;今天正好碰到这种情况&#xff0c;想起来写一写。有时从客户那里拿到的CSV等文件&#xff0c;直接用excel打开是这样的&#xff1a;其实观察一下会发现&#xff1f;的地方一般就是中文&#xff0c;实质上是中文字符显示不出来。有小伙伴一直是单独下个W…

if嵌套while循环语句_Python学习笔记015--while循环嵌套

while循环嵌套前面学习过if的嵌套了&#xff0c;想一想if嵌套是什么样子的&#xff1f;类似if的嵌套&#xff0c;while嵌套就是&#xff1a;while里面还有while<1>while嵌套的格式while 条件1:条件1满足时&#xff0c;做的事情1条件1满足时&#xff0c;做的事情2条件1满足…

long类型怎么转换成string_Python知识点-Python变量类型有哪些?

Python的内置变量类型是非常基础的知识点&#xff0c;善用变量类型转换在日常的工作学习中会给我们带来非常大的帮助。相对于其他语言&#xff0c;Python的变量类型既有自己的特色方法&#xff0c;也有借鉴前辈的优秀之处。今天酷仔整理总结了关于Python内置变量类型一文&#…

mysql连接服务密码_Hydra爆破常见服务密码

Hydra是一款专业的服务密码探测工具&#xff0c;支持FTP、LDAP、MYSQL、Oracle、POP3、SMB、SSH等网络服务&#xff0c;多用于信息安全检查工作中弱口令登陆测试。实验环境操作系统&#xff1a;Kali 2019.4目标网络&#xff1a;192.168.168.0/24爆破教程第一步 升级系统、软件版…

矩阵求逆c语言实现_[V-SLAM] Bundle Adjustment 实现

SLAM问题的后端有主要有滤波和优化两种方案。目前&#xff0c;普遍认为优化的方法在精度上要超过滤波方法&#xff0c;因为它可以进行多次的线性化。近年来出现的SLAM算法也大都是基于优化的算法&#xff08;如ORB-SLAM、DSO等&#xff09;。优化问题的核心便是Bundle Adjustme…

centos安装 mysql_Linux centos 安装 mysql 5.6

一、mysql下载1、方式一(简单粗暴)直接在linux 目录下wget https://dev.mysql.com/get/Downloads/MySQL-5.6/mysql-5.6.43-linux-glibc2.12-x86_64.tar.gz2、方式二(官方下载)浏览器打开网址&#xff1a;https://www.mysql.com如下图依次点击1、2、3、4步如下图设置对应版本点击…

tracepro杂散光分析例子_光刻机的蜕变过程及专利分析

来源&#xff1a;芯通社近两年&#xff0c;中国芯片产业受到了严重打击&#xff0c;痛定思痛之余也让国人意识到芯片自主研发的重要性。从2008年以来&#xff0c;十年间&#xff0c;芯片都是我国第一大宗进口商品&#xff0c;进口额远超于排名第二的石油。2018年我国进口集成电…

docker mysql日志_面试官问:了解Mysql主从复制原理么?我呵呵一笑

搭建Mysql主从同步之前&#xff0c;我们先来说他们之间同步的过程与原理&#xff1a;同步复制过程献上一张图&#xff0c;这张图诠释了整个同步过程主从复制过程&#xff1a;slave节点与主节点进行连接&#xff0c;建立主从关系&#xff0c;并把从哪开始同步&#xff0c;及哪个…

查看socket缓冲区数据_什么是socket缓冲区?

Socket 就是发送和接收网络数据&#xff0c;Socket 有发送缓冲也有接收缓冲&#xff0c;这些缓冲区有什么作用&#xff1f;1、什么是Socket缓冲区&#xff1f;熟悉 Socket 的读者都知道&#xff0c;Socket 的发送和接收&#xff0c;就是调用 send 和 recv 函数。实际操作中&…

哈工大大数据实验_科研常用 | 实验大数据分析方法

对于每个科研工作者而言&#xff0c;对实验数据进行处理是在开始论文写作之前十分常见的工作之一。但是&#xff0c;常见的数据分析方法有哪些呢&#xff1f;常用的数据分析方法有&#xff1a;聚类分析、因子分析、相关分析、对应分析、回归分析、方差分析。1、聚类分析(Cluste…

搭建github服务器_搭建一个属于自己的公网博客

相信每一位程序员都喜欢拥有一个属于自己的博客。当然&#xff0c;在我认为&#xff0c;内容以及模块都要自己进行可扩展定义才是真正属于自己的。那么想要一个博客就必须要有一个服务器和一个域名&#xff0c;这样的话才能让自己的博文内容发扬光大&#xff0c;但是服务器的性…

mysql修改级联表数据_MySQL数据库 外键,级联, 修改表的操作

1.外键: 用来建立两张表之间的关系- 一对多- 多对多- 一对一研究表与表之间的关系:1.定义一张 员工部门表id, name, gender, dep_name, dep_desc- 将所有数据存放在一张表中的弊端:1.结构不清晰 ---> 不致命2.浪费空间 ---> 不致命3.可扩展性极差 ---> 不可忽视的弊端…

OpenCV学习笔记 - 使用密集光流检测运动的简单方法

一、简述 使用光流进行运动检测的方法与帧间差分方法类似。主要区别在于第一步,我们将从光流而不是帧差分中获取初始运动信息(一些神经网络模型也是基于光流和原始图像进行运动识别训练的)。 该算法概述如下: 1、计算密集光流 2、获得运动掩模的阈值光流 3、在运动蒙版中查…

mysql 5.6.27安装图解_Linux下MySQL 5.6.27 安装教程

本文实例为大家分享了Linux下MySQL 5.6.27 安装教程&#xff0c;供大家参考&#xff0c;具体内容如下1、下载地址2、将压缩包上传到服务器3、解压tar -zxf mysql-5.6.27-linux-glibc2.5-x86_64.tar.gz4、移动压缩包至mysql文件夹下mp mysql-5.6.27-linux-glibc2.5-x86_64 /usr…

linux部署tomcat项目404_Tomcat部署项目的几种常见方式

点击蓝字“程序员考拉”欢迎关注&#xff01;1 /直接将web项目文件件拷贝到webapps目录中这是最常用的方式&#xff0c;Tomcat的Webapps目录是Tomcat默认的应用目录&#xff0c;当服务器启动时&#xff0c;会加载所有这个目录下的应用。如果你想要修改这个默认目录&#xff0c;…

mysql bug_MySQL 记一次 Bug发现过程

水平有限有误请谅解这个问题是一位朋友DBA-老庄的,他们使用的是PXC环境如下:MySQL:5.7.18-15wsrep:29.20os:Red Hat Enterprise Linux Server release 6.5实际上我对PXC并不是很熟&#xff0c;通过分析pstack还是找到了问题。并且提交Bug&#xff0c;percona确认了。虽然我不是…

正则表达式 任意数字_作为运维还不会正则表达式?赶快看这篇学习一下

概述正则表达式是很多运维薄弱的一项技能。大家很多时候都会觉得正则表达式难记、难学、难用&#xff0c;但不可否认的是正则表达式是一项很重要的技能&#xff0c;所有今天将学习和使用正则表达式时的关键点整理如下&#xff0c;仅供参考。什么是正则表达式&#xff1f;正则表…