乐趣区

关于jvm:一次线上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 堆内存到文件中,但在咱们的场景中,这个计划难以失效,如下:

  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() 办法调用时,还是会读取残余未读数据,查问没法提前终止,故放弃之。

退出移动版