
上礼拜参与一次代码评审看到某个查询接口的排序参数从 Controller 一路传到了 Mapper XML 里写的是${sortField}。我下意识问了一句“如果外部把sortField传成一个不是字段名的值会发生什么”同事反应很快“那肯定有 SQL 注入风险我们平时不是都用#{}吗”我说“那你先回答我另一个问题——Mybatis 既然提供了#{}为什么还要保留${}”会议室安静了几秒。这个问题我在面试里问过很多次也在代码评审里追问过很多次。大多数人的第一反应都是“${}不安全应该不用。”但仔细想想如果这个结论成立Mybatis 没有理由保留一个注定被误用的特性。真正有信息量的答案要从一条 SQL 是怎么被“注入”讲起再讲到 JDBC 预编译、SQL 结构动态化和白名单校验。这篇文章就按这个思路拆开讲清楚顺便聊一下面试时什么样的回答能拿高分。1. 先还原问题一条 SQL 是怎么被“注入”的1.1 直接拼接用户输入等于把分支选择权交给了外部假设一个最普通的查询用户逻辑不用 Mybatis直接用 JDBC 字符串拼接String sql SELECT * FROM user WHERE name name ;当name是一个普通用户名时这条语句没有任何问题。问题出现在name的值可以包含 SQL 特殊字符。一旦外部输入里带上了单引号、等号、or、注释符这类内容整个查询条件就可能被改写成完全不同的语义。我不展开具体的攻击语法因为开发侧真正要理解的不是某一条攻击字符串而是背后的机制字符串拼接让外部输入直接进入了 SQL 文本。数据库在解析这条语句时无法区分哪个部分是程序员写的 SQL哪个部分是用户填的参数。于是“外部输入”从本该承载数据的位置跳到了能够改变 SQL 逻辑的位置。1.2 注入的本质是“数据”跨到了“指令”一侧你可以把 SQL 语句看作一张固定格式的审批单。“姓名”“部门”“申请事由”这些字段本来应该填写用户的输入。但如果有人在“申请事由”里直接写了“通过并追加我为审批人”整张单子的语义就变了。SQL 注入也一样。它绕过的是“数据”和“指令”之间的边界。防御注入的所有手段本质上都在做同一件事确保外部输入只能停留在“数据位”不能进入“指令位”。这也是为什么 SQL 注入不容易被普通单元测试发现。单测时填的是正常输入逻辑当然没问题。真正危险的是边界输入、异常输入、恶意输入。所以工程里防注入必须默认所有外部输入都不可信而不是假设用户不会输入特殊字符。1.3 换一个角度Mybatis 做了什么Hands On 一点说Mybatis 本身是一层 ORM 框架它不是数据库也不是 JDBC 驱动。它真正能做的是替你选择合适的 JDBC API。这个选择直接决定了你的参数是被当作“数据”提交还是被拼进“SQL 文本”提交。#{}和${}的差别就在这里。2. 解构#{}安全的不是这个符号是 JDBC 预编译2.1 从#{}到 PreparedStatementMybatis 的 XML 里经常这样写select idselectByName resultTypeUser SELECT * FROM user WHERE name #{name} /selectMybatis 解析这条语句时#{name}会被替换成 JDBC 的占位符?参数会通过PreparedStatement.setString()注入。最终发送给数据库的是SELECT * FROM user WHERE name ?数据库在编译这条 SQL 时已经确定这个查询只有一个参数位。之后无论外部输入是什么数据库都把它当作“值”而不是“SQL 语法的一部分”。关键点在这里#{}能做到这一点不是因为 Mybatis 做了什么神秘过滤而是因为它在底层走了 JDBC 的PreparedStatement预编译机制。真正把参数和 SQL 语法隔开的是数据库本身的预编译能力。2.2 预编译的性能收益其实不值得当作重点很多资料强调PreparedStatement预编译可以复用执行计划性能更高。在早年间这个优势确实明显。但现代数据库连接池、SQL 缓存、查询计划缓存的出现让“预编译一定更快”这个结论变得不稳定。我的建议是不要拿“性能更好”作为使用#{}的主要理由。防注入才是PreparedStatement更稳定、更重要的价值。你可以把囤积执行计划当作额外的收益但不能把它当成救命稻草。2.3#{}的正确边界参数占位不是 SQL 全能力#{}并不是万能的。它只能出现在 SQL 表达式中允许出现“值”的位置。像表名、列名、ORDER BY字段、GROUP BY字段这类位置属于 SQL 结构不属于“值”。举个例子SELECT * FROM ? WHERE id 1绝大多数数据库会直接报语法错误因为FROM后面跟的是表名不是值表达式。PreparedStatement的占位符?无法替代数据库对象标识符。这一点非常重要它是理解${}存在意义的前提。2.4 LIKE 和 IN 场景不要急着上${}很多团队在模糊查询时写成SELECT * FROM user WHERE name LIKE %${keyword}%这是不必要的风险。LIKE场景完全可以走预编译。比如 MySQL 里写SELECT * FROM user WHERE name LIKE CONCAT(%, #{keyword}, %)IN场景也不需要用${}拼接字符串。Mybatis 的foreach可以生成多个#{}占位符SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach所以很多对外宣称“只能用${}”的场景其实都是对#{}的使用方式理解不到位。真正的${}应用场景要比大家想象的窄得多。3. 为什么还要保留${}3.1 数据库对象标识符无法参数化回到数据库语法本身。一个 SQL 语句由两部分组成结构部分表名、列名、排序字段、分组字段、函数名、SQL 关键字数据部分WHERE条件里的值、SET子句的值、INSERT里的具体内容PreparedStatement的?只能替代“数据部分”。结构部分必须在 SQL 文本里直接写清楚。但业务场景并不总是一成不变的。比如分库分表后表名可能是user_202501、user_202502登录后台可能有排序功能排序字段由前端配置决定报表模块里需要按不同维度做GROUP BY某些统计查询会动态选择分组单位按月、按天、按小时这些场景下结构部分是动态的。想要动态生成结构就必须把外部确定好的字段名、表名拼接到 SQL 文本里。Mybatis 里对应的能力就是${}。换句话说${}不是“为了不安全而存在”而是为了“SQL 结构动态化”保留的一个合法入口。3.2 哪些场景真的只能依靠${}场景能否用#{}原因推荐的安全做法WHERE后的普通字段值能参数处于“值”位优先用#{}SQL 函数入参能函数参数也是数据优先用#{}LIKE模糊查询能用CONCAT或bind传参优先用#{}IN集合能用foreach生成多个#{}优先用#{}ORDER BY排序字段不能字段名属于 SQL 结构枚举白名单映射GROUP BY字段不能字段名属于 SQL 结构枚举白名单映射表名不能对象标识符无法参数化配置中心或常量白名单列名不能对象标识符无法参数化配置中心或常量白名单一段 SQL 片段不能需要注入完整语法片段服务端模板 白名单禁止外部输入直传3.3 用了${}就一定要补白名单校验${}本身没有错错的是“直接把外部输入交给${}”。所谓白名单校验就是限制能进入${}的字符串集合必须来自开发者预先定义好的值而不是用户请求里的任意文本。以一个动态排序为例。Java 侧定义一个枚举public enum OrderField { CREATE_TIME(create_time), ID(id), UPDATE_TIME(update_time); private final String column; OrderField(String column) { this.column column; } public String getColumn() { return column; } }Service 层做转换String sortInput request.getParameter(sortField); OrderField field OrderField.valueOf(sortInput.toUpperCase());Mapper XML 里接收到的其实已经是经过枚举约束的固定值if testsortField ! null and sortField ! ORDER BY ${sortField} ${sortOrder} /if这里有几个工程细节sortField在进入 XML 之前已经被约束成几个固定字符串不可能出现任意 SQL 片段。sortOrder也建议用枚举约束只允许ASC和DESC两个值。未来如果新增可排序列先扩展枚举而不是开放一个“字符串直通通道”。注意凡是走${}的参数都不能允许字符串从 HTTP 参数、配置面板、开放接口直接透传。一定要在进入 Mapper 之前做一次映射或校验。3.4 动态 SQL 片段复用也绕不开结构拼接Mybatis 的sql、include、where等标签可以让条件拼接更优雅但最终落到 SQL 文本时只要涉及表名、列名、排序字段的动态变化仍然要走${}。所以我更建议团队在代码评审里把${}当成“特例通道”而不是“快捷拼接方式”。默认使用#{}只有在确认是 SQL 结构动态化场景时才使用${}并且必须附加白名单校验。4. 面试官想听的答案不是“禁用${}”这么简单4.1 一个能拿高分的回答顺序如果面试时被问到“Mybatis 如何防止 SQL 注入有#{}为什么还要设计${}”可以按这个顺序回答先给结论#{}会走 JDBC 预编译参数作为数据传入能有效防止 SQL 注入。再讲机制SQL 注入的本质是外部输入参与了 SQL 语法解析PreparedStatement让参数值不再参与 SQL 语法编译因此从机制上隔离了注入。然后讲边界#{}只能替代 SQL 中的“值”。表名、列名、排序字段、分组字段属于 SQL 结构无法用占位符替代。最后给方案遇到结构动态化需求使用${}时必须配合白名单约束。白名单的作用是让可拼接的内容提前收敛到固定集合。可以补一句LIKE和IN场景不需要用${}分别用CONCAT包裹参数和foreach生成占位符即可。这样的回答会呈现出三层认知基础层知道#{}安全机制层知道为什么安全方案层知道${}的边界和补救措施。4.2 面试官可能会继续追问这些点追问一“${}到底能不能用”能但要分清场景。不能项目里全部禁用也不能放任用户输入直接拼接。更准确的说法是“外部输入默认只能当参数如果它要被拼接到 SQL 结构必须先经过服务端白名单映射。”追问二“排序字段做白名单会不会很麻烦”会比直接透传麻烦一些但这是可控的代价。而且这个麻烦换来的收益是根除了“用户任意控制 SQL 结构”的风险。一个排序字段的枚举类往往只需要维护一次。追问三“MyBatis-Plus 的Wrapper为什么相对安全”因为Wrapper底层在大多数情况下仍然生成PreparedStatement占位符参数不会直接拼入 SQL 文本。但这不代表所有Wrapper场景都绝对安全关键还是看使用方式。4.3 三个典型的错误回答错误回答一“${}是 Mybatis 的安全漏洞。”这不是漏洞而是为 SQL 结构动态化保留的能力。没有这个能力你会在动态表名、动态排序场景里无计可施。错误回答二“我们项目全部用#{}就绝对不会注入。”这个说法太绝对。如果外部输入没有进入#{}而是绕过 Mapper 直接拼进动态 SQL 片段或者项目里有人把${}塞进了sql标签依然可能出问题。只能说“默认通道换成#{}能规避绝大多数风险”不能说是绝对安全。错误回答三“预编译比普通拼接慢/快。”性能不是这个问题的核心。在大部分业务场景里防注入价值远大于性能收益。而且现代数据库的查询计划缓存已经让“预编译性能差异”这个问题变得非常复杂不要拿它作为论证重点。5. 落到工程里怎么把这条边界做成项目纪律5.1 第一步先盘点项目里所有${}通道排查前先摸底不要凭记忆判断。我一般会先在代码仓库里搜索 Mapper XML 和注解 SQL 中的${}。把所有结果列成清单逐个分类这个${}里的值来自哪里是常量还是配置项还是外部接口传参如果是外部传参有没有在白名单里映射过如果改成#{}SQL 语义会不会变化很多团队的现状是${}分散在不同模块里有些已经被白名单保护有些纯靠“人肉自觉”。摸底之后才能制定修复优先级。5.2 一条可执行的清理链路建议按这个顺序推进盘点所有外部输入入口确认哪些会进入 Mapper 层。搜索 XML 和注解 SQL 中的所有${}。给每个${}分类必须保留 / 可以改成#{}。能改的优先改改LIKE、IN时需要同步调整参数组装方式。对必须保留的位置补上枚举、正则或配置中心白名单。在 CI 中加入自动化扫描把“外部输入直连${}”变成门禁失败条件。用单元测试覆盖正常输入、异常输入和边界输入。生产环境保留慢 SQL 和异常 SQL 日志定期人工审计。5.3 排查链路怀疑有问题时按这个顺序查如果线上出现数据异常、接口报错或者安全扫描报告提示可能存在注入风险排查顺序应该是现象先定位是报错、数据泄露、权限绕过还是接口异常。入口追踪哪些 Controller、消息消费者、外部服务参数会进入 Mapper。通道确认参数最终流到#{}还是${}。内容如果是${}继续查参数是否经过白名单、枚举、正则校验。版本确认 Mybatis 版本、数据库驱动版本是否存在已知问题。日志在安全环境开启完整 SQL 日志人工观察拼接后的 SQL 语义有没有被改写。这个顺序的逻辑是先判断问题是否真的出在 SQL 层再追踪输入来源最后看技术和版本层面的可能性。不要一上来就盯着某一行 XML 代码看很容易漏掉上游入口。5.4 把规则写进评审模板而不是靠个人经验比“不用${}”更精确的团队规范是外部输入默认只能当参数。如果它要被用于拼接 SQL 结构必须先经过服务端白名单映射并在代码评审中明确说明该场景为什么无法参数化。把这句放到代码评审模板里每次评审时过一遍比每次靠一个“有经验的人”盯着要可靠得多。回到那个面试问题其实面试官问“有#{}为什么还要设计${}”真正想考察的不是你背没背过答案而是你是否理解 SQL 语句本身由“结构”和“数据”两部分组成。预编译负责把这两者隔离开动态拼接则是手动打开结构之门。#{}是默认通道${}是特例通道。特例通道一旦打开必须有白名单守门。理解了这一层你自然知道什么时候该用#{}什么时候必须用${}以及用了${}之后要补哪些防护。这个问题真正值得长期记住的不是“哪个符号安全”而是“任何外部输入进入 SQL 结构之前都必须经过一道明确的约束”。做到这一点项目里的 SQL 注入防线才算真正立住了。