百萬級別數據Excel導出優化

2020-12-11 cuixiaoyande

這篇文章不是標題黨,下文會通過一個仿真例子分析如何優化百萬級別數據Excel導出。

筆者負責維護的一個數據查詢和數據導出服務是一個相對遠古的單點應用,在上一次雲遷移之後擴展為雙節點部署,但是發現了服務經常因為大數據量的數據導出頻繁Full GC,導致應用假死無法響應外部的請求。因為某些原因,該服務只能夠「分配2GB的最大堆內存」,下面的優化都是以這個堆內存極限為前提。通過查看服務配置、日誌和APM定位到兩個問題:

啟動腳本中添加了CMS參數,採用了CMS收集器,該收集算法對內存的敏感度比較高,大批量數據導出容易瞬間打滿老年代導致Full GC頻繁發生。數據導出的時候採用了一次性把目標數據全部查詢出來再寫到流中的方式,大量被查詢的對象駐留在堆內存中,直接打滿整個堆。對於問題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. 關閉輸出流}複製代碼

一個例子如下:

@Datapublic 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端應用比較常見的文件導出都是使用後一種實現,基本的交互流程如下:

為了節省伺服器的內存,這裡的返回數據和數據傳輸部分可以設計為分段處理,也就是查詢的時候考慮把查詢全量的結果這個思路改變為每次只查詢部分數據,直到得到全量的數據,每批次查詢的結果數據都寫進去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(); @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(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@Repositorypublic 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:

@Datapublic 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 paymentTime; @ExcelProperty(value = "訂單狀態", order = 4) private String orderStatus;}@Service@RequiredArgsConstructorpublic 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需要分Sheet WriteSheet 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行):

小結

這篇文章詳細地分析大數據量導出的性能優化,最要側重於內存優化。該方案實現了在儘可能少佔用內存的前提下,在效率可以接受的範圍內進行大批量的數據導出。這是一個可復用的方案,類似的設計思路也可以應用於其他領域或者場景,不局限於數據導出。

