原創:扣釘日記(微信公眾號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=https://www.cnblogs.com/codelogs/archive/2023/04/01/$(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()方法呼叫時,還是會讀取剩余未讀資料,查詢沒法提前終止,故放棄之,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/548891.html
標籤:其他
上一篇:JAVASE:注解與反射筆記
