ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

同一条 SQL 用 # 和 $ 差了 40 秒:我把 MyBatis 一条 SQL 的完整旅程拆成了 8 步

同一条 SQL 用 # 和 $ 差了 40 秒:我把 MyBatis 一条 SQL 的完整旅程拆成了 8 步 title: 同一条 SQL 用 # 和 $ 差了 40 秒我把 MyBatis 一条 SQL 的完整旅程拆成了 8 步date: 2026-10-01tags: [MyBatis, 源码解析, Executor, StatementHandler, SQL 注入, Java]2023 年双 11 前压测运营后台一个按订单号查询的接口QPS 上到 200 以后数据库 CPU 直接打满。我抓了慢 SQL 日志发现同一条件的查询在日志里出现了几千个不同的 SQL 文本——每条只差订单号。打开 Mapper 一看WHERE order_no ${orderNo}用的是美元符占位。换成#{orderNo}之后预编译语句命中了数据库的执行计划缓存同样 QPS 下数据库 CPU 从 92% 掉到了 11%P99 从 2 秒出头回到 80 毫秒以内。这个案例我后来在新人培训里讲了不下五遍。但每次讲完都有人追问为什么一个井号一个美元符差别这么大要回答透这个问题就得把一条 SQL 在 MyBatis 内部的完整旅程走一遍。一、入口Mapper 接口为什么没有实现类也能调用我们写的 Mapper 是个接口没有实现类调用却能查出数据。魔法在MapperProxypublic class MapperProxyT implements InvocationHandler, Serializable { Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { // toString/hashCode 这类 Object 方法直接走本地调用 return method.invoke(this, args); } // 缓存 MapperMethod避免每次调用都重新解析 return cachedInvoker(method).invoke(proxy, method, args, sqlSession); } }逐行解释- MyBatis 用 JDK 动态代理为 Mapper 接口生成代理对象所有接口方法调用都进入invoke。-cachedInvoker把方法解析结果缓存起来解析内容是这条方法对应哪个 SQL、参数怎么处理、返回值怎么映射。- 真正执行时拿着sqlSession去走执行链。也就是说接口方法名到 SQL 的映射是启动时扫描 XML / 注解注册进MappedStatement的运行期按接口全限定名 方法名查找。二、执行链Executor → StatementHandler 的四层结构拿到MappedStatement后进入Executor。以最常用的SimpleExecutor为例doQuery的主干public E ListE doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { Statement stmt null; try { Configuration configuration ms.getConfiguration(); // 第 1 步创建 StatementHandler内部会先创建 ParameterHandler StatementHandler handler configuration.newStatementHandler( wrapper, ms, parameter, rowBounds, resultHandler, boundSql); // 第 2 步拿数据库连接并创建 JDBC Statement stmt prepareStatement(handler, ms.getStatementLog()); // 第 3 步执行查询并映射结果 return handler.query(stmt, resultHandler); } finally { closeStatement(stmt); } }这一层下面还包着三个角色ParameterHandler负责把 Java 参数塞进预编译占位符ResultSetHandler负责把 ResultSet 映射成对象TypeHandler负责具体的类型转换。一条 SQL 的旅程可以总结成 8 步接口代理 → MappedStatement 查找 → 参数封装ParameterMap→ SQL 动态解析OGNL→ Statement 创建 → 参数绑定 → 执行 → 结果映射。三、# 和 $ 的分水岭动态 SQL 解析阶段差异发生在生成BoundSql的阶段。XML 里的占位符会被SqlSourceBuilder处理public class SqlSourceBuilder extends BaseBuilder { public SqlSource parse(String originalSql, Class? parameterType, MapString, Object additionalParameters) { ParameterMappingTokenHandler handler new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters); // 把 #{...} 替换成 ?同时收集每个 ? 对应的参数映射 GenericTokenParser parser new GenericTokenParser(#{, }, handler); String sql parser.parse(originalSql); return StaticSqlSource(sql, handler.getParameterMappings(), configuration); } }逐行解释-#{}会被替换成 JDBC 的?占位符参数值和 SQL 文本分离各自存放在 BoundSql 和 ParameterMappings 里。- 执行阶段由DefaultParameterHandler调用PreparedStatement.setXxx绑定真实值。- 因为参数从未拼进 SQL 文本同一段 SQL 文本在数据库端只有一份解析结果执行计划可以复用——这就是我们压测案例提速的根本原因硬解析变成了软解析。而${}是在 OGNL 求值后直接做字符串替换值会原样拼进 SQL 文本。两个后果一是数据库每次看到的 SQL 都不同只能硬解析执行计划缓存全废二是注入风险——如果 orderNo 传入1 OR 11拼出来就是全表语义。那${}是不是就该彻底禁用我不这么看。ORDER BY列名、动态表名这类位置不允许绑定变量只能拼接。我的做法是${}只允许出现在列名/表名位置且必须在 Java 层用白名单校验取值范围评审时看到WHERE/VALUES后面跟${}一律打回。四、缓存与批处理两个容易被忽略的 ExecutorExecutor是个策略接口除了 SimpleExecutor 还有两个值得认识的实现。一个是BatchExecutor把多条同构语句攒在 JDBC batch 里一次提交。我们同步 10 万行数据的定时任务从逐条 insert 改成 batch 模式后耗时从 26 分钟降到 3 分半——开销主要省在网络往返和每次提交的事务开销上。另一个是CachingExecutor装饰在真实 Executor 外面负责二级缓存。但我的观点很直接二级缓存默认不开业务代码里也别开。它的失效粒度是 namespace 级多表关联时任何一张表所在 namespace 的更新都无法精确失效另一边的缓存脏读概率不低。一级缓存SqlSession 级在 Spring 集成下每次请求都是新 session基本不构成收益偶尔还会因为同一 session 内两次相同查询拿到旧值造成困惑——我们就被坑过一次同请求内先查后改再查第三次查返回的还是改之前的值排查了一小时才意识到一级缓存还在生效。五、插件机制所有 SQL 监控的挂载点MyBatis 允许用Interceptor拦截执行链上的四大对象分页、SQL 审计、慢 SQL 打点全都挂在这个机制上。一个记录慢 SQL 的最小插件Intercepts({ Signature(type StatementHandler.class, method query, args {Statement.class, ResultHandler.class}) }) public class SlowSqlInterceptor implements Interceptor { private final long thresholdMs 200; Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); // 拿到解析后的最终 SQL 文本打印前先格式化参数 BoundSql boundSql handler.getBoundSql(); String sql boundSql.getSql().replaceAll(\\s, ); long start System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost System.currentTimeMillis() - start; if (cost thresholdMs) { // 打点进监控系统带上 SQL 指纹方便聚合 Metrics.timer(mybatis.slow, sql, sql.substring(0, 120)) .record(cost, TimeUnit.MILLISECONDS); } } } }逐行解释-Signature声明拦截点StatementHandler 的 query 方法所有 select 都会路过这里。-intercept里先取出 BoundSql——注意拿到的已经是动态标签解析完的最终 SQL和压测日志里看到的一致。- finally 块保证即使查询抛异常也能记录耗时慢查询在故障排查时往往比成功请求更有信息量。插件是动态代理的又一个应用MyBatis 用Plugin.wrap给四大对象套上代理链多个插件按注册顺序层层包裹。这里有一个隐藏顺序问题我踩过分页插件和慢 SQL 插件的执行顺序取决于注册顺序分页插件改写 SQL 之后慢 SQL 插件拿到的才是真 SQL——注册顺序写反了监控里记的就是改写前的原始 SQL聚合出来的慢查询和 DBA 慢日志对不上号。这类谁先谁后的细节文档里只有一句话实操全靠踩。六、结果映射TypeHandler 在最后一公里ResultSet 到 Java 对象的转换由ResultSetHandler完成具体类型转换委托给TypeHandler。默认的StringTypeHandler取列值前会做 null 判断public class StringTypeHandler extends BaseTypeHandlerString { Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { // JDBC 的 getString 对 NULL 列返回 null不需要特殊处理 return rs.getString(columnName); } }但自定义类型就没这么省心了。我们有个枚举字段用MappedTypes注册了自定义 TypeHandler早期版本在getNullableResult里没有判 null遇到 NULL 列直接 NPE而且发生在结果映射阶段——堆栈里全是反射调用第一眼根本看不出是哪个字段。规则很朴素自定义 TypeHandler 的取值方法第一行先判 null这是比任何测试用例都管用的护栏。七、复盘真实数字压测场景订单号查询QPS 200修复前数据库 CPU 92%P99 超 2s慢 SQL 每分钟上千条不同文本修复后CPU 11%P99 80ms改动量一个占位符从${}改成#{}连带收益DBA 告警群里关于该接口的慢查询告警归零八、我的取舍判断参数值一律#{}这是我团队 Mapper 评审的硬规矩没有例外。${}只用于列名/表名且必须有白名单校验代码评审看到就问一句值从哪来。性能敏感的批量写入场景优先ExecutorType.BATCH比调大连接池有效得多。二级缓存不碰一级缓存的行为要写进团队新人手册省下每个新人一小时的排查时间。九、思考题你的项目里搜一下\$\{看看有多少处美元符占位逐个确认它们拼进的是不是参数值。如果有在 WHERE 条件里的先别急着改——先确认有没有依赖它做动态排序的调用方。欢迎在评论区说说你搜出来几处。
返回列表