相關焦點

  • ef+Npoi導出百萬行excel之踩坑記
    excel,本文是記錄我在做需求過程中遇到的幾個問題和解題方法,給大家分享一下,一來可以幫助同樣遇到問題的朋友,二呢,各位大神也許有更好的方法可以指點小弟一下,讓我順便學習一下。背景::工頭:「小鍾啊,xx界面加個導出excel功能03以後的格式,需要能支持到excel的最大行,同時需要5個並發就行」我:「收到,但是數據大的時候速度可能比較慢。」工頭:「你先做後續客戶反饋了在給他加進度條。」
  • 優雅 | 今天很水的文章-Excel導入導出
    exceldownloadExl = () => { const { results } = this.props //需要導出的json數據 let datas = _.clone(results)//這裡為了不影響項目的數據的使用 採用了lodash中的深克隆方法 let json = datas.map(item=> { //將json
  • 微信好友如何導出?微信好友導出excel
    微信好友導出excel 2020年03月20日 10:58作者:網絡編輯:宏偉   微信好友如何導出微信好友導出excel。在日常工作中,我們難免會刪除微信好友,但微信裡保存了大量微信好友以及客戶資料,對於經常使用微信社交的朋友,都有一個共同的煩惱,那就是怕一個不小心誤刪了微信好友,不要慌,這款微信導出助手來幫你!
  • Springboot整合easyExcel導入導出Excel
    excel,因此在此記錄學習一下如何使用Springboot整合easyExcel;需求:資料庫中有張user表,有個業務要求可以導入、導出「用戶名單.xls」表一、準備:創建項目:關於springboot項目如何創建這裡不再贅述,放一張項目結構圖:1、導入easyexcel、mybatis、mysql依賴
  • NPOI 導出 excel 性能測試
    excel 性能測試Intro網上看到很多人說 NPOI 的性能不行,自己寫了一個 NPOI 的擴展庫,於是想嘗試看看 NPOI 的性能究竟怎麼樣,道聽途說始終不如自己動手一試。= new ExcelPackage(); var sheet = excel.Workbook.Worksheets.Add("tempSheet"); for (var i = 1; i <= RowsCount; i++) { for (var j = 1; j <
  • Java大數據導出Excel解決方案(POI、JXL、IO流(CSV)對比)
    即數據在兩設備間的傳輸稱為流,流的本質是數據傳輸,根據數據傳輸特性將流抽象為各種類,方便更直觀的進行數據操作,而上面提到的POI和JXL實際都是基於IO流封裝的工具。         該方式採用最原始的形式進行導出工作,選擇合適的流工作效率會非常出色。但是僅支持對文本文件的操作,如:CSV、TXT等,且導出的文件會相對較大。
  • R語言數據導入與導出
    在用R語言分析數據時,我們首先要進行數據的導入與導出。R支持多種文件格式,包括但不限於常見的txt,csv,xlsx,和tsv等等。數據的導入依賴於各種R包,方法大同小異,可根據實際情況舉一反三。在R中將數據導出為.csv文件在R中導出數據為.csv文件時使用 write.csv()函數。以下示例分別為將mydata導出到當前工作路徑或到指定路徑。
  • excel圖片導出的方法
            excel教程中圖片導出的操作應該怎樣進行呢?  假設一個excel工作表中有上百張圖片,如何批量導出到某一文件夾呢?  excel圖片導出的方法如下介紹。
  • 微信好友怎麼導出excel?微信導出原來這麼簡單
    微信好友怎麼導出excel?excel? 安卓專業、安全、靠譜的微信好友導出恢復軟體 我們手機微信內存是有上限的,防止數據丟失,可以把一些比較重要微信數據導出來保存,這樣也不怕新數據寫入導致數據丟失了,1、我們可以通過手機自帶的應用商店或者百度搜索卓師兄官網
  • 微信導出通訊錄:微信好友導出excel詳細教程
    微信導出通訊錄:微信好友導出excel詳細教程。微信是目前社會中最流行的社交工具了,不僅如此,我們的衣食住行、以及與好友間的語音視頻聊天都在微信,可隨著微信通訊錄裡的好友增加,我們在清理微信好友時也經常會誤刪!這個好友恢復方法很實用,趕緊收藏。
  • 數據科學 | pandas數據導入與導出
    當我們開始著手做一個數據分析項目時,選擇和導入數據集是第一個步驟,而導出數據雖然非必需,但有時候我們也需要保存處理或者分析後的結果,方便下次使用。在pandas中,它已經為我們提供了很多不同格式數據的導入和導出方法,下面這篇文章將具體介紹一些較為常用的方法,包括excel、csv文件以及資料庫的導入導出等。數據導入和導出是pandas中很基礎且重要的一個部分。
  • 如何使用JavaScript實現前端導入和導出excel文件
    使用JavaScript實現前端基於Table數據一鍵導出excel文件同樣的, 我們實現將table數據一鍵導出為excel也是類似, 不過方案有所不同, 我們先來看看在Dooring中的實現效果.2.1 一鍵導出為excel實現效果以上就是用戶基於後臺採集到的數據, 一鍵導出excel文件的流程, 最後一張圖是生成的excel文件在office軟體中的呈現.
  • C 導出 Excel 的 6 種簡單方法!你會幾種?
    ,資料庫,CSV / TSV,和微軟網格控制項等)數據導出到 Excel 。將數據導出到 Excel 可以以更容易理解的方式可視化數據。該特性有助於生成財務報告、銀行報表和發票,同時還支持篩選大數據、驗證數據、格式化數據等。
  • 批量識別電子發票excel快速導出?
    識別電子發票對於開票量大的企業來說一直大都是一件令人頭疼的事情,每次利用增值稅發票綜合服務平臺開票的時候,都只能逐條錄入信息,如果一天要識別幾十上百張發票以及導出就非常耗費時間,且都在重複操作。為了解決這一困擾,我發現了一款實用性很高的小程序,今天我就把它安利給大家,來幫助我們財務人員最大化的提升效率,節約時間成本和人工成本,批量識別電子發票以及快速導出!1.使用支付寶,搜索:理票俠2.進入理票俠,領取理票俠專屬郵箱。
  • 牛逼的EasyExcel,讓Excel導入導出更加簡單,附詳細教程!
    EasyExcel在做excel導入導出的時候,發現項目中封裝的工具類及其難用,於是去gitHub上找了一些相關的框架,最終選定了EasyExcel。之前早有聽聞該框架,但是一直沒有去了解,這次藉此學習一波,提高以後的工作效率。
  • ng-notadd 0.15 發布,新增表格及 excel 導出
    新功能普通表格數據表格動態表格excel 導出 (可指定行,列)功能技術棧TypescriptAngular
  • 簡潔實用的Excel倉庫管理手機導出表格
    對於常規的倉庫管理表格可以實現手機來操作並且導出excel表格、這是一個不錯的主意。最後我們可以輕鬆的導出報表實現excel表格列印和分享給翎導。其實我們在加上好友的同時可以加上相關翎導實現數據共享。當然我們為了自已的方便就自行操作、最後對於企業直接報表導出就可以、立即生成和發送更加的簡單和實用。
  • 網際網路海量新鮮文章批量導出&海量關鍵詞內容規劃可導出Excel分析
    1、素材搜索&Word、Txt 素材搜索的數據搜集來源十分廣泛,如下圖,不僅有全網收錄的素材,當今熱門的主流媒體平臺數據,5118也為用戶即時搜集整理並分類。
  • 程式設計師:java導出Excel,附帶依賴、後端代碼和前端JS
    -- Apache POI庫用於導出Excel --><dependency><groupId>org.apache.poi</groupId><artifactId>poi</artifactId>
  • 增值稅電子發票識別軟體/一鍵導出excel表格
    關鍵詞:電子發票 電子發票識別 電子發票識別SDK 電子發票識別軟體 電子發票導出excel表格一、何為電子發票?大家都知道,無論是紙質的發票還是電子發票都不是電子數據,想要整理成表格或者存進管理系統都需要財務人員手工錄入,速度慢而且還容易出錯,生產力很低!因此,採用先進的技術手段來提升工作效率就顯得彌足重要!像紙質的發票和電子發票轉化為電子數據,就需要通過OCR發票識別技術來實現了!