Administrator
发布于 2020-06-14 / 2149 阅读
40

MyBatis 动态 SQL 的性能陷阱

压测时发现的怪事:接口 TP99 高但 CPU 才 30%

六月份做订单列表接口的压测,200 并发、跑 5 分钟,结果很怪:QPS 卡在 1400 上不去,TP99 到了 620ms,但应用Ubuntu 服务器 CPU 只有 30% 出头,数据库连接池(HikariCP,最大 20)也没打满,MySQL 那边 slow log 一条都没有。

CPU 没满、DB 没慢,那时间花哪了?我直接在压测期间打了三次 jstack,把 RUNNABLE 状态的线程按栈顶方法聚合,结果排第一的不是 JDBC,是它:

"http-nio-8080-exec-113" #221 daemon prio=5 os_prio=0 tid=0x00007f... runnable
    at org.apache.ibatis.ognl.OgnlRuntime.getProperty(OgnlRuntime.java:2437)
    at org.apache.ibatis.ognl.ASTProperty.getValueBody(ASTProperty.java:114)
    at org.apache.ibatis.scripting.xmltags.OgnlCache.getValue(OgnlCache.java:47)
    at org.apache.ibatis.scripting.xmltags.ExpressionEvaluator.evaluateBoolean(...)
    at org.apache.ibatis.scripting.xmltags.IfSqlNode.apply(IfSqlNode.java:36)
    at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:33)
    at org.apache.ibatis.scripting.xmltags.DynamicSqlSource.getBoundSql(...)

20% 左右的采样都落在 OGNL 求值上。问题出在我自己写的那段动态 SQL。

问题代码:一个长了 48 个 if 的查询

订单列表要支持十几种筛选条件,我图省事全堆在一个 select 里了:

