用了不少年后端开发,在数据库访问层打交道最多的还是 MyBatis 和它的衍生品。早期用 MyBatis 基本都是 XML 起步,Mapper 里全是手写的 SQL 和 resultMap,后来 MyBatis-Plus 出来以后,开发效率提升了一个档次。尤其当项目里全是常规单表操作时,BaseMapper 的通用能力直接省掉了一大半模板代码。这篇内容我不打算按官方文档翻译一遍,而是按我自己在实际项目里的使用路径来梳理,从 BaseMapper 的基础操作讲到 Wrapper、插件、注入器这些扩展点,顺便把踩过的坑一并讲清楚。
先说适合谁看。如果你是刚接触 MyBatis-Plus 的新手,这篇内容能帮你把 Mapper 层的使用逻辑理顺;如果你已经用过一阵子,但只是会 CTRL+C/V 那几种写法,这篇内容也能帮你找到更省事的模式。文章里的代码都是能直接跑的真代码,不是伪代码,我尽量把每个关键点都讲透。
1. 先搞清楚:BaseMapper 到底替你做了什么
MyBatis-Plus 的 Mapper 层和原生 MyBatis 最大的区别在于通用 CRUD。你定义一个接口继承 BaseMapper,MyBatis-Plus 会自动帮你生成一批常用的 SQL 方法,不需要写 XML,也不需要写注解。这是第一个要理解的核心:这不是简单的代码生成器帮你生成一堆 XML 文件,而是在运行期通过动态 SQL 拼接出对应语句。换句话说,你的 Mapper 接口里的方法名和泛型类型,决定了它能用哪些通用方法。
1.1 从最简单的一条 selectById 说起
假设有一张用户表,实体类叫 User,Mapper 接口写成这样:
public interface UserMapper extends BaseMapper<User> { }就这样,一个 Mapper 接口就成立可以用了。你没有写任何 SQL,也没有配置 XML,直接注入 UserMapper 就能调用:
User user = userMapper.selectById(1L);这条语句会生成类似SELECT id,name,email,age FROM user WHERE id=?的 SQL。注意,MyBatis-Plus 默认会按实体类上的字段映射来生成列,如果你在实体类里用了@TableField指定列名,它也会遵守。
有个容易忽略的点:selectById 查出来的结果不会执行联表查询,也不会自动加载关联对象。很多新手以为它是某种 ORM 的“关联查询”,其实不是,它就是很纯粹的单表查询。想关联查询,要么手写 XML,要么用注解写自定义 SQL,这些后面讲。
1.2 Mapper 接口与 XML 的边界在哪里
MyBatis-Plus 并不会禁用 XML 映射,你可以混用。定义通用方法用 BaseMapper 提供的就够,但一旦涉及多表 JOIN、子查询、批量动态更新成绩,通用 Mapper 就不好使了,得回到 XML 或注解方式。
边界怎么划?我的实践经验是:
- 所有单表、不带复杂业务条件的读写,优先使用 BaseMapper 通用方法。
- 所有返回结构不是实体本身的,比如 VO、DTO、统计结果,单独写成自定义方法。
- 所有涉及多表关联的,明确放到 XML 里,用明确命名和返回类型,而不是硬塞进通用方法里。
这样做的好处很好,代码的可读性和维护性都有保障。如果你的项目里全是selectById、selectList、insert这种调用,那核心逻辑都在 Service 层,Mapper 层非常薄。
2. 基础用法:CRUD 的新手变形记
这一节我按“增删改查”四个维度把常用写法过一遍,顺便把 Wrapper 的三种层次讲透。CRUD 本身不难,难的是你知道什么时候该用哪种写法,以及某些隐藏行为为什么和你预期的不一样。
2.1 单表 CRUD 的标准写法
先说增。新增主要用 insert 方法:
User user = new User(); user.setName("张三"); user.setEmail("zhangsan@example.com"); user.setAge(28); int rows = userMapper.insert(user);插入成功后,如果主键是自增,MyBatis-Plus 会自动回填到实体对象的主键字段里。这一点很实用,你不需要在插入后再查一次获取自增 ID。如果你用默认的 ASSIGN_ID 策略,它会生成一个雪花 ID 并赋值给实体的主键字段,同样不需要手动设置。
如果你之前使用原生 MyBatis,可能已经习惯在 insert 里使用useGeneratedKeys="true"并显式指定 keyProperty。MyBatis-Plus 的简单之处在于它把这个过程全部默认处理了。
再说删。常见的有三种:
// 根据主键删除 int rows = userMapper.deleteById(1L); // 根据条件删除 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getId, 1L); userMapper.delete(wrapper); // 批量删除 userMapper.deleteBatchIds(Arrays.asList(1L, 2L, 3L));这里要提醒一下,千万注意 delete 和 deleteById 的区别。delete 是允许你传一个 Wrapper 条件,里面可能是姓名、邮箱、甚至扫描现有业务条件,条件不同结果完全不一样。在实际项目里,delete 这类危险操作我都习惯先做一次 count 或 selectList 验证条件,再执行删除。
改的写法分两种。
更新整个实体:
User user = userMapper.selectById(1L); user.setAge(29); userMapper.updateById(user);更新部分字段,使用 UpdateWrapper:
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(User::getId, 1L) .set(User::getAge, 30) .set(User::getEmail, "newemail@example.com"); userMapper.update(null, updateWrapper);注意 update(null, wrapper) 的第一个参数是实体对象,如果你传 null,SQL 里只会用到 UpdateWrapper 中的 set 字段。如果你同时传实体和 UpdateWrapper,实体中的非空字段也会被拼接进 SET 子句,有时候这不是你想看到的。
所以我在项目中有一个约定:要么用实体方式更新,要么用 UpdateWrapper 方式更新,不混用。即使混用,也一定是实体内有少量业务公共字段,比如更新时间、操作者等,再靠 Wrapper 限定 update 的范围。
查的操作最常用,看下面的综合示例:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1); wrapper.like(StringUtils.isNotBlank(name), User::getName, name); wrapper.orderByDesc(User::getCreateTime); wrapper.last("limit 10"); List<User> userList = userMapper.selectList(wrapper);这一套写法基本能覆盖绝大多数查询场景了。eq、ne、gt、lt、ge、le、like、likeLeft、likeRight、in、notIn、between、isNull、isNotNull、orderBy、groupBy 都有对应方法,不需要自己拼 SQL。
2.2 Wrapper 查询的三个层次
Wrapper 是 MyBatis-Plus 条件构造器,分三层理解你会轻松很多:
第一个层次是普通的 QueryWrapper,使用字符串列名。比如:
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("name", "张三"); List<User> users = userMapper.selectList(wrapper);这种写法有个缺点,列名写错的话要等到运行期才知道,重构时字段改名也没有编译期检查。所以除非你在写动态表名、或者在特定场景下必须用字符串拼接,否则我建议少用它。
第二个层次是 LambdaQueryWrapper,使用实体方法引用。上面刚刚的例子就是它:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getName, "张三");它不仅能避免列名写错,用了 Lambda 表达式还能利用 Java 的编译期类型检查。这个是平时用得最多的。
第三个层次是链式查询方式,使用 lambdaQuery() 和 lambdaUpdate()。Service 层的IService默认提供了lambdaQuery()方法:
List<User> users = userService.lambdaQuery() .eq(User::getStatus, 1) .like(User::getName, "张") .orderByDesc(User::getCreateTime) .list();这写法和 LambdaQueryWrapper 其实是同一套运算,不过链式风格更紧凑。我在简单场景下喜欢用链式,复杂场景下还是单独构造 Wrapper,因为单测更好写,也更清晰。
2.3 条件构造器的那些坑
Wrapper 用多了,有几个坑你大概率会遇到。
第一个坑:某些查询结果等于 null,排查了老半天,发现是参数没生效。比如:
wrapper.eq(StringUtils.isBlank(name), User::getName, name);第二个参数是 boolean 类型的 condition,如果为 false,那么这整个条件会被忽略。这是 MyBatis-Plus 故意设计的,目的是让我们做动态条件。但新手经常忘记这个机制,或者前端传了一个空字符串,结果条件直接被吃掉了。你要明确一点,就算你传了空字符串给 eq,如果条件判断是StringUtils.isNotBlank,它照样会把name = ''拼上去。
第二个坑:selectOne并不总是“一条”。如果你调selectOne时实际满足条件的数据有多条,MyBatis-Plus 会直接抛异常,异常信息大概是“Expected one result or null but was ...”。有些项目里确实存在业务上只查一条,数据库却因为数据脏或并发插入了多条数据的情况。更稳妥的做法是把这类查询改为selectList后判断list.size() == 1,或者分组后取第一条。如果再讲究一点,可以在 Wrapper 里last("limit 1")强行限制查询条数,但要明确这是拼接 SQL,只能配固定字符串,不能拼接外部参数。
第三个坑:last() 方法可以拼接任意 SQL 片段,同时也带来了注入风险。如果你在代码里写成wrapper.last(orderClause),这个 orderClause 是从前端传进来的,那就等于把一个 SQL 注入漏洞留给攻击者。我见过几个项目里的排序字段直接由前端传列名,然后拼进 last() 里,这很危险。建议对所有排序字段做白名单映射,前端传 sort=userName,后端只映射到固定的列名。
3. 分页、逻辑删除、填充与乐观锁:日常命中的几个增强插件
MyBatis-Plus 之所以好用,不只是因为 CRUD 方便,更重要的是它的插件机制帮我们解决了一系列数据库层面的横向问题。这一节我把分页、逻辑删除、自动填充、乐观锁四个常用功能串起来讲,因为它们几乎在每个业务系统里都会出现。
3.1 数据库分页的落地方式
MyBatis-Plus 分页依赖拦截器,你需要在配置类里注册插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注意 DbType 要根据你的数据库类型来配,MySQL、PostgreSQL、达梦等各不相同。配好之后,分页查询变成:
Page<User> page = new Page<>(1, 10); LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1); Page<User> result = userMapper.selectPage(page, wrapper); List<User> records = result.getRecords(); long total = result.getTotal();核心原理是拦截器在运行时把原始 SQL 拦截下来,如果是分页方法就自动生成 count 查询和带 limit 的查询。这里有两点要特别注意:
一是 count 查询生成。默认情况下它会生成类似SELECT COUNT(*) FROM user WHERE status = ?的语句。如果你的表数据量巨大,count 会扫描很多行,这时候要考虑在 SQL 层面做优化,或者用last("limit 1000")限制最大扫描行数,不过这样做返回的 total 就不准确了。所以业务上要权衡一下。
二是Page对象的 current 是当前页,size 是每页大小。如果不设置 current,默认是 1,但如果是前端传页码,一定要做参数校验,避免current <= 0导致异常。
3.2 逻辑删除字段的特殊处理
逻辑删除现在几乎成了标配,不用物理删除,好处是便于数据恢复,坏处是每次查询都必须带deleted = 0条件,很容易漏。
MyBatis-Plus 的@TableLogic注解帮你解决了这个问题。实体字段:
@TableLogic private Integer deleted;然后在配置里指定全局逻辑删除值:
mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0配置完成后,调用deleteById时生成的 SQL 会变成UPDATE user SET deleted = 1 WHERE id = ? AND deleted = 0,而 select 查询会自动追加AND deleted = 0。
这个功能在日常开发中确实省心,但有一个问题我特别想强调:如果你在 XML 里写自定义 SQL,MyBatis-Plus 是不会自动拼接逻辑删除条件的。比如你在 Mapper XML 里写了:
<select id="selectCustomUser" resultType="user"> select * from user where name = #{name} </select>那这里的 deleted 条件需要你手动加,否则你可能会把已经“删除”的数据也查出来。这是最容易踩的坑之一,特别是在老项目里同时存在通用方法和自定义 XML 方法时,特别容易“时灵时不灵”。
还有一个反向问题:有时候业务上确实想查被删除的记录,比如后台数据列表要展示所有记录包括已删除的。这个时候你必须在自定义方法里手动写 SQL,绕过通用方法。记住,通用方法的逻辑删除是强制的,想查全量不要抱着侥幸心理去拼接条件绕过,那只会让代码更乱。
3.3 自动填充与乐观锁实战
自动填充解决的是创建时间、更新时间、操作人等字段每次都要手动 set 的问题。实现步骤分两步。
第一步,实体字段标记填充策略:
@TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;第二步,实现 MetaObjectHandler:
@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()); } }这里有个细节,strictInsertFill 要求实体对象对应的字段不能为 null。如果你自己在业务代码里给 createTime 赋值了,那么 MyBatis-Plus 的自动填充不会覆盖。这个行为有时候是优点,有时候是陷阱。建议团队内部约定:自动填充字段一律不手动赋值,包括批量插入场景也要检查是否有填充策略生效。
乐观锁用于解决并发更新中的覆盖问题。用法也简单,实体类里加:
@Version private Integer version;注册插件:
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());这样执行 updateById 时,MyBatis-Plus 会生成类似:
UPDATE user SET name=?, version=version+1 WHERE id=? AND version=?如果 update 影响行数为 0,说明 version 已经变了,需要业务层重试或报错。要注意一点,乐观锁只对updateById和update(entity, wrapper)有效,对 UpdateWrapper 方式的部分更新需要手动在实体上或 wrapper 条件里把 version 带上,否则它不会自动帮你加版本条件。
我在项目里看过不少人在 update 操作前查询实体,然后又手动对 updateWrapper 拼上一个eq("version", version),其实不用这么麻烦,直接用通用 updateById,只要实体里 version 值是从 DB 查出来的,插件会自动处理版本条件。
4. 高级扩展:从自定义方法到 SQL 注入器
如果项目只用 BaseMapper,那 MyBatis-Plus 的使用还停留在“模板”层面。真正的价值在于它的扩展能力。这一节讲两个方向:一是在 Mapper 里自定义方法,包括注解和 XML 方式,二是通过 SQL 注入器扩展通用方法,让通用方法变成一个内部脚手架。
4.1 自定义方法:@Select 注解与 XML 的取舍
自定义方法最简单的方式是直接在 Mapper 接口上写注解:
public interface UserMapper extends BaseMapper<User> { @Select("SELECT id, name, email, age FROM user WHERE email = #{email}") User selectByEmail(String email); @Update("UPDATE user SET status = #{status} WHERE id = #{id}") int updateStatusById(Long id, Integer status); }优点是快,可读性好,适合简单 SQL。缺点是复杂 SQL 写起来难看,比如动态条件一多,字符串里拼<script>标签就非常难受。而且注解 SQL 里的 resultMap 处理也不方便。
我再强调一次逻辑删除的问题:注解 SQL 同样不会自动带deleted = 0,你必须自己处理,或者在 SQL 里手动加AND deleted = 0。
XML 方式的好处是 SQL 和 Java 代码分离,适合复杂查询、动态条件、JOIN 等场景。项目里规范是:凡是超过三行或带动态<where>的 SQL,都必须写进 XML。注意 namespace 务必与 Mapper 接口全限定名一致,否则会绑定失败。
<mapper namespace="com.example.mapper.UserMapper"> <select id="selectUserWithAddress" resultType="com.example.dto.UserAddressDTO"> SELECT u.id, u.name, u.email, a.city, a.street FROM user u LEFT JOIN address a ON a.user_id = u.id WHERE u.id = #{id} </select> </mapper>这里 resultType 可以直接用 DTO,前提是列名能映射上。如果列名和 DTO 字段不一致,要么给 SQL 列名取别名,要么在 resultMap 里配置映射。别以为 resultType 和 resultMap 可以乱用,实际场景中遇到联表查询,推荐用 resultMap 显式映射,避免列名相同导致取值错误。
4.2 通用 SQL 注入器:把重复方法收敛到框架层
当你发现多个实体 Mapper 都有“查询某字段是否唯一”“逻辑批量恢复”等相同方法时,就可以考虑把公共逻辑下沉到 SQL 注入器,让每个 Mapper 都自动具备这些方法。
实现逻辑不复杂。第一步,定义 AbstractMethod:
public class SelectOneByEmail extends AbstractMethod { @Override public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) { String methodName = "selectOneByEmail"; String sql = "SELECT " + tableInfo.getAllSqlSelect() + " FROM " + tableInfo.getTableName() + " WHERE email = #{email} LIMIT 1"; SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass); return addSelectMappedStatementForTable(mapperClass, methodName, sqlSource, tableInfo); } }第二步,继承 DefaultSqlInjector:
public class MySqlInjector extends DefaultSqlInjector { @Override public List<AbstractMethod> getMethodList(Class<?> mapperClass) { List<AbstractMethod> methods = super.getMethodList(mapperClass); methods.add(new SelectOneByEmail()); return methods; } }第三步,注册到配置:
mybatis-plus: global-config: sql-injector: com.example.injector.MySqlInjector然后在 Mapper 接口里声明对应方法:
User selectOneByEmail(@Param("email") String email);所有继承 BaseMapper 的 Mapper 接口就自动具备了这个方法。这块属于进阶玩法,适合团队里封装通用能力。不建议新手一开始就玩这个,先把 BaseMapper 和自定义 XML 用利索,再去抽象。
4.3 多租户与动态表名的插件思路
MyBatis-Plus 拦截器体系里还有一些比较高级的插件,比如多租户插件和动态表名插件。
多租户的场景是 SaaS 系统,每次查询都要自动拼上 tenant_id 条件。注册TenantLineInnerInterceptor后,SQL 会被自动改写,给涉及的表加上租户条件。这个确实能避免业务代码里到处写tenant_id = ?,但要注意,某些跨表查询、或者子查询里的表如果没参与租户过滤,就会出现漏数据。我在实际项目中用过一版,后来发现需要一个大字段列表来排除某些表,维护成本有点高。建议小团队谨慎评估,如果租户只要一个 id,还是自己写条件更可控。
动态表名更适合按月份分表、按租户分表的场景,注册DynamicTableNameInnerInterceptor,在运行时根据某种上下文更换表名。这个插件的风险是缓存和多线程下的表名错乱,需要确保线程上下文隔离。
总而言之一句话:拦截器体系很强大,但复杂业务的成本不低,不是非用不可就尽量别用。
5. 常见问题与排查实录
最后这一节,我把这些年实际遇到的高频问题集中写出来,每条都附上排查思路。如果你在使用 MyBatis-Plus 时遇到类似情况,可以直接对照着处理。
5.1 查询不到数据,原来是逻辑删除过滤在作怪
现象:执行 selectList 查某条件的数据,明明数据库里有记录,返回却是空列表。
排查:先确认实体类的字段是否有@TableLogic。有的话,通用方法的 SQL 会自带deleted = 0,你查的数据如果 deleted 字段是 1,自然查不到。再看日志,MyBatis-Plus 默认会打印 SQL,检查 SQL 里是否带上了条件。如果 SQL 没带,说明逻辑删除没生效,大概率是配置没写对或实体字段标注错了。最后再看 XML 或注解自定义方法,这类方法不会自动过滤 deleted,这时候查不到数据说明是你手动加了条件,但加错了位置。
5.2 insert 后又打印了 selectById?自增主键回填的问题
现象:调用 insert 后,日志打印了两条 SQL,先是 INSERT,后面跟着一条 select 语句,看起来像在查询刚才插入的数据。
排查:MyBatis-Plus 在 insert 之后,对于自动生成的主键会执行回填,有的数据库驱动需要SELECT LAST_INSERT_ID()才能拿到主键。这是正常的。如果你的实体主键字段用的是默认的IdType.ASSIGN_ID,MyBatis-Plus 会在应用层生成雪花 ID,根本不需要回查。如果你的主键是数据库自增,驱动层面会返回主键。如果整个 insert 后还打印了一条 select 查询,大概率是你业务代码里主动调用了 selectById,或者使用的某些持久层扩展在插入后执行了缓存刷新。查一下代码调用栈就能定位。
5.3 LambbdaQueryWrapper 字段找不到或泛型问题
现象:编译不通过,或者运行时异常说can not find lambda。
排查:Lambda 方式依赖 Java 的SerializedLambda,如果实体类的 getter 方法被私有化、改名、或者被其他框架代理,可能导致拿到 Lambda 表达式失败。还有一种情况是 Lombok 生成的 getter 没在类路径上。解决办法是先确保实体类能正常序列化,最好别用内部类做 lambda 谓词,复杂场景下直接用 LambdaQueryWrapper 构造即可。
5.4 分页查询 total 不对劲,count 语句被 join 拖慢了
现象:分页查询功能正常,但 count 查询特别慢,或者返回总数不正确。
排查:PaginationInnerInterceptor 会自动生成 count 语句,但对复杂 SQL 的执行性能不如人意。如果是单表分页,一般没问题;如果是带 JOIN 的分页,它生成的 count SQL 可能会把大表的关联都算一遍,导致慢查询。对策是手动指定一个优化的 count SQL 方法,在自定义方法里用@Select或 XML 单独写一个计数查询。如果总数不准确,看看 Page 的构造参数是否传混了 current 和 size,以及有没有在 Wrapper 里误用 last 改变了 SQL 结构。
5.5 更新操作不生效或影响行数为 0 的数据被忽略
现象:updateById 返回 0,但数据库里确实查到该记录。
排查:先看实体主键字段是不是 null,主键为空则 MyBatis-Plus 无法生成WHERE id = ?。再看是否受逻辑删除影响,如果实体有@TableLogic字段,更新语句会在末尾拼上AND deleted = 0,一旦该记录已经被标为删除,更新自然失败。这两个原因占绝大多数。
5.6 表字段类型是 JSON、枚举、大文本时处理异常
有些字段类型在 MySQL 里是 JSON,实体类用 String 映射。这在 MyBatis-Plus 里默认可以兼容。如果是枚举类型,MyBatis-Plus 默认按枚举的 name 存储,如果你希望按一个自定义值存储,可以实现枚举的 IEnum 接口或配置 typehandler。这个建议阅读官方文档,但实操中建议尽量少在实体里使用数据库不直接支持的复杂类型,保持 ORM 层干净。
最后再分享几个省心的小习惯
我在项目里长期保持几个使用习惯,算是踩过几次坑后的总结。
第一个习惯,实体类字段上加 @TableField 注解,显式指明数据库列名,不管类属性和列名是否同名。这样在重构代码时即使属性名改了,你也能一眼看到数据库列的对应关系,不至于 SQL 生成出来查不到列。
第二个习惯,任何涉及 delete 的通用方法,在调用之前都先构造一个条件明确的对象,并打印日志。我在生产环境见过一次 delete 操作把所有未删除记录都清空的惨状,起因就是有人用了一个空 Wrapper 调 delete。如果需要清表,不如直接显式执行删表 SQL,至少有 DBA 审核环节。
第三个习惯,不要在任何 Wrapper 表达式里直接拼接前端传参。所有参数值都用 column 上的 set 方法,比如 eq、like 这些方法内部会用#{}预编译参数。如果你坚持要自己拼条件,务必用params或手动绑定参数。排序字段、动态表名这类无法预编译的只能做白名单。
第四个习惯,分页插件、乐观锁插件、逻辑删除这类全局配置是团队级约定,最好沉淀到基建库里统一初始化。如果每个业务模块自己配置,经常出现配置翻车、环境依赖不一致的问题。
现在写业务代码,我更多是先把业务对象和表结构梳理清楚,再确定哪些用 BaseMapper,哪些要自定义 XML。MyBatis-Plus 的本质是帮你把繁琐的通用重复代码减掉,但它不是银弹,复杂查询和性能优化仍然要求你理解 SQL 本身。明白这一点,很多东西就顺了。