SpringBoot整合MyBatis实战:参数传递、动态SQL、分页与事务管理详解
1. 项目概述为什么“常用”二字值得深挖上次我们聊了SpringBoot整合MyBatis的基础搭建把架子搭起来了能跑通一个简单的查询。但说实话那只是“Hello World”级别。真正进入项目实战你会发现日常开发中80%的时间其实都花在了处理那些“常用”但细节繁多的场景上。比如怎么优雅地传参、如何高效地写动态SQL、分页到底用哪种方案、事务边界怎么控制……这些才是决定一个项目代码是优雅还是“屎山”的关键。所以这篇“二常用”我们就来啃这些硬骨头。我不会再重复配置pom.xml或者application.yml这种基础操作而是直接切入实战中最高频、最容易出问题的环节。目标很明确让你看完之后手里的SpringBootMyBatis项目代码质量能立刻上一个台阶同时避开那些我踩过的、以及安全扫描比如奇安信经常报的坑。无论你是刚接手一个老项目还是正在从零搭建新系统这里面的内容都是你马上就能用上的“弹药”。2. 核心细节解析与实操要点2.1 参数传递的“道”与“术”告别混乱MyBatis的参数传递看似简单但用不对就是Bug之源。核心原则就一个明确类型使用命名参数。1. 简单类型与Param注解当你DAO层接口的方法只有一个参数时在XML中可以直接用#{参数名}引用。但这里有个巨坑如果这个参数是基本类型int, long或StringMyBatis允许你随便起名比如#{id}、#{value}甚至#{aaa}都能工作。这导致了极大的不一致性。我的强制规范是即使只有一个参数也强烈建议使用Param注解明确命名。// 不推荐可读性差容易混淆 User selectById(Long id); // XML中必须用 #{id}但容易记错 // 强烈推荐清晰明确 User selectById(Param(“userId”) Long id); // XML中使用 #{userId}一目了然2. Map与POJO传参的抉择多个参数时无非两种方式用一个Map装起来或者用一个POJO实体类或DTO对象。Map传参灵活适合参数不固定、临时查询的场景。但缺点非常明显类型不安全键名容易拼写错误运行时才报错可读性极差。除非是极端动态的场景否则我基本不用。POJO传参这是主流和推荐的方式。将查询条件封装成一个专门的QueryDTO属性名就是参数名。这不仅安全而且语义清晰后期维护方便。// 推荐使用DTO传参 Data // 使用Lombok public class UserQueryDTO { private String username; private Integer status; private Date createTimeStart; private Date createTimeEnd; } ListUser selectByCondition(UserQueryDTO query);在XML中直接使用#{username},#{status}即可MyBatis会自动从DTO对象中获取对应属性的值。3. 复杂参数List、Array的传递查询id in (?)的场景太常见了。传List或数组时关键在于XML中如何使用。ListUser selectByIds(Param(“idList”) ListLong ids);对应的XML写法强烈推荐使用foreach标签绝对不要用字符串拼接。select id“selectByIds” resultType“User” SELECT * FROM user WHERE id IN foreach collection“idList” item“id” open“(” separator“,” close“)” #{id} /foreach /select这里collection的值就是Param注解里指定的“idList”。如果你不用Param传了一个单独的List参数那么这里collection必须写“list”这是MyBatis的默认别名可读性很差所以再次证明Param的重要性。2.2 动态SQL灵活与安全的平衡艺术动态SQL是MyBatis的精华也是SQL注入漏洞的重灾区。核心就那几个标签if,choose,where,set,foreach。1.where和set标签的魔法这两个标签能帮你自动处理WHERE和SET子句前的AND/OR或者逗号避免语法错误。select id“selectByCondition” resultType“User” SELECT * FROM user where if test“username ! null and username ! ‘’“ AND username LIKE CONCAT(‘%’, #{username}, ‘%’) /if if test“status ! null” AND status #{status} /if /where /select注意where标签内的if条件我依然以AND开头。where标签会智能地去除第一个多余的AND或OR。set标签同理用于更新操作。2. 终极安全红线#{}与${}的区别这是面试必问更是安全扫描的核心关注点。一句话99%的场景用#{}${}要慎之又慎。#{}是预编译参数占位符。MyBatis会将其替换为?然后通过PreparedStatement设置参数。可以防止SQL注入是绝对安全的。${}是字符串替换。MyBatis会直接将参数值替换到SQL语句中。存在SQL注入风险。那${}什么时候用极少数动态表名、列名的场景。比如按不同月份查询分表order_202401order_202402。select id“selectFromDynamicTable” SELECT * FROM ${tableName} WHERE id #{id} !-- 参数部分依然用#{} -- /select重要警告使用${}时参数值绝对不能来自用户直接输入如前端表单。必须是在服务端内部可控、枚举或严格校验过的值。奇安信等安全扫描工具一旦发现${}中有用户可控参数必定会报SQL注入漏洞。3.choose标签处理多分支逻辑类似于Java的switch-case适合“多选一”的场景。select id“selectComplex” SELECT * FROM task where choose when test“type ‘urgent’” AND priority 1 AND status 0 /when when test“type ‘normal’” AND priority 3 /when otherwise AND status 1 /otherwise /choose /where /select2.3 结果映射解决“名不对姓”的烦恼数据库字段user_nameJava实体属性userName这种不一致太常见了。有几种解决方案1. 起别名最直接但繁琐在SQL中写SELECT user_name AS userName。2. 开启驼峰命名自动映射推荐在application.yml中配置这是SpringBoot整合后最方便的方式。mybatis: configuration: map-underscore-to-camel-case: true # 开启下划线转驼峰开启后user_name会自动映射到userName。但要注意如果数据库字段就是username驼峰而实体是userName这个配置不会生效因为它是“下划线转驼峰”不是“大小写转换”。3. 使用resultMap进行自定义映射最强大对于复杂查询比如关联查询、字段类型转换resultMap是终极武器。resultMap id“DetailedUserMap” type“User” id property“id” column“id”/ result property“userName” column“user_name”/ result property“createTime” column“create_time”/ !-- 一对一关联 -- association property“department” javaType“Department” id property“deptId” column“dept_id”/ result property“deptName” column“dept_name”/ /association !-- 一对多关联 -- collection property“roles” ofType“Role” id property“roleId” column“role_id”/ result property“roleName” column“role_name”/ /collection /resultMap使用resultMap后你的select标签的resultType就要换成resultMap指向这个映射的id。虽然写起来稍多但结构清晰尤其适合作为复杂查询的公共映射定义。3. 实操过程与核心环节实现3.1 分页查询从PageHelper到MyBatis-Plus的选择分页是高频需求。在SpringBoot生态里主要有两种主流选择。方案一使用PageHelper传统、简单PageHelper是国内开发者最熟悉的插件原理是使用MyBatis的拦截器在SQL执行前自动拼接LIMIT语句。引入依赖dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version最新版本/version /dependency使用方式Service public class UserServiceImpl { public PageInfoUser getUsers(int pageNum, int pageSize) { // 关键这行代码必须紧跟在执行查询的代码之前 PageHelper.startPage(pageNum, pageSize); // 接下来执行你的查询方法这个方法里就是普通的查询SQL不需要写LIMIT ListUser userList userMapper.selectByExample(new Example()); // 用PageInfo包装结果里面包含了分页信息总条数、总页数等 return new PageInfo(userList); } }踩坑提醒PageHelper.startPage(pageNum, pageSize)必须紧贴执行SQL的Mapper方法调用之前中间不能有其它数据库查询操作否则分页会失效或错乱。这是一个非常容易出错的地方。方案二使用MyBatis-Plus的内置分页现代、集成度高如果你已经使用了MyBatis-PlusMP那么它的分页功能更优雅、更安全。配置分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 根据数据库类型调整 return interceptor; } }使用方式Service public class UserServiceImpl { Autowired private UserMapper userMapper; // 假设继承自BaseMapper public IPageUser getUsers(int pageNum, int pageSize) { // 1. 创建分页对象 PageUser page new Page(pageNum, pageSize); // 2. 创建查询条件可选 LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(User::getName, “张”); // 3. 执行分页查询 return userMapper.selectPage(page, wrapper); // 返回的page对象本身就包含了数据列表和所有分页信息 } }MP的分页将分页参数和查询逻辑分离避免了PageHelper的“紧贴”陷阱代码更清晰。个人建议新项目直接上MyBatis-Plus它的分页、条件构造器等功能能极大提升开发效率。3.2 事务管理让数据操作要么全成功要么全失败SpringBoot中通过Transactional注解声明事务简单到令人发指但细节决定成败。1. 基本使用在Service层的方法上添加注解即可。Service public class OrderService { Transactional(rollbackFor Exception.class) // 关键指定回滚的异常类型 public void createOrder(Order order) { // 1. 保存订单主表 orderMapper.insert(order); // 2. 扣减库存可能失败 inventoryService.deduct(order.getSkuId(), order.getQuantity()); // 3. 生成日志 logService.record(order); // 如果第二步或第三步抛出异常第一步的插入也会回滚 } }2. 关键参数与避坑指南rollbackFor Exception.class务必加上。默认情况下Transactional只在抛出运行时异常RuntimeException和Error时回滚受检异常Exception不会触发回滚。加上这个属性让所有异常都触发回滚更符合业务直觉。事务失效的常见场景方法非publicTransactional只能用于public方法。自调用问题在同一个类中一个非事务方法A调用另一个有Transactional注解的方法B事务是不会生效的。因为事务是基于AOP代理实现的自调用不走代理。异常被捕获如果在方法内部用try-catch吞掉了异常事务管理器感知不到异常自然不会回滚。数据库引擎不支持MySQL的MyISAM引擎不支持事务必须使用InnoDB。3. 多数据源事务如果你的项目配置了多个数据源那么默认的Transactional可能无法管理跨库事务。这是一个复杂话题通常需要引入分布式事务解决方案如Seata或者从设计上避免跨库的写操作。对于大多数应用确保一个事务方法内的所有数据库操作都在同一个数据源上是最佳实践。3.3 高级特性类型处理器与插件开发1. 类型处理器TypeHandler用于处理Java类型和JDBC类型之间的特殊转换。比如把Java的ListString以JSON字符串格式存入数据库的varchar字段。自定义TypeHandlerMappedJdbcTypes(JdbcType.VARCHAR) MappedTypes(List.class) public class StringListTypeHandler extends BaseTypeHandlerListString { private final ObjectMapper objectMapper new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, objectMapper.writeValueAsString(parameter)); } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String json rs.getString(columnName); return json null ? null : objectMapper.readValue(json, new TypeReferenceListString(){}); } // ... 其他getNullableResult重载 }注册并使用 可以在配置文件中全局注册也可以在具体的result映射中指定。mybatis: type-handlers-package: com.yourpackage.handler或者在XML中result column“tags” property“tagList” typeHandler“com.yourpackage.handler.StringListTypeHandler”/2. 插件开发InterceptorMyBatis插件功能强大可以拦截四大核心对象Executor,ParameterHandler,ResultSetHandler,StatementHandler。常用场景分页、数据权限过滤、SQL执行时间监控、通用字段自动填充如create_time,update_time。 下面是一个简单的SQL执行时间监控插件示例Intercepts({ Signature(type StatementHandler.class, method “query”, args {Statement.class, ResultHandler.class}), Signature(type StatementHandler.class, method “update”, args {Statement.class}) }) Component Slf4j public class SqlCostInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { long startTime System.currentTimeMillis(); try { return invocation.proceed(); // 执行原方法 } finally { long cost System.currentTimeMillis() - startTime; StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); String sql boundSql.getSql(); if (cost 1000) { // 超过1秒的SQL记录为慢查询 log.warn(“慢SQL执行耗时{} ms SQL: {}”, cost, sql); } else { log.debug(“SQL执行耗时{} ms”, cost); } } } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { } }注意插件会拦截所有SQL生产环境要谨慎使用避免性能开销和日志泛滥。通常用于开发调试阶段。4. 常见问题与排查技巧实录4.1 SQL注入漏洞排查与修复这是安全扫描如奇安信的重点关照对象。除了前面强调的禁止在${}中使用用户输入外还有以下隐蔽场景1. Like查询的注入风险错误的写法if test“name ! null” AND name LIKE ‘%${name}%’ !-- 高危直接拼接 -- /if正确的写法if test“name ! null” AND name LIKE CONCAT(‘%’, #{name}, ‘%’) !-- 使用#{}和CONCAT函数 -- /if或者在Java代码中拼接好再传参String nameParam “%” name “%”; query.setName(nameParam);AND name LIKE #{name} !-- XML中直接使用 --2. In查询的动态参数个数有时in语句的参数列表是动态生成的数量不定。务必使用foreach标签配合#{}切勿拼接字符串。!-- 安全 -- WHERE id IN foreach collection“ids” item“id” open“(” separator“,” close“)” #{id} /foreach !-- 危险 -- WHERE id IN (${idListStr})3. Order By 动态排序按用户选择字段排序是一个常见需求但字段名不能使用#{}会被加上引号导致语法错误又不能用${}直接接用户输入。解决方案在服务端进行校验和映射。// 前端传 “name_asc” 或 “createTime_desc” String sortField getSortField(request.getSort()); // 通过一个安全映射方法获取 // getSortField 方法实现示例 private String getSortField(String input) { MapString, String safeMap new HashMap(); safeMap.put(“name_asc”, “name ASC”); safeMap.put(“createTime_desc”, “create_time DESC”); // 只返回预设的安全值否则返回默认排序 return safeMap.getOrDefault(input, “id DESC”); }然后在XML中使用${safeSortField}。因为safeSortField的值是服务端完全可控的所以是安全的。4.2 映射失败与N1查询问题1. 属性映射为null检查点1数据库字段名和实体属性名是否匹配是否开启了map-underscore-to-camel-case检查点2查询SQL中是否包含了该字段有时手写SQL漏了字段。检查点3实体类属性是否有正确的getter/setter方法如果使用Lombok的Data检查编译后的class文件是否生成了对应方法。2. 经典的N1查询问题在关联查询一对多时如果先在主查询中查出N条订单然后循环每条订单再去查询其关联的订单项就会产生1主查询 N循环查询次数据库请求性能极差。解决方案使用MyBatis的关联查询collection一次性查出所有数据。resultMap id“orderWithItemsMap” type“Order” id property“id” column“order_id”/ collection property“itemList” ofType“OrderItem” id property“id” column“item_id”/ result property“productName” column“item_product_name”/ !-- 注意关联查询时所有column必须唯一通常通过起别名解决 -- /collection /resultMap select id“selectOrderWithItems” resultMap“orderWithItemsMap” SELECT o.id as order_id, i.id as item_id, i.product_name as item_product_name FROM order o LEFT JOIN order_item i ON o.id i.order_id WHERE o.id #{id} /select这样一次查询就能拿到订单及其所有明细避免了N1问题。4.3 性能调优与日志排查1. 开启MyBatis SQL日志在开发环境开启日志对于调试和性能分析至关重要。logging: level: com.yourpackage.mapper: debug # 将你的Mapper接口所在包级别设为debug或者更精确地只打印SQLlogging: level: org.apache.ibatis: info com.apache.ibatis.jdbc: debug这样可以在控制台看到执行的SQL语句和参数方便检查SQL是否正确、是否有全表扫描等。2. 识别慢查询与全表扫描通过上面提到的自定义插件监控SQL耗时。结合数据库的慢查询日志如MySQL的slow_query_log进行分析。检查关键查询是否使用了索引。对于复杂的动态查询要特别注意if条件可能导致索引失效的情况。例如对某个字段进行NULL判断或函数操作WHERE UPPER(name) …会使该字段上的索引失效。3. 批量操作提升性能对于大量数据插入或更新使用MyBatis的批量操作能极大提升性能。Transactional public void batchInsert(ListUser userList) { // 方式一使用MyBatis的foreach标签适用于数据量不是特别大如几千条 // 在XML中写INSERT INTO user (...) VALUES foreach.../foreach // 方式二使用ExecutorType.BATCH更高效适合大数据量 SqlSession sqlSession sqlSessionTemplate.getSqlSessionFactory().openSession(ExecutorType.BATCH); UserMapper batchMapper sqlSession.getMapper(UserMapper.class); try { for (User user : userList) { batchMapper.insert(user); } sqlSession.commit(); // 批量提交 } finally { sqlSession.close(); } }注意批量操作时单次提交的数据量不宜过大通常建议1000-5000条一批避免内存溢出和数据库事务锁持有时间过长。4.4 与其它框架整合时的配置冲突1. 与Flowable工作流引擎整合如热词中提到的“flowable-ui覆盖了mybatis的配置”这是因为Flowable自带了自己的MyBatis配置和SessionFactory。在同一个SpringBoot项目中如果你既有业务MyBatis又引入了Flowable需要小心配置冲突。解决方案明确区分两个数据源和两个MyBatis配置。通常业务数据源和Flowable的流程引擎数据源是分开的。你需要为业务MyBatis配置指定明确的SqlSessionFactoryBean和MapperScannerConfigurer并限定其扫描的包路径避免扫描到Flowable的Mapper接口。2. 多数据源配置当项目需要连接多个数据库时每个数据源都需要自己独立的DataSource、SqlSessionFactory和TransactionManager。配置的关键在于使用Primary注解指定一个主数据源并为其他数据源的Bean使用Qualifier进行区分。同时在Service层使用Transactional注解时如果需要指定非主数据源的事务管理器需要使用Transactional(value “otherTransactionManager”)。3. 配置文件优先级与覆盖SpringBoot中MyBatis的配置参数如mybatis.configuration.map-underscore-to-camel-case可能会被其他方式覆盖。例如如果你在代码中显式创建了一个SqlSessionFactoryBean并设置了Configuration属性那么配置文件中的同名设置可能会失效。排查配置问题时要检查所有可能设置该值的地方。5. 工程化实践超越CRUD的代码组织当项目规模变大Mapper和XML文件会急剧膨胀。好的组织方式能让你事半功倍。1. Mapper接口与XML的分离与约定位置约定通常将Mapper接口放在com.xxx.mapper包下对应的XML文件放在resources/mapper目录或resources/com/xxx/mapper目录下。命名约定Mapper接口名UserMapper.java对应的XML文件名为UserMapper.xml。这是MyBatis的默认查找规则。使用MapperScan在启动类或配置类上使用MapperScan(“com.xxx.mapper”)避免在每个Mapper接口上写Mapper注解。2. 使用MyBatis-Plus进一步提效强烈推荐MyBatis-PlusMP在MyBatis基础上做了大量增强能极大减少样板代码。通用CRUDMapper接口继承BaseMapperT即可获得insert,selectById,update,delete等一系列方法无需写XML。条件构造器使用QueryWrapper或LambdaQueryWrapper可以用Java代码流畅地构建查询条件避免在XML中写大量if标签类型安全编译期就能发现问题。LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1) .like(User::getName, “王”) .between(User::getCreateTime, startDate, endDate) .orderByDesc(User::getId); ListUser list userMapper.selectList(wrapper);代码生成器MP提供的代码生成器可以一键生成Entity, Mapper, XML, Service, Controller层代码是快速启动新项目的利器。3. 自定义通用Mapper与Service即使不使用MP也可以抽象出通用的方法。定义通用Mapper接口public interface BaseMapperT, PK { T selectById(PK id); int insert(T entity); int updateById(T entity); int deleteById(PK id); // ... 其他通用方法 }使用泛型实现然后让你的业务Mapper如UserMapper继承这个BaseMapperUser, Long。具体的SQL实现可以通过一个通用的XML模板或者利用MyBatis的Provider注解如SelectProvider动态生成SQL。这需要一定的MyBatis高级知识但能带来极大的复用性。4. 单元测试与集成测试为Mapper层编写测试至关重要。SpringBoot Test可以很方便地集成。SpringBootTest Transactional // 测试后自动回滚不污染数据库 Rollback class UserMapperTest { Autowired private UserMapper userMapper; Test void testSelectById() { User user userMapper.selectById(1L); assertNotNull(user); assertEquals(“张三”, user.getUsername()); } }使用内存数据库如H2进行测试可以保证测试环境独立、快速。6. 总结与个人工具箱分享走完这一趟你会发现SpringBoot整合MyBatis的“常用”部分远不止是配置和基础CRUD。它涉及了安全、性能、可维护性、团队协作等多个维度。我的个人体会是在中小型项目中直接采用“SpringBoot MyBatis-Plus”的组合是性价比最高的选择它能帮你规避掉很多原生MyBatis的繁琐和坑尤其是条件构造器和分页插件。对于大型复杂项目可能需要更精细的控制那么深入理解原生MyBatis的插件机制、自定义类型处理器、以及多数据源/事务管理就变得必不可少。无论哪种选择有几条原则是共通的安全第一永远对用户输入保持警惕${}的使用要经过严格评审。明确约定团队内对参数传递、结果映射、文件组织方式要有明确的规范。日志驱动开发环境打开SQL日志它是你调试和性能分析的第一手资料。测试覆盖为复杂的动态SQL和业务逻辑编写足够的Mapper层测试。最后分享一个我自己的“踩坑检查清单”在代码Review或上线前可以快速过一遍[ ] 所有${}的使用参数是否服务端完全可控防注入[ ] 动态SQL中的if条件是否考虑了字段为null、空字符串、集合为空等多种边界情况[ ] Like查询是否使用了CONCAT或代码拼接避免了${}[ ] 分页查询是否使用了推荐插件PageHelper或MP并注意了使用姿势[ ]Transactional注解是否添加了rollbackFor Exception.class[ ] 关联查询是否避免了N1问题检查是否有循环内查数据库[ ] 复杂的resultMap其column别名是否唯一防止映射错乱[ ] 批量操作是否考虑了批处理大小和事务提交时机把这些点都做到位你的SpringBootMyBatis项目就不仅“能用”而且“健壮”、“高效”、“好维护”了。技术栈是死的但如何用好它才是体现工程师价值的地方。