<select id="queryOrders" resultType="OrderVO">
    SELECT o.*, u.nick_name, a.address
    FROM t_order o
    LEFT JOIN t_user u ON o.user_id = u.id
    LEFT JOIN t_address a ON o.address_id = a.id
    <where>
        <if test="status != null">AND o.status = #{status}</if>
        <if test="statusList != null and statusList.size() > 0">
            AND o.status IN
            <foreach collection="statusList" item="s" open="(" separator="," close=")">
                #{s}
            </foreach>
        </if>
        <if test="userId != null">AND o.user_id = #{userId}</if>
        <if test="keyword != null and keyword != ''">
            AND (o.order_no LIKE concat('%', #{keyword}, '%')
                 OR u.nick_name LIKE concat('%', #{keyword}, '%'))
        </if>
        <!-- 后面还有四十来个 if,渠道、时间区间、金额区间、省份…… -->
    </where>
    ORDER BY o.create_time DESC
    LIMIT #{offset}, #{size}
</select>

开销在哪:OGNL 每执行一次都要解析一遍

MyBatis 处理动态 SQL 的流程是:每次调用 mapper 方法 → DynamicSqlSource.getBoundSql() → 挨个遍历 IfSqlNode/ForeachSqlNode → 每个节点用 OGNL 对 test 表达式求值。

关键在 OgnlCache 这个类。它缓存的是表达式解析后的语法树(AST),不是求值结果:

// org.apache.ibatis.scripting.xmltags.OgnlCache
public static Object getValue(String expression, Object root) {
    try {
        Map<Object, OgnlClassResolver> context = Ognl.createDefaultContext(root);
        return Ognl.getValue(parseExpression(expression), context, root);
    } catch (OgnlException e) { ... }
}

所以缓存命中了也省不掉 Ognl.getValue 的遍历开销。实测我这段 48 个 if 的语句,单次 getBoundSql 大概 0.18ms——听上去不多,但 QPS 1400 就是每秒 25 万次 OGNL 求值。

另外我踩了一个更蠢的坑:statusList.size() > 0。这个 size() 是通过反射调用的,OGNL 每次都要走一遍 OgnlRuntime.getProperty 里的反射查找。改成 statusList != null and statusList.size > 0(去掉括号)或者干脆用 MyBatis 自带的判断方式,能省一次反射。

第二个坑:foreach 塞了一万个 ID

运营后台有个"批量导出选中订单"的功能,前端一次性把勾中的 ID 全传过来。我看了下日志,最大的一次 statusList 有 11372 个元素。生成的 SQL 是这样:

SELECT ... FROM t_order o WHERE o.order_no IN (?, ?, ?, ... )  -- 11372 个占位符

这条语句的问题不止一处:

  • SQL 长度:拼出来 240KB,超过了 MySQL max_allowed_packet(我们配的 4MB 没超,但超过了 max_prepared_stmt_count 的单语句参数上限 65535)。
  • prepare 耗时:MySQL 服务端硬解析这条 IN 列表花了 1.2s,从 general log 里能直接看到。
  • 执行计划退化:优化器评估出 IN 里匹配行数太多,直接放弃索引走全表扫描,扫描 480 万行。

解决办法是按固定大小切批,我切的是 500 一批:

private static final int BATCH_SIZE = 500;

public List<OrderVO> batchQuery(List<Long> ids) {
    if (CollectionUtils.isEmpty(ids)) {
        return Collections.emptyList();
    }
    if (ids.size() > 5000) {
        throw new IllegalArgumentException("单次导出最多 5000 条,当前 " + ids.size());
    }
    List<OrderVO> result = new ArrayList<>(ids.size());
    List<List<Long>> chunks = Lists.partition(ids, BATCH_SIZE);
    for (List<Long> chunk : chunks) {
        result.addAll(orderMapper.queryByIds(chunk));
    }
    return result;
}

11372 个 ID 从一条 12s 的 SQL,变成 23 条平均 40ms 的 SQL,总耗时 900ms 左右。前端再限制一下勾选上限,基本收敛了。

第三个坑:差点写出一个 SQL 注入

这是 code review 时被组里老哥拦下来的。有一段排序逻辑我是这么写的:

<select id="queryOrders" resultType="OrderVO">
    SELECT * FROM t_order
    <where>...</where>
    ORDER BY ${orderBy} ${orderDir}
</select>

#{}PreparedStatement 的参数绑定,值会被转义;${}字符串直接拼接,MyBatis 拿到什么就往 SQL 里贴什么。字段名、表名这类位置本来就没法用 #{}(占位符只能放值),所以很多人就顺手用了 ${}

如果 orderBy 是从请求参数透传的,构造一个 orderBy=id; DROP TABLE t_order; -- 就能打进来。MySQL 的 JDBC 驱动默认 allowMultiQueries=false 挡住了这种写法,但换成 id,(SELECT SLEEP(10)) 这种注入照样能把连接池拖死。

我的做法是白名单枚举,绝不让外部字符串进 SQL:

// 只允许这几个字段排序
private static final Set<String> SORT_FIELDS = new HashSet<>(
        Arrays.asList("create_time", "pay_amount", "status"));

public List<OrderVO> query(OrderQuery query) {
    String orderBy = SORT_FIELDS.contains(query.getSortBy())
            ? query.getSortBy() : "create_time";
    String orderDir = "asc".equalsIgnoreCase(query.getSortDir()) ? "asc" : "desc";
    // 放进 Map 传给 mapper
    Map<String, Object> params = new HashMap<>();
    params.put("orderBy", orderBy);
    params.put("orderDir", orderDir);
    return orderMapper.queryOrders(params);
}

这样即使 ${orderBy} 是拼接的,拼进去的也只能是白名单里的常量。

补充:OGNL 里那些容易写错的写法

拆 mapper 的时候顺手把 test 表达式整理了一遍,有几个我以前一直写错的。

字符串判断别用 != '' 单独判断数字。OGNL 会把 0 和空字符串做类型转换比较:

<!-- status 是 Integer 类型,值为 0 时这个判断是 false -->
<if test="status != null and status != ''">AND status = #{status}</if>

<!-- 正确:数字类型只判 null -->
<if test="status != null">AND status = #{status}</if>

我们有个"查询未支付订单"的功能(status=0)一直查不出数据,就是这个原因。OGNL 在比较 Integer(0)String("") 时会尝试把字符串转成数字,转换失败后再用 compareWithConversion 走一套复杂的比较逻辑,最终 0 被认为等于空字符串。

集合判断用 size() 而不是 isEmpty()。MyBatis 3.5 的 OGNL 版本对 isEmpty() 的支持有 bug,偶尔会抛 MethodFailedException

<!-- 偶尔报错 -->
<if test="statusList != null and !statusList.isEmpty()">

<!-- 稳定写法 -->
<if test="statusList != null and statusList.size() > 0">

<where><trim> 的行为<where> 只在有内容时才插入 WHERE,并且会去掉开头的 AND/OR。但如果你的 AND 写在条件后面而不是前面,它管不了:

<!-- 错误:AND 在后面,where 标签去不掉 -->
<where>
    <if test="a != null">col_a = #{a} AND</if>
    <if test="b != null">col_b = #{b}</if>
</where>
<!-- 当只有 a 生效时,拼出来是:WHERE col_a = ? AND -->

改完之后

三条一起改完,压测数据:

指标优化前优化后
QPS14002350
TP99620ms310ms
CPU30%52%
单次 getBoundSql0.18ms0.05ms

CPU 涨上去是好事——之前都耗在反射求值这种"虚活"上,现在才是在干正事。另外我把那个 48 个 if 的巨型 select 拆成了 4 个:列表查询、导出查询、统计查询、详情查询,每个只保留自己需要的分支。

就写到这。如果哪天你也被《MyBatis 动态 SQL 的性能陷阱》里同一个坑绊住,回来翻这篇,能省半小时。

参考