java導(dǎo)出Excel實(shí)現(xiàn)八倍效率優(yōu)化的方法詳解
背景
老后臺系統(tǒng)的數(shù)據(jù)記錄導(dǎo)出Excel功能被財(cái)務(wù),運(yùn)營吐槽難用有時(shí)候甚至用不了,我負(fù)責(zé)重構(gòu)老后臺系統(tǒng)代碼,剛好把Excel導(dǎo)出功能重新優(yōu)化設(shè)計(jì)一下。接下來我會分析當(dāng)前問題,針對優(yōu)化性能,進(jìn)行代碼分層解藕實(shí)現(xiàn)優(yōu)雅封裝 盡可能在提高10秒同步流返回最大的記錄條數(shù)
關(guān)鍵字: ES滾動(dòng)查詢,讀寫分離,阻塞隊(duì)列,生產(chǎn)消費(fèi)模型,工廠模式與策略接口,抽象模板與資源控制
原代碼邏輯
public ResponseEntity export(PrizeDto param) {
param.setPageNum(1);
param.setPageSize(10000);
ESPageEntity<ESPrizeVo> result = null;
try {
result = esUtil2.query(authCode, indexName, fuzzyQueryOrder, fuzzyQueryOrderVersion, param, ESPrizeVo.class);
} catch (Throwable ex) {
log.error(ex.getMessage(), ex);
}
ExcelUtil<ESPrizeVo> util = new ExcelUtil<>(ESPrizeVo.class);
List<ESPrizeVo> list = JSON.parseArray(JSON.toJSONString(result.getList()), ESPrizeVo.class);
return util.exportExcel(list, "數(shù)據(jù)記錄", null);
}
//util.exportExcel 基本與若以框架自帶的ExcelUtils一致
這一看,代碼確實(shí)簡單粗暴,因?yàn)閿?shù)據(jù)是存在ES庫中的,公司的ES庫 分頁搜索最多支持到第一萬條數(shù)據(jù),所以代碼簡單粗暴的直接一次查1w條
缺點(diǎn)可以說相當(dāng)大,首先導(dǎo)出數(shù)據(jù)不支持一萬條以上的數(shù)量,第二就是整個(gè)效率低下,查詢與寫記錄是同步阻塞。第三就是內(nèi)存還有待優(yōu)化,一個(gè)list 最大情況下會保存一萬條記錄。
光1W條記錄導(dǎo)出用了15s,效率實(shí)在太低


用戶的訴求是希望能夠?qū)С?W條左右的數(shù)據(jù),接下來開始進(jìn)行優(yōu)化分析
優(yōu)化點(diǎn)
1.首先要突破1W條記錄數(shù)量限制需要使用ES的滾動(dòng)查詢,同時(shí)需要確定好沒次滾動(dòng)的數(shù)量,太長會導(dǎo)致單次查詢較慢,同時(shí)list元素過多占用堆內(nèi)存過多,滾動(dòng)數(shù)量太少則會增加滾動(dòng)次數(shù),整體效率慢
2.優(yōu)化整體效率,因?yàn)槭褂昧藵L動(dòng)查詢,那么可以進(jìn)行讀寫分離,每次滾動(dòng)的數(shù)據(jù)交給異步線程池進(jìn)行異步的寫Excel。
3.優(yōu)化內(nèi)存空間占用,這里主要涉及到讀寫分離時(shí)中間的緩沖池的容量大小,這個(gè)容量最好能過做到寫數(shù)據(jù)不需要空等待數(shù)據(jù)進(jìn)來,生產(chǎn)數(shù)據(jù)不會出現(xiàn)緩存池滿了阻塞的情況。還有一個(gè)寫Excel操作的配置,配置SXSSFWorkbook的 rowAccessWindowSize 屬性為每次滾動(dòng)查詢的大小,同時(shí)我還優(yōu)化了ES的查詢模板,減少了運(yùn)營與財(cái)務(wù)不需要的數(shù)據(jù)字段,大幅降低了單條數(shù)據(jù)的大小

整體設(shè)計(jì)
先上架構(gòu)圖:

