news 2026/10/6 13:55:29

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 id="selectById" resultType="User"> 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 id="selectByOrder" resultType="Order"> 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 id="login" resultType="User"> 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 id="login" resultType="User"> 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 id="selectByCurrency" resultType="Order"> SELECT * FROM ${tableName} WHERE merchant_id = #{merchantId} AND create_time BETWEEN #{startTime} AND #{endTime} </select>

这种写法${}只承接表名,条件值全是#{},既保证结构灵活,又把注入面压到最低。前提是tableName必须在代码层做白名单校验,绝不能直接把前端传参透传过来。

GROUP BY场景也类似:

<select id="selectGroupSummary" resultType="Map"> 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、3,SQL文本一模一样,属于同一个缓存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 id="selectUsers" resultType="User"> SELECT * FROM user WHERE status = 1 <if test="name != null and name != ''"> AND name = #{name} </if> <if test="type != null"> AND type = ${type} </if> </select>

当type = null时,这段SQL拼接出来是正常的。但当type = 1的时候,注意:${type}拼进去的是1,不带引号,如果你的type字段在数据库里是varchar类型,这个查询条件大概率匹配不到数据,而且type值如果被传入恶意内容,注入风险直接拉满。

正确写法是这样:

<select id="selectUsers" resultType="User"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name = #{name} </if> <if test="type != null"> AND type = #{type} </if> </where> </select>

里面还有两个细节值得注意。一个是把WHERE改成<where>标签,它能自动处理条件前面多余的AND,不会出现SQL语法错误。另一个是name != ''这个判断,很多初学者写<if test="name != null">就结束了,如果前端传了空字符串,条件就会带上一个AND name = '',查不到任何数据。

4.3 模糊查询LIKE的占位符写法

模糊查询几乎是每个项目都逃不掉的场景,这里的占位符坑也非常典型。TRIM_TOOK_LIKE的常见写法有三种,我给你逐个对比一下:

第一种,错误示范,也是新手最常写的:

<select id="searchUser" resultType="User"> SELECT * FROM user WHERE name LIKE '%${keyword}%' </select>

这个写法有两个问题:一是SQL注入风险,keyword直接拼进SQL;二是如果keyword里本身就带了单引号,这条SQL直接就语法报错了。

第二种是#{}直接配合字符串拼接的写法:

<select id="searchUser" resultType="User"> SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%') </select>

这种写法安全,参数值不会参与SQL编译,但要注意:如果keyword传了%或_这种MySQL通配符,它会被当成通配符处理,可能匹配到超预期范围的数据。用户搜索"100%"这种文本时就会踩坑。

第三种是我在项目中比较推荐的做法:

<select id="searchUser" resultType="User"> 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

重点来了,如果是${}拼接的SQL,Preparing那行会直接看到完整的替换后的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 Set<String> SORT_WHITELIST = Set.of("create_time", "order_amount", "status", "id"); public List<Order> 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里的${},看看哪些是能用#{}替代的,哪些是必须保留但缺少白名单校验的。每一次这样的自查,都是在给你的系统减少一个潜在的线上事故。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 13:55:28

PADS Router布线前必做的工程准备与规则设置避坑指南

PADS Router 布线从来不是打开软件就拉线这么简单。真正决定一块板子能不能顺利布通、信号干不干净、后期改版痛不痛苦的&#xff0c;往往是你坐在 Router 面前之前&#xff0c;在 Layout 和规则设置上下的功夫。我在这行做了十多年&#xff0c;经手的板子从双面板到十几层高速…

作者头像 李华
网站建设 2026/10/6 13:55:26

Java运算符与控制流:从优先级陷阱到位运算与分支循环实战

学习Java到这章&#xff0c;算是正式进入语法核心地带了。上一章写过class、main、变量声明之后&#xff0c;你马上要面对的就是最常用的运算写法&#xff0c;以及控制程序走向的语句结构。我见过不少新手被运算符优先级绕晕&#xff0c;也在面试里问过几十次自增自减和浮点数比…

作者头像 李华
网站建设 2026/10/6 13:55:21

hyperframe:TSN网络帧突发调度实战与Linux taprio配置

1. 为什么我会花两周时间研究 hyperframes 这篇文章我想聊聊 hyperframes。年初我接手了一套车载以太网的网络改造&#xff0c;把原来的 CAN 总线迁移到 TSN 确定性以太网上。需求本身并不复杂&#xff1a;几条摄像头数据流加一批控制报文&#xff0c;要在 1ms 周期内保证端到端…

作者头像 李华
网站建设 2026/10/6 13:53:41

SAP-HR薪酬管理全流程实战:从工资项到成本分摊

简介&#xff1a;一份面向中国石化SAP-HR系统薪酬管理模块的应用培训课件&#xff0c;适合HR、薪酬专员及ERP实施人员快速掌握薪酬核算整体流程。课件系统梳理了薪酬管理业务实现、人工成本计划编制与审批、工资总额下达与监控、工资核算范围、成本中心等核心概念&#xff0c;并…

作者头像 李华
网站建设 2026/10/6 13:46:41

SpringBoot+MyBatis Plus+Vue农产品销售平台:从零搭建到答辩通关实战

简介&#xff1a;一份面向计算机专业毕业设计论文的参考文档&#xff0c;主题为洛川县苹果销售管理平台的设计与实现。内容严格遵循需求分析、技术选型、系统设计、编码实现、系统测试的软件开发流程&#xff0c;从选题意义与研究目标切入&#xff0c;详细论述了Spring Boot框架…

作者头像 李华
网站建设 2026/10/6 13:45:52

C++深拷贝与浅拷贝:拷贝构造函数、内存管理与避坑指南

1. 从一个崩溃现场讲起&#xff1a;为什么拷贝这件事必须较真先说个我踩过的坑。当时我在写一个日志缓冲模块&#xff0c;核心结构体里有一个char*指针&#xff0c;指向一块堆上分配的内存。模块运行一周都很平稳&#xff0c;直到某一天缓存队列做了一次排空&#xff0c;程序直…

作者头像 李华