原创:扣钉日记(微信公众号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()办法调用时,还是会读取剩下未读数据,查询无法提前终止,故放弃之。