新闻详情

新闻详情

首页 / 资讯中心 / 详情

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

发布时间:2026/10/1 15:33:24来源:尧图网络
同一条 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 条件里的先别急着改——先确认有没有依赖它做动态排序的调用方。欢迎在评论区说说你搜出来几处。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

用Python拉取东京证券交易所历史行情并实现量化回测 2026/10/1 18:42:14

用Python拉取东京证券交易所历史行情并实现量化回测

做跨境量化或者单纯研究日本股市,最头疼的往往不是策略本身,而是“数据从哪里来”。东京证券交易所官网确实可以找到日次行情的Excel表格,但真要手动把十年历史数据一份一份保存下来,效率低到让人想放弃。我自己从踩坑到跑通这套流…

阅读更多 →
YOLOv5+TT100K交通标志识别全流程指南:从数据转换到部署 2026/10/1 18:42:14

YOLOv5+TT100K交通标志识别全流程指南:从数据转换到部署

简介:一套基于YOLOv5的交通标志牌识别完整项目,主要面向计算机、人工智能、电子信息等相关专业的学生,也适合算法初学者作为实战练习。项目选用TT100K数据集进行模型训练与验证,代码经过测试可稳定运行,可直接用于课程…

阅读更多 →
Java集合框架全解析:HashMap、ArrayList、HashSet对比与选型指南 2026/10/1 18:42:14

Java集合框架全解析:HashMap、ArrayList、HashSet对比与选型指南

很多人觉得 Java 集合框架只是面试题里背一背的八股文,实际上它在日常开发中的出现频率高得吓人。哪怕你写一个简单的接口,几行代码之内就可能用到ArrayList、HashMap或者HashSet。而我之所以决定用 DeepSeek 把这些集合的异同彻底梳理一遍,起…

阅读更多 →
用Univer实现在线表格填表:锁定单元格与模板权限控制的完整实战 2026/10/1 18:42:13

用Univer实现在线表格填表:锁定单元格与模板权限控制的完整实战

去年我接了一个内部系统的需求,要给业务部门做一套在线填表工具。业务方的想法很朴素:把原来的Excel模板搬到网页上,用户打开链接就能填,但只能填指定的几个格子,其他单元格(公式区、表头、说明区&#xff…

阅读更多 →
伪努力自救指南:识别逃避式勤奋,用行动产出结束无效忙碌 2026/10/1 18:42:07

伪努力自救指南:识别逃避式勤奋,用行动产出结束无效忙碌

你现在手头最重要的事,拖了多久了?这句话可能比任何激励都扎心。我见过太多人,包括我自己,白天把时间表排得满满当当,晚上发读书笔记、晒学习打卡,可三个月过去了,真正能改变现状的那个关键动作…

阅读更多 →
逃避式努力:用忙碌掩盖真问题,如何从伪努力到真正成长 2026/10/1 18:42:06

逃避式努力:用忙碌掩盖真问题,如何从伪努力到真正成长

最近有个词很火,叫“逃避式努力”。很多人看到这个词的第一反应是愣一下,然后苦笑——因为太真实了。你每天加班到深夜、周末报课、桌上堆满工具书、手机备忘录里全是待办清单,觉得自己特别拼、特别对得起自己。但问题来了:忙了这…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