可以看到我通過數(shù)據(jù)范圍的大小分為了兩種處理器,一種是同步流返回處理器,面對數(shù)據(jù)量較小范圍的數(shù)據(jù)進(jìn)行同步導(dǎo)出,而數(shù)據(jù)量較大的則需要完全異步后端處理,前端再輪訓(xùn)或者后端主動(dòng)通知的方法告知其文件下載。(1.5w 其實(shí)可以調(diào)整至5w,能過實(shí)現(xiàn)5秒處理)
這兩種處理器的處理邏輯其實(shí)是類似的,全部都采用了生產(chǎn)消費(fèi)模型,實(shí)現(xiàn)讀寫異步的功能。在代碼上封裝統(tǒng)一提交任務(wù)與消費(fèi)數(shù)據(jù)兩個(gè)主要功能。
代碼細(xì)節(jié)
ES 滾動(dòng)查詢封裝
es滾動(dòng)主要分為三個(gè)步驟,第一個(gè)步驟是 發(fā)送第一次滾動(dòng)查詢請求,ES內(nèi)部會生成一份查詢數(shù)據(jù)快照。API: POST /<index_name>/_search?scroll=<timeout> timeout 是這個(gè)快照保存的時(shí)間,可以設(shè)置為1m 即一分鐘,請求體需要有一個(gè)size,即每次滾動(dòng)的大小。請求返回一個(gè)scrollID,以及第一次滾動(dòng)查詢的數(shù)據(jù)!
{
"size": 100, // 每次滾動(dòng)返回的文檔數(shù)量
"query": {
"match_all": {} // 你的查詢條件,可以是任何復(fù)雜的查詢
},
"sort": ["_doc"] // 【最佳實(shí)踐】使用 `_doc` 排序以獲得最高效的檢索性能
}
第二個(gè)步驟是開始滾動(dòng)查詢數(shù)據(jù),API: POST /_search/scroll 你需要使用第一步返回的scrollId 作為請求體去請求,不需要攜帶查詢模板。這個(gè)步驟會再返回一個(gè)新的scrollId,然后在用新的scrollID 重復(fù)步驟二即開始滾動(dòng)數(shù)據(jù),需要注意此步驟不可并發(fā),即使有些獲取的scrollId是相同的。
{
"scroll": "1m", // 【重要】可以重新設(shè)置 scroll 的超時(shí)時(shí)間,重置倒計(jì)時(shí)
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
刪除查詢快照,在滾動(dòng)獲取數(shù)據(jù)為空的時(shí)候需要去請求ES刪除這次的查詢快照,如果不請求,那么會在設(shè)置的timeout超時(shí)時(shí)間后自動(dòng)刪除。API: DELETE /_search/scroll
封裝初始化滾動(dòng)查詢,以及后續(xù)的滾動(dòng),使用TransmittableThreadLocal存儲scrollId
public class ESScrollUtil extends ESUtil implements EsSearch {
?
@Value("${es.xxx.order.authCode}")
private String authCode;
?
@Value("${es.xxx.order.indexName}")
private String indexName;
?
public static final TransmittableThreadLocal<String> localScrollId = new TransmittableThreadLocal<>();
// 每次滾動(dòng)完進(jìn)行TL清除
@Override
public void cleanLocalScrollId() {
localScrollId.remove();
}
?
public <R extends BaseESResult> ScrollRes<R> getFirstScrollId(Class<R> clazz, String templateName, String templateVersion, Object body) {
ParameterizedTypeImpl type = new ParameterizedTypeImpl(new Type[]{clazz}, null, ESPageEntity.class);
ScrollRes<R> baseESResultScrollRes = super.queryForScroll(authCode, indexName, templateName, templateVersion, body, type);
localScrollId.set(baseESResultScrollRes.getScrollId());
return baseESResultScrollRes;
}
?
public <R extends BaseESResult> ScrollRes<R> nextScrollPage(Class<R> clazz){
if (StringUtilsExt.isEmpty(localScrollId.get())) {
log.error("請先調(diào)用getFirstScrollId方法獲取scrollId");
throw new CustomException("請先調(diào)用getFirstScrollId方法獲取scrollId");
}
ParameterizedTypeImpl type = new ParameterizedTypeImpl(new Type[]{clazz}, null, ESPageEntity.class);
ScrollRes<R> res = super.queryForScrollNextPage(authCode, indexName, localScrollId.get(), type);
localScrollId.set(res.getScrollId());
return res;
}
}
這樣在業(yè)務(wù)處理代碼只需要執(zhí)行 getFirstScrollId 然后 while 執(zhí)行 nextScrollPage 不需要處理scrollId賦值的問題
基本Excel導(dǎo)出處理夫類,封裝滾動(dòng)任務(wù)執(zhí)行以及Excel寫入任務(wù),內(nèi)部使用阻塞隊(duì)列實(shí)現(xiàn)生產(chǎn)消費(fèi)模型
@Slf4j
public class BaseExcelExporter {
// clazz緩存, key為類名, value為字段名和注解的map
protected static final HashMap<Class<?>, List<Excel>> cacheExcelInfo = new HashMap<>();
?
// 解析Excel緩存, key為 className + "_" + 注解name, value為字段
protected static final HashMap<String,Field> cacheField = new HashMap<>();
?
protected static ExecutorService executorService;
?
static {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
// 兩倍最大并發(fā)數(shù)滿足了最大處理情況(單次oss處理器需要兩個(gè)線程),需要使用包裝Ttl線程池,繼承ScrollID
threadPoolTaskExecutor.setCorePoolSize(MAX_CONCURRENCY*2);
threadPoolTaskExecutor.setMaxPoolSize(MAX_CONCURRENCY*2);
threadPoolTaskExecutor.setQueueCapacity(0);
threadPoolTaskExecutor.setThreadNamePrefix("excel-exporterThread-");
threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
threadPoolTaskExecutor.initialize();
executorService = TtlExecutors.getTtlExecutorService(threadPoolTaskExecutor.getThreadPoolExecutor());
}
/**
* 提交ES查詢?nèi)蝿?wù),將查詢結(jié)果放入阻塞隊(duì)列,異?;虿樵償?shù)據(jù)為空時(shí)將空數(shù)據(jù)放入隊(duì)列
* @param task 滾動(dòng)查詢?nèi)蝿?wù)
* @param taskResQueue 結(jié)果阻塞隊(duì)列
* @param <R> 結(jié)果類型
*/
protected <R extends BaseESResult> void commitEsQueryTask(Supplier<ScrollRes<R>> task,
BlockingQueue<List<R>> taskResQueue){
try {
executorService.submit(() -> {
try {
for (;;){
ScrollRes<R> taskResult = task.get();
List<R> res = taskResult.getRes();
if(res.isEmpty()){
log.debug("任務(wù)已完成,退出循環(huán)");
taskResQueue.put(new ArrayList<>());
return;
}
taskResQueue.put(res);
}
}catch (Exception e) {
log.error("ES滾動(dòng)查詢出現(xiàn)異常", e);
}
});
} catch (RejectedExecutionException e) {
log.error("Excel導(dǎo)出線程池已滿,無法提交任務(wù)", e);
throw e;
}
}
?
protected <R extends BaseESResult> void syncWriteTaskResult(String handlerName, Class<R> clazz,
BlockingQueue<List<R>> dataQueue,
Sheet sheet, CellStyle dateCellStyle){
long start;
try {
for (;;){
List<R> taskRes = dataQueue.poll(9, TimeUnit.SECONDS);
if(taskRes == null){
log.error("【{}】獲取阻塞隊(duì)列數(shù)據(jù)超時(shí)! 線程退出阻塞等待", handlerName);
break;
}
log.info("主線程獲取到阻塞隊(duì)列數(shù)據(jù),數(shù)據(jù)大小:{} 當(dāng)前隊(duì)列剩余數(shù)據(jù)批數(shù):{}",taskRes.size(), dataQueue.size());
// 獲取的list為空則結(jié)束了(可能任務(wù)執(zhí)行異常了)
if(taskRes.isEmpty()){
log.info("Excel導(dǎo)出任務(wù)已完成,主線程退出循環(huán)");
break;
}
// 寫入數(shù)據(jù)
start = System.currentTimeMillis();
writeRows(sheet, taskRes, clazz, dateCellStyle);
log.info("主線程寫入數(shù)據(jù)耗時(shí):{} ms", System.currentTimeMillis() - start);
}
} catch (InterruptedException e) {
log.error("【{}】 消費(fèi)線程被中斷,線程池繁忙", handlerName, e);
throw new CustomException("系統(tǒng)導(dǎo)出繁忙中,請稍后重試");
}
}
?
public void analysisExcelAnno(Class<?> clazz){
List<Excel> excels = cacheExcelInfo.computeIfAbsent(clazz, k -> new ArrayList<>());
//獲取字段上的注解
Field[] declaredFields = clazz.getDeclaredFields();
for (Field declaredField : declaredFields) {
declaredField.setAccessible(true);
Excel excel = declaredField.getAnnotation(Excel.class);
if (excel != null) {
excels.add(excel);
cacheField.put(clazz.getSimpleName()+"_"+excel.name(), declaredField);
}
}
}
?
/**
* 獲取excel表頭, 值為 {@link Excel} 注解的 name 屬性, 順序與字段順序一致
* 如果是第一次導(dǎo)出該類則會進(jìn)行 {@link #analysisExcelAnno(Class)} 解析
* @param clazz 待導(dǎo)出的類
* @return 表頭
*/
public String[] getExcelHeader(Class<?> clazz){
List<Excel> excels = cacheExcelInfo.getOrDefault(clazz, null);
if (excels == null) {
analysisExcelAnno(clazz);
excels = cacheExcelInfo.get(clazz);
}
return excels.stream().map(Excel::name).toArray(String[]::new);
}
?
public Field getFieldByExcelName(Class<?> clazz, String name){
Field field = cacheField.get(clazz.getSimpleName()+"_"+name);
if(field == null){
throw new CustomException("未找到對應(yīng)的字段,請檢查是否執(zhí)行analysisExcelAnno(clazz) 過該類");
}
return field;
}
?
/**
* 寫入數(shù)據(jù)行,需要注意寫入后list數(shù)據(jù)已被清空,無法再次使用
* @param sheet 工作表
* @param data 待寫入數(shù)據(jù)
* @param clazz 數(shù)據(jù)類型
* @param dateCellStyle 單元格樣式
*/
public void writeRows(Sheet sheet, List<?> data, Class<?> clazz, CellStyle dateCellStyle){
String[] headers = getExcelHeader(clazz);
// 獲取當(dāng)前行數(shù),判斷是否需要?jiǎng)?chuàng)建表頭
int currentRowNum = sheet.getPhysicalNumberOfRows();
// 如果當(dāng)前行數(shù)為 0,說明表頭還未創(chuàng)建,需要?jiǎng)?chuàng)建表頭
if (currentRowNum == 0) {
Row headerRow = sheet.createRow(0); // 第 0 行作為表頭行
for (int i = 0; i < headers.length; i++) {
// 創(chuàng)建表頭單元格,值為注解的 name 屬性
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
// 設(shè)置每個(gè)列的寬度
Excel excel = cacheExcelInfo.get(clazz).get(i);
sheet.setColumnWidth(i, (int) ((excel.width() + 0.72) * 256));
}
currentRowNum++; // 表頭占用第 0 行,所以數(shù)據(jù)從第 1 行開始
}
?
// 寫入數(shù)據(jù)行
for (Object item : data) {
Row dataRow = sheet.createRow(currentRowNum++); // 從當(dāng)前行開始寫入數(shù)據(jù)
for (int j = 0; j < headers.length; j++) {
Cell cell = dataRow.createCell(j); // 創(chuàng)建每列單元格
Field field = getFieldByExcelName(clazz,headers[j]); // 根據(jù)表頭名稱獲取字段
try {
Object value = field.get(item); // 獲取字段值
if (value instanceof Date) {
// 如果是日期類型,設(shè)置日期值和樣式
cell.setCellValue((Date) value);
cell.setCellStyle(dateCellStyle);
} else {
// 其他類型直接轉(zhuǎn)換為字符串
cell.setCellValue(value == null ? "" : value.toString());
}
} catch (IllegalAccessException e) {
log.error("獲取字段值異常", e);
throw new CustomException("獲取字段值異常");
}
}
}
// 顯式清空輔助jvm回收
data.clear();
}
}
Excel導(dǎo)出接口,對Excel handler 的抽象接口
public interface ExcelExportHandler {
// 導(dǎo)出接口,執(zhí)行此方法即完成了excel導(dǎo)出
public <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, ScrollRes<R> firstScroll, Supplier<ScrollRes<R>> nextScroll, SXSSFWorkbook workbook, CellStyle dateCellStyle);
// 判斷該處理器是否支持處理該滾動(dòng)查詢,這里通過res的count即文檔數(shù)量來選擇同步返回流處理器還是Oss異步處理器
public Boolean support(ScrollRes<?> scrollRes);
}
?
同步流Excel導(dǎo)出處理器-簡單導(dǎo)出處理器
?
/**
* 簡單Excel導(dǎo)出處理器,支持導(dǎo)出數(shù)據(jù)量小于40000的數(shù)據(jù)。
* 主線程寫入數(shù)據(jù),子線程請求ES進(jìn)行滾動(dòng)查詢,提高導(dǎo)出效率。
*/
@Service
@Slf4j
public class SampleExportHandler extends BaseExcelExporter implements ExcelExportHandler {
?
@Override
public <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, ScrollRes<R> firstScroll,
Supplier<ScrollRes<R>> nextScroll, SXSSFWorkbook workbook,
CellStyle dateCellStyle) {
ServletOutputStream outputStream = null;
try {
Sheet sheet = workbook.createSheet("Sheet1");
// 設(shè)置一個(gè)較小值,size 即為緩沖池的滾動(dòng)批次大小,這里最多允許兩個(gè)滾動(dòng)批次結(jié)果存在隊(duì)列中
BlockingQueue<List<R>> dataQueue = new LinkedBlockingQueue<>(2);
// 提交Es滾動(dòng)查詢?nèi)蝿?wù)至線程池中
commitEsQueryTask(nextScroll, dataQueue);
// 后寫入第一次滾動(dòng)的數(shù)據(jù)
writeRows(sheet, firstScroll.getRes(), clazz, dateCellStyle);
// 主線程消費(fèi)阻塞隊(duì)列的寫入數(shù)據(jù)
syncWriteTaskResult("SampleExcel導(dǎo)出處理器",clazz, dataQueue, sheet, dateCellStyle);
HttpServletResponse response = getResponse();
// 獲取輸出流
outputStream = response.getOutputStream();
// 將工作簿寫入輸出流
workbook.write(outputStream);
// 刷新輸出流
outputStream.flush();
return new ExcelHandleRes(true);
} catch (Exception e){
log.error("導(dǎo)出 Excel 時(shí)發(fā)生異常",e);
return new ExcelHandleRes(false);
}
finally {
if(outputStream != null){
try {
outputStream.close();
} catch (Exception e) {
log.error("outputStream 關(guān)閉時(shí)發(fā)生異常, ", e);
}
}
}
}
?
private HttpServletResponse getResponse() {
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
try {
response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("export.xlsx", "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
return response;
}
?
@Override
public Boolean support(ScrollRes<?> scrollRes) {
return scrollRes.getCount() <= 40000;
}
?
}
?
兩個(gè)處理器的代碼是相似的,這里就不重復(fù)寫了。小細(xì)節(jié)是先去提交滾動(dòng)查詢?nèi)蝿?wù)至線程池中然后再去寫入第一次滾動(dòng)數(shù)據(jù),如果順序相反則第一次寫入數(shù)據(jù)是阻塞讀任務(wù)的。
導(dǎo)出工廠,通過第一次滾動(dòng)查詢的結(jié)果數(shù)量 調(diào)用excelExportHandler.support 找到合適的處理器
@Component
public class ExcelExportHandlerFactory {
?
@Autowired
private List<ExcelExportHandler> handlers;
?
public ExcelExportHandler buildHandler(ScrollRes<?> scrollRes) {
for (ExcelExportHandler handler : handlers) {
if (handler.support(scrollRes)) {
return handler;
}
}
return null;
}
}
Exccel 導(dǎo)出工具, 直接用于service 調(diào)用
public class LargeExcelUtil implements ApplicationContextAware {
private static ExcelExportHandlerFactory excelExportHandlerFactory;
?
public static final int MAX_CONCURRENCY = 2;
// 最大并發(fā)數(shù)量
private static final AtomicInteger atomicInteger = new AtomicInteger(MAX_CONCURRENCY);
?
/**
* 一定要設(shè)置分頁大?。?
* @param clazz 返回結(jié)果的類型,必須使用 {@link Excel} 注解
* @param esSearch es查詢工具類
* @param templateName es查詢模板名稱
* @param templateVersion es查詢模板版本
* @param body es查詢參數(shù)
* @return
* @param <R>
*/
public static <R extends BaseESResult> ExcelHandleRes export(Class<R> clazz, EsSearch esSearch, String templateName, String templateVersion, Object body, Integer pageSize) {
if(atomicInteger.decrementAndGet() < 0){
atomicInteger.incrementAndGet();
throw new CustomException("系統(tǒng)導(dǎo)出繁忙中,請稍后重試");
}
// 通過第一次滾動(dòng)獲取scrollID以及滾動(dòng)數(shù)據(jù)量再通過數(shù)據(jù)量獲取處理器來執(zhí)行導(dǎo)出
ScrollRes<R> firstScrollId = esSearch.<R>getFirstScrollId(clazz, templateName, templateVersion, body);
ExcelExportHandler excelExportHandler = excelExportHandlerFactory.buildHandler(firstScrollId);
if (excelExportHandler == null) {
throw new RuntimeException("待導(dǎo)出數(shù)據(jù)太多了,暫不支持的導(dǎo)出");
}
SXSSFWorkbook workbook = null;
try {
// 創(chuàng)建 SXSSFWorkbook工作簿,內(nèi)存最大保存pageSize個(gè)數(shù)據(jù), 開啟壓縮臨時(shí)文件減少磁盤空間,但不要開啟使用共享字符會提高一倍多的寫入時(shí)間
workbook = new SXSSFWorkbook(null,pageSize,true,false);
CellStyle dateCellStyle = workbook.createCellStyle();
// 格式化日期數(shù)據(jù)
CreationHelper creationHelper = workbook.getCreationHelper();
dateCellStyle.setDataFormat(creationHelper.createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss"));
return excelExportHandler.export(clazz, firstScrollId, () -> esSearch.nextScrollPage(clazz), workbook, dateCellStyle);
}
finally {
// 因?yàn)槭褂昧薼ocalStorage存儲scrollID,使用完后必須清除
esSearch.cleanLocalScrollId();
atomicInteger.incrementAndGet();
try {
if(workbook != null){
// 先嘗試關(guān)閉工作簿同時(shí)釋放了內(nèi)存與臨時(shí)文件
workbook.close();
}
} catch (IOException e) {
log.error("SXSSFWorkbook close 失敗,錯(cuò)誤信息", e);
}finally {
if(workbook != null){
// 如果關(guān)閉工作簿失敗則釋放臨時(shí)文件
workbook.dispose();
}
}
}
}
?
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
excelExportHandlerFactory = applicationContext.getBean(ExcelExportHandlerFactory.class);
}
}
實(shí)現(xiàn)效果
機(jī)器2C4G 每次滾動(dòng)數(shù)量2000 時(shí) 寫入 與 讀取的耗時(shí)比較接近,下圖中ES滾動(dòng)查詢 平均比寫入多大概50ms

