ARTICLE DETAIL

资讯详情

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

MyBatis中#{}和${}的区别:源码解析、SQL注入与缓存实战

MyBatis中#{}和${}的区别:源码解析、SQL注入与缓存实战 1. 面试官到底在考什么占位符问题背后的四个考点先说说我对这道题的理解。面了这么多年MyBatis相关的问题里#{}和${}的区别大概是出场率最高的一道没有之一。但这道题能问到什么深度完全取决于你怎么答。你要是只说#{}是预编译${}是字符串拼接——这基本就是入门级答案面试官不会追问但也不会给你加分。要是你能从JDBC层面讲清楚PreparedStatement和Statement的本质差异再往上扯到MyBatis的SqlSource解析流程最后落到SQL注入、缓存命中、动态SQL使用场景这些实际问题上——那这道题就不是一道送分题了而是你的展示题。我总结了一下面试官问这个问题背后其实藏了四个考点你到底有没有真正理解预编译这个底层概念还是只是背了结论你有没有SQL注入的安全意识知道什么时候能用${}你有没有在真实项目里踩过占位符的坑比如动态排序、模糊查询、LIKE拼接这些场景你能不能从源码层面讲清楚MyBatis处理SQL的全流程而不是停留在写Mapper的层面所以这篇文章我就按这个逻辑来拆先讲清楚底层原理再上源码然后给几个高频追问的标准答案最后给一份可以直接用的面试应答话术。你要是能把这篇吃透这道题基本就稳了。顺便说一下这个问题我后面会持续更新每次面试遇到新的问法或者看到有意思的坑都会补进来。你可以先收藏回头有新内容直接看增量部分。2. 核心区别拆解#{}与${}的底层原理2.1#{}的底层逻辑JDBC预编译源码实例#{}在MyBatis里最终会被解析成JDBC的?占位符然后通过PreparedStatement的setXxx()方法传入参数。这是什么概念呢我直接给你扒到JDBC层面看一下。JDBC原生写法是这样的String sql SELECT * FROM user WHERE id ?; PreparedStatement ps conn.prepareStatement(sql); ps.setLong(1, 10086L); ResultSet rs ps.executeQuery();这条SQL在执行之前就会被数据库编译生成执行计划后面传入的10086L只是一个纯参数值不会再参与SQL语句本身的编译。MyBatis里的#{}本质上就是帮你做了上面这几行代码的事情。你写的select idselectById resultTypeUser SELECT * FROM user WHERE id #{id} /select在MyBatis底层会被改写成SELECT * FROM user WHERE id ?然后执行时调用ps.setLong(1, id);这里有两个关键点你需要深刻理解。第一参数是值而不是SQL片段。数据库看到的就是一条带问号的完整SQL和一组干净的参数值参数不可能改变SQL的结构这就是SQL注入防护的本质。第二预编译的SQL可以被缓存复用。如果SQL语句结构完全相同只是参数值不同数据库连接池可以直接复用同一个PreparedStatement对象不用每来一个请求就重新编译一遍SQL这对高并发场景的性能提升是实打实的。2.2${}的底层逻辑字符串拼接引发的安全问题${}就完全不一样了。MyBatis会对这段文本做纯字符串替换替换完之后才形成最终的SQL语句然后再交给数据库去执行。也就是说${}拼进去的是SQL片段它本身参与了SQL语句的编译。典型写法是这样的select idselectByOrder resultTypeOrder SELECT * FROM order_info ORDER BY ${orderBy} /select传入orderBy create_time DESC最终生成的SQL是SELECT * FROM order_info ORDER BY create_time DESC如果你传入的是orderBy create_time DESC; DROP TABLE order_info; --那最终生成的SQL就变成了一条带破坏性语句的拼接结果。这就是为什么我一再强调能用#{}的地方绝对不要用${}。2.3 一条老生常谈的SQL注入案例SQL注入不是理论概念是真真切切能打到线上系统的。我给你还原一个最简单也最经典的登录绕过场景。假设你的Mapper写的是select idlogin resultTypeUser SELECT * FROM user WHERE username ${username} AND password ${password} /select正常用户输入用户名admin、密码123456拼出来的SQL很干净。但如果有人在用户名框里输入这个admin --那最终拼出来的SQL就变成了SELECT * FROM user WHERE username admin -- AND password 123456--是MySQL的注释符后面的条件全被注释掉了。这条SQL的查询结果就是整个user表里的admin账户记录如果有人用这套代码做登录接口那他根本不需要知道密码直接就能以admin身份登录系统。你用#{}改写一下就完全不存在这个问题select idlogin resultTypeUser SELECT * FROM user WHERE username #{username} AND password #{password} /select不管输入什么都会被当成一个字符串参数丢给PreparedStatement处理数据库层面就把代码和数据区分开了。这就是预编译防注入的核心理念。2.4 两者多维度对比速查表区别讲完了整理一张对照表面试的时候你可以边说边在心里过一遍这些维度对比维度#{}${}底层实现PreparedStatement的占位符?纯字符串拼接是否预编译是否SQL注入风险无有参与SQL结构变更否只传值是可拼接任意片段排序字段/表名/列名不支持支持模糊查询支持配合CONCAT支持直接拼缓存命中影响同一SQL结构始终复用SQL文本变化缓存失效日志可读性参数和SQL分开打印完整SQL一眼看清这张表基本把面试官能追问的点都覆盖到了。背下来只能算及格真正把每一行的为什么讲清楚才算深度解析。3. 源码层面MyBatis是如何处理这两种占位符的如果面试官追问了一句你从源码角度讲讲这时候你的回答层级就上去了。我按MyBatis的执行链路拆给你看。3.1 SQL解析阶段SqlSourceBuilder和DynamicSqlSource的分工MyBatis在解析Mapper XML文件的时候会根据SQL中是否包含动态SQL标签if、where、foreach等或者${}来决定生成哪种SqlSource。如果SQL里只有#{}和静态文本MyBatis会生成RawSqlSource——这是最理想的情况。如果SQL里混了动态SQL或者${}就会生成DynamicSqlSource每次执行都需要重新解析SQL。关键点来了在解析阶段#{}会被解析成ParameterMapping并替换为JDBC的占位符?而${}根本不会被替换它会在执行阶段通过TextSqlNode里面的BindingTokenParser做文本替换。也就是说${}的解析发生在SQL拼接的运行时而#{}的解析发生在SQL结构确认的编译期。一个简单的判断方法你去翻MyBatis源码的SqlSourceBuilder类里面核心方法就是处理#{}的它会遍历SQL文本把形如#{property}的部分替换成?同时生成对应的ParameterMapping列表。而${}的替换逻辑在GenericTokenParser配合TokenHandler的实现类里说白了就是找到${}取出内部的表达式然后调用相关类得到结果值把这个值替换到SQL文本中。3.2 参数绑定阶段PreparedStatementHandler的setParameters再往后走#{}和${}的分化就更明显了。${}在之前的阶段已经把值拼进SQL了所以走到Executor执行的时候它就是一串已经定型的SQL字符串。而#{}的SQL是带?的需要通过PreparedStatementHandler.instantiateStatement()创建PreparedStatement再调用parameterize()方法。parameterize()内部会调用DefaultParameterHandler.setParameters()这个方法遍历之前解析好的ParameterMapping列表对每一个ParameterMapping根据JavaType和JdbcType调用PreparedStatement上对应的setString、setLong、setInt等方法把参数值绑定进去。这个过程你不需要把所有源码背下来但你要能说出这个链路Mapper方法调用 - SqlSession - Executor - StatementHandler得到BoundSql - ParameterHandler处理#{}参数绑定 - PreparedStatement.setXxx() - 执行SQL你把这个链路讲出来面试官基本就会认为你确实看过源码了。3.3 项目实战结合spring boot mybatis 项目里的真实应用说回实际项目。现在我们做spring boot mybatis开发大多数业务SQL都用#{}但也有些场景逼着你用${}。我把我在项目里总结的判断标准放这里能用#{}的场景查询的等值匹配条件插入和更新操作中的字段值批量插入的每条记录值子查询的过滤条件分页查询的limit和offset参数配合PageHelper时内部会处理不得不用${}的场景ORDER BY后面的排序字段GROUP BY后面的分组字段表名或数据库名比如月份分表的逻辑查询IN子句里的动态字段名但值还是要用#{}举个例子做国际化电商项目时多商户跨境商城的订单查询经常需要按不同币种、不同汇率表查询。如果表设计成order_usd、order_eur这种按月或按币种分表结构那表名就必须用${}动态传入select idselectByCurrency resultTypeOrder SELECT * FROM ${tableName} WHERE merchant_id #{merchantId} AND create_time BETWEEN #{startTime} AND #{endTime} /select这种写法${}只承接表名条件值全是#{}既保证结构灵活又把注入面压到最低。前提是tableName必须在代码层做白名单校验绝不能直接把前端传参透传过来。GROUP BY场景也类似select idselectGroupSummary resultTypeMap SELECT ${groupColumn}, COUNT(*) AS cnt, SUM(order_amount) AS totalAmount FROM order_info WHERE status #{status} GROUP BY ${groupColumn} /select${groupColumn}这个值如果是从枚举或者固定配置表里取的安全风险可控。要是直接从前端接收——早晚会出事。4. 高频追问缓存、动态SQL和模糊查询的大坑4.1 缓存命中${}如何在无意识中击穿缓存很多人在实际项目里配了MyBatis的一级缓存和二级缓存却没注意过占位符对缓存命中率的影响。这是个非常隐蔽的问题。MyBatis缓存的核心Key中包含了一条SQL的完整文本。如果SQL完全一致缓存才能命中。#{}参数化后SQL文本是固定不变的比如SELECT * FROM user WHERE id ?传id等于1、2、3SQL文本一模一样属于同一个缓存Key只是参数值不同。而${}每次替换不同值后SQL文本都在变-- 传入1时 SELECT * FROM user WHERE id 1 -- 传入2时 SELECT * FROM user WHERE id 2 -- 传入3时 SELECT * FROM user WHERE id 3这几条SQL在数据库层面的执行计划缓存里是完全不同的语句在MyBatis缓存里的Key也不一样等于每次都强制走全流程解析缓存形同虚设。我之前在项目里排查过一个线上慢查询的问题有个报表查询接口每次响应都超过3秒明明配了二级缓存却没生效。后来翻日志发现Mapper里写的是WHERE id ${id}改成#{}之后这个接口的响应时间直接降到了200毫秒以内。这个坑不踩一次你真的不会意识到占位符和缓存之间还有这层关系。顺带说一句面试里如果被问到MyBatis一级缓存和二级缓存的区别你可以把这个点带进去讲会让你的回答内容明显更充实。4.2 动态SQL的经典陷阱if判空和等于条件的坑动态SQL是MyBatis最实用的能力之一但也是占位符相关坑的重灾区。最常见的一个坑是if标签的判定条件写错导致条件不生效。比如你写了这么一段select idselectUsers resultTypeUser SELECT * FROM user WHERE status 1 if testname ! null and name ! AND name #{name} /if if testtype ! null AND type ${type} /if /select当type null时这段SQL拼接出来是正常的。但当type 1的时候注意${type}拼进去的是1不带引号如果你的type字段在数据库里是varchar类型这个查询条件大概率匹配不到数据而且type值如果被传入恶意内容注入风险直接拉满。正确写法是这样select idselectUsers resultTypeUser SELECT * FROM user where if testname ! null and name ! AND name #{name} /if if testtype ! null AND type #{type} /if /where /select里面还有两个细节值得注意。一个是把WHERE改成where标签它能自动处理条件前面多余的AND不会出现SQL语法错误。另一个是name ! 这个判断很多初学者写if testname ! null就结束了如果前端传了空字符串条件就会带上一个AND name 查不到任何数据。4.3 模糊查询LIKE的占位符写法模糊查询几乎是每个项目都逃不掉的场景这里的占位符坑也非常典型。TRIM_TOOK_LIKE的常见写法有三种我给你逐个对比一下第一种错误示范也是新手最常写的select idsearchUser resultTypeUser SELECT * FROM user WHERE name LIKE %${keyword}% /select这个写法有两个问题一是SQL注入风险keyword直接拼进SQL二是如果keyword里本身就带了单引号这条SQL直接就语法报错了。第二种是#{}直接配合字符串拼接的写法select idsearchUser resultTypeUser SELECT * FROM user WHERE name LIKE CONCAT(%, #{keyword}, %) /select这种写法安全参数值不会参与SQL编译但要注意如果keyword传了%或_这种MySQL通配符它会被当成通配符处理可能匹配到超预期范围的数据。用户搜索100%这种文本时就会踩坑。第三种是我在项目中比较推荐的做法select idsearchUser resultTypeUser SELECT * FROM user WHERE name LIKE CONCAT(%, #{keyword}, %) ESCAPE / /select配合在Java层对关键字做转义public String escapeLikeWildcard(String keyword) { if (keyword null) { return null; } return keyword .replace(/, //) .replace(%, /%) .replace(_, /_); }这样既防了SQL注入又规避了通配符误匹配搜索准确度会明显提升。4.4 配置打印SQL排查占位符问题的神器遇到占位符相关疑难杂症第一件事就是把MyBatis的SQL日志打印打开。我通常在application.yml里配mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.your.mapper.package: debug这样控制台会输出Preparing和Parameters两行 Preparing: SELECT * FROM user WHERE id ? AND status ? Parameters: 10086(Long), 1(Integer) Total: 1重点来了如果是${}拼接的SQLPreparing那行会直接看到完整的替换后的SQL内容如果是#{}你只会看到问号参数在Parameters行里。这对快速判断代码里到底用的是哪种占位符非常有帮助。还有一个经验加了mybatis-plus的项目可以在配置里打开mybatis-plus.configuration.log-impl或者直接用p6spy来做更直观的SQL监控效果都差不多。面试里被问到你怎么排查SQL相关问题时能说出这个工具链印象分会上去不少。5. 面试现场一份可以直接用的应答模板和加分话术5.1 精简应答框架45秒版本如果面试官只是让你简单说两句你可以按这个框架来#{}会被MyBatis解析成JDBC的预编译占位符对应PreparedStatement的?参数通过setXxx传入不会改变SQL结构能防SQL注入也是我的默认选择${}是纯字符串替换拼进去的内容会参与SQL编译存在注入风险所以只在排序字段、表名这类无法参数化的场景下使用并且这个值必须在Java代码侧做白名单校验。这个回答的核心亮点是最后半句——提到白名单校验。大多数候选人只会背概念你能主动说出安全兜底方案面试官会觉得你是真的在项目里做过。5.2 追问场景拆解不同深度的加分回答追问一为什么#{}能防SQL注入不要只说因为它用了预编译要往下说一层。数据库在编译SQL时已经确定了语句的角色——哪些部分是关键字、哪些部分操作符、哪些部分是参数值。PreparedStatement在SQL编译阶段就把?的位置预留了之后set进去的任何值都只会被当作参数值处理不会重新改变SQL语句的结构。而${}是先拼SQL再编译那参数内容就已经成为SQL结构的一部分了。结构可被输入改变就是注入产生的原因。追问二那${}是不是一无是处不是。凡是SQL的结构化部分需要动态变化#{}就无能为力了比如排序字段、分组字段、表名。但这种情况下正确的做法是在Service层做严格的值的枚举校验。给你一个完整的示例排序白名单校验private static final SetString SORT_WHITELIST Set.of(create_time, order_amount, status, id); public ListOrder queryOrderList(String sortField, String sortOrder) { if (!SORT_WHITELIST.contains(sortField)) { throw new BizException(非法的排序字段); } String safeOrder DESC.equalsIgnoreCase(sortOrder) ? DESC : ASC; return orderMapper.selectOrderList(sortField safeOrder); }反编译看这行代码的逻辑sortField确认在枚举列表里sortOrder强制二选一用户输入根本没有到达SQL层面的机会。追问三分页插件PageHelper和占位符的关系加分项来了。PageHelper本质上会在你的SQL外面包一层LIMIT ?它内部用的就是参数化的方式。所以你在Mapper里别写死limit条件交给PageHelper更安全也更优雅。这个点不在原问题范围内但你能主动带出来就显得纵向知识体系比普通候选人扎实。5.3 面试避坑清单根据我过往的面试经验这道题有一批高频踩坑点专门列出来警醒一下只背结论说不清原理。#{}比${}安全只是一句话没有JDBC层面的支撑这个回答没有说服力。说#{}不能做模糊查询。这是完全错误的。配合CONCAT完全可以做只是不能直接写在LIKE %#{}%里那种写法——那会变成字面量LIKE %?不对实际上是%#{keyword}%压根不会被替换成参数因为MyBatis不认为这是一个合法的#{}表达式。严谨一点说LIKE %#{keyword}%这种写法里的#{}会被当成普通字符串查询结果永远为空。类似但不完全一样。盲目说${}不能用。太绝对了。动态表名、动态排序字段这些场景绕不开${}关键是配套的安全机制。忽略了缓存层面的影响。高级面试官会拿缓存命中率来追加提问提前准备好答案很有必要。不能主动说出什么时候用#{}什么时候必须用${}。这个判断标准就是工作经验的分水岭。5.4 持续更新的内容计划这个答题模板我会保持更新。后面计划补的内容包括#{}和${}在多商户跨境电商项目里的实际应用案例拆解MyBatis二级缓存与${}冲突时的优化方案spring boot mybatis做动态表名分库分表时的占位符设计模式SQL注入绕过的常见变体和对应的代码层防御策略有新的面试真题进来我会第一时间把答案和源码验证补上。6. 一个过来人的最后忠告我见过很多开发者在面对这个问题时把大部分精力放在背诵区别列表上。说实话背下来并不难难的是你把这个知识点放进真实的工程场景里理解它为什么这么设计以及什么时候必须打破默认选择。如果你是在准备面试我建议你按这个顺序过一遍先把这个文档看明白然后打开MyBatis源码把SqlSourceBuilder和ParameterHandler这两个类翻一下最后回到自己的项目里用日志打印的方式把#{}和${}的拼装结果对比看一遍。这套动作做完这道题你就真正掌握了。如果你已经工作了那我给你一个实在的建议回去检查一下代码仓库搜一遍Mapper XML里的${}看看哪些是能用#{}替代的哪些是必须保留但缺少白名单校验的。每一次这样的自查都是在给你的系统减少一个潜在的线上事故。
返回列表