MyBatis中#{}和${}的区别:SQL注入防范与动态SQL场景解析

📅 发布时间:2026/9/8 4:49:52
MyBatis中#{}和${}的区别:SQL注入防范与动态SQL场景解析
之前带过的几个实习生去面试 Java 后端岗回来后都提到同一个问题面试官问“MyBatis 里#{}和${}有什么区别明明#{}能防 SQL 注入为什么 MyBatis 还要设计${}”。说实话这个问题看似基础但要把原理、源码、应用场景讲清楚确实能筛选掉很大一批只会写 CRUD 的候选人。本文就围绕这个面试题把 MyBatis 参数绑定的底层机制、SQL 注入的成因、${}在真实项目中不可替代的场景全部拆开讲一遍。适合刚学 MyBatis 的读者建立安全认知也适合有经验的开发者在面试前系统梳理。1. SQL 注入的本质与危害1.1 什么是 SQL 注入SQL 注入SQL Injection是指攻击者把恶意 SQL 片段通过输入参数、URL 参数、表单字段等方式传入后台后台在拼接 SQL 字符串时未做任何过滤或参数化处理导致恶意代码被数据库当作合法 SQL 指令执行。看一个最经典的登录场景。很多早期系统的登录 SQL 是这么写的SELECT * FROM sys_user WHERE username admin AND password 123456如果用户在用户名输入框里输入的是这段内容admin AND 1 1 --后台 Java 代码直接做字符串拼接那么最终执行的真实 SQL 就变成了SELECT * FROM sys_user WHERE username admin AND 1 1 -- AND password 123456--在 MySQL 中表示注释符后面内容全部被忽略。也就是说攻击者连密码都不知道只要构造一段巧妙的字符串就能把密码校验逻辑完全“注释”掉。这就是“万能密码”登录的底层原理。1.2 为什么说 SQL 注入危害很大SQL 注入被 OWASP 长期列为 Web 应用十大安全风险之首原因在于它能直接威胁到数据库的机密性、完整性和可用性危害类型具体表现数据泄露攻击者可以通过UNION SELECT、报错注入等方式读取任意表的数据数据篡改通过注入UPDATE、DELETE语句恶意修改或删除业务数据权限提升如果数据库账号权限过高可能读写文件、执行命令、控制整个服务器业务瘫痪批量删除表、写入垃圾数据、拖库后勒索而且注入手法的花样很多比如UNION联合注入、布尔盲注、时间盲注、报错注入、堆叠注入、内联注释注入等。但不管形式怎么变核心都是同一个问题用户输入被当成了 SQL 代码执行。1.3 安全的本质代码与数据分离理解了注入原理就能得出一个很重要的结论防止 SQL 注入的关键不是靠“过滤特殊字符”这种黑名单思路而是要从架构上做到SQL 结构代码和用户输入数据彻底分离。参数就是数据永远不应该成为 SQL 结构的一部分。这也是 MyBatis#{}能够防注入的根本原因。2. MyBatis 中的#{}预编译参数占位2.1 基本用法在 MyBatis 的 Mapper XML 文件中我们可以这样写一个根据 ID 查用户的 SQL!-- 文件路径src/main/resources/mapper/UserMapper.xml -- select idselectUserById resultTypecom.example.entity.User SELECT * FROM sys_user WHERE id #{id} /select对应的 Mapper 接口方法// 文件路径src/main/java/com/example/mapper/UserMapper.java package com.example.mapper; import com.example.entity.User; public interface UserMapper { User selectUserById(Integer id); }注意这里的 SQL 文本在执行时并不是直接把#{id}替换成参数值而是先把 SQL 解析成带占位符的模板SELECT * FROM sys_user WHERE id ?#{}会被解析成一个 JDBC 的预编译占位符?真正的参数值通过PreparedStatement的 setter 方法传入。2.2 底层执行过程MyBatis 在解析 SQL 时会调用GenericTokenParser解析#{}占位符并产生一个ParameterMapping最终在真正执行时通过 JDBC 的PreparedStatement来完成参数绑定。可以打开 JDBC 的日志或者借助idea mybatis log free这类插件看到 MyBatis 最终发送给数据库的语句是 Preparing: SELECT * FROM sys_user WHERE id ? Parameters: 1(Integer) Columns: ID, USERNAME, PASSWORD, EMAIL Row: 1, admin, 123456, adminexample.com注意这里Preparing阶段的 SQL 中就是?参数单独列出来。MySQL 服务端拿到的是已经参数化后的协议包而不是拼接好的 SQL 字符串。2.3 为什么#{}能防 SQL 注入关键在于PreparedStatement的预编译机制。当Connection.prepareStatement(sql)被调用时SQL 的结构已经被数据库驱动或者数据库服务端解析、编译、确定了。之后传入的参数只会被当作纯文本数据不会再参与 SQL 语法解析。以下面这个 MyBatis Mapper 为例select idlogin resultTypecom.example.entity.User SELECT * FROM sys_user WHERE username #{username} AND password #{password} /select攻击者在username中传入admin AND 1 1 --MyBatis 执行时实际发送的 SQL 是这样的 Preparing: SELECT * FROM sys_user WHERE username ? AND password ? Parameters: admin AND 1 1 -- (String), 123456(String)数据库会把整段admin AND 1 1 --当作完整的字符串参数值去匹配username字段。由于sys_user表中不存在用户名为这段内容的用户查询结果为空注入自然失败。PreparedStatement还会正确处理参数中的特殊字符例如单引号会被自动转义。所以在 MyBatis 中只要参数是“值”就应该用#{}。3. MyBatis 中的${}字符串直接拼接3.1 基本用法${}在 MyBatis 中的语义和#{}完全相反它不会生成占位符而是直接把传入的字符串内容嵌入到 SQL 文本中。看下面的示例select idselectUserByOrder resultTypecom.example.entity.User SELECT * FROM sys_user ORDER BY ${orderBy} /select接口方法// 文件路径src/main/java/com/example/mapper/UserMapper.java ListUser selectUserByOrder(Param(orderBy) String orderBy);如果调用时传入id DESC最终拼出来的 SQL 就是SELECT * FROM sys_user ORDER BY id DESC如果传入的是create_time ASCSQL 就变成SELECT * FROM sys_user ORDER BY create_time ASC这个过程中字符串是被“原样替换”进 SQL 的。3.2 底层执行过程MyBatis 在解析${}时同样使用GenericTokenParser但不会生成ParameterMapping而是直接把 token 对应的值替换进 SQL 字符串。可以简单理解为 MyBatis 在拿到完整 SQL 之后才通过Statement而不是PreparedStatement发送给数据库执行。依然是借助插件或日志观察输出 Preparing: SELECT * FROM sys_user ORDER BY id DESC整个 SQL 文本是完整的没有?也没有单独的 Parameters 输出项。3.3${}的安全隐患由于${}是字符串替换攻击者完全可以在参数中构造额外的 SQL 片段。假设我们有一个按条件筛选用户的 Mapperselect idselectUserByCondition resultTypecom.example.entity.User SELECT * FROM sys_user WHERE status ${status} /select正常情况下我们期望调用是这样的ListUser list userMapper.selectUserByCondition(1);但如果攻击者传入1 OR 1 1最终执行的 SQL 就变成SELECT * FROM sys_user WHERE status 1 OR 1 1表里所有用户数据全部被查出来——信息泄露这是最低级别的危害。如果拼接位置在ORDER BY后攻击者还可能利用报错注入、布尔盲注等方式逐步拖库。所以结论很明确${}是非常危险的不能对不可信的用户输入直接使用。4.#{}和${}的核心区别对比4.1 语法语义维度对比维度#{}${}类型预编译参数占位符字符串替换符底层机制PreparedStatement 参数绑定Statement 直接拼接 SQL是否处理 SQL 结构不处理内容永远是一个值直接嵌入可能改变 SQL 结构特殊字符自动转义安全不做转义存在注入风险典型使用位置WHERE 条件值、INSERT 值、UPDATE 值表名、列名、ORDER BY、GROUP BY、动态 SQL 片段性能可复用预编译语句执行计划可缓存每次拼接新 SQL编译开销大防注入能力能有效防注入不能防注入必须严格控制输入4.2 源码层面看两者差异MyBatis 解析 SQL 的核心逻辑在SqlSourceBuilder和DynamicSqlSource中。简单概括解析#{}时MyBatis 会创建一个ParameterMappingTokenHandler生成一个?占位符并记录参数映射信息后续通过PreparedStatement的 setter 方法赋值解析${}时则使用TextSqlNode.BindingTokenParser直接将值作为 SQL 片段替换进文本。对应到 JDBC 层面请对比两段代码// 危险写法Statement 拼接 String username admin OR 1 1; Statement stmt connection.createStatement(); ResultSet rs stmt.executeQuery( SELECT * FROM sys_user WHERE username username );// 安全写法PreparedStatement 预编译 String username admin OR 1 1; PreparedStatement ps connection.prepareStatement( SELECT * FROM sys_user WHERE username ? ); ps.setString(1, username); ResultSet rs ps.executeQuery();第一种写法会把整段注入字符串拼进 SQL 并执行第二种写法中username只是一段被绑定到?上的数据即使是注入串也不会改变 SQL 语义。4.3 一个容易踩的坑动态 SQL 标签内的#{}有些开发者在if、foreach标签中使用#{}时容易产生误区。例如希望拼接一个 IN 列表select idselectUsersByIds resultTypecom.example.entity.User SELECT * FROM sys_user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /select这段代码是安全的MyBatis 最终会生成WHERE id IN (?, ?, ?)这样的带占位符 SQL。但如果你在循环中写成${id}参数就会被直接输出成1, 2, 3拼接进 SQL 文本一旦 id 是带注入载荷的字符串危险就发生了。5. 面试官真正想问为什么有了#{}还要设计${}这是整个面试题的核心转折点。如果候选人只知道“#{}安全${}不安全”那只是背答案。面试官想考察的是你是否理解实际开发的完整场景。既然#{}只能绑定“值”无法改变 SQL 结构那么当我们需要动态改变 SQL 结构时#{}就无能为力了。5.1 场景一动态表名典型的业务场景是分表比如订单表按月拆分成order_202501、order_202502等。查询时表名只能动态传入select idselectByTableName resultTypecom.example.entity.Order SELECT * FROM ${tableName} WHERE order_id #{orderId} /select这里tableName不能用#{}因为如果写成SELECT * FROM ?数据库不支持把表名作为绑定变量。如果强行传参会直接报语法错误。这时只能使用${}动态替换表名。需要注意的是动态表名场景开发人员必须自己保证tableName参数来源安全绝不能直接把前端请求参数原样传进来。最稳妥的办法是维护一张“表名白名单”映射。// 文件路径src/main/java/com/example/service/OrderService.java private static final MapString, String TABLE_MAP Map.of( 202501, order_202501, 202502, order_202502, 202503, order_202503 ); public ListOrder queryByMonth(String month, Long orderId) { String tableName TABLE_MAP.get(month); if (tableName null) { throw new IllegalArgumentException(非法月份 month); } return orderMapper.selectByTableName(tableName, orderId); }5.2 场景二动态排序列和排序方向在管理后台中表格列排序通常由前端传入column和order。排序字段如果写成#{}最终 SQL 变成ORDER BY ?数据库不会将绑定参数当作列名解析查询会失败。select idselectUserPage resultTypecom.example.entity.User SELECT * FROM sys_user ORDER BY ${orderByColumn} ${orderByDirection} /select这里同样需要后端做严格校验。比如orderByDirection只允许ASC或DESC两个值orderByColumn只能用白名单映射。// 文件路径src/main/java/com/example/service/UserService.java private static final SetString SAFE_COLUMNS Set.of(id, username, create_time); private static final SetString SAFE_DIRECTIONS Set.of(ASC, DESC); public ListUser page(String column, String direction) { if (!SAFE_COLUMNS.contains(column) || !SAFE_DIRECTIONS.contains(direction)) { throw new IllegalArgumentException(非法排序参数); } return userMapper.selectUserPage(column, direction); }5.3 场景三动态 SQL 片段复用在 MyBatis 中sql片段配合${}可以动态引入公共的查询条件。例如不同业务模块对“数据权限”的过滤条件差异很大通过${}传入拼接好的“权限 SQL 片段”。select idselectByBizData resultTypecom.example.entity.DataEntity SELECT * FROM data_entity WHERE biz_type #{bizType} AND ${dataPermissionSql} /select数据权限 SQL 片段在服务端根据当前登录用户的角色和部门权限生成不直接信任前端输入。这种用法在大型系统中很常见能极大减少重复 SQL但也要求生成片段的位置必须具备极高的可信度。5.4 场景四动态列名比如报表模块中业务方选择同步哪些字段或者做动态行转列SQL 中的列名只能靠${}方案。select idselectDynamicColumn resultTypejava.util.Map SELECT ${columnName} AS result_value FROM biz_table WHERE id #{id} /select5.5 总结面试回答思路面试时可以按照下面这个递进逻辑回答#{}会被解析成 JDBC 预编译占位符?通过PreparedStatement绑定参数所以既能防止 SQL 注入又能提升 SQL 复用度。${}是字符串原样替换存在注入风险性能也不如预编译。但${}能用于表名、列名、排序字段、SQL 片段等无法通过绑定参数完成的结构性位置在实际开发中不可替代。使用${}的正确姿势是配合服务端白名单校验、枚举映射、参数清洗等手段从来源上切断恶意输入。原则是能用#{}的地方坚决不用${}只能用${}的地方必须做安全兜底。这个回答体现了对问题本质的把握也展示了工程经验面试官通常会比较满意。6. 完整实战模拟 MyBatis 防 SQL 注入验证6.1 项目结构下面用 Spring Boot MyBatis 搭建一个最小可运行示例演示#{}和${}在同一个 SQL 注入攻击下的表现差异。项目结构如下sql-injection-demo ├── pom.xml └── src/main/java/com/example/demo ├── SqlInjectionDemoApplication.java ├── controller/UserController.java ├── mapper/UserMapper.java ├── mapper/UserMapper.xml └── entity/User.java6.2 添加依赖pom.xml核心依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies注意版本以你的实际项目为准这里使用的是笔者测试时较稳定的组合。6.3 编写 Mapper 接口// 文件路径src/main/java/com/example/demo/mapper/UserMapper.java package com.example.demo.mapper; import com.example.demo.entity.User; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper { User selectByUsernamePassword(Param(username) String username, Param(password) String password); ListUser selectByUsernameOrder(Param(orderColumn) String orderColumn, Param(orderDirection) String orderDirection); }selectByUsernamePassword使用#{}演示防注入selectByUsernameOrder使用${}演示订单排序场景。6.4 编写 Mapper XML!-- 文件路径src/main/java/com/example/demo/mapper/UserMapper.xml -- ?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idselectByUsernamePassword resultTypecom.example.demo.entity.User SELECT * FROM sys_user WHERE username #{username} AND password #{password} /select select idselectByUsernameOrder resultTypecom.example.demo.entity.User SELECT * FROM sys_user ORDER BY ${orderColumn} ${orderDirection} /select /mapper6.5 编写 Controller 模拟攻击// 文件路径src/main/java/com/example/demo/controller/UserController.java package com.example.demo.controller; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; RestController public class UserController { private final UserMapper userMapper; public UserController(UserMapper userMapper) { this.userMapper userMapper; } GetMapping(/login/safe) public User loginSafe(RequestParam String username, RequestParam String password) { return userMapper.selectByUsernamePassword(username, password); } GetMapping(/order/risk) public ListUser orderRisk(RequestParam String orderColumn, RequestParam String orderDirection) { return userMapper.selectByUsernameOrder(orderColumn, orderDirection); } }6.6 攻击与验证模拟攻击登录接口curl http://localhost:8080/login/safe?usernameadmin%20AND%201%3D1password123观察日志JDBC Connection : SELECT * FROM sys_user WHERE username ? AND password ? Parameters : admin AND 11(String), 123(String)查询结果为空注入失败。再模拟排序接口注入攻击curl http://localhost:8080/order/risk?orderColumnidorderDirectionASC;DROP%20TABLE%20sys_user虽然 MySQL 的 JDBC 驱动默认不允许一次执行多条语句但如果你输入orderColumnid、orderDirectionDESC之外的恶意字段比如curl http://localhost:8080/order/risk?orderColumnid%20OR%20EXTRACTVALUE(1,CONCAT(0x7e,(SELECT%20database())))orderDirectionASC这条请求很可能触发 MySQL 报错并在错误信息中暴露数据库名。这个例子非常直观地说明${}被外部参数直接控制时的风险。6.7 预期结论通过这个演示可以看到#{}作用下注入字符串被整体当作参数值攻击载荷没有任何“执行”机会。${}作用下注入片段会被当作 SQL 语句片段参与解析轻则报错泄露信息重则更新、删除数据。真实项目中必须严格管控${}的可信来源。7. 常见问题与排查思路7.1${}处单引号报错如果项目里使用了${}通常会出现这样的错误### SQL: SELECT * FROM sys_user WHERE name 张三 OR 1 1 ### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: ### You have an error in your SQL syntax排查思路检查 SQL 文本和参数值拼接后的完整 SQL确认是不是${}把参数原样插入了不该插入的位置。修复方式是把参数值部分改成#{}或者把${}限定使用在表名、列名、排序方向等结构性位置。7.2 误以为#{}可以用于 ORDER BY有开发者在ORDER BY中写ORDER BY #{column}结果查询报错或者排序不生效。原因数据库不会把一个绑定变量当作列名解析。列名属于 SQL 结构的一部分只能使用字符串替换。正确做法是配合白名单映射后使用${}。7.3 动态表名使用了${}且无白名单现象接口传入一个tableName参数攻击者传入sys_user; DROP TABLE sys_user --虽然 JDBC 默认不允许多语句执行但如果数据库配置开启allowMultiQueriestrue就会造成严重后果。排查步骤检查数据库连接串中是否包含allowMultiQueriestrue。检查所有涉及${}的位置。对每个${}评估参数来源和可信度。7.4 常见面试追问汇总面试追问参考回答要点预编译一定能防住所有 SQL 注入吗预编译能防住参数值注入但如果把用户输入拼进表名、列名、ORDER BY 片段依然可能注入#{}性能一定比${}好吗预编译 SQL 可复用执行计划${}每次都会重新编译但差别在小数据量下不明显如何彻底避免${}风险白名单校验、参数枚举、服务端拼接可信 SQL 片段不信任前端传参MyBatis Plus 中Wrapper的排序方法安全吗基类QueryWrapper.orderBy底层一般会通过反例检测做白名单判断但需要注意版本具体用法和限制按版本文档确认数据库本身有没有防御机制MySQL 可以使用NO_BACKSLASH_ESCAPES、SQL Mode 加固但应用层防注入才是正解模糊查询拼接%怎么处理推荐使用CONCAT(%, #{keyword}, %)配合#{}避免写%${keyword}%8. 最佳实践与工程建议8.1 使用原则一个铁律能用#{}的地方绝对不用${}只能用${}的地方必须做二次校验。在实际编码时可以把所有 SQL 分为两类数据值类WHERE id ?、INSERT INTO ... VALUES (?)、SET name ?——统一用#{}。结构类表名、列名、排序关键字、SQL 片段——使用${}但参数必须经过白名单或枚举处理后才能进入 SQL。8.2 白名单映射落地建议在 Service 层把所有可能进入${}的参数映射成固定值。// 文件路径src/main/java/com/example/demo/common/SqlParamValidate.java package com.example.demo.common; import java.util.Map; import java.util.Set; public final class SqlParamValidate { private static final MapString, String ORDER_COLUMN_MAP Map.of( id, id, username, username, createTime, create_time, updateTime, update_time ); private static final SetString ORDER_DIRECTION Set.of(ASC, DESC); private SqlParamValidate() { } public static String validateOrderColumn(String input) { String realColumn ORDER_COLUMN_MAP.get(input); if (realColumn null) { throw new IllegalArgumentException(非法排序列: input); } return realColumn; } public static String validateOrderDirection(String input) { if (!ORDER_DIRECTION.contains(input.toUpperCase())) { throw new IllegalArgumentException(非法排序方向: input); } return input.toUpperCase(); } }这样即使前端传入的是id,DROP TABLE后端也只会拿到一个明确的异常不会把恶意字符串拼进 SQL。8.3 日志与监控建议在项目开启 MyBatis SQL 日志打印。在application.yml中配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl但要注意生产环境直接打印完整 SQL 可能泄露数据建议开发环境打印完整 SQL 方便排查。生产环境只记录慢 SQL、报错 SQL且对参数值做脱敏处理。定期检查日志中是否有异常 SQL 片段比如OR 11、UNION SELECT、EXTRACTVALUE、SLEEP()等关键字。8.4 权限与账号即使应用代码做得再安全数据库账号权限也要遵循最小化原则。业务连接池账号只授予业务所需的SELECT、INSERT、UPDATE、DELETE权限。禁止业务账号使用DROP、ALTER、CREATE等高权限操作。线上数据库账号不要用root运行。涉及批量数据变更时建议走工单系统执行前备份变更后校验。8.5 代码评审重点代码评审时专门添加一条 SQL 安全审查项搜索 Mapper XML 中所有${}。逐一确认${}的使用位置。检查参数是否经过白名单映射或枚举校验。检查 Controller 是否直接传递了未处理的请求参数。检查日志框架是否打印了未经脱敏的 SQL 参数。这样可以最大程度避免开发同学图省事而引入一个“看似能用、实则危险”的${}拼接。9. 总结与后续学习方向本文从 SQL 注入的成因讲起梳理了 MyBatis 中#{}和${}的底层原理及应用边界。核心信息只有三条#{}会被解析为?占位符通过预编译绑定参数从机制上防范 SQL 注入。${}是字符串原样替换能实现动态表名、动态列名、排序字段等结构级需求但直接拼接不可信输入会带来严重安全风险。在实际项目中真正的安全不是依赖 MyBatis 某一个语法而是开发者的意识和一套完整的风控手段白名单、参数校验、最小权限、代码评审、日志监控。接下来你可以继续深入以下几个方向阅读 MyBatis 源码重点看SqlSourceBuilder、DynamicSqlSource、PreparedStatementHandler这几个类理解#{}的完整解析链路。学习 MyBatis 一级缓存和二级缓存了解参数绑定机制如何与缓存交互。结合 MyBatis-Plus 的QueryWrapper对比不同 ORM 框架在防注入上的设计思路。自己动手搭一个靶场项目用安全合法的测试环境验证各种注入手法和防御方案。面试中再遇到“有#{}为啥还要设计${}”不妨从 SQL 的“值”和“结构”两个维度来答。你不仅说了是什么还讲清了为什么、在什么场景下用、以及如何安全使用这种回答才能真正让面试官眼前一亮。