在導(dǎo)出4W條數(shù)據(jù)時(shí),接口總耗時(shí)6911ms

總結(jié) (偷個(gè)懶)
1.突破數(shù)據(jù)量限制 - 引入 ES 滾動(dòng)查詢 (Scroll API)
動(dòng)作:替換原有的簡單分頁查詢,采用 ES 滾動(dòng)查詢機(jī)制。
關(guān)鍵設(shè)計(jì):
- 創(chuàng)建查詢快照,保證數(shù)據(jù)一致性。
- 分批次獲取數(shù)據(jù),通過
size參數(shù)控制每批數(shù)據(jù)量(如 2000 條),平衡單次查詢速度和網(wǎng)絡(luò)請求次數(shù)。 - 使用
TransmittableThreadLocal在線程池間安全地傳遞滾動(dòng) ID (scroll_id),優(yōu)雅地封裝了查詢細(xì)節(jié)。
2.提升處理效率 - 生產(chǎn)消費(fèi)模型 & 異步化
動(dòng)作:將 數(shù)據(jù)查詢(生產(chǎn)) 和 Excel 寫入(消費(fèi)) 分離。
關(guān)鍵設(shè)計(jì):
- 生產(chǎn)者:一個(gè)后臺線程專門負(fù)責(zé)執(zhí)行滾動(dòng)查詢,將獲取到的數(shù)據(jù)批次放入一個(gè)阻塞隊(duì)列 (
BlockingQueue)。 - 消費(fèi)者:主線程(或另一個(gè)消費(fèi)者線程)從隊(duì)列中取出數(shù)據(jù)批次,異步寫入 Excel。
- 效益:讀寫操作并行,消除了查詢等待寫入的阻塞時(shí)間,極大提升了 CPU 和 I/O 利用率。
3.優(yōu)化內(nèi)存占用 - 流式處理與緩存
動(dòng)作:避免大數(shù)據(jù)量的整體駐留。
關(guān)鍵設(shè)計(jì):
- 使用
SXSSFWorkbook:替換傳統(tǒng)的XSSFWorkbook,設(shè)置rowAccessWindowSize(如 2000),實(shí)現(xiàn)了磁盤換內(nèi)存的流式寫入,極大降低了內(nèi)存壓力。 - 精簡數(shù)據(jù)字段:優(yōu)化 ES 查詢模板,只獲取導(dǎo)出必需的字段,從源頭上減少了單條數(shù)據(jù)大小和網(wǎng)絡(luò)傳輸量。
- 可控的隊(duì)列容量:設(shè)置合理的阻塞隊(duì)列大小(如容量為 2),作為生產(chǎn)者和消費(fèi)者之間的緩沖,防止內(nèi)存堆積。
4.架構(gòu)擴(kuò)展性 - 策略模式與工廠模式
動(dòng)作:根據(jù)數(shù)據(jù)量大小智能選擇不同的處理策略。
關(guān)鍵設(shè)計(jì):
定義 ExcelExportHandler 接口:統(tǒng)一導(dǎo)出處理器的行為。
實(shí)現(xiàn)不同處理器:
SampleExportHandler:處理數(shù)據(jù)量較?。ㄈ?≤ 2W 條)的場景,直接同步響應(yīng)流返回文件。OssExportHandler(文中提及):處理大數(shù)據(jù)量場景,異步導(dǎo)出至 OSS,前端通過輪詢或通知下載。
工廠模式自動(dòng)選擇:通過 ExcelExportHandlerFactory 根據(jù)第一次查詢的結(jié)果總數(shù),自動(dòng)選擇最合適的處理器。
5.資源保護(hù) - 限流與清理
動(dòng)作:防止系統(tǒng)過載和資源泄漏。
關(guān)鍵設(shè)計(jì):
- 并發(fā)控制:使用原子計(jì)數(shù)器 (
AtomicInteger) 限制最大同時(shí)導(dǎo)出任務(wù)數(shù),超出則友好拒絕,保護(hù)系統(tǒng)穩(wěn)定性。 - 資源清理: finally 塊中確保關(guān)閉
SXSSFWorkbook(釋放臨時(shí)文件)和清除ThreadLocal中的scroll_id(釋放 ES 服務(wù)端資源)。
優(yōu)化成果
- 功能上:徹底打破了 1W 條的數(shù)據(jù)限制,可支持海量數(shù)據(jù)導(dǎo)出。
- 性能上:耗時(shí)從 15 秒(1W條)降低到約 7 秒(4W條) ,性能提升超過 8 倍,且吞吐量大幅增加。
- 穩(wěn)定性上:內(nèi)存占用可控,無 OOM 風(fēng)險(xiǎn);通過線程池和隊(duì)列管理,系統(tǒng)負(fù)載更加平穩(wěn)。
- 架構(gòu)上:代碼清晰,模塊解耦,擴(kuò)展性強(qiáng),為未來處理更復(fù)雜的導(dǎo)出需求打下了良好基礎(chǔ)。
代碼設(shè)計(jì)模式與架構(gòu)總結(jié)
1.BaseExcelExporter(公共父類) - 模板方法模式 & 資源復(fù)用
設(shè)計(jì)意圖:抽取共性,封裝流程。將導(dǎo)出過程中不變的核心算法骨架(生產(chǎn)-消費(fèi)模型)與可變的實(shí)現(xiàn)細(xì)節(jié)(具體如何查詢、如何寫入)分離。
實(shí)現(xiàn)要點(diǎn):
模板方法:commitEsQueryTask 和 syncWriteTaskResult 這兩個(gè) protected 方法定義了“提交查詢?nèi)蝿?wù)”和“消費(fèi)寫入數(shù)據(jù)”的標(biāo)準(zhǔn)流程。子類只需調(diào)用它們即可完成核心邏輯,無需關(guān)心多線程同步和隊(duì)列管理的復(fù)雜細(xì)節(jié)。
公共資源:
- 線程池:集中管理所有導(dǎo)出任務(wù)的線程資源,避免重復(fù)創(chuàng)建,方便參數(shù)調(diào)優(yōu)(如使用
TtlExecutors解決線程池中ThreadLocal傳遞問題)。 - 緩存機(jī)制:
cacheExcelInfo和cacheField使用HashMap緩存了類的注解信息,通過“懶加載”機(jī)制,避免了每次導(dǎo)出都通過反射解析注解的巨大開銷,這是性能優(yōu)化的關(guān)鍵點(diǎn)之一。
好處:極大減少了子類的代碼量,確保了所有導(dǎo)出器行為的一致性,并且將性能優(yōu)化手段(緩存、線程池)集中在父類,便于維護(hù)。
2.ExcelExportHandler(接口類) - 策略模式
設(shè)計(jì)意圖:定義標(biāo)準(zhǔn),開放擴(kuò)展。聲明一個(gè)通用的導(dǎo)出契約,將不同的導(dǎo)出算法(如同步流、異步OSS)抽象為不同的策略,使它們可以相互替換。
實(shí)現(xiàn)要點(diǎn):
export(...)方法是策略的核心執(zhí)行方法,接收所有必要的參數(shù)(如首次查詢結(jié)果、工作簿、后續(xù)查詢的Supplier等)。support(...)方法是策略的選擇依據(jù),根據(jù)數(shù)據(jù)量等條件判斷該處理器是否適用。
好處:
- 符合開閉原則:未來如果需要增加新的導(dǎo)出方式(如導(dǎo)出為CSV、分片ZIP下載),只需實(shí)現(xiàn)新的
ExcelExportHandler即可,無需修改現(xiàn)有任何代碼。 - 解耦:使用方(如
LargeExcelUtil)只依賴于接口,不依賴于具體實(shí)現(xiàn),降低了系統(tǒng)各個(gè)部分的耦合度。
3.ExcelExportHandlerFactory(工廠類) - 工廠模式
設(shè)計(jì)意圖:對象創(chuàng)建與使用分離。負(fù)責(zé)根據(jù)業(yè)務(wù)規(guī)則(數(shù)據(jù)量)自動(dòng)選擇并創(chuàng)建具體的策略實(shí)現(xiàn)對象。
實(shí)現(xiàn)要點(diǎn):
- 利用 Spring 的依賴注入(
@Autowired private List<ExcelExportHandler> handlers)自動(dòng)收集所有實(shí)現(xiàn)了ExcelExportHandler的 Bean。 buildHandler方法遍歷處理器列表,通過調(diào)用每個(gè)處理器的support方法來找到最合適的那個(gè)。
好處:
- 簡化客戶端代碼:使用方(
LargeExcelUtil.export)無需知道有哪些處理器,也無需寫一堆if-else來判斷,只需調(diào)用工廠方法即可獲得合適的處理器。代碼非常簡潔和清晰。 - 集中管理:所有處理器的選擇邏輯都集中在工廠里,規(guī)則變化只需修改一處。
4.LargeExcelUtil(Utils類) - 外觀模式 & 資源管理
設(shè)計(jì)意圖:提供簡潔的高層接口,整合復(fù)雜子系統(tǒng)。它不再是傳統(tǒng)的靜態(tài)工具類,而是一個(gè)集成了復(fù)雜流程和資源管理的門戶類(Facade) 。
實(shí)現(xiàn)要點(diǎn):
外觀模式:對外提供一個(gè)非常簡單的 export 靜態(tài)方法,內(nèi)部卻整合了參數(shù)校驗(yàn)、并發(fā)控制、ES查詢初始化、處理器工廠、工作簿創(chuàng)建、資源清理等一整套復(fù)雜流程。對調(diào)用者而言,導(dǎo)出功能變得非常簡單。
資源管理:
- 并發(fā)控制:使用
AtomicInteger實(shí)現(xiàn)了一個(gè)簡單的令牌桶限流器,是系統(tǒng)穩(wěn)定性的重要保障。 - 資源清理:在
finally塊中嚴(yán)格保證了workbook.close()/dispose()和scrollId的清理,避免了內(nèi)存泄漏和資源懸掛,體現(xiàn)了良好的編程習(xí)慣。
實(shí)現(xiàn)了 ApplicationContextAware:這是一個(gè)巧妙的設(shè)計(jì),讓這個(gè)靜態(tài)工具類能夠獲取到 Spring 容器中的 Bean(ExcelExportHandlerFactory),解決了靜態(tài)方法無法直接注入 Spring Bean 的問題。
5.ESScrollUtil(Utils類) - 職責(zé)單一 & 封裝
設(shè)計(jì)意圖:封裝復(fù)雜細(xì)節(jié),提供友好API。將ES滾動(dòng)查詢的三個(gè)步驟(初始化、滾動(dòng)、清理)及其資源管理(TransmittableThreadLocal)封裝起來。
實(shí)現(xiàn)要點(diǎn):
總結(jié):架構(gòu)圖景
- 提供了
getFirstScrollId和nextScrollPage兩個(gè)核心方法,內(nèi)部處理了scroll_id的存儲和傳遞,讓業(yè)務(wù)代碼可以像使用迭代器一樣簡單地進(jìn)行滾動(dòng)查詢。 - 職責(zé)非常單一,就是管理ES滾動(dòng)查詢,符合單一職責(zé)原則。
總結(jié):架構(gòu)圖景
- 調(diào)用層 (
Service) -> 門戶層 (LargeExcelUtil):提供簡單接口,負(fù)責(zé)流程編排和資源管理。 - 門戶層 -> 策略工廠 (
ExcelExportHandlerFactory):根據(jù)上下文選擇策略。 - 策略工廠 -> 具體策略 (
SampleExportHandler等):執(zhí)行特定算法。 - 具體策略 -> 抽象模板 (
BaseExcelExporter):復(fù)用基礎(chǔ)流程和組件。 - 抽象模板 -> 工具層 (
ESScrollUtil):調(diào)用更底層的技術(shù)組件。
以上就是java導(dǎo)出Excel實(shí)現(xiàn)八倍效率優(yōu)化的方法詳解的詳細(xì)內(nèi)容,更多關(guān)于java導(dǎo)出Excel的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
springBoot熱部署、請求轉(zhuǎn)發(fā)與重定向步驟詳解
這篇文章主要介紹了springBoot熱部署、請求轉(zhuǎn)發(fā)與重定向,本文通過示例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-06-06
Java SSH 秘鑰連接mysql數(shù)據(jù)庫的方法
這篇文章主要介紹了Java SSH 秘鑰連接mysql數(shù)據(jù)庫的方法,包括引入依賴的代碼和出現(xiàn)異常報(bào)錯(cuò)問題,需要的朋友可以參考下2021-06-06
淺談Java數(shù)據(jù)結(jié)構(gòu)之稀疏數(shù)組知識總結(jié)
今天帶大家了解一下Java稀疏數(shù)組的相關(guān)知識,文中有非常詳細(xì)的介紹及代碼示例,對正在學(xué)習(xí)java的小伙伴們有很好地幫助,需要的朋友可以參考下2021-05-05
MybatisPlus查詢條件空字符串和NULL問題背景分析
文章詳細(xì)分析了MybatisPlus在處理查詢條件時(shí),空字符串和NULL值的問題,MP 3.3.0及以上版本提供了多種解決方法,包括在Bean屬性上使用注解、全局配置等,推薦使用全局配置的方式來解決這個(gè)問題,以避免在SQL查詢中出現(xiàn)不必要的空字符串條件,感興趣的朋友跟隨小編一起看看吧2025-03-03
ThreadLocal數(shù)據(jù)存儲結(jié)構(gòu)原理解析
這篇文章主要為大家介紹了ThreadLocal數(shù)據(jù)存儲結(jié)構(gòu)原理解析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-10-10
Maven項(xiàng)目如何在pom文件中引入lib下的第三方j(luò)ar包并打包進(jìn)去
在使用Maven進(jìn)行項(xiàng)目開發(fā)時(shí),引入第三方私有的Jar包可能會遇到問題,一種常見的解決方案是將Jar包添加到項(xiàng)目的lib目錄,并通過IDE進(jìn)行配置,但這需要每個(gè)開發(fā)者單獨(dú)操作,效率低下,更好的方法是通過Maven的pom.xml文件管理這些Jar包2024-09-09

