原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,非公眾號轉載保留此聲明。 上個月,我們一個java服務上線後,偶爾會發生記憶體OOM(Out Of Memory)問題,但由於OOM導致服務不響應請求,健康檢查多次不通過,最後部署平臺kill了java進程,這導致定位這次OOM問題也變得困 ...
原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,非公眾號轉載保留此聲明。
上個月,我們一個java服務上線後,偶爾會發生記憶體OOM(Out Of Memory)問題,但由於OOM導致服務不響應請求,健康檢查多次不通過,最後部署平臺kill了java進程,這導致定位這次OOM問題也變得困難起來。
最終,在多次review代碼後發現,是SQL意外地查出大量數據導致的,如下:
<sql id="conditions">
<where>
<if test="outerId != null">
and `outer_id` = #{outerId}
</if>
<if test="orderType != null and orderType != ''">
and `order_type` = #{orderType}
</if>
...
</where>
</sql>
<select id="queryListByConditions" resultMap="orderResultMap">
select * from order <include refid="conditions"/>
</select>
查詢邏輯類似上面的示例,在Service層有個根據outer_id的查詢方法,然後直接調用了Mapper層一個通用查詢方法queryListByConditions。
但我們有個調用量極低的場景,可以不傳outer_id這個參數,導致這個通用查詢方法沒有添加這個過濾條件,導致查了全表,進而導致OOM問題。
我們內部對這個問題進行了復盤,考慮到OOM問題還是蠻常見的,所以給大家也分享下。
事前
在OOM問題發生前,為什麼測試階段沒有發現問題?
其實在編寫技術方案時,是有考慮到這個場景的,但在提測時,忘記和測試同學溝通此場景,導致遺漏了此場景的測試驗證。
關於測試用例不全面,其實不管是疏忽問題、經驗問題、質量意識問題或人手緊張問題,從人的角度來說,都很難徹底避免,人沒法像機器那樣很聽話的、不疏漏的執行任何指令。
既然人做不到,那就讓機器來做,這就是單元測試、自動化測試的優勢,通過逐步積累測試用例,可覆蓋的場景就會越來越多。
當然,實施單元測試等方案,也會增加不少成本,需要權衡質量與研發效率誰更重要,畢竟在需求不能砍的情況下,質量與效率只能二選其一,這是任何一本項目管理的書都提到過的。
事中
在感知到OOM問題發生時,由於進程被部署平臺kill,導致現場丟失,難以快速定位到問題點。
一般java裡面是推薦使用-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/dump/
這種JVM參數來保存現場的,這兩個參數的意思是,當JVM發生OOM異常時,自動dump堆記憶體到文件中,但在我們的場景中,這個方案難以生效,如下:
- 在堆占滿之前,會發生很多次FGC,jvm會盡最大努力騰挪空間,導致還沒有OOM時,系統實際已經不響應了,然後被kill了,這種場景無dump文件生成。
- 就算有時幸運,JVM發生了OOM異常開始dump,由於dump文件過大(我們約10G),導致dump文件還沒保存完,進程就被kill了,這種場景dump文件不完整,無法使用。
為瞭解決這個問題,有如下2種方案:
方案1:利用k8s容器生命周期內的Hook
我們部署平臺是套殼k8s的,k8s提供了preStop生命周期鉤子,在容器銷毀前會先執行此鉤子,只要將jmap -dump
命令放入preStop中,就可以在k8s健康檢查不通過並kill容器前將記憶體dump出來。
要註意的是,正常發佈也會調用此鉤子,需要想辦法繞過,我們的辦法是將健康檢查也做成腳本,當不通過時創建一個臨時文件,然後在preStop腳本中判斷存在此文件才dump,preStop腳本如下:
if [ -f "/tmp/health_check_failed" ]; then
echo "Health check failed, perform dumping and cleanups...";
pid=`ps h -o pid --sort=-pmem -C java|head -n1|xargs`;
if [[ $pid ]]; then
jmap -dump:format=b,file=/home/work/logs/applogs/heap.hprof $pid
fi
else
echo "No health check failure detected. Exiting gracefully.";
fi
註:也可以考慮在堆占用高時才dump記憶體,效果應該差不多。
方案2:容器中掛腳本監控堆占用,占用高時自動dump
#!/bin/bash
while sleep 1; do
now_time=$(date +%F_%H-%M-%S)
pid=`ps h -o pid --sort=-pmem -C java|head -n1|xargs`;
[[ ! $pid ]] && { unset n pre_fgc; sleep 1m; continue; }
data=$(jstat -gcutil $pid|awk 'NR>1{print $4,$(NF-2)}');
read old fgc <<<"$data";
echo "$now_time: $old $fgc";
if [[ $(echo $old|awk '$1>80{print $0}') ]]; then
(( n++ ))
else
(( n=0 ))
fi
if [[ $n -ge 3 || $pre_fgc && $fgc -gt $pre_fgc && $n -ge 1 ]]; then
jstack $pid > /home/dump/jstack-$now_time.log;
if [[ "$@" =~ dump ]];then
jmap -dump:format=b,file=/home/dump/heap-$now_time.hprof $pid;
else
jmap -histo $pid > /home/dump/histo-$now_time.log;
fi
{ unset n pre_fgc; sleep 1m; continue; }
fi
pre_fgc=$fgc
done
每秒檢查老年代占用,3次超過80%或發生一次FGC後還超過80%,記錄jstack、jmap數據,此腳本保存為jvm_old_mon.sh文件。
然後在程式啟動腳本中加入nohup bash jvm_old_mon.sh dump &
即可,添加dump參數時會執行jmap -dump
導全部堆數據,不添加時執行jmap -histo
導對象分佈情況。
事後
為了避免同類OOM case再次發生,可以對查詢進行兜底,在底層對查詢SQL改寫,當發現查詢沒有limit時,自動添加limit xxx,避免查詢大量數據。
優點:對資料庫友好,查詢數據量少。
缺點:添加limit後可能會導致查詢漏數據,或使得本來會OOM異常的程式,添加limit後正常返回,並執行了後面意外的處理。
我們使用了Druid連接池,使用Druid Filter實現的話,大致如下:
public class SqlLimitFilter extends FilterAdapter {
// 匹配limit 100或limit 100,100
private static final Pattern HAS_LIMIT_PAT = Pattern.compile(
"LIMIT\\s+[\\d?]+(\\s*,\\s*[\\d+?])?\\s*$", Pattern.CASE_INSENSITIVE);
private static final int MAX_ALLOW_ROWS = 20000;
/**
* 若查詢語句沒有limit,自動加limit
* @return 新sql
*/
private String rewriteSql(String sql) {
String trimSql = StringUtils.stripToEmpty(sql);
// 不是查詢sql,不重寫
if (!StringUtils.lowerCase(trimSql).startsWith("select")) {
return sql;
}
// 去掉尾部分號
boolean hasSemicolon = false;
if (trimSql.endsWith(";")) {
hasSemicolon = true;
trimSql = trimSql.substring(0, trimSql.length() - 1);
}
// 還包含分號,說明是多條sql,不重寫
if (trimSql.contains(";")) {
return sql;
}
// 有limit語句,不重寫
int idx = StringUtils.lowerCase(trimSql).indexOf("limit");
if (idx > -1 && HAS_LIMIT_PAT.matcher(trimSql.substring(idx)).find()) {
return sql;
}
StringBuilder sqlSb = new StringBuilder();
sqlSb.append(trimSql).append(" LIMIT ").append(MAX_ALLOW_ROWS);
if (hasSemicolon) {
sqlSb.append(";");
}
return sqlSb.toString();
}
@Override
public PreparedStatementProxy connection_prepareStatement(FilterChain chain, ConnectionProxy connection, String sql)
throws SQLException {
String newSql = rewriteSql(sql);
return super.connection_prepareStatement(chain, connection, newSql);
}
//...此處省略了其它重載方法
}
本來還想過一種方案,使用MySQL的流式查詢並攔截jdbc層ResultSet.next()
方法,在此方法調用超過指定次數時拋異常,但最終發現MySQL驅動在ResultSet.close()
方法調用時,還是會讀取剩餘未讀數據,查詢沒法提前終止,故放棄之。