1、問題根因分析
????????多人同時導出Excel導致內存溢出(OOM)的核心原因是:在短時間內,大量數據被加載到JVM堆內存中,且創建了大量大對象(如Apache POI的Cell、Row、Sheet對象),超過了堆內存的最大限制(-Xmx)。
同步處理與高并發:導出請求通常是同步的。當多個用戶同時觸發導出時,每個請求都會在服務器端創建一個處理線程,并在該線程中構建一個完整的、包含大量數據的Excel工作簿(Workbook)對象。
Apache POI的內存模型:傳統的Apache POI(如HSSF/XSSF)在構建Excel時,所有單元格、樣式、數據都保存在內存中的Java對象里。一個幾十萬行的Excel文件,其對應的Workbook對象可能輕松占用幾百MB甚至上GB的內存。
數據一次性加載:為了生成Excel,通常需要從數據庫一次性查詢出所有數據,這個巨大的
ResultSet
也會占用大量內存。JVM堆空間不足:如果JVM堆內存設置本身不大,或者并發導出的用戶數足夠多,就很容易將堆內存耗盡,觸發
java.lang.OutOfMemoryError: Java heap space
。
2、解決方案(從低代價到高代價,從臨時到根本)
2.1?應用層優化(代碼改造 - 最根本的解決方案)
這是最推薦的方式,從根源上解決內存問題。
a) 使用流式API (SXSSF)
Apache POI提供了專門用于處理大數據量的流式API:SXSSF。
原理
SXSSF(Streaming Usermodel API)在XSSF的基礎上擴展,它只會將一部分行(例如100行)保留在內存中,生成一行,刷新一行到磁盤臨時文件,從而實現低內存占用。它通過滑動窗口機制來管理內存中的行。
優點:內存占用極低且恒定(僅與
rowAccessWindowSize
有關),是解決此問題的最佳武器。缺點:不支持一些高級特性(如公式計算、單元格合并等在刷新后可能受限),會生成臨時文件。
代碼示例
// 設置一個滑動窗口值,表示在內存中保留多少行,超出的行會被寫入磁盤
int rowAccessWindowSize = 100;
SXSSFWorkbook workbook = new SXSSFWorkbook(rowAccessWindowSize);
SXSSFSheet sheet = workbook.createSheet("數據導出");// 寫入表頭
Row headerRow = sheet.createRow(0);
headerRow.createCell(0).setCellValue("姓名");
headerRow.createCell(1).setCellValue("年齡");// 流式分頁查詢數據庫并寫入
int pageSize = 1000;
int pageNum = 1;
List<Data> dataList;
int currentRow = 1;do {// 1. 分頁查詢,避免一次性加載所有數據dataList = dataService.getExportData(pageNum, pageSize);if (dataList.isEmpty()) {break;}// 2. 將本頁數據寫入Excelfor (Data data : dataList) {Row row = sheet.createRow(currentRow++);row.createCell(0).setCellValue(data.getName());row.createCell(1).setCellValue(data.getAge());// ... 其他單元格}// 3. 非常重要!手動清理掉滑動窗口之外的行,釋放內存// ((SXSSFSheet)sheet).flushRows() 也可以,但注意參數((SXSSFSheet) sheet).flushRows(dataList.size()); // 刷新并清理已處理的行pageNum++;
} while (!dataList.isEmpty());// 將workbook寫入HttpServletResponse的輸出流
workbook.write(response.getOutputStream());
workbook.dispose(); // 刪除臨時文件
workbook.close();
b) 分頁查詢數據庫
如上例所示,在數據獲取層,絕對不要一次性SELECT * FROM huge_table
,必須使用分頁查詢(如MySQL的LIMIT offset, size
)。這大大降低了數據庫和Java應用兩邊的內存壓力。
2.2?架構與流程優化
a) 異步導出
將同步請求改為異步任務。
流程:
- 用戶點擊導出后,服務端立即返回一個任務ID或一個URL:“正在生成,請稍后查看下載鏈接”。
- 后臺使用一個獨立的、線程池大小可控的任務(如使用
@Async
、消息隊列、Job調度)來執行真正的導出操作。 - 導出完成后,將文件上傳到OSS或文件服務器,并將下載鏈接通過通知系統(站內信、郵件等)告知用戶,或者更新任務狀態供用戶查詢。
- 優點:
避免了HTTP請求超時。
可以對后臺任務隊列進行控流,避免同時處理過多導出任務,從而控制內存使用峰值。
用戶體驗更好,不會因為長時間等待而導致瀏覽器卡死。
缺點:系統設計更復雜,不能立即下載。
b) 限流與隊列
如果必須同步導出,可以在應用入口進行限流。
使用RateLimiter(Guava)或Sentinel等工具,限制單位時間內允許的導出請求數量。例如,最多只允許同時處理2個導出請求,后續請求排隊等待或直接返回“系統繁忙,請稍后再試”。
優點:簡單粗暴,有效防止系統被瞬時高并發打垮。
缺點:用戶體驗差(需要等待或失敗)。
2.3?運維與配置優化(臨時緩解措施)
這些不能根治問題,但可以作為一個緩沖或輔助手段。
a) 增加JVM堆內存
通過調整啟動參數?-Xmx4g -Xms4g
?來增大最大堆內存。
優點:簡單,快速。
缺點:
只是推遲了OOM發生的時間,如果數據量或并發量持續增長,遲早還會溢出。
大內存會帶來更長時間的Full GC(Garbage Collection),導致應用“卡頓”。
b) 優化GC參數
針對大內存和創建大量短命對象(導出任務中的對象基本都是短命對象)的場景,使用G1垃圾收集器可能效果更好。
參數示例:
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
c) 文件拆分與壓縮
對于極端大量的數據,可以考慮不再導出單一Excel文件,而是導出多個壓縮包(如每10萬行一個Excel,然后打包成ZIP)。但這更多是業務邏輯的變更。
2.4 總結
立即止損(線上緊急情況):
短期:如果正在頻繁OOM,可以先增大堆內存?
-Xmx
?并重啟服務,快速恢復業務。同時:在網關/應用層緊急添加導出限流策略,防止問題復發。
根本解決(中期必須完成):
改造代碼:將導出邏輯從使用
HSSFWorkbook/XSSFWorkbook
遷移到SXSSFWorkbook
。優化數據查詢:確保數據獲取是分頁的,而不是一次性加載。
優化體驗與架構(長期規劃):
改為異步導出,并提供任務查詢界面。這是對用戶和最系統都最友好的方式。
考慮將生成的大文件存儲到OSS等對象存儲中,減輕應用服務器磁盤IO壓力。
技術選型參考
首選:
SXSSF
?+?分頁查詢
?+?異步導出
。備選:如果數據模型非常簡單,也可以考慮直接生成CSV文件,CSV是純文本格式,內存開銷遠小于Excel。但缺點是無法處理樣式和多個Sheet。