资讯中心

MyBatis与MyBatis-Plus防SQL注入:从预编译原理到实战安全编码

📅 2026/7/4 11:02:34
MyBatis与MyBatis-Plus防SQL注入:从预编译原理到实战安全编码
1. 项目概述为什么后端开发者必须掌握SQL注入防御在Java后端开发领域尤其是涉及数据库操作的场景SQL注入SQL Injection是一个老生常谈却又历久弥新的安全问题。无论是使用原生的MyBatis还是其增强工具MyBatis-Plus理解其如何从框架层面帮助我们规避SQL注入风险是每一位合格开发者必须掌握的“内功”。这不仅仅是面试八股文里的一个考点更是保障线上业务数据安全、避免重大生产事故的底线技能。我见过太多因为不当的SQL拼接导致的数据库被拖库、用户信息泄露的案例。很多开发者尤其是刚入行的朋友可能会过度依赖框架认为用了MyBatis或MyBatis-Plus就高枕无忧了。这种想法是危险的。框架提供了强大的防护机制但如果你错误地使用它防护墙就会形同虚设。本文将从原理层面拆解MyBatis和MyBatis-Plus的防注入机制并结合大量实战代码和踩坑经验让你不仅知道“怎么用”更透彻理解“为什么这么用”以及“在哪些边界情况下依然可能出问题”。无论你是正在准备面试还是在日常开发中希望构建更健壮的系统这篇内容都将提供直接的参考。2. MyBatis 防止 SQL 注入的核心原理预编译与参数化查询要理解MyBatis如何防注入首先得抛开框架回到数据库访问的本质。Java操作数据库最终都是通过JDBC驱动与数据库通信。而SQL注入发生的根本原因在于将用户输入的数据与SQL语句的逻辑结构指令混淆在一起执行。2.1 危险的字符串拼接注入是如何发生的假设我们有一个根据用户ID查询的简单需求。最原始、最危险的做法是字符串拼接String userId request.getParameter(id); // 用户输入假设是 1 OR 11 String sql SELECT * FROM user WHERE id userId; Statement stmt connection.createStatement(); ResultSet rs stmt.executeQuery(sql);当用户输入的id参数是1 OR 11时最终执行的SQL变成了SELECT * FROM user WHERE id 1 OR 11。WHERE条件永远为真导致查询出所有用户数据这就是一次典型的注入攻击。为什么危险因为数据库服务器将userId变量的值直接当成了SQL指令的一部分进行解析和执行。用户输入中的OR 11被解释为一个有效的逻辑表达式改变了原SQL的语义。2.2 JDBC的救赎PreparedStatement 与参数化查询JDBC提供了PreparedStatement接口来从根本上解决这个问题。其核心思想是预编译Precompilation和参数占位符。String sql SELECT * FROM user WHERE id ?; // 使用 ? 作为参数占位符 PreparedStatement pstmt connection.prepareStatement(sql); // 预编译SQL模板 pstmt.setInt(1, Integer.parseInt(userId)); // 将参数值安全地设置进去 ResultSet rs pstmt.executeQuery();这个过程分为两步预编译数据库驱动先将SQL语句模板SELECT * FROM user WHERE id ?发送给数据库。数据库对其进行语法分析、编译和优化生成一个执行计划。此时SQL的结构哪里是表名、哪里是列名、哪里是条件已经固定?只是一个等待填充的“空位”。参数设置通过pstmt.setXxx()方法将具体的参数值传入。此时无论传入的值是什么即使是1 OR 11数据库都会将其严格视为一个纯粹的字符串或数字值而不会将其作为SQL语法进行解析。对于上面的例子数据库会去查找id字段值等于**字符串“1 OR 11”**的记录显然找不到从而安全地返回空结果。这就是参数化查询Parameterized Query它是防止SQL注入的黄金标准。MyBatis的防注入能力正是建立在这一基石之上。2.3 MyBatis 如何实现参数化查询MyBatis在内部封装了JDBC操作。当你编写一个Mapper接口方法并配合XML或注解定义SQL时MyBatis的核心工作就是帮你生成最终的PreparedStatement。在XML映射文件中select idselectUserById resultTypeUser SELECT * FROM user WHERE id #{id} /select这里的#{id}就是MyBatis的参数占位符。MyBatis在运行时会将其转换为JDBC的?并调用相应的setInt或setString方法安全地设置参数值。这是MyBatis默认的、安全的传参方式。与之相对的危险方式是${}select idselectUserByOrder resultTypeUser SELECT * FROM user ORDER BY ${orderByColumn} /select${orderByColumn}是字符串替换。MyBatis在解析SQL时会直接将传入的orderByColumn参数值替换到SQL字符串中然后再发送给数据库。如果这个参数值来自不可信的用户输入例如id; DROP TABLE user--那么后果不堪设想。关键心得#{}和${}的区别是MyBatis面试和实战中最核心的安全考点。简单记#{}是预编译参数安全${}是字符串拼接危险需谨慎。${}仅在动态传入表名、列名等SQL本身的关键字且这些关键字来自可信的、内部定义的枚举或常量时才可考虑使用并必须做严格的白名单校验。3. MyBatis-Plus 在防注入上的增强与便利MyBatis-Plus简称MP在MyBatis的基础上提供了更强大的CRUD封装和条件构造器。它的防注入机制同样基于预编译但在使用体验和安全性上做了更多封装。3.1 条件构造器自动化的安全查询构建MP最常用的功能就是QueryWrapper或LambdaQueryWrapper。它们通过链式调用方法构建查询条件底层会自动将这些条件转换为使用#{}的参数化SQL。// 使用 QueryWrapper QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(name, userName) .gt(age, 18) .like(email, domain.com); ListUser userList userMapper.selectList(wrapper); // 使用 LambdaQueryWrapper (类型安全推荐) LambdaQueryWrapperUser lambdaWrapper new LambdaQueryWrapper(); lambdaWrapper.eq(User::getName, userName) .gt(User::getAge, 18) .like(User::getEmail, domain.com); ListUser userList userMapper.selectList(lambdaWrapper);生成的SQL类似于SELECT * FROM user WHERE name ? AND age ? AND email LIKE ?参数userName,18,%domain.com%都会被安全地设置为预编译参数。开发者几乎无需关心SQL拼接的细节从而从根本上避免了手写SQL时可能出现的${}误用风险。3.2 自定义SQL与条件构造器结合有时我们需要编写复杂的自定义SQL但查询条件又想用MP的Wrapper来安全地构建。MP提供了${ew.customSqlSegment}的用法。!-- 在Mapper XML中 -- select idselectUserPage resultTypeUser SELECT u.*, d.dept_name FROM user u LEFT JOIN department d ON u.dept_id d.id ${ew.customSqlSegment} !-- 这里注入Wrapper生成的WHERE条件 -- /select// 在Service中 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .like(User::getName, 张) .orderByDesc(User::getCreateTime); IPageUser page new Page(1, 10); userMapper.selectUserPage(page, wrapper);这里的安全关键点${ew.customSqlSegment}虽然使用了${}进行字符串替换但替换进去的内容是Wrapper动态生成的条件表达式片段如WHERE status ? AND name LIKE ?而其中的参数值1,%张%仍然是通过#{}预编译方式传递的。Wrapper内部已经确保了条件构建的安全性。注意事项${ew.customSqlSegment}只能用于注入由MP的Wrapper生成的SQL片段。绝对不要用它来拼接用户输入的原始值。它的安全性依赖于Wrapper本身的正确使用。3.3 插件机制与全局拦截MP提供了丰富的插件接口如PaginationInterceptor分页插件、TenantLineInnerInterceptor多租户插件等。这些插件在SQL执行前进行拦截和改写其改写过程同样遵循参数化查询的原则。例如分页插件会自动将你的查询语句改写成数据库方言对应的分页SQL如LIMIT ?, ?其中的分页参数offset, limit也是通过预编译设置的从而避免了分页参数注入的风险。4. 从实战场景看防注入的边界与深水区掌握了基本原理不代表在实际开发中就能万无一失。下面结合几个常见但易错的场景分析防注入的边界。4.1 场景一IN查询的动态参数处理这是一个高频需求根据一个动态的ID列表查询用户。错误做法是直接在XML里拼接!-- 危险 -- select idselectUsersByIds resultTypeUser SELECT * FROM user WHERE id IN (${ids}) /select如果ids是字符串1,2,3) OR 11 --注入就发生了。安全方案一MyBatis 动态 SQLforeach配合#{}select idselectUsersByIds resultTypeUser SELECT * FROM user WHERE id IN foreach collectionlist itemid open( separator, close) #{id} /foreach /selectMyBatis的foreach标签会遍历集合为每个元素生成一个#{}占位符最终SQL是WHERE id IN (?, ?, ?)安全。安全方案二MyBatis-Plus 的in方法LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.in(User::getId, idList); // idList 是 ListLong这是最简单、最推荐的方式MP会帮你安全地处理。潜在问题与优化当IN列表长度极大比如上万生成的SQL会包含大量占位符?可能超出数据库限制或影响性能。此时应考虑分批查询或使用临时表等方案。4.2 场景二动态排序与动态表名/列名正如前文所述ORDER BY、GROUP BY后面跟的列名或者表名本身如果需要动态传入不能使用#{}因为#{}会给值加上引号ORDER BY create_time导致语法错误。这时似乎只能使用${}。安全实践严格的白名单校验// 定义一个允许排序的字段白名单 private static final SetString ALLOWED_ORDER_FIELDS Set.of(create_time, update_time, name); public String validateOrderField(String inputField) { if (!ALLOWED_ORDER_FIELDS.contains(inputField)) { // 默认使用一个安全字段或者抛出业务异常 return create_time; } return inputField; } // 在Wrapper中使用 String safeOrderField validateOrderField(userInputOrder); wrapper.orderBy(true, true, safeOrderField);在XML中使用${}前也必须对传入的参数值进行同样的白名单过滤。永远不要将未经校验的用户输入直接用于${}替换。4.3 场景三Like查询的模糊匹配模糊查询LIKE也需要特别注意虽然用#{}是安全的但参数格式要正确。wrapper.like(name, 张); // MP会自动在值两边加上 %生成 name LIKE %张% wrapper.likeLeft(name, 张); // name LIKE %张 wrapper.likeRight(name, 张); // name LIKE 张%如果你需要自定义模式比如查询中间包含特定字符可以wrapper.apply(name LIKE CONCAT(%, #{pattern}, %), userInput);这里的#{pattern}仍然是预编译参数CONCAT是数据库函数整体是安全的。一个经典陷阱在XML中手动写LIKE时错误的拼接方式。!-- 错误#{}在引号内会被当成普通字符串 -- select idselectLike resultTypeUser SELECT * FROM user WHERE name LIKE %#{keyword}% /select !-- 正确做法1使用CONCAT函数 -- select idselectLike resultTypeUser SELECT * FROM user WHERE name LIKE CONCAT(%, #{keyword}, %) /select !-- 正确做法2在Java代码中拼接好模式需注意数据库方言 --String pattern % keyword %; wrapper.like(name, pattern); // 推荐使用MP方法4.4 场景四MyBatis注解开发中的陷阱除了XMLMyBatis也支持注解开发。在注解中使用${}同样危险。// 危险 Select(SELECT * FROM user WHERE name ${name}) User findUserByName(Param(name) String name); // 安全 Select(SELECT * FROM user WHERE name #{name}) User findUserByName(Param(name) String name);注解开发时由于SQL直接写在Java代码中更容易因为疏忽而写出不安全的字符串拼接。务必坚持使用#{}。5. 进阶插件开发与SQL审计拦截对于中大型项目除了正确使用框架我们还需要一道“安全审计”防线。可以开发一个MyBatis插件拦截所有执行的SQL检查其中是否包含不安全的${}使用除了已知安全的场景如${ew.customSqlSegment}。5.1 实现一个简单的SQL审计插件Intercepts({Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class})}) public class SqlInjectionAuditInterceptor implements Interceptor { // 定义一些已知安全的、允许使用${}的片段模式正则 private static final SetPattern SAFE_PATTERNS Set.of( Pattern.compile(\\$\\{ew\\.customSqlSegment\\}), Pattern.compile(\\$\\{.*?wrapper.*?\\}) // 根据项目规范自定义 ); Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); String sql boundSql.getSql(); // 检查SQL中是否包含${} Pattern pattern Pattern.compile(\\$\\{[^}]\\}); Matcher matcher pattern.matcher(sql); while (matcher.find()) { String matched matcher.group(); boolean isSafe SAFE_PATTERNS.stream().anyMatch(p - p.matcher(matched).matches()); if (!isSafe) { // 记录告警日志甚至抛出异常阻断执行 log.warn(检测到潜在不安全的SQL字符串替换: {}, 完整SQL: {}, matched, sql); // 生产环境可以考虑抛出运行时异常强制代码审查 // throw new RuntimeException(发现潜在SQL注入风险请检查SQL语句: matched); } } return invocation.proceed(); } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { } }将这个插件配置到MyBatis的SqlSessionFactory中它会在每次SQL预编译前进行检查为你的项目增加一层运行时防护。5.2 MyBatis-Plus 的 TenantLineInnerInterceptor 原理借鉴MP的多租户插件是一个很好的安全设计范例。它通过拦截器自动在所有涉及租户表的SQL的WHERE条件中追加tenant_id ?条件。这个追加的过程也是通过改写SQL语句并安全地添加预编译参数实现的而不是简单的字符串拼接。研究其源码TenantLineInnerInterceptor类可以加深对MyBatis插件机制和安全SQL构建的理解。6. 常见问题排查与安全加固清单即使理解了原理在实际开发和排查问题时仍可能遇到困惑。下面是一些常见问题的实录。6.1 问题一日志里看到的SQL有参数值是不是被注入了现象开启MyBatis的SQL日志后看到控制台打印的SQL语句是带具体参数值的完整语句例如SELECT * FROM user WHERE id 1 AND name test。这让人担心参数值是不是被直接拼接进SQL了分析与排查这通常是日志框架如Logback、Log4j2配置了mybatis.configuration.log-impl为StdOutImpl或Slf4jImpl并且日志级别为DEBUG。此时打印的“完整SQL”是驱动如P6Spy或MyBatis自身为了便于调试在日志层面将预编译SQL和参数值合并后模拟输出的并不是真正发送给数据库的SQL。真正发送给数据库的仍然是带?的预编译语句和独立的参数包。验证方法开启数据库层面的通用日志如MySQL的general_log查看接收到的真实SQL语句你会发现是带?的。所以只要你的代码中正确使用了#{}或MP的条件构造器就不用担心。6.2 问题二使用了#{}但程序还是报SQL语法错误可能原因1参数类型不匹配。例如数据库字段是datetime但你传入了一个字符串#{createTime}而字符串内容格式不对。#{}只能防止注入不能保证数据格式正确。解决方案是确保传入参数的类型与数据库字段类型兼容或使用JSR-310的LocalDateTime等类型由MyBatis-TypeHandler处理。可能原因2#{}出现在了SQL关键字位置。如ORDER BY #{field}这会导致ORDER BY create_time语法错误。此处应使用${}白名单校验或使用MP的orderBy方法。可能原因3动态SQL标签使用错误。例如在if标签内条件判断写成了testname ! null and name ! #{name}这会造成解析混乱。应写为testname ! null and name ! 参数传递在标签体内用#{name}。6.3 问题三MyBatis-Plus的update或delete方法如何防注入MP的updateById、deleteById等方法其WHERE条件是基于主键的主键值通过#{}传递是安全的。而使用UpdateWrapper或LambdaUpdateWrapper进行更新时其set和where条件构建机制与QueryWrapper类似都是参数化安全的。LambdaUpdateWrapperUser updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(User::getName, 旧名字) .set(User::getName, 新名字); // set的值也是通过预编译参数传递 userMapper.update(null, updateWrapper);生成的SQL是UPDATE user SET name ? WHERE name ?安全。6.4 安全加固自查清单在代码审查或项目上线前可以对照以下清单进行快速自查检查项安全做法危险做法基础传参一律使用#{param}使用${param}传递业务数据值动态表/列名使用${}严格白名单校验直接使用用户输入拼接${columnName}IN 查询使用foreach或 MP的in()方法使用IN (${ids})直接拼接字符串LIKE 查询使用CONCAT(%, #{k}, %)或 MP的like()方法在SQL中写LIKE %${k}%ORDER BY使用${}白名单校验或 MP的orderBy()方法ORDER BY #{field}或ORDER BY ${userInput}注解开发Select(... #{value} ...)Select(... ${value} ...)Wrapper使用优先使用LambdaQueryWrapper在Wrapper的apply()方法中拼接不可信字符串插件/拦截器审查所有自定义插件确保其SQL改写使用参数化插件内直接拼接SQL字符串7. 总结与个人实战体会回顾整个MyBatis和MyBatis-Plus的防注入机制其核心万变不离其宗充分利用数据库的预编译PreparedStatement功能严格区分SQL指令结构和用户数据参数。在实际项目开发中我的体会是框架不是银弹MyBatis-Plus极大地提升了开发效率和安全基线但它只是一个工具。最大的风险往往来自于开发者对工具的错误使用比如为了图一时方便在动态排序时直接拼接用户输入。建立团队内的代码规范和定期的安全代码评审至关重要。“默认安全”原则在团队内推行“默认使用#{}”和“默认使用LambdaQueryWrapper”的规范。只有当确实需要动态SQL关键字表名、列名、排序字段时才允许使用${}并且必须配套一个显式的、可审查的白名单校验函数。这样能将安全风险控制在极小的、可见的范围内。防御深度不要只依赖框架一层。结合Web应用防火墙WAF对输入进行过滤在DAO层进行参数校验在数据库层面使用最小权限原则应用数据库用户只拥有必要的CRUD权限而非DROP、ALTER等形成纵深防御体系。持续学习与测试安全是一个持续的过程。可以定期使用SQL注入漏洞扫描工具如SQLMap仅用于授权测试自己的测试环境对应用接口进行扫描。同时在单元测试和集成测试中加入针对边界情况和异常输入的测试用例验证系统的健壮性。最后再分享一个排查SQL注入相关性能问题的小技巧如果你发现某个使用IN查询或复杂动态条件的接口突然变慢除了检查索引别忘了去数据库查看慢查询日志。有时不安全的SQL拼接或错误的动态SQL生成可能会导致数据库无法有效使用索引例如对参数进行函数操作WHERE DATE(create_time) ?从而引发性能问题而性能问题往往是安全问题的先兆或伴生现象。保持对SQL的敬畏之心是后端开发者的重要素养。