压测时发现的怪事:接口 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 -->
改完之后
三条一起改完,压测数据:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| QPS | 1400 | 2350 |
| TP99 | 620ms | 310ms |
| CPU | 30% | 52% |
| 单次 getBoundSql | 0.18ms | 0.05ms |
CPU 涨上去是好事——之前都耗在反射求值这种"虚活"上,现在才是在干正事。另外我把那个 48 个 if 的巨型 select 拆成了 4 个:列表查询、导出查询、统计查询、详情查询,每个只保留自己需要的分支。
就写到这。如果哪天你也被《MyBatis 动态 SQL 的性能陷阱》里同一个坑绊住,回来翻这篇,能省半小时。