原創:扣釘日記(微信公眾號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()
方法呼叫時,還是會讀取剩餘未讀資料,查詢沒法提前終止,故放棄之。