一次線上OOM問題的個人覆盤

扣釘日記發表於2023-04-01
原創:扣釘日記(微信公眾號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堆記憶體到檔案中,但在我們的場景中,這個方案難以生效,如下:

  1. 在堆佔滿之前,會發生很多次FGC,jvm會盡最大努力騰挪空間,導致還沒有OOM時,系統實際已經不響應了,然後被kill了,這種場景無dump檔案生成。
  2. 就算有時幸運,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()方法呼叫時,還是會讀取剩餘未讀資料,查詢沒法提前終止,故放棄之。

相關文章