Mybatis防SQL注入:有#{}为何还要${}?预编译与动态SQL全解析

📅 发布时间:2026/9/7 10:08:20
Mybatis防SQL注入:有#{}为何还要${}?预编译与动态SQL全解析
Java面试官Mybatis如何防止SQL注入有#{}为啥还要设计${}很多 Java 开发者准备面试的时候对 Mybatis 防 SQL 注入的答案都能背得滚瓜烂熟“用 #{} 而不是 ${}#{} 走预编译${} 是字符串拼接”。但面试官往往不会就此打住他会接着追问一句“既然 #{} 这么好为什么 Mybatis 还要设计 ${} 这种带风险的东西直接删掉不就行了”如果只是背过答案到这里大概率会卡壳。因为这个问题考察的不再是“知不知道 #{} 和 ${} 的区别”而是你有没有从 SQL 执行原理、JDBC 规范、动态 SQL 真实场景三个层面理解 Mybatis 的设计取舍。这类问题在 Java 八股文里属于高频考点但也是典型的“背答案容易、讲原理难”的题目。本文会把这条链路完整拆开从 SQL 注入的本质讲起再回到 JDBC 的 Statement 和 PreparedStatement 对比然后落到 Mybatis 的 #{} 与 ${} 实现机制最后给出必须使用 ${} 时的安全兜底方案。读完之后你不光能答上这道面试题还知道在真实项目里怎么写出既灵活又安全的 SQL。1. 这篇文章真正要解决的问题先说一个真实场景。我见过不少工作了三年左右的 Java 开发在项目中写 Mapper.xml 时养成了两个极端习惯一种是从头到尾只用 #{}不管什么字段都往 #{} 里塞。结果遇到动态排序、动态表名、IN 列表这种场景时发现写不出来只能把整段 SQL 改成自定义拼接反而引入更大的风险。另一种是知道 ${} 能拼接 SQL于是图省事到处用甚至把用户传来的排序字段直接拼进去等于在项目里给攻击者留了一扇门。这两种习惯背后的共同问题是很多人只记住了“#{} 安全、${} 危险”这个结论但没有真正理解两者的边界在哪里以及为什么 Mybatis 要同时保留这两个能力。面试官追问的恰恰是这个边界感。这篇文章适合以下几类读者准备 Java 后端面试想把 Mybatis 高频题从“背答案”升级为“讲原理”的开发者。项目中已经用到了 ${}但不确定自己的写法是否安全的开发者。正在做代码评审发现同事在 Mapper.xml 里大量使用 ${}想去说服对方但又讲不清原理的技术人员。读完这篇文章你会得到三个明确收益第一理解 SQL 注入的本质和防御原理不是停留在“加个 # 就行”的层面第二搞清楚 #{} 和 ${} 在 Mybatis 中的真实实现路径第三掌握一组“必须用 ${} 时如何安全落地”的工程方法。2. SQL注入的本质为什么拼字符串会出事聊 Mybatis 之前先回到最底层的问题SQL 注入为什么能成功攻击者到底利用了哪一环的漏洞SQL 注入的本质是用户输入的数据被当作 SQL 代码的一部分执行了。正常情况下“数据”和“代码”是两个不同的东西。用户输入‘admin’ 时你希望数据库把它当成一个字符串值但如果直接把用户输入拼进 SQL 语句数据库无法区分哪一段是代码、哪一段是数据于是攻击者就能借机改变 SQL 的语义。举个例子这是一段典型的拼接写法// 错误示例字符串拼接SQL String sql SELECT * FROM user WHERE name username AND password password ;假设用户输入的 username 是admin OR 11拼接后的 SQL 变成SELECT * FROM user WHERE name admin OR 11 AND password 在 SQL 的优先级规则里AND 的优先级高于 OR所以真正执行的逻辑变成了SELECT * FROM user WHERE name admin OR (11 AND password )11永远为真最终这条 SQL 返回了全表数据。攻击者甚至不需要知道密码就能绕过认证。这就是经典的“万能密码”绕过原理。需要注意的是这里写出来只是为了教学演示用于理解防御思路千万不要在任何非授权的系统上尝试。在 MySQL 中还存在一种更隐蔽的“注释注入”手法。比如用户输入admin --拼接后得到SELECT * FROM user WHERE name admin -- AND password --是 MySQL 的注释符它后面所有的内容都会被忽略于是密码校验逻辑直接被注释掉了。看到这里你应该能明白一个关键点SQL 注入之所以防不住拼接写法是因为拼接从根本上混淆了“代码”和“数据”的边界。数据库拿到这条完整 SQL 时根本不知道admin OR 11这段内容是用户输入的数据还是查询条件的一部分。那么防御 SQL 注入到底要解决什么问题说白了就是一句话让数据库在执行 SQL 时能够把用户输入严格当成“值”来处理而不是可以改变语句结构的“代码”。3. JDBC层的答案Statement和PreparedStatement的区别Mybatis 的 #{} 和 ${} 之争往前追溯其实是 JDBC 中Statement和PreparedStatement的区别。如果不先把这一层讲清楚后面讲 Mybatis 原理时你会觉得悬在空中。先看 JDBC 中使用Statement的写法// 文件路径任意测试类中 String username admin OR 11; Statement statement connection.createStatement(); String sql SELECT * FROM user WHERE name username ; ResultSet rs statement.executeQuery(sql);这段代码做了什么它在 Java 层直接把用户输入拼进 SQL 字符串然后把完整的字符串发给数据库。数据库收到后需要先解析这条 SQL 的语法结构然后才能执行。问题在于用户输入在到达数据库之前已经和 SQL 语句牢牢粘在一起了数据库解析时无法区分它只能把所有内容都当作语法的一部分去理解于是注入就成功了。再对比PreparedStatement的写法// 文件路径任意测试类中 String sql SELECT * FROM user WHERE name ? AND password ?; PreparedStatement ps connection.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password); ResultSet rs ps.executeQuery();这里的关键变化是什么SQL 语句在发送给数据库之前会先经过一次“预编译”。prepareStatement(sql)这一步数据库已经把 SQL 语句的结构解析好了WHERE name ? AND password ?是一段固定的语法结构问号只是占位符代表“这里之后会填一个值但不会改变语句结构”。当调用ps.setString(1, username)时用户输入是通过参数的方式传过去的而不是作为 SQL 的一部分传过去的。即使你传入admin OR 11PreparedStatement 也会把它当做一个完整的字符串值传给数据库数据库会把admin OR 11整体作为 name 字段的内容去比较而不是把它当作 SQL 代码去解析。为了验证这个差异可以在线上环境或本地 MySQL 中开启通用日志执行下面两条语句-- 方式一Statement 拼接执行 SELECT * FROM user WHERE name admin OR 11; -- 方式二PreparedStatement 预编译执行 SELECT * FROM user WHERE name ?; -- 通过 setString 传入admin OR 11你可以把这两种方式理解为“打印一份合同”和“填写一份固定格式表格”的区别。Statement 是先把用户内容直接排版进合同正文打印出来后合同内容可能被改写PreparedStatement 是合同格式已经固定用户只能在指定的空格处填写内容不管填什么都不会改变合同条款。JDBC 规范选择支持 PreparedStatement本质上就是让开发者有办法把“程序逻辑”和“用户数据”在传输层就分离。而 Mybatis 的 #{}底层调用的正是 PreparedStatement 的setString、setInt这些方法。4. Mybatis中的#{}和${}一个参数占位一个字符串替换JDBC 我们讲清楚了现在把视角拉回 Mybatis。Mapper.xml 中的 SQL 是怎么被执行的它不可能是 Mybatis 自己执行 SQL最终一定要交给 JDBC。所以 Mybatis 做的事情是对 JDBC API 的一层封装和增强而 #{} 和 ${} 就是在这一层进行的不同处理。下面是一个典型的 Mapper.xml!-- 文件路径src/main/resources/mapper/UserMapper.xml -- mapper namespacecom.example.demo.mapper.UserMapper select idselectByName resultTypecom.example.demo.entity.User SELECT * FROM user WHERE name #{name} /select select idselectByOrder resultTypecom.example.demo.entity.User SELECT * FROM user ORDER BY ${orderColumn} /select /mapper这两段 SQL 看起来只差一个符号但 Mybatis 在执行时走的是完全不同的路径。先看#{name}。Mybatis 解析这条 SQL 时会把#{name}替换为 JDBC 的占位符?也就是把 SQL 变成SELECT * FROM user WHERE name ?然后调用 PreparedStatement 的setString(1, name)把参数值传进去。再看${orderColumn}。Mybatis 解析这段 SQL 时会直接把${orderColumn}的值替换到 SQL 字符串中。如果你传入的值是create_time最终执行的 SQL 是SELECT * FROM user ORDER BY create_time这个过程没有任何占位符、没有 set 方法纯粹是字符串替换。为了让你更直观地看到两者差异这里补充一个典型的日志对比。Mybatis 在执行 SQL 时如果开启了日志你能看到类似的输出 Preparing: SELECT * FROM user WHERE name ? Parameters: admin(String) Preparing: SELECT * FROM user ORDER BY create_time Parameters:第一段 SQL 里?只是占位符参数通过Parameters单独打印第二段 SQL 里${}的值已经直接写进了 Preparing 的 SQL 文本中。这就是两种语法最本质的差异。顺带提一个开发时的常见需求很多人在本地调试时会用 IDEA 的 MyBatis Log Free 插件把 SQL 日志还原成可执行的完整 SQL。这类插件之所以能做到“还原”恰恰是因为日志里保留了Parameters部分。而在${}场景下SQL 本来就是完整的直接复制就能执行。这也从侧面说明了两者的执行路径完全不同。5. 为什么#{}能防SQL注入核心在预编译现在可以回答一个很多人其实没有深入思考过的问题#{} 为什么能防 SQL 注入核心原因有两点。第一SQL 结构在参数进入之前就已经固定了。前面提到Mybatis 会把 SQL 中的#{}翻译成?然后交给 PreparedStatement 预编译。数据库在预编译阶段就已经完成了 SQL 语句的词法分析和语法解析。也就是说SELECT * FROM user WHERE name ?这句话的结构已经确定之后不管 name 参数是什么内容都不会影响语句结构。攻击者传入admin OR 11这句话只会被当作一个普通字符串值去匹配 name 字段。第二参数按“值”传入自动进行类型转义。PreparedStatement 的setString等方法会根据 SQL 类型对参数做必要的转义处理。单引号、反斜杠这些特殊字符会被当作数据内容的一部分而不是 SQL 语法的一部分。这里必须澄清一个常见的误区很多人以为 #{} 能防注入是因为 Mybatis 做了什么“过滤”或“转义”操作。实际上Mybatis 本身并没有针对恶意关键字做过滤它只是遵守了 JDBC 的预编译机制。防注入的真正功臣是数据库端的 PreparedStatement 能力和参数化传输方式Mybatis 只是把#{}翻译成了?。所以在实际项目中你可以把“用 #{} 防 SQL 注入”理解成一种兜底能力只要参数走了预编译通道即使你传入的内容里有OR、AND、--、单引号等危险字符数据库也不会把它们当作 SQL 代码执行。但这里也有一个边界要讲清楚预编译只能保护“值”的部分。如果用户输入通过${}进入了 SQL 语句的关键字位置比如表名、排序字段、列名那预编译机制就完全帮不上忙了因为这部分内容在 SQL 结构固定之前就已经被拼进去了。6. 面试官真正想听为什么Mybatis还要设计${}这是全篇文章的题眼。面试官既然知道 ${} 有风险为什么还要问“为什么设计它”他想听的是你对 Mybatis 功能设计的理解而不是一句“历史遗留”或者“设计失误”。在真实业务中${} 是 Mybatis 动态 SQL 能力不可替代的一部分。6.1 动态排序字段最常见的场景是列表排序。用户在前端点击“按时间排序”或“按更新时间排序”后端拿到排序字段名后需要拼到ORDER BY后面。SQL 不支持参数化排序字段因为你不能写成SELECT * FROM user ORDER BY ?如果传create_time或者ASC作为参数数据库不知道怎么执行。所以只能用${}拼接select idselectUserList resultTypecom.example.demo.entity.User SELECT * FROM user ORDER BY ${orderColumn} ${orderDirection} /select6.2 动态表名和列名分库分表、历史表归档、动态条件表这种业务里表名经常是运行时才知道的。同样SQL 语法不允许这样写SELECT * FROM ? WHERE id ?表名和列名无法作为绑定参数传入只能用 ${} 拼进去。比如按月分表的场景select idselectByMonth resultTypecom.example.demo.entity.Order SELECT * FROM ${tableName} WHERE create_time #{createTime} /select6.3 SQL片段复用中的参数传递在 Mybatis 的动态 SQL 中有时候需要在sql片段里定义可复用的条件块再通过include引入。一些复杂场景下片段内部必须使用${}来插入公共的过滤条件、时间段条件等。虽然也可以改用其他方式但在某些团队约定好的模板化写法里${} 仍然是更直接的选择。6.4 特殊写法下的 IN 条件IN条件有两种常见写法。一种是用foreach遍历集合生成多个?这是安全的但如果你是从另一个接口直接拿到了以逗号分隔的字符串有人会图省事直接拼select idselectByIds resultTypecom.example.demo.entity.User SELECT * FROM user WHERE id IN (${ids}) /select我明确不建议用这种方式因为如果 ids 是用户传上来的攻击者可以传入1) OR 11 --这类内容改写整条 SQL。正确写法应该用foreachselect idselectByIds resultTypecom.example.demo.entity.User SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select6.5 为什么#{}解决不了这些场景整理一下${} 之所以被保留是因为在 SQL 语法层面存在一类无法用占位符表达的内容表名、列名、排序关键字、SQL 关键字。占位符只能出现在值的上下文里一旦出现在语法关键字的位置预编译机制就无法工作。所以换个角度看${} 不是 Mybatis 的设计缺陷而是为了覆盖动态 SQL 的完整能力空间而存在的。真正的问题不在于“要不要用 ${}”而在于“用 ${} 时有没有把好安全关口”。7. 必须用${}时如何安全落地白名单验证是关键到了工程实践环节。前面说了 ${} 有风险但在排序、表名这些场景下又躲不开。那该怎么办最靠谱的思路就是四个字白名单验证。白名单的含义是在把用户输入交给 ${} 之前先用代码判断这个输入是否在可接受范围之内。不在白名单里的值直接拒绝或使用默认值绝不拼进 SQL。以排序字段为例一个安全的实现分三步。第一步在 Service 层对前端传入的排序字段做校验。注意这一步不能只做“非空校验”或者“长度校验”因为攻击者完全能传入一个长度合法但内容危险的字符串。必须做“枚举匹配校验”。// 文件路径src/main/java/com/example/demo/service/UserService.java private static final SetString ALLOWED_ORDER_COLUMNS new HashSet(Arrays.asList( id, name, create_time, update_time )); private static final SetString ALLOWED_ORDER_DIRECTIONS new HashSet(Arrays.asList( ASC, DESC )); public String validOrderColumn(String input) { // 白名单校验不在集合中直接走默认值 if (input null || !ALLOWED_ORDER_COLUMNS.contains(input.toLowerCase())) { return create_time; } return input; } public String validOrderDirection(String input) { if (input null || !ALLOWED_ORDER_DIRECTIONS.contains(input.toUpperCase())) { return DESC; } return input; }第二步把校验通过后的值传给 Mapper// 文件路径src/main/java/com/example/demo/service/UserService.java public ListUser getUserList(String orderColumn, String orderDirection) { String safeColumn validOrderColumn(orderColumn); String safeDirection validOrderDirection(orderDirection); return userMapper.selectUserList(safeColumn, safeDirection); }第三步Mapper.xml 中接收这些已经通过白名单校验的值。因为值已经被限定在安全集合内此时使用 ${} 就不会再有注入风险!-- 文件路径src/main/resources/mapper/UserMapper.xml -- select idselectUserList resultTypecom.example.demo.entity.User SELECT * FROM user ORDER BY ${orderColumn} ${orderDirection} /select同理动态表名场景下应该维护一个“表名白名单 Map”而不是直接信任前端传来的字符串。表名这种资源通常非常有限崩说一张表一个名字全系统能动态选择的表一般不超过几张完全有条件在代码中写死可选项。这里要强调一个安全常识白名单验证是防御 ${} 注入的唯一可靠手段。不要寄希望于“过滤单引号”“过滤 OR”这种黑名单思路因为攻击手法千变万化你永远无法列全所有的危险组合。白名单从源头上限制了可接受值即使攻击者传入了恶意字符串也会因为不在白名单中被拦截。8. 完整示例从建表到验证一个安全的排序查询为了让你能完整跑通流程这里提供一个最小可运行示例从建表、Maven 依赖到 Mapper 编写和日志验证一整套链路。8.1 建表和准备测试数据在 MySQL 中执行以下 SQLCREATE DATABASE IF NOT EXISTS demo DEFAULT CHARACTER SET utf8mb4; USE demo; CREATE TABLE IF NOT EXISTS user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); INSERT INTO user (name, create_time) VALUES (zhangsan, 2024-12-01 10:00:00), (lisi, 2024-12-02 11:00:00), (wangwu, 2024-12-03 12:00:00);8.2 Maven 依赖创建 Spring Boot 项目时需要引入 Mybatis 相关依赖。版本请以实际项目为准这里只给出核心坐标!-- 文件路径pom.xml -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency如果项目用的是 MyBatis-Plus核心配置思路也完全一致MyBatis-Plus 底层仍然遵循 #{} 和 ${} 的分工规则。8.3 Mapper 接口和 XML新建 User 实体类和 UserMapper 接口XML 中同时展示 #{} 和 ${} 的用法// 文件路径src/main/java/com/example/demo/entity/User.java public class User { private Long id; private String name; private String createTime; // getter / setter 此处省略 }// 文件路径src/main/java/com/example/demo/mapper/UserMapper.java public interface UserMapper { ListUser selectByName(String name); ListUser selectUserList(Param(orderColumn) String orderColumn, Param(orderDirection) String orderDirection); }!-- 文件路径src/main/resources/mapper/UserMapper.xml -- mapper namespacecom.example.demo.mapper.UserMapper select idselectByName resultTypecom.example.demo.entity.User SELECT * FROM user WHERE name #{name} /select select idselectUserList resultTypecom.example.demo.entity.User SELECT * FROM user ORDER BY ${orderColumn} ${orderDirection} /select /mapper8.4 开启 Mybatis SQL 日志为了验证执行路径可以在配置文件中开启 SQL 日志# 文件路径src/main/resources/application.yml mybatis: mapper-locations: classpath:/mapper/*.xml logging: level: com.example.demo.mapper: debug8.5 编写测试接口写一个简单的 Controller 用于测试// 文件路径src/main/java/com/example/demo/controller/UserController.java RestController RequestMapping(/user) public class UserController { Autowired private UserService userService; GetMapping(/list) public ListUser list(RequestParam String orderColumn, RequestParam String orderDirection) { return userService.getUserList(orderColumn, orderDirection); } }启动项目后访问http://localhost:8080/user/list?orderColumncreate_timeorderDirectionDESC预期 SQL 日志输出为 Preparing: SELECT * FROM user ORDER BY create_time DESC Parameters:再访问http://localhost:8080/user/list?orderColumncreate_time;%20DROP%20TABLE%20user--orderDirectionDESC由于 Service 层白名单校验会把不合法输入替换为默认值create_time所以日志输出还是 Preparing: SELECT * FROM user ORDER BY create_time DESC Parameters:这就是白名单校验的价值恶意输入不是被“过滤”了而是直接被“拒绝并替换”了。如果希望验证 #{} 的预编译效果可以访问http://localhost:8080/user/list?orderColumnnameorderDirectionASC并观察这行日志中 name 参数的使用方式。如果想看某个具体查询的#{}预编译效果可以再写一个按姓名查询的接口访问时传入namezhangsan OR 11日志里能看到 SQL 中仍然是?占位符参数单独列出。9. 常见问题与排查思路在实际开发和面试交流中围绕 #{} 和 ${} 的问题远不止“用哪个”这么简单。这里整理了几类高频问题。问题现象可能原因排查方式解决方案使用 #{column} 做排序字段SQL 执行报错排序字段无法使用占位符查看日志中 SQL 是否包含?在 ORDER BY 后面排序字段改用 ${}并配合白名单校验使用 ${value} 传入普通查询值项目被 SQL 注入用户输入进入字符串拼接查看完整 SQL 日志确认用户输入是否直接在 SQL 文本中改回 #{value}确保参数走预编译使用 ${orderColumn} 后查询结果顺序不对前端传入了非法排序字段名被直接拼进 SQL打印 Service 层接收到的原始参数增加白名单枚举校验不在白名单走默认值日志显示 SQL 正常但程序报了 SQLSyntaxErrorException${} 拼接后产生了语法错误比如多余逗号或引号将日志中 SQL 复制到数据库客户端执行检查拼接内容是否存在特殊字符强化参数校验团队代码评审发现多处使用 ${}不确定是否安全部分场景确实必须用 ${}但缺少统一封装逐一审查每个 ${} 的输入来源封装白名单校验工具要求所有 ${} 入参经过校验MyBatis-Plus 项目中也出现了 ${} 写法MyBatis-Plus 底层仍然支持 ${}和原生 Mybatis 一致查看自定义 SQL 中的写法自定义 SQL 同样遵循“能 #{} 就 #{}必须 ${} 就白名单”原则还有一个常见误区必须单独提出来不要在业务代码中写自定义的“SQL 注入过滤器”来保护 ${}。你可能会看到有人写一个工具类把所有特殊字符替换成空字符串然后传给 ${}。这种做法非常危险因为数据库方言差异、注释符差异、编码绕过手段都可能导致过滤不完整。安全兜底只有两条路要么用 #{}要么用白名单。10. 最佳实践与工程建议聊完原理和示例最后分享几组在实际项目中真正能落地的工程建议。10.1 默认原则能 #{} 就 #{}必须 ${} 才 ${}这个原则应该成为团队代码规范的一部分。普通条件值、插入值、更新值无脑用 #{} 即可。只有表名、列名、排序关键字这类 SQL 语法关键字位置才允许用 ${}且必须经过白名单校验。10.2 所有 ${} 入口必须经过统一校验建议封装一个通用的参数校验工具把动态表名、动态排序字段、动态列名的白名单集中管理起来。这样在代码评审时很容易通过搜索${}找到所有使用点逐一定位是否都走了校验逻辑。如果没有经过校验评审直接打回。10.3 不要把用户输入直接映射到表名动态表名的安全要求比动态排序更高。表名是数据资源级的变量做分库分表时尤其要谨慎。更稳妥的做法是前端只传一个业务标识后端通过 Map 映射出真正的表名而不是把表名本身传过来。10.4 开启日志但生产环境注意脱敏Mybatis 日志能帮助你定位 SQL 问题但生产环境不要无脑打印完整 Parameters。比如密码、身份证号这类敏感字段就不应该出现在日志中。可以在测试环境开启 DEBUG 日志调 SQL在生成环境将敏感 SQL 的日志级别调整到合理范围。10.5 理解 MyBatis-Plus 的区别但没有本质差异近两年很多新项目直接上了 MyBatis-Plus有些面试者会误以为 MyBatis-Plus 不再有 ${} 问题。实际上MyBatis-Plus 内置的 QueryWrapper 在大部分场景下通过eq、like等方法使用预编译参数但当你写自定义 SQL 或使用last、apply等能力时同样可能引入字符串拼接。不要因为用了 MyBatis-Plus 就觉得 SQL 注入已经自动消失。10.6 关于面试回答的结构建议如果面试官问起这道题推荐按“结论 → 原理 → 场景 → 防护”的结构回答先说明 #{} 预编译、${} 拼接再讲 PreparedStatement 的参数化原理然后提到动态排序、动态表名是 ${} 存在的必要性最后补充白名单校验这一安全兜底方案。这样回答既有深度又体现了工程意识正好对上“Java 面试八股文”类题目中最喜欢考察的“第二层认知”。11. 总结与面试回答参考回到文章开头的问题有 #{} 为啥还要设计 ${}答案可以浓缩成一句话#{} 解决“值”的问题${} 解决“结构”的问题。SQL 语句中凡是值的位置都应该用 #{} 走预编译凡是表名、列名、排序关键字这些语法结构的位置只有 ${} 才能胜任但必须用白名单为它兜底。如果你想让这段理解更深刻一点可以尝试做一个小练习把文章中的示例代码复制到本地分别用#{}和${}执行一个name admin OR 11的查询观察控制台输出的 Preparing 日志。你会直观看到前者生成name ?参数单独传入后者把恶意输入直接拼进了 SQL 文本。看到那一幕比背十遍理论都管用。对于准备面试的同学建议把这个题目纳入“Mybatis 高频面试题”清单同时把它和“Mybatis 缓存”“Mybatis 源码执行流程”“MyBatis-Plus 和 Mybatis 的区别”这几道题串联起来复习。因为面试官问 Mybatis 时往往是同一个话题连续深挖你如果能从预编译原理顺畅地讲到动态 SQL 的边界再到 Mybatis 的整体设计思路基本就通过了这一环的考察。真正的技术面试从来不问“这个符号安全不安全”而是问“你知不知道这个设计背后的原因”。这篇文章写的就是这个原因。