OOM 幾乎是筆者工作中遇到的線上 bug 中最常見的,一旦平時正常的頁面線上上出現頁面崩潰或者服務無法調用,查看伺服器日誌後你很可能會看到“Caused by: java.lang.OutOfMlemoryError: Java heap space” 這樣的提示,那麼毫無疑問表示的是 Java ... ...
目錄
前言
OOM 幾乎是筆者工作中遇到的線上 bug 中最常見的,一旦平時正常的頁面線上上出現頁面崩潰或者服務無法調用,查看伺服器日誌後你很可能會看到“Caused by: java.lang.OutOfMlemoryError: Java heap space” 這樣的提示,那麼毫無疑問表示的是 Java 堆記憶體溢出了。
其中又當屬集合記憶體溢出最為常見。你是否有過把整個資料庫表查出來的全欄位結果直接賦值給一個 List 對象?是否把未經過過濾處理的數據賦值給 Set 對象進行去重操作?又或者是在高併發的場景下創建大量的集合對象未釋放導致 JVM 無法自動回收?
我的解決方案的核心思路有兩個:一是從代碼入手進行優化;二是從硬體層面對機器做合理配置。
一、代碼優化
下麵先說從代碼入手怎麼解決。
1.1Stream 流自分頁
/**
* 以下示例方法都在這個實現類里,包括類的繼承和實現
*/
@Service
public class StudyServiceImpl extends ServiceImpl<StudyMapper, Study> implements StudyService{}
在迴圈里使用 Stream 流的 skip()+limit() 來實現自分頁,直至取出所有數據,不滿足條件時終止迴圈
/**
* 避免集合記憶體溢出方法(一)
* @return
*/
private List<StudyVO> getList(){
ArrayList<StudyVO> resultList = new ArrayList<>();
//1、資料庫取出源數據,註意只拿 id 欄位,不至於溢出
List<String> idsList = this.list(new LambdaQueryWrapper<Study>()
.select(Study::getId)).stream()
.map(Study::getId)
.collect(Collectors.toList());
//2、初始化迴圈
boolean loop = true;
long number = 0;
long perSize = 5000;
while (loop){
//3、skip()+limit()組合,限制每次只取固定數量的 id
List<String> ids = idsList.stream()
.skip(number * perSize)
.limit(perSize)
.collect(Collectors.toList());
if (CollectionUtils.isNotEmpty(ids)){
//根據第3步的 id 去拿資料庫的全欄位數據,這樣也不至於溢出,因為一次只是 5000 條
List<StudyVO> voList = this.listByIds(ids).stream()
.map(e -> e.copyProperties(StudyVO.class))
.collect(Collectors.toList());
//addAll() 方法也比較關鍵,快速地批量添加元素,容量是比較大的
resultList.addAll(voList);
}
//4、判斷是否跳出迴圈
number++;
loop = ids.size() == perSize;
}
return resultList;
}
1.2資料庫分頁
這裡是用資料庫語句查詢符合條件的指定條數,迴圈查出所有數據,不滿足條件就跳出迴圈
/**
* 避免集合記憶體溢出方法(二)
* @param param
* @return
*/
private List<StudyVO> getList(String param){
ArrayList<StudyVO> resultList = new ArrayList<>();
//1、構造查詢條件
String id = "";
//2、初始化迴圈
boolean loop = true;
int perSize = 5000;
while (loop){
//分頁,固定每次迴圈都查 5000 條
Page<Study> studyPage = this.page(new Page<>
(NumberUtils.INTEGER_ZERO, perSize),
wrapperBuilder(param, id));
if (Objects.nonNull(studyPage)){
List<Study> studyList = studyPage.getRecords();
if (CollectionUtils.isNotEmpty(studyList)){
//3、每次截取固定數量的標識,數組下標減一
id = studyList.get(perSize - NumberUtils.INTEGER_ONE).getId();
//4、判斷是否跳出迴圈
loop = studyList.size() == perSize;
//添加進返回的 VO 集合中
resultList.addAll(studyList.stream()
.map(e -> e.copyProperties(StudyVO.class))
.collect(Collectors.toList()));
}
else {
loop = false;
}
}
}
return resultList;
}
/**
* 條件構造
* @param param
* @param id
* @return
*/
private LambdaQueryWrapper<Study> wrapperBuilder(String param, String id){
LambdaQueryWrapper<Study> wrapper = new LambdaQueryWrapper<>();
//只查部分欄位,按照 id 的降序排列,形成順序
wrapper.select(Study::getUserAvatar)
.eq(Study::getOpenId, param)
.orderByAsc(Study::getId);
if (StringUtils.isNotBlank(id)){
//這步很關鍵,只查比該 id 值大的數據
wrapper.gt(Study::getId, id);
}
return wrapper;
}
1.3其它思考
以上從根本上還是解決不了記憶體里處理大量數據的問題,取出 50w 數據放記憶體的風險就很大了。以下是我的其它解決思路:
- 從業務上拆解:明確什麼情況下需要後端處理這麼多數據?是否可以考慮在業務流程上進行拆解?或者用其它形式的頁面交互代替?
- 資料庫設計:數據一般都來源於資料庫,庫/表設計的時候儘量將表與表之間解耦,表欄位的顆粒度放細,即多表少欄位,查詢時只拿需要的欄位;
- 數據放在磁碟:比如放到 MQ 里存儲,然後取出的時候註意按固定數量批次取,並且註意釋放資源;
- 非同步批處理:如果業務對實時性要求不高的話,可以非同步批量把數據添加到文件流里,再存入到 OSS 中,按需取用;
- 定時任務處理:詢問產品經理該功能或者實現是否是結果必須的?是否一定要同步處理?可以考慮在一個時間段內進行多次操作,緩解大數據量的問題;
- 咨詢大數據團隊:尋求大數據部門團隊的專業支持,對於處理海量數據他們是專業的,看能不能提供一些可參考的建議。
二、硬體配置
核心思路:加大伺服器記憶體,合理分配伺服器的堆記憶體,並設置好彈性伸縮規則,當觸發告警時自動伸縮擴容,保證系統的可用性。
2.1雲伺服器配置
以下是阿裡雲 ECS 管理控制台的編輯頁面,可以對 CPU 和記憶體進行配置。在 ECS 實例伸縮組創建完成後,即可以根據業務規模去創建一個自定義伸縮配置,在業務量大的時候會觸發自動伸縮。
如果是部署在私有雲伺服器,需要對具體的 JVM 參數進行調優的話,可能還得請團隊的資深大佬、或者運維團隊的老師來幫忙處理。
三、文章小結
本篇文章主要是記錄一次線上 bug 的處理思路,在之後的文章中我會分享一些關於真實項目中處理高併發、緩存的使用、非同步/解耦等內容,敬請期待。
那麼今天的分享到這裡就結束了,如有不足和錯誤,還請大家指正。或者你有其它想說的,也歡迎大家在評論區交流!