MyBatisPlus Service层配置详解:从IService到分页事务避坑
1. 先理清Service层在MyBatisPlus里的定位1.1 Service层当初差点被设计成“透明转发层”的原因之前在某项目组做代码评审时我看到大量类似的代码Service接口里定义了一个getUserById实现类里就只有一句话return baseMapper.selectById(id)。上层再写一个ControllerController里也是直接new一个Service去调。说实话这种“透明转发层”最大的问题不是报错而是它让所有业务逻辑失去了落点。业务逻辑散落在Controller、工具类、甚至页面脚本里最后整个项目一改需求就到处开花。Service层在MyBatisPlus里之所以存在不只是为了满足三层架构的规范而是因为MyBatisPlus的IService和ServiceImpl已经把大量通用CRUD、批量操作、条件构造、分页查询都封装好了。你如果只是把Service层当成Mapper的别名那这些能力全部浪费了。我的看法是MyBatisPlus的Service层配置核心任务有两个——第一把基类提供的通用能力充分用起来第二在基类之上留出业务扩展的边界。它不是让你写一堆空壳方法也不是让你把所有SQL都堆在Service里而是让你能够在正确的位置表达业务规则。所以第一步不是写代码而是先想清楚Service层到底要承担什么。1.2 IService和ServiceImpl这对基类到底省了多少事MyBatisPlus提供了一对经典的泛型基类接口层继承IService 实现层继承ServiceImplM, T。很多人第一次看这段代码时会有疑问为什么要一个接口加一个实现类其实这和MyBatisPlus的整体设计有直接关系。IService定义了通用的服务方法集合包括单表CRUD、批量操作、分页查询、链式查询等。ServiceImpl则是这些方法的默认实现内部通过baseMapper去调用MyBatis的Mapper方法。你一旦让UserService继承IService 让UserServiceImpl继承ServiceImplUserMapper, User就自动获得了以下能力单条记录的save、updateById、removeById、getById批量操作的saveBatch、updateBatchById、removeByIds批量新增或更新的saveOrUpdate、saveOrUpdateBatch基于条件构造器的list(Wrapper)、count(Wrapper)、page(Page, Wrapper)链式查询lambdaQuery()、链式更新lambdaUpdate()还有getOne、listByIds、listMaps等一堆重载方法这些方法的实现都不是空壳它们内部处理了实体字段策略、主键生成策略、批量SQL拼接等细节。你自己去写一遍这些通用逻辑至少得花大半天而且很难保证在各种边界条件下都不出问题。所以配置Service层的第一个原则就是能用基类方法就不要重复造轮子。不过话说回来基类方法多也容易造成另一个极端就是不管什么需求都往Service里塞一个方法不够就再加一个最后Service变成了一个上千行的“万能类”。后面我会专门聊这个粒度问题先把标准配置姿势讲清楚。2. Service层配置的具体姿势接口、实现类与泛型对应关系2.1 以用户模块为例搭建一套标准的Service配置假设现在要做一个用户模块实体是UserMapper是UserMapper。最标准的配置通常是三个文件加一个注解注入。实体类Data TableName(sys_user) public class User { TableId(type IdType.ASSIGN_ID) private Long id; private String username; private String email; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }Mapper接口public interface UserMapper extends BaseMapperUser { }Service接口public interface UserService extends IServiceUser { }Service实现类Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }Controller里要注入的是Service接口不是Mapper也不是实现类RestController RequestMapping(/user) public class UserController { Resource private UserService userService; }这套配置看着简单但有两个细节值得注意。第一个细节是TableName(sys_user)和TableId主键策略必须提前定好不然ServiceImpl里saveOrUpdate的“是否存在”判断会失真。第二个细节是Controller层注入用Resource还是Autowired在不同框架版本里可能有兼容差异我更习惯在Service注入上保持统一避免同一个项目里两种风格混用。2.2 泛型映射与Mapper注入的底层关系ServiceImpl的完整定义是ServiceImplM extends BaseMapperT, T implements IServiceT。这个泛型M不是随便传的它必须是当前实体对应的Mapper类型ServiceImpl内部会通过baseMapper持有这个Mapper实现。你不需要再额外注入XxxMapper不然会出现两个Mapper引用同时存在一个是基类内部管理的一个是你自己管理的。常见的错误写法是在UserServiceImpl里又写一个Resource private UserMapper userMapper;然后所有方法都用userMapper去查。这样不是不能用但你会绕过ServiceImpl里很多默认处理比如entity的填充、批量SQL的分批、Page参数的研究等。正确做法是直接使用基类的getBaseMapper()方法或者通过this调用Service层接口方法。Hibernate和MyBatisPlus这种设计背后的逻辑是Mapper负责单表基础SQLService负责业务聚合与事务边界。泛型M让你不需要强制转型T让你能安全地处理任意实体类型。理解这一点后当你看到ServiceImplOrderItemMapper, OrderItem时一眼就知道这个Service是针对OrderItem这个实体的对应的底层Mapper是OrderItemMapper。如果项目里有多个模块我建议把Service接口放在api包实现类放在service.impl包Controller依赖接口而不是实现类。这样后续做单元测试或者多人并行开发时替换实现、加AOP切面都会方便很多。这算是我在配置Service层时最常强调的包结构规范。3. Service层内置方法的生产力条件构造器、批量与分页3.1 LambdaQueryWrapper如何让查询条件不再写死字段名很多老项目里Service层方法长这样先手动拼SQL条件再调用baseMapper.selectList。比如QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(username, username); wrapper.eq(status, 1); return list(wrapper);这种写法最大的隐患是字段名硬编码。一旦数据库字段从username改成loginName你会面临全局替换如果漏改一处就等着出线上事故。MyBatisPlus的LambdaQueryWrapper能直接从实体字段引用生成列名编译期就能发现字段变化。我推荐在Service层优先用lambdaQuery和lambdaUpdate这两个链式入口它们是ServiceImpl自带的方法不需要额外引入什么User user lambdaQuery() .eq(User::getUsername, username) .eq(User::getStatus, 1) .one();这段代码的意思很直白查一个username等于传入值且status等于1的用户。如果查询结果不止一条one()会报异常提醒你数据有问题这比selectList后自己取第一条要安全得多。更新场景也一样lambdaUpdate() .set(User::getStatus, 0) .eq(User::getId, id) .update();lambdaUpdate只更新指定的字段不会像updateById那样把整个实体都带上去也不会受到实体中null字段策略的影响。在批量禁用、批量修改状态、批量做逻辑删除前置操作时这个方式效率很高。有条件就用lambda链式Log记录也清晰这在做代码评审时是加分项。3.2 saveBatch、saveOrUpdate、page的配置与使用细节Service层配置里最容易被忽视但实用性最高的是批量操作和分页配置。先看批量新增ListUser userList new ArrayList(); // 填充userList userService.saveBatch(userList);saveBatch默认并不是一次性拼接一条巨型SQL而是按每批1000条来拆。如果你导入了两万条数据直接saveBatch会默认分成多批执行中间任意一批失败会走事务回滚前提是方法上加了Transactional。你还可以指定批次大小userService.saveBatch(userList, 500);批量操作不是越大越快。批次太大单条SQL过大容易触发MySQL的max_allowed_packet限制批次太小网络往返次数太多性能上不去。我实践下来的习惯是普通行数据用1000字段很多的实体用500既能保证执行效率又不至于把SQL撑爆。saveOrUpdate就更实用了它内部会根据主键是否存在决定新增还是更新userService.saveOrUpdate(user);这个判断逻辑依赖主键值。如果主键是null就执行insert如果主键有值先尝试执行update更新不到记录再尝试insert。需要注意的是如果实体里设置了主键但数据库里确实没有这条记录且主键策略是ASSIGN_ID那么可能出现两种情况一种是update影响行数为0然后自动改为insert另一种是并发下产生重复主键的隐患。所以在高并发导入场景里我建议先明确主键的生成方式不要盲目依赖saveOrUpdate。分页查询的配置比较特殊光在Service里调用page方法是不够的必须在启动配置里添加MyBatisPlusInterceptor并注册分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }Service层这样写PageUser page new Page(1, 10); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getStatus, 1); PageUser result userService.page(page, wrapper);如果你没有注册分页插件page方法不会直接报错而是返回全量数据然后内存分页看起来功能正常但数据量大时性能会有明显问题。这个问题我身边不止一个人踩过都在做小项目时没注意等数据量大了才回头排查出来。4. Service层配置里最隐蔽的坑事务、更新策略与插件顺序4.1 事务注解失效的三种常见现场做Service层配置时事务是绕不开的话题。很多开发者在ServiceImpl里这样写代码Override public void doProcess() { orderService.updateStatus(); userService.doSomething(); }看起来没什么问题但事务可能根本没生效。最容易踩的坑是同类内部调用比如Service public class OrderServiceImpl extends ServiceImplOrderMapper, Order implements OrderService { Override public void doProcess() { this.updateStatus(); this.insertLog(); } Transactional(rollbackFor Exception.class) public void updateStatus() { // 业务逻辑 } }这里的updateStatus方法虽然标了Transactional但它是被当前对象this直接调用的没有经过Spring代理所以事务不生效。同理Transactional标注在private方法上也不会生效Spring默认只代理public方法。还有一个经典场景是方法内部自己catch住了异常Transactional(rollbackFor Exception.class) public void doProcess() { try { // 业务逻辑 mapper.insert(entity); } catch (Exception e) { // 只打印日志不抛出 } }异常被吃掉了事务自然就不会回滚。Service层配置事务时的原则应该是事务注解加在外部入口方法上内部方法不要使用自身调用后缀了注解的兄弟方法除非你明确知道自己在做什么。如果确实需要内部事务隔离可以把方法拆分到另一个Service中或者使用代理对象自调用。我自己更推荐拆分Service的方式代码更直白也便于测试。4.2 字段策略与逻辑删除对Service方法的影响ServiceImpl里的updateById和saveOrUpdate默认只处理非null字段这是MyBatisPlus的全局字段策略在起作用。默认策略是NOT_NULL意味着你调用updateById时实体里为null的字段不会被更新成数据库里的null。很多人在做“清空某个字段”的需求时直接在实体里setNull然后updateById结果数据库里旧值纹丝不动这就是没理解字段策略。要更新null字段你有两种配置方式。一种是在实体的字段上加注解TableField(updateStrategy FieldStrategy.IGNORED) private String resetToken;另一种是在全局配置文件里修改mybatis-plus: global-config: db-config: update-strategy: ignored全局配置影响范围大如果不是特殊需求我建议只在确实允许为空的字段上加注解。否则所有update操作都更新null字段很容易把不应该动的字段冲掉。逻辑删除同样会影响Service层。如果实体上有TableLogic字段那么removeById、removeByIds执行的不是物理delete而是逻辑删除也就是把逻辑删除字段更新为1查询时会自动带上“删除标记为0”的条件。这个特性在Service层是透明的但有一个坑如果在Mapper里写了自定义SQL并且没有自动拼接逻辑删除条件那你必须自己在SQL里手动加。这也是为什么我建议复杂业务SQL尽量少在Mapper里裸写多利用ServiceImpl封装的通用方法。4.3 分页插件配置漏掉之后的现象前面提到了分页插件这里展开讲讲漏掉之后的排查思路。有次一个同事跑过来问为什么selectPage返回的记录数总是比预期多而且翻页无效。我第一反应就是问分页插件有没有配。他信誓旦旦说肯定配了还翻了代码给我看。结果配在了某个不生效的配置类里那个配置类根本没有被Spring扫描到。分页插件漏配时的现象很有迷惑性你传了Page参数MyBatisPlus不会报错它会把Page里的current和size参数应用到查询上但因为缺少SQL拦截器最终执行的SQL没有LIMIT语句。所有数据都查出来然后再在内存里截取当前页。数据量小时完全看不出问题数据量一大接口延迟立刻暴露。排查时可以打开MyBatis的SQL日志看看执行的SQL里有没有LIMIT关键字。如果SQL末尾没有LIMIT就说明分页插件没生效。检查点包括Configuration类是否在扫描路径下、MybatisPlusInterceptor Bean是否真的被注册、分页插件是否添加成功。还有一个顺序问题如果同时配置了多个InnerInterceptor我个人的习惯是把PaginationInnerInterceptor放在最后添加避免与其他拦截器在处理Page对象时发生冲突。这个顺序在官方文档里没有特别强调但实际项目里遇到过所以特别记一下。5. 让Service层配置真正经得起代码评审的经验补充5.1 自定义业务方法应该放Service还是Mapper在配置Service层时最容易被问到的边界问题是一个自定义查询方法到底放Service接口还是Mapper接口我的答案很简单如果这个方法要被多个Service复用或者需要和Service层已有方法组合那就放Service如果它就是纯粹的SQL映射逻辑不涉及业务判断放Mapper也无妨但Service接口不要因此暴露一堆Mapper方法。比如你要写一个“根据用户名和状态查用户”这个逻辑很通用我可以直接在UserService里定义User getByUsernameAndStatus(String username, Integer status);然后在ServiceImpl里这么写Override public User getByUsernameAndStatus(String username, Integer status) { return lambdaQuery() .eq(User::getUsername, username) .eq(User::getStatus, status) .one(); }这个方法虽然没有新增SQL但把查询条件固化在了Service层调用方不需要知道Wrapper如何构造。代码评审时这种封装的意义在于维护业务规则时你只需要改一个地方。如果是复杂报表类需求比如多表关联、分组统计那更适合在Mapper里写Select注解的方法Service层负责先组装参数再调用。有人会觉得“自定义SQL放到Mapper里是不是破坏了Service层整洁”其实不是MyBatisPlus本来就是Mapper负责SQL、Service负责业务各司其职才最高效。5.2 如何划分Service粒度避免万能Service和碎片ServiceService层配置的另一个争论点是一个实体对应一个Service还是一个业务场景对应一个Service。最保守的方案是一张表一个ServiceImpl几乎都继承IService和ServiceImpl。好处是通用方法自动带上来坏处是当你做一个跨多张表的订单流程时会发现所有逻辑只能写在某个实体Service里或者散落在Controller里。我经历过一个订单项目最初的代码是OrderService里塞了订单创建、库存扣减、支付回调、日志记录等全部逻辑方法超过四十个每次改动都要小心翼翼。后来把支付逻辑拆成了PaymentService把库存扣减拆成了StockServiceOrderService负责编排协调。这就是Service粒度从“表”往“业务领域”演进的例子。MyBatisPlus的IService并不阻止你重新组合。一个业务Service可以依赖多个实体Service比如PaymentService内部调用OrderService和AccountService但对外只暴露一个pay(orderId)方法。这种粒度更容易写事务边界也更方便单元测试时mock依赖。代码评审时如果看到Service方法超过二十个我通常建议拆业务子Service如果看到某个Service只继承IService却一个自定义方法都没有那也可以考虑直接合并到调用方。5.3 我习惯在项目里补上的几个配置项除了基类和事务配置我会把下面几个MyBatisPlus配置作为Service层配套配置一起补齐。首先是自动填充Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体里对应的字段加上TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)。这样所有通过save、saveOrUpdate、updateById走的数据都能自动维护时间字段不用每个Service方法手动set。需要注意自动填充只对MyBatisPlus内置方法生效如果你在Mapper里自定义了SQL绕过这些方法填充是不会触发的。还有一个配置是全局主键策略。如果项目里所有表都使用雪花ID可以在配置里统一指定mybatis-plus: global-config: db-config: id-type: assign_id这样就不需要在每个实体上重复写TableId(type IdType.ASSIGN_ID)。但如果你有部分表使用数据库自增那就必须在这些实体上单独配置否则会出现ID写入为空的情况。我建议宁可每个实体写清楚也不要依赖全局配置因为主键策略直接影响saveOrUpdate的走向含糊不得。最后再说一下逻辑删除和乐观锁。逻辑删除建议在实体上用TableLogic统一处理配置logic-delete-field即可乐观锁需要添加OptimisticLockerInnerInterceptor并且在实体上用Version注解版本字段。这些配置一旦设置好ServiceImpl的update、page等操作都会按预期处理代码会简洁很多。我在配置Service层时最常检查三件事第一自定义方法是否真的有必要出现在Service接口里第二事务注解是不是放在了外部入口第三分页插件和自动填充是否在配置扫描范围内。这三件事只要不出问题Service层的配置基本就稳了。