
从接触 MyBatis-Plus 的第一天起我就有一个强烈的感觉这个框架已经把单表 CRUD 逼到了“写代码都算浪费时间”的地步——BaseMapper、IService、LambdaQueryWrapper 这三板斧下来绝大多数简单增删改查根本不需要手写 SQL。但真正把项目做大之后你会发现光有框架能力还不够如果每个业务 Service 都在重复写同样的 save、update、page 方法本质上还是在造重复轮子只是把 SQL 换成了 Java 代码而已。今天聊的这套“增删改查通用化封装”就是我在几个中大型项目里沉淀下来的一套写法核心目标只有一个把单表 CRUD 的代码量再压缩掉 70%同时保留足够的扩展空间。1. 为什么要做通用化封装先搞清楚痛点再动手1.1 项目里的“重复代码病”很多团队用上 MyBatis-Plus 之后代码大概是这样的每个实体对应一个 Mapper 接口继承 BaseMapper 之后啥都不用写每个业务类再继承一个 ServiceImpl然后开始堆方法。问题出在“堆方法”这一步。比如一个标准的用户模块你可能会写这些saveUser(User user)其实就是调save但为了规范还是包了一层updateUser(User user)又是updateById的套壳deleteUser(Long id)继续套壳getUserById(Long id)继续套壳pageUser(PageVO vo)开始用 LambdaQueryWrapper 拼条件然后调page一个模块如此没什么但项目里有几十个模块呢每个模块都来一套这种“套壳方法”代码量就变得非常可观。更麻烦的是这种代码没有任何业务含量后期维护的人还得逐个打开看想确认一下到底是不是只是单纯调了框架方法。我用一个词形容这种状态无效忙碌。所以做通用化封装的第一动机不是炫技而是把这些“不需要动脑子的方法”全部收拢到公共代码里让业务 Service 只保留真正有业务逻辑的方法。1.2 通用化封装到底封装什么做这件事之前先想清楚边界。MyBatis-Plus 官方提供的IService和ServiceImpl已经非常强了它们本身就实现了save、saveBatch、updateById、list、page等通用方法。所以我们要做的不是重复造一个更底层的轮子而是做三件事第一把“基类”做得更好用。官方ServiceImpl的方法参数通常是实体对象但在很多场景下我们需要更灵活的条件删除、条件更新或者基于字段的查询。基类里应该补充这一类方法。第二提供一个无状态的静态调用入口。新版的 MyBatis-Plus 其实已经提供了Db工具类我们可以顺着这个思路把常用的增删改查进一步封装成静态方法让没有 Service 的轻量场景也能优雅地操作数据库。第三把“查询条件构造”这条最烦人的线梳理清楚。条件构造器虽然好用但每个业务类里都在拼类似的非空判断、时间区间、排序规则这些都是重复劳动。封装之后我们应该能用一个模型对象 一个泛型方法搞定大部分查询。1.3 使用到的 MyBatis-Plus 底层能力在开始写代码之前先把我们要依赖的框架能力列一下方便后面理解BaseMapper提供了单表的insert、deleteById、updateById、selectById等基础能力是 Mapper 层的根。IService / ServiceImplService 层的通用 CRUD 骨架内置了批量操作和链式查询。LambdaQueryWrapper / LambdaUpdateWrapper类型安全的条件构造器编译期就能校验字段名避免硬编码字段名的低级错误。Wrapper 子类针对复杂查询的 QueryWrapper以及支持 set 语句的 UpdateWrapper。MybatisPlusInterceptor分页插件、乐观锁插件、防全表更新与删除插件等都通过这个拦截器机制生效。MetaObjectHandler字段自动填充比如创建时间、更新时间自动写入。TableLogic逻辑删除支持删除操作自动变成update。理解这些底层能力之后封装思路就很清晰了框架给了我零件我要做的只是把它们组装成一套好用的工具而不是从零开始造发动机。2. Service 层基类设计一行代码继承全套 CRUD2.1 基础抽象类的泛型设计先给出一套我在多个项目中验证过的抽象类设计它是对官方ServiceImpl的增强解决了几类高频问题。public abstract class BaseServiceImplM extends BaseMapperT, T extends ServiceImplM, T { /** * 按 ID 查询实体找不到返回 null不抛异常 */ public T getByIdQuietly(Serializable id) { if (id null) { return null; } return baseMapper.selectById(id); } /** * 按条件查询单条记录存在多条时只取第一条 */ public T getOne(WrapperT queryWrapper) { return baseMapper.selectOne(queryWrapper); } /** * 条件删除直接传 Wrapper */ public boolean remove(WrapperT queryWrapper) { return baseMapper.delete(queryWrapper) 0; } /** * 条件更新传 UpdateWrapper */ public boolean update(WrapperT updateWrapper) { return baseMapper.update(null, updateWrapper) 0; } /** * 判断某个条件下是否存在记录 */ public boolean exists(WrapperT queryWrapper) { return baseMapper.selectCount(queryWrapper) 0; } }这个基类的核心思路是把 Wrapper 的用法提升到 Service 层。官方IService其实也有getOne(Wrapper)、remove(Wrapper)这些方法但很多团队用不起来原因在于继承链路太长而且官方方法的某些行为比如getOne默认在多条记录时会抛异常不符合实际操作习惯。我在基类里重新封装的方法行为更贴近实际开发尤其适合那种“查一下有没有没有就拉倒”的场景。使用的时候业务 Service 的写法就变成了public interface UserService extends IServiceUser { // 业务方法再单独声明 } Serivce public class UserServiceImpl extends BaseServiceImplUserMapper, User implements UserService { // 只写真正的业务逻辑CRUD 全在基类里 }这里有一个细节值得注意泛型T必须是实体类型M是 Mapper 类型。官方ServiceImpl的泛型定义是ServiceImplM extends BaseMapperT, T我们保持同样的顺序这样所有继承者都能自动获得 Spring 注入的baseMapper。2.2 无状态与多数据源问题“无状态”这个词在热词里出现过也正是Db工具类的核心思想——查询方法不依赖某个 Service 实例的状态你随时传一个条件进去就能拿结果出来。这非常适合在工具类、监听器、定时任务这类没有 Service 注入的场合使用。MyBatis-Plus 的Db工具类内部通过SqlHelper获取当前请求对应的 SqlSession并自动识别IService的泛型类型来定位 Mapper。它的好处是无侵入但使用上有个限制如果你的项目配置了多数据源静态工具类是没法直接感知“当前应该用哪个数据源”的。这种情况我更推荐在项目里维护一个“数据源路由”的上下文或者在调用Db方法之前通过DynamicDataSourceContextHolder切换数据源。public class MyDbUtils { /** * 指定数据源执行查询 */ public static T T queryOn(String dsName, SupplierT supplier) { DynamicDataSourceContextHolder.push(dsName); try { return supplier.get(); } finally { DynamicDataSourceContextHolder.poll(); } } }类似的封装很轻量但能规避静态方法在多数据源场景下带来的隐患。实际项目里我见过因为直接用Db.list()查到了默认库排查了大半天才发现是数据源没切换的问题这类坑提前预防很有必要。2.3 Db 工具类的静态调用方式如果你是新项目或者不想为每个模块都维护一个 Service 基类我更推荐直接用构造工具类的思路来提供静态 CRUD。MyBatis-Plus 3.5.x 之后官方提供了比较完整的Db工具类我们自己也可以在它的基础上做一层薄封装public class CrudKit { public static T T getById(ClassT clazz, Serializable id) { return Db.getById(id, clazz); } public static T boolean save(T entity) { return Db.save(entity); } public static T boolean updateById(T entity) { return Db.updateById(entity); } public static T boolean saveOrUpdate(T entity) { return Db.saveOrUpdate(entity); } public static T boolean removeById(ClassT clazz, Serializable id) { return Db.removeById(id, clazz); } public static T ListT list(ClassT clazz, WrapperT queryWrapper) { return Db.list(queryWrapper, clazz); } public static T PageT page(ClassT clazz, PageT page, WrapperT queryWrapper) { return Db.page(page, clazz, queryWrapper); } }写完之后你的业务代码可以完全绕开 Service直接在 Controller 或者 Application Service 里调用例如PostMapping(/users) public ResultLong createUser(RequestBody User user) { CrudKit.save(user); return Result.ok(user.getId()); } GetMapping(/users/{id}) public ResultUser detail(PathVariable Long id) { return Result.ok(CrudKit.getById(User.class, id)); }这里的调用非常直观但它不适合承载复杂事务。如果你的操作需要多个步骤并且必须保证原子性还是老老实实走 Service 层并加上Transactional静态工具类只管简单的单表单操作。这一点后面会重点展开。3. 条件构造与查询封装再也不用手写几十个 Mapper3.1 LambdaQueryWrapper 的链式条件把增删改查方法收敛之后剩下最影响代码量的就是条件构造。我见过不少同事写查询条件时还在手动拼接字符串或者写一长串的if判断往 QueryWrapper 里塞条件。这种写法不仅丑而且容易漏条件。LambdaQueryWrapper 的核心价值在于字段名是强类型引用的不是字符串。只要实体字段改了名编译期就能发现错误比运行期 SQL 报错强太多。LambdaQueryWrapperUser wrapper Wrappers.lambdaQuery(User.class) .eq(StringUtils.isNotBlank(user.getUserName()), User::getUserName, user.getUserName()) .ge(user.getCreateTime() ! null, User::getCreateTime, user.getCreateTime()) .le(user.getEndTime() ! null, User::getCreateTime, user.getEndTime()) .orderByDesc(User::getId);但这样写还是有问题每个查询都要来一遍非空判断。如果能把“查询参数对象”统一处理代码会清爽很多。我的做法是定义一个通用的查询基类和配套的 Wrapper 构建器。3.2 分页查询的封装与排序注入分页是后端 CRUD 里最高频的场景。MyBatis-Plus 的分页逻辑靠PaginationInnerInterceptor完成它会把Page对象里的current和size解析成LIMIT语句同时自动生成COUNT查询。使用前必须确保已经添加了分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)这一步很重要它能防止有人一不小心查全表把数据库打爆。我建议每个项目都加上这个上限。分页查询的封装建议做一个通用的请求模型public class PageQuery { private long pageNum 1; private long pageSize 10; private String orderByColumn; private String orderByDirection asc; // getter/setter 略 public T PageT toPage() { PageT page new Page(pageNum, pageSize); if (StringUtils.isNotBlank(orderByColumn)) { // 这里必须防止 SQL 注入不要直接拼接字段名 String column SqlKeywordUtils.camelToUnderline(orderByColumn); if (desc.equalsIgnoreCase(orderByDirection)) { page.addOrder(OrderItem.desc(column)); } else { page.addOrder(OrderItem.asc(column)); } } return page; } }注意排序字段这块有两个坑防止 SQL 注入排序字段不能直接拼进 SQL更不能让前端传任意字段名进来。推荐的做法是把前端传的字段名映射到一个白名单集合里然后转换成数据库列名否则有人传个id; drop table user这种值就麻烦了。字段映射前端通常传驼峰字段名如createTime底层排序要用下划线列名如create_time。如果你不显式指定MyBatis-Plus 在部分场景下会原样拼接导致排序不生效。分页之后返回给前端的结果建议也统一封装一下public class PageResultT { private ListT records; private long total; private long pageNum; private long pageSize; }这样前端拿到的是一个结构固定的对象而不是直接裸用 MyBatis-Plus 的Page。Page里带了很多 MyBatis-Plus 的内部字段直接序列化给前端不仅冗余有时还会暴露不需要的信息。3.3 批量操作的性能优化通用化封装很容易忽略的一个点是批量操作。很多项目里业务代码直接循环调save或者updateById一条一条地发 SQL性能极差。MyBatis-Plus 的saveBatch正是解决这个问题的但它也有一点小坑默认情况下saveBatch并不是真正的 JDBC 批量提交。ServiceImpl的saveBatch底层走的是SqlHelper.executeBatch虽然会攒一批再执行但如果连接 URL 上没有开启rewriteBatchedStatementstrueMySQL 驱动并不会真正把多条 SQL 合并成一条批量执行。我踩过这个坑之后统一在数据源配置里加了参数spring.datasource.urljdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrue加了之后插 10 万条数据的时间能下降一个量级。如果是批量更新同样的参数也有效。另一个提升批量性能的思路是“分段批量插入”即把一个大列表切成若干小批每批 500 或 1000 条而不是一次性把所有数据交给saveBatch。原因是单次批处理太多会导致 SQL 语句过长数据库解析压力大反而影响性能。封装之后大致这样public static T void batchSaveSafely(IServiceT service, ListT list, int batchSize) { if (CollectionUtils.isEmpty(list)) { return; } int size 100; for (int i 0; i list.size(); i size) { ListT subList list.subList(i, Math.min(i size, list.size())); service.saveBatch(subList); } }如果对实时性要求不高批量操作放在异步线程池里执行也是一个常见的优化方向但要注意线程池的隔离策略和数据库连接数上限避免异步任务压垮数据库连接池。4. 踩坑实录这些细节不过处理上线就出事4.1 saveBatch 不起作用的真相有一个高频问题saveBatch用了但查看日志发现 SQL 还是一条条 insert。可能的原因除了上面说的rewriteBatchedStatements没开还可能是MyBatis-Plus 的 SQL 注入器没有正确识别批量方法。如果你的项目自定义了 SQL 注入器或者继承了DefaultSqlInjector一定要记得把批量插入对应的Method也继承下来。很多人自定义注入器只为了加一两个自定义方法结果覆盖了默认方法列表导致saveBatch退化成了逐条插入。排查方法很简单把 MyBatis 的日志级别调到 TRACE看Preparing语句是不是带VALUES (?, ?), (?, ?)这种多组占位符。如果没有说明批量没有生效逐个检查 URL 参数和注入器配置。4.2 this 调用方法导致事务失效这是 Spring 事务里最常见的坑但在 MyBatis-Plus 的 Service 封装中更容易出现。因为封装之后业务方法往往直接写在BaseServiceImpl里然后在子类里调this.save()、this.remove()。Transactional的生效原理是基于 Spring AOP 动态代理。当你通过this调用同一个类内部的另一个方法时调用的是 this 对象的原始方法根本没经过代理对象事务注解自然不生效。很多人以为事务切的是整个类其实切面只对“从外部进入代理对象”的调用生效。所以我的封装建议是把事务边界尽量控制在 Controller 调用的那一个 Service 方法上。如果同类内部要互相调用拆到不同的 Service 类中通过注入的方式互相调用让调用链经过代理。或者使用Resource注入自身代理对象Lazy解决循环依赖但这种方式不推荐代码可读性差。Service public class OrderServiceImpl extends BaseServiceImplOrderMapper, Order implements OrderService { Resource private OrderService self; Override Transactional(rollbackFor Exception.class) public void createOrderWithItems(Order order, ListOrderItem items) { self.save(order); itemService.saveBatch(items); // 这里如果抛异常事务会回滚 } }rollbackFor Exception.class也很重要。Spring 事务默认只在遇到RuntimeException或者Error时回滚如果业务方法抛的是受检异常比如IOException事务不会自动回滚。强烈建议所有Transactional都显式声明rollbackFor避免这类隐性风险。4.3 逻辑删除和字段映射的坑很多项目用逻辑删除。MyBatis-Plus 的做法是在实体字段上加TableLogic这样删除变成update set deleted 1 where id ?查询自动加上deleted 0条件。这里有两个坑唯一索引冲突逻辑删除后记录还在表里如果业务上要求某个字段唯一比如用户手机号第二次插入相同手机号会触发唯一索引冲突。解决方案是唯一索引里包含deleted字段但deleted通常只有 0 和 1删除多条后再次插入还是会冲突。更稳妥的方案是用deleted字段存删除时间戳如deleted 0表示未删除删除时写入当前时间戳但这样需要自定义实现不能直接用TableLogic默认逻辑。字段映射问题TableLogic逻辑删除字段要求数据库列名和实体属性名严格映射。如果数据库列名是is_deleted实体属性是deleted必须显式配置TableField(is_deleted)否则 MyBatis-Plus 默认会按驼峰转下划线去匹配匹配不上就可能导致 SQL 错误或查出来的数据不对。此外逻辑删除字段不建议用Boolean类型映射因为 MySQL 的tinyint转 Boolean 在某些驱动版本下会有兼容问题直接用Integer或者Long更稳妥。4.4 快速排查速查表为了节省大家时间我把上面提到的坑和方案汇总成了一张速查表问题现象可能原因解决方案saveBatch 速度没提升缺少 rewriteBatchedStatements 参数数据源 URL 拼接该参数saveBatch 退化逐条插入自定义 SQL 注入器覆盖了默认方法继承 DefaultSqlInjector 并保留默认方法Controller 调用 Service 方法事务不生效this 调用内部方法绕过代理拆 Service 或注入自身代理对象分页排序不生效字段名未映射为下划线列名显式指定 OrderItem 列名逻辑删除后唯一索引冲突deleted 字段只有 0/1用 delete 时间戳方案或业务层加校验大列表内存溢出一次查全表且未分页强制分页或限制最大条数这些坑大多数只在数据量变大、并发变高之后才会暴露。写代码的时候图快上线之后就会付出排查的代价所以从第一天开始就应该把这些规范固化进基类封装里。5. 扩展思路与实际体会5.1 还能封装什么自动填充、乐观锁、租户隔离通用化 CRUD 封装做到这一步其实已经覆盖了 80% 的单表操作需求。剩下 20% 是一些横切能力也建议一并在基类层统一处理。字段自动填充。创建时间、更新时间、创建人、更新人这些字段几乎每张业务表都有。与其每个 Service 手动 set不如实现MetaObjectHandlerComponent 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)。这样所有继承基类的实体都能自动填充完全从业务代码中剥离。乐观锁。做更新操作时并发控制很关键。MyBatis-Plus 的乐观锁插件原理是更新时自动带上version 旧值的条件并且set version version 1。如果更新影响行数为 0说明并发冲突了。我建议所有核心业务表都加上version字段并在配置类中注册乐观锁插件interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());多租户隔离。如果你是 SaaS 项目MyBatis-Plus 的TenantLineInnerInterceptor可以在 SQL 层面自动追加租户条件避免每个查询都手动拼租户 ID。但这个拦截器一定要谨慎配置“忽略表”比如一些全局字典表就不能加租户条件否则会出现查不到数据的问题。5.2 我的几条实际操作建议聊到最后分享几个实践中沉淀下来的原则第一封装要用但不要滥。通用化封装解决的是 80% 的重复劳动千万不要试图覆盖所有场景。遇到特殊业务比如多表关联查询、复杂子查询、存储过程果断使用自定义 Mapper 方法没必要为了“统一”而强行把代码往基类里塞。第二参数校验不能丢。很多人封装完之后Controller 里直接丢一个实体进来就 save 了连基本的非空校验都没有。结果数据库各种异常排查得头疼。建议在 Controller 层引入 Bean Validation在 Service 层也保留必要的业务校验封装只是帮你省重复方法不是省掉该做的防御。第三统一返回结构。增删改查的入参和出参项目里一定要定好固定的返回包装类。比如ResultT里面包含 code、message、data 三个字段就行。不要今天这个接口返回Map明天那个接口返回JSONObject后面对前端和调用方都是灾难。第四多看看框架更新。MyBatis-Plus 的版本迭代很快比如Db工具类是 3.5.x 才补全的之前大家只能自己封装。我每隔一段时间会看一眼官方更新日志很多新特性能直接简化掉旧代码。如果让我用一句话总结这套封装的价值那就是把该留的灵活都留下把能省的重复都省掉。它在实际项目中帮我减少了大量机械编码也让新成员接手时的认知负担小了很多。你完全可以按自己的业务习惯调整基类的覆盖面核心思路其实是相通的——先用框架能力把地基打好再在基类层统一发力让业务代码真正做到“只写业务”。