刚入行那两年,我每次看到项目里写的public interface UserMapper extends BaseMapper<UserEntity>,心里都会冒出一连串问号:这个BaseMapper到底是个什么神仙接口?为什么我啥方法都没写,就能直接userMapper.selectById()、userMapper.selectList()?UserEntity放在尖括号里又有什么讲究?那时候我对MyBatis的印象还停留在XML里的<select>、<insert>标签上,突然看到这么一行“声明式”的写法,总觉得像变魔术。
后来踩了不少坑、翻了源码、在线上环境被奇奇怪怪的问题教育过之后,才算是把这一行代码彻底吃透了。说实话,extends BaseMapper<UserEntity>这行代码,就是MyBatis-Plus整个“低代码”能力的总开关。你把它弄明白了,后面用条件构造器、分页插件、逻辑删除、自动填充这些功能,都会顺畅很多;你要是没弄明白,遇到“为什么我继承了一个接口就能CRUD”这类问题,就只能靠背结论过日子,一旦报错就抓瞎。
这篇我从四个维度来拆:先讲这行代码拆开看分别是什么;再讲MyBatis-Plus在背后做了什么才让“零方法CRUD”成立;接着讲日常实操里最常用的方法和批量操作怎么玩;最后聊聊它跟Spring Data JPA的区别,以及我自己总结的几个排查思路和心得体会。
1. 先把这行代码拆开看:extends、BaseMapper、UserEntity各是什么
1.1 extends在Java接口里表示“继承接口能力”
很多初学者看到extends第一反应是“这不是继承类的关键字吗”?没错,Java里extends确实主要用于类继承,但它同样可以用于接口继承接口。类继承讲究的是“继承父类的属性和方法”,而接口继承讲究的是“继承父接口的方法定义”。
public interface UserMapper extends BaseMapper<UserEntity>这句话翻译成大白话就是:我声明了一个叫UserMapper的接口,它继承了BaseMapper<UserEntity>这个泛型接口的全部方法签名。继承之后,任何地方拿到UserMapper这个对象,都可以直接调用BaseMapper里定义好的那些方法,比如insert、deleteById、updateById、selectById、selectList等等。
这里有个关键点:接口和类的继承不同,接口里只有方法签名没有方法体。也就是说,UserMapper本身并没有真正实现这些方法,它只是声明“我具备这些能力”。那方法体谁来实现?这就回到了MyBatis的核心机制:动态代理。后面我会详细讲,这里先留个钩子。
1.2 BaseMapper :MyBatis-Plus内置的“通用CRUD方法库”
BaseMapper<T>是MyBatis-Plus框架预先定义好的一个泛型Mapper接口。它不是一个抽象类,也不是一个普通类,而是一个接口。里面定义了一整套针对单表的增删改查方法:
int insert(T entity):插入一条记录int deleteById(Serializable id):根据主键删除int updateById(T entity):根据主键更新T selectById(Serializable id):根据主键查询List<T> selectList(Wrapper<T> queryWrapper):根据条件查询列表IPage<T> selectPage(IPage<T> page, Wrapper<T> queryWrapper):分页查询
这些方法都带有泛型参数T,T代表你要操作的具体实体类型。BaseMapper本身不关心你的表长什么样,它只负责定义“通用操作”。等到项目启动时,MyBatis-Plus根据你传入的泛型类型UserEntity,去自动解析实体类上的注解、驼峰转下划线规则、主键策略等信息,然后动态生成对应的SQL语句。
我当初最大的困惑也在这里:明明BaseMapper里那些方法没有SQL标注,也没有XML实现,MyBatis-Plus凭什么知道selectById应该执行SELECT * FROM user WHERE id = ??后来明白了,它靠的就是“接口+泛型+反射+动态代理”,这四样组合在一起,才实现了看起来像魔法一样的效果。
1.3 :告诉框架“我操作的是哪张表”
尖括号里的UserEntity是泛型的具体化。它的作用有两个:
第一,明确操作对象。MyBatis-Plus在运行时会拿这个UserEntity.class去分析,读取类上的@TableName("user")注解(如果没有注解,就默认把类名转成下划线风格,比如UserEntity转成user_entity),读取字段上的@TableId、@TableField注解,然后建立起“实体类字段 <-> 数据库列名”的映射关系。
第二,决定返回值类型。selectList返回的是List<UserEntity>,selectById返回的是UserEntity,这些泛型信息在运行时通过反射是可以拿到的。MyBatis-Plus正是利用这一点,才知道结果集应该映射到哪个实体类上。
有一个细节新手很容易忽略:BaseMapper<UserEntity>和BaseMapper<User>是两套完全不同的映射体系。哪怕UserEntity和User都是指向同一张表,只要你换了实体类,MyBatis-Plus就会重新做一次映射解析。所以千万不要为了“省事”随便共用一个实体类去继承BaseMapper,除非它们的字段映射完全一致,否则很容易出现查出来的对象某些字段为null的问题。
2. 魔法背后:MyBatis-Plus是怎么让“零方法CRUD”成立的
2.1 MapperScan扫描接口并注册Bean
整个流程的第一步,是Spring将UserMapper这个接口注册成Bean。我们通常在启动类上写@MapperScan("com.example.mapper"),或者在Mapper接口上标@Mapper,这两种方式最终都会把接口交给Spring容器管理。
但问题来了:接口不能直接实例化,Spring怎么把它变成一个Bean?答案是MapperFactoryBean。MyBatis(注意,这里是MyBatis本身的能力,不完全是MyBatis-Plus)在扫描到Mapper接口后,会为每个接口创建一个MapperFactoryBean的Bean定义。这个FactoryBean的getObject()方法返回的是一个动态代理对象,类型正好就是你的UserMapper接口。
也就是说,你@Autowired注入的UserMapper,其实是一个代理对象,不是什么神仙实现类。这个代理对象拦截了所有方法调用,然后把调用转发给MyBatis的SqlSession去执行。
2.2 MapperProxy拦截方法与SQL绑定
动态代理的核心类是MapperProxy。当你调用userMapper.selectById(1)时,真实发生的过程是这样的:
方法调用被MapperProxy的invoke方法拦截。它会根据被调用的方法名,生成一个“Statement ID”。这个ID的格式是:Mapper接口的全限定名 + 点号 + 方法名。例如com.example.mapper.UserMapper.selectById。
然后它拿着这个ID去MyBatis的配置中心寻找对应的MappedStatement。MappedStatement是MyBatis里一个非常重要的概念,它封装了一条SQL语句的完整信息:SQL语句本身、参数映射、返回类型、是否缓存等。
那么问题来了:BaseMapper里那些方法根本没有对应的XML或注解SQL,MappedStatement是从哪来的?这就是MyBatis-Plus登场的地方。MyBatis-Plus在启动时,会遍历所有继承了BaseMapper<T>的Mapper接口,针对每个接口预先解析泛型类型T,然后用T的实体信息动态生成一批MappedStatement并注册到MyBatis配置中。
我简单画个流程理解起来会更直观:
- Spring启动,
@MapperScan扫描到UserMapper - MyBatis-Plus发现
UserMapper继承了BaseMapper<UserEntity> - 通过反射拿到
UserEntity的泛型信息 - 解析
UserEntity上的表名注解、字段注解、主键策略 - 生成了
insert、deleteById、updateById、selectById等一堆内置方法的SQL模板,并注册成MappedStatement - 创建
UserMapper的动态代理对象,放入Spring容器 - 业务代码通过代理对象调方法,按“接口全限定名 + 方法名”找到对应
MappedStatement执行
2.3 TableInfo:实体映射信息的本地缓存
在第五步“解析实体信息”这件事上,MyBatis-Plus专门做了一个缓存对象,叫TableInfo。你可以把它理解成一张“字段映射表”。
TableInfo里存了什么?
- 表名
- 主键字段对应的实体属性名和数据库列名
- 主键策略(自增/雪花/输入等)
- 普通字段的映射列表
- 逻辑删除字段的映射
- 乐观锁字段的映射
- 自动填充字段的映射
这个TableInfo是以实体类的Class对象为key进行缓存的。也就是说,同一个实体类只会被解析一次,后续所有操作都直接走缓存。这也是为什么MyBatis-Plus启动后第一次CRUD可能会稍微慢一点,后续就很快。
如果你改了实体类的字段却不重启应用,对不起,缓存不会自动刷新。某些线上问题就是“我加了字段为什么报错”,一查发现是没重启或者热部署没触发缓存刷新。
关于TableInfo,有两点实操提醒:
第一,实体类里如果有一个字段在数据库表里不存在,且没有加@TableField(exist = false),那MyBatis-Plus生成SQL时大概率会报错,因为它默认认为所有字段都对应该表的列。所以那些“非表字段”一定要标注exist = false。
第二,@TableName注解的值一定要和真实表名完全一致,大小写、下划线都不能差。数据库表名是t_user,你就写@TableName("t_user"),别写成@TableName("user"),查不出数据是小,全表扫描、字段错乱才是大坑。
3. 日常实操:继承BaseMapper后到底该怎么用
3.1 内置方法就是我用的最多的“懒人套餐”
说实话,我做了这么多年CRUD项目,BaseMapper内置的那些方法覆盖了我大概80%的数据库操作需求。真的不用每张表都去写XML了。
我列一下日常最常用的一组:
T selectById(Serializable id):最常用的单条查询,注意它会把该表所有列都查出来,字段多时略微浪费,但对绝大多数场景可接受。List<T> selectBatchIds(Collection<? extends Serializable> idList):按主键集合批量查询。注意这个方法底层是WHERE id IN (...),如果idList过大,比如一次性传三五万个,生成的SQL会非常长,数据库可能直接报错。我一般会分批,每批控制在500到1000个主键。int insert(T entity):插入单条数据。如果你没有设置主键,MyBatis-Plus默认会帮你生成雪花ID。int updateById(T entity):根据主键更新。这里有个容易误伤的地方:updateById默认只会更新实体类中值不为null的字段,不会把null字段也更新到数据库。如果你想把某个字段更新成null,你需要用UpdateWrapper的set方法明确指定。int deleteById(Serializable id):根据主键删除。List<T> selectList(Wrapper<T> queryWrapper):条件查询,参数可以传null,传null就等价于全表查询。全表查询在没有加limit的情况下,如果表数据量巨大,会直接拖垮数据库,这个后面讲坑的时候会细说。IPage<T> selectPage(IPage<T> page, Wrapper<T> queryWrapper):分页查询,配合MyBatis-Plus的分页插件使用。
我自己用下来的体会是,大部分业务查询都不是“按主键查”这种单一场景,而是“按某个状态+某个时间范围”这种条件查询。这种场景我就靠selectList配LambdaQueryWrapper或LambdaUpdateWrapper,确实很爽。
3.2 Wrapper条件构造器:把SQL WHERE条件搬进Java代码
Wrapper大概是MyBatis-Plus最让人上瘾的一个设计。它让你用Java的链式调用去拼SQL条件,既能避开字符串拼接的SQL注入风险,又能让代码可读性大幅提升。
我举一个最典型的例子。以前用MyBatis写XML,查“最近7天注册的VIP用户列表”:
<select id="selectRecentVipUsers" resultType="com.example.entity.UserEntity"> SELECT * FROM user WHERE vip_flag = 1 AND create_time >= #{startTime} ORDER BY create_time DESC </select>现在用BaseMapper加LambdaQueryWrapper:
LambdaQueryWrapper<UserEntity> wrapper = Wrappers.lambdaQuery(); wrapper.eq(UserEntity::getVipFlag, 1) .ge(UserEntity::getCreateTime, startTime) .orderByDesc(UserEntity::getCreateTime); List<UserEntity> userList = userMapper.selectList(wrapper);这两种方式在功能上是等价的,但后者省去了Mapper接口方法声明、XML映射、参数传递等一大堆样板代码。我自己用lambda风格最大的好处是:UserEntity::getVipFlag这种写法是类型安全的,如果字段改名,编译期就能发现错误。用字符串"vip_flag"的话,要等到运行时才知道哪里不对。
条件构造器有几个常见注意点:
eq、ne、gt、ge、lt、le分别对应=、!=、>、>=、<、<=。like默认是两侧都带%,likeLeft是%xxx,likeRight是xxx%。模糊查询如果你在字段上建了索引,like '%xxx%'大概率索引失效,数据量大时要特别注意。in方法的集合不能传空集合,传空集合会生成出IN ()这种非法SQL,导致数据库报语法错误。apply方法可以拼接原生SQL片段,但一定要谨慎,因为它不参与参数预编译处理。
还有一点:Wrapper条件里如果需要对字段做函数的处理,比如DATE(create_time) = '2024-01-01',用apply可以拼,但尽量用数据库函数去兼容索引。如果非要写date_format(create_time, '%Y-%m-%d') = '2024-01-01',那该字段上的索引就别指望能命中了。
3.3 批量操作:为什么我不推崇在Mapper循环insert
很多接触MyBatis-Plus的开发者,最开始都会遇到批量插入的问题:我有一千条数据要入库,直接for循环userMapper.insert(entity),行不行?
行,但不推荐。原因有几个:
第一,性能太差。循环一千次,就意味着有一千次网络往返、一千次SQL解析、一千次事务提交(如果你没手动开事务的话)。数据库连接是宝贵的资源,这种方式纯粹是挥霍。第二,如果中途某一条报错,前面已插入的数据就会处于不确定状态。第三,MyBatis-Plus官方就提供了现成的批量能力,没必要自己造轮子。
MyBatis-Plus里更推荐的做法是使用IService层的saveBatch方法。你没看错,这就是标题里那个热搜词“mybatis-plus 批量”最常见的场景。用法是这样的:
先定义Service接口和实现类:
public interface UserService extends IService<UserEntity> { } @Service public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService { }然后在业务代码里:
@Autowired private UserService userService; List<UserEntity> list = new ArrayList<>(); // 往list里塞一千条数据 userService.saveBatch(list);这里saveBatch实际上做了什么?它内部通过SqlSession拿到了一个批处理模式的执行器,在一个事务里分批把数据攒起来,最后一次性执行。具体的批量大小可以通过batchSize参数控制,默认是1000,我一般会根据实际数据量和字段数量调整,比如字段少的表可以一次2000,字段多的大表一次300到500更合适。
这里有个反直觉的点:saveBatch并不是真的“一条SQL插入一千行”,它内部其实也是调用BaseMapper的insert方法,但通过批处理执行器,把多条INSERT语句攒到一个PreparedStatement里批量执行,减少了SQL预编译和网络往返的开销。
我自己实测下来,一千条数据用for循环插入,大概要花2到4秒(取决于网络和数据库性能),而用saveBatch,通常能压到几百毫秒。数据量越大,差距越明显。
3.4 自定义方法:继承BaseMapper接口但不局限于它
UserMapper extends BaseMapper<UserEntity>不意味着你只能用它内置的方法。你完全可以在UserMapper里继续声明自己的方法,然后通过XML或注解SQL实现。
比如你有一个多表关联查询,BaseMapper处理不了,那就正常加方法:
public interface UserMapper extends BaseMapper<UserEntity> { @Select("SELECT u.*, o.order_id FROM user u LEFT JOIN orders o ON u.id = o.user_id WHERE u.vip_flag = #{vipFlag}") List<UserOrderVO> selectUserWithOrders(@Param("vipFlag") Integer vipFlag); }这里有个注意点:MyBatis-Plus不会拦截你自定义的方法执行逻辑。它只接管了BaseMapper内置方法的SQL生成和Wrapper条件解析。你的自定义方法走的就是原生MyBatis那套流程,通过注解或XML解析SQL。
在实际项目里,我见过不少人把UserMapper当成一个大杂烩,什么多表查询都往里塞。我的建议是:单表CRUD用BaseMapper内置方法,复杂查询、多表关联、复杂统计,在UserMapper里加自定义方法没问题。如果某个Mapper自定义方法实在太多,说明你可能该考虑把一些报表查询拆到独立的统计Mapper里,别让一个Mapper膨胀到一千多行。
4. 常见问题与排查技巧:我在生产环境踩过的坑
4.1 方法调用和SQL的对应关系速查
要排查问题,首先得知道哪个方法最终会执行什么样的SQL。我整理了一张对照表,都是我平时排查问题时必查的:
| 方法签名 | 生成的SQL(示意) | 注意事项 |
|---|---|---|
T selectById(id) | SELECT * FROM user WHERE id=? | 会将所有字段查出 |
List<T> selectBatchIds(ids) | SELECT * FROM user WHERE id IN (?,?,?) | id集合不能过大 |
int insert(entity) | INSERT INTO user(...) VALUES(...) | 主键为空时自动生成雪花ID |
int updateById(entity) | UPDATE user SET name=?, age=? WHERE id=? | 仅更新非null字段 |
int deleteById(id) | DELETE FROM user WHERE id=? | 若配置逻辑删除则变成UPDATE |
List<T> selectList(wrapper) | SELECT * FROM user WHERE ... | wrapper为null时全表查询 |
IPage<T> selectPage(page, wrapper) | 分页插件改写为带LIMIT的SQL | 需配置分页插件才生效 |
这张表里最容易被忽略的是逻辑删除对SQL的影响。如果你的实体类上有@TableLogic注解,那么deleteById执行的就不再是DELETE,而是UPDATE user SET deleted=1 WHERE id=?。而selectList这类查询方法自动拼接AND deleted=0条件。有些新手在调试时发现“删了之后还能查到”,第一反应以为代码Bug,其实是逻辑删除生效了。
4.2 排查问题的两个利器:SQL日志和异常堆栈
先说SQL日志。MyBatis-Plus在开发阶段,强烈建议你把SQL日志打开。在application.yml里配置:
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样每次执行SQL,控制台就会打印出最终执行的SQL语句和参数值。我排查90%的问题都靠这个日志:查不出数据就看看WHERE条件是不是多了一个deleted=0;更新没生效就看看SET子句里是不是没有想更新的字段;分页失效就看看SQL后面是不是没有LIMIT。
另外一个利器是异常堆栈。MyBatis-Plus的报错信息里通常包含了TableInfo相关的线索。比如常见的“xxxis not found in table info”,意思就是实体里某个字段在TableInfo缓存里找不到对应的数据库列。常见原因是字段没有加@TableField,且开启了驼峰转下划线但字段名匹配不上;或者字段加了@TableField(exist = false),但你在updateById时传入的实体对象该字段有值——这两种情况都要重新梳理实体映射。
4.3 为什么updateById无法把字段更新成null
这是我在论坛上看到被问烂了的问题,也是我自己刚接触时踩过的坑。updateById这个方法的默认行为是:只更新非null字段。也就是说,如果你执行:
UserEntity entity = new UserEntity(); entity.setId(1L); entity.setName(null); userMapper.updateById(entity);理想中你是不是想把name更新为null?错了,MyBatis-Plus会忽略这个null字段,所以这条SQL实际只执行了UPDATE user WHERE id=?,name字段纹丝不动。
这个设计初衷是防止调用方没设置某个字段时就把它覆盖成null。但如果你确实有把某字段更新为null的需求,怎么做?要用UpdateWrapper:
LambdaUpdateWrapper<UserEntity> wrapper = Wrappers.lambdaUpdate(); wrapper.eq(UserEntity::getId, 1L) .set(UserEntity::getName, null); userMapper.update(null, wrapper);这段代码会生成UPDATE user SET name=null WHERE id=?。注意第一个参数传了null实体,因为真正的更新操作完全由wrapper的set来表达了。
4.4 分页失效的几种典型原因
MyBatis-Plus的分页不是天然好用的,它需要配置PaginationInnerInterceptor插件。如果你的项目没有配置这个拦截器,那么selectPage不会报错,但也不会自动加LIMIT,而是把全表数据查出来后在内存里“分页”,这种问题数据量一大就暴雷。
另外还有几种分页失效的情况:
selectPage的IPage参数必须作为第一个入参传递,否则拦截器可能识别不到。某些自定义的SQL分页,如果SQL里已经带了LIMIT,拦截器识别起来会有问题。多表关联查询使用分页时,如果COUNT语句生成有误,可能导致总条数不正确。我遇到过join查询分页,count语句把join的表都统计进去导致count值翻倍的情况,这种时候我一般会直接获取分页结果但手动处理总数,或者改造SQL用子查询方式避免join对count的影响。
配置分页插件的方式如下,我建议在配置类中显式声明:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不配置这个Bean,分页功能就是哑弹。
4.5 字段名不是驼峰导致查询结果为null
MyBatis-Plus默认开启了驼峰转下划线映射:userName自动对应user_name。如果你的数据库列名不遵循这个规则,比如数据库列叫NAME,实体字段叫name,这通常没问题;但如果数据库列叫userName,实体字段也叫userName,中间没有下划线,那反而会映射不上,因为MyBatis-Plus默认期望userName对应user_name。
解决办法就是在实体字段上显式标注@TableField:
@TableField("userName") private String userName;这是很小但很容易卡住人的细节。我见过不止一个同事排查半天,最后发现只是列名映射问题。
4.6 大表全表查询的隐患
selectList(null)这个操作非常危险。没有条件、没有LIMIT,直接全表查出所有行并映射成对象列表。如果表里有几百万行数据,这个过程会直接OOM或者把数据库IO打满。
我在实际项目里给自己的铁律是:业务查询必须带wrapper条件,且如果条件可能查全表,要主动加last("LIMIT 100")或者page限制条数。特别是一些后台导出功能、报表统计功能,千万不要一条selectList(null)写到代码里,踩雷无非早晚。
5. 说清MyBatis-Plus和Spring Data JPA的区别
既然热搜词里出现了“spring data jpa和mybatis-plus的区别”,这里我就以BaseMapper<T>为切入点,把它和JPA的Repository<T, ID>做个对比。很多团队做技术选型时都在这两者之间纠结过。
先说结论:它们是两种风格完全不同的数据访问方案,MyBatis-Plus本质是MyBatis的增强工具,属于“SQL优先”;Spring Data JPA属于“实体优先”,底子是Hibernate这套ORM体系。
在接口设计上,对比太明显了:
// MyBatis-Plus 风格 public interface UserMapper extends BaseMapper<UserEntity> { // 灵活使用Wrapper或自定义SQL } // Spring Data JPA 风格 public interface UserRepository extends JpaRepository<UserEntity, Long> { // 方法名即查询:findByUserNameAndAge List<UserEntity> findByUserNameAndAge(String userName, int age); }JPA让我最快感觉到差异的是方法名解析查询。你在Repository里声明一个findByUserNameAndAge方法,Spring Data JPA会自动根据方法名解析出查询条件。这在简单查询场景下非常高效,甚至不用写SQL。但当查询变复杂,比如多表联查、子查询、动态条件拼接,JPA要么用@Query写JPQL/HQL,要么用Criteria API,后者的学习成本并不低。
MyBatis-Plus这边,复杂查询用LambdaQueryWrapper动态拼接条件,多表查询用自定义SQL,结构更接近传统MyBatis开发者的思维习惯。
在“批量操作”这个维度上,两者也有区别。JPA的saveAll本质是逐条merge或persist,官方并没有特别强调批处理优化。而MyBatis-Plus的saveBatch内部通过MyBatis的批量执行器做了SQL攒批,性能上限更高。当然两者都能用jdbc的rewriteBatchedStatements=true加持,但MyBatis-Plus的saveBatch实现更贴近开发者熟悉的批处理模型。
在“动态SQL能力”上,MyBatis-Plus的Wrapper无疑更直接;在“复杂对象关系映射”和“缓存一致性模型”上,JPA的实体生命周期管理更强。如果项目里有大量对象关系关联、级联操作,JPA的entityManager体系能省不少事;如果是互联网高并发、SQL调优要求较高的场景,MyBatis-Plus更合适,毕竟SQL都在你眼里,你能精确控制每条SQL的形态。
还有一个选型角度是团队背景。我用下来感觉是:从SSM/MyBatis转过来的团队,接受MyBatis-Plus几乎零成本;团队如果本来就是Spring Boot + JPA出身,硬切MyBatis-Plus反而要在SQL思维上适应一阵子。两种方案都能写出好代码,关键是团队能不能驾驭,别只看网上哪边呼声大。
6. 我这几年用下来的几点小心得
最后分享几条很个人的经验,不是教科书上会写的东西。
第一,把BaseMapper<T>这行代码当作你的“极简API入口”,能少写很多XML。但它不是万能药,如果你经常要对一个实体做非常特殊的查询,后续自定义的SQL越来越多,我建议重新审视一下是不是该把复杂SQL拆出去,让Mapper接口保持“内置通用方法 + 少量核心自定义方法”的状态。我曾经维护过一个Mapper接口里塞了两三百行自定义SQL的项目,改个表结构要眼瞎,后来拆分之后清爽太多了。
第二,泛型实体上宁可多写几个注解,也别偷懒。@TableName、@TableId、@TableField这些注解看起来啰嗦,但在表结构变更、多环境切换时,显式声明比依赖命名约定要可靠得多。我吃过一次亏:某个表的主键字段叫user_id,实体字段叫userId,没有加@TableId,MyBatis-Plus默认按id去找主键,启动时不报错,但调用updateById、deleteById时行为完全不对。
第三,条件构造器里慎用apply和直接拼SQL。虽然它看起来只在Java代码里,但apply里的片段最终会被原样拼进SQL语句。一旦条件里有外部传入的字符串参数,SQL注入风险就回来了。我一般只允许apply拼接固定常量,绝不拼用户输入。
第四,性能和正确性有冲突时,先保正确性。比如saveBatch确实快,但它不像for循环那样每次都能精确定位哪一条出错了。批量操作时我的习惯是:先把数据做基本校验,再批量执行,异常时回滚整个批次后,再用单条方式重跑,定位具体数据。
extends BaseMapper<UserEntity>这行代码,你把它当成“一个带大量通用SQL实现的增强接口”去理解,平时写代码就会很顺;把它背后的代理机制和TableInfo映射搞明白,遇到问题才不会被表象牵着走。我用MyBatis-Plus也有五年多了,坦白说这行代码的设计让我少写了几万行XML,也让更多Java开发者能快速上手数据访问层。但工具毕竟是工具,掌握它是为了让你有更多精力去处理真正的业务复杂度,而不是为了少写SQL而丢掉对SQL本身的理解。