ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringCloud微服务数据层利器:MyBatis-Plus注解配置与避坑指南

2026/10/2 19:07:45 拓冰建站 浏览量
SpringCloud微服务数据层利器:MyBatis-Plus注解配置与避坑指南 做后端的人应该都有这种感觉同一个单表的增删改查项目里七八个模块都在重复写几乎一样的SQL。尤其在SpringCloud微服务项目里服务一多这种重复感会被放大到让人崩溃。我最近把黑马的SpringCloud微服务课程day1重新捋了一遍第一天就是用MyBatis-Plus把数据层先立起来当时第一反应是这玩意儿要是早点出现我能少加多少班。MyBatis-Plus说白了就是MyBatis的增强工具官方定位是“只做增强不做改变”。它把单表CRUD、分页查询、逻辑删除、自动填充、乐观锁这类日常高频能力全部内置引入之后连BaseMapper里自带的十几个方法都够用。它不碰你原有的MyBatis能力反而把原来要写XML、写条件SQL的活儿替你做掉一大半。这篇文章是我把课程day1的内容结合自己折腾的经验整理出来的重点讲三件事快速上手的步骤、常用注解怎么用、配置项怎么设顺带把我在实际项目里踩过的坑一并列出。适合刚接触MP的初学者参考也适合从原生MyBatis迁移过来的老手快速对齐。1. 为什么微服务项目要先搞定MyBatis-Plus1.1 从原生MyBatis到MP省掉的不是SQL而是重复劳动原生MyBatis强不强强。但它的强体现在灵活性和SQL控制力上代价是赤裸裸的体力活。一个简单的分页查询你要写SQL、写count查询、处理LIMIT参数一个联表查询要把resultMap配一遍一个动态更新要判断每个字段是否为null。这些事不是不会而是每天都干、每个模块都干一遍属于性价比极低的稳定消耗。MyBatis-Plus的思路正好卡在这个痛点上底层还是MyBatis那一套但给你预置了一套通用CRUD。用它的BaseMapper继承之后不需要写任何方法就已经有insert、deleteById、updateById、selectById、selectList、selectPage这些方法。这些方法背后是MP通过解析实体类上的注解自动生成SQL。也就是说你把实体类换掉这些方法就变成另一张表的管理器。微服务场景下这个价值会被放大。一个订单服务、一个用户服务、一个库存服务各自都有好几张单表要维护。要是每张表都手搓一套XML服务和服务的区别就淹没在无意义的样板代码里。MP至少在单表这一层把时间收了回来让开发者能把精力留给真正有业务价值的复杂SQL。1.2 课程为什么把MP排到day1这是我个人的理解。SpringCloud这套东西的核心在于服务注册、服务发现、远程调用、网关、配置中心、链路追踪这些但不管上面怎么编排每个服务最终都要落到数据访问。day1先拎出MP目的很明确——让你在后续学Nacos、Feign、Sentinel的时候不被数据层的繁琐代码分散注意力。数据层用MP三五下写完你才能把有限的认知带宽腾给微服务本身的通信和治理逻辑。另外黑马这个课程里的项目是做微服务拆分改造的数据库基本是“每个服务独享”的模型。MP最擅长的恰恰就是这个场景每个服务各自一套标准数据访问层不跨库、不纠结复杂事务单表CRUD全交给它复杂查询再手写SQL。这种搭配非常顺。2. 快速入门十分钟跑通第一个MP查询2.1 环境准备与依赖引入先交代基础环境我本地的组合是JDK 8、Spring Boot 2.7、MySQL 8.0、Maven 3.8。Spring Boot 3.x也能用但依赖坐标不一样这个坑稍后专门讲。Maven依赖Spring Boot 2.x用这个坐标dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependencySpring Boot 3.x要把坐标换成spring-boot3专用版老坐标会报各种奇怪的启动错误dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.3.2/version /dependency数据库驱动也要配好MySQL 8对应com.mysql.cj.jdbc.Driver。另外建议把Lombok加上实体类不写getter/setter能省很多篇幅。一句话总结先把MP版本和Spring Boot版本对齐再谈其他。2.2 准备一张表建表SQL我贴一个实际用过的故意多加了deleted字段后面讲逻辑删除时就用它CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, username varchar(32) NOT NULL COMMENT 用户名, password varchar(64) NOT NULL COMMENT 密码, phone varchar(11) DEFAULT NULL COMMENT 手机号, status tinyint(1) DEFAULT 1 COMMENT 状态1正常 0禁用, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, deleted tinyint(1) DEFAULT 0 COMMENT 逻辑删除0未删 1已删, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;表名用user和实体类名一致省掉一个TableName。实际项目里我建议不管一致不一致都把注解写全后面讲为什么。2.3 实体类、Mapper、配置文件三步走实体类Data TableName(user) public class User { TableId(value id, type IdType.AUTO) private Long id; private String username; private String password; private String phone; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; TableLogic private Integer deleted; }逐行解释Data来自Lombok生成getter/setterTableName声明表名TableId声明主键字段和主键生成策略TableLogic声明逻辑删除字段。其余没注解的字段MP默认把属性名转下划线去匹配数据库列名username对应usernamecreateTime对应create_time。Mapper接口长这样就一行干净得让人不习惯Mapper public interface UserMapper extends BaseMapperUser { }BaseMapper里我常用的方法列一下insert / deleteById / updateById / selectByIdselectOne(Wrapper)selectList(Wrapper)selectPage(PageT, Wrapper)selectCount(Wrapper)配置文件application.ymlspring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mp_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: falsemap-underscore-to-camel-case默认就是true写出来是提醒你它存在。banner: false是关掉启动时那个大Logo强迫症福音。log-impl是打印SQL日志开发阶段必开。2.4 测试CRUD写个测试类验证SpringBootTest public class UserMapperTest { Autowired private UserMapper userMapper; Test void testSelect() { User user userMapper.selectById(1L); System.out.println(user); } Test void testInsert() { User user new User(); user.setUsername(zhangsan); user.setPassword(123456); user.setStatus(1); userMapper.insert(user); } }跑完testInsert控制台会打印INSERT语句你还能看到主键回填的效果——insert之后直接读user.getId()就有值了。如果没配主键策略MP默认走雪花算法生成一个Long型ID这个默认值坑在后面有专门篇幅讲。到这里快速入门就完成了。全程没有写一条SQL但数据已经能增删改查了这就是MP最直观的体验。3. 核心注解逐个拆解3.1 TableName表名映射先讲TableName因为它是实体类上的第一个注解。Java实体类命名习惯是驼峰数据库表命名习惯是下划线而且很多老项目的表名跟实体类名根本对不上。TableName就是干这个的Data TableName(tb_user) public class User { ... }它有几个常用属性value指定表名schema指定schema一般用不到keepGlobalPrefix配合全局表前缀使用autoResultMap在涉及特殊类型或逻辑删除时建议设true。提示就算实体类名和表名完全一致我也建议把TableName写上。写清楚是为了减少后续表改名、换库时的排查成本注解里看到真实表名比靠默认规则去猜踏实得多。3.2 TableId主键策略TableId是使用频率最高的注解重点在type属性决定主键怎么生成TableId(value id, type IdType.AUTO) private Long id;IdType的常用策略我按重要程度排个序策略说明典型场景AUTO数据库自增MySQL单库最省心ASSIGN_ID雪花算法生成Long型分布式ID微服务、分库分表ASSIGN_UUID生成UUID字符串主键不需要有序ID的场景INPUT主键由程序手动设置业务自带主键规则重点说ASSIGN_ID。MySQL自增ID在单库单表下没问题可一旦微服务拆分、多张表甚至多个库需要协调ID时自增就不行了。雪花算法的核心思想是用时间戳机器标识自增序列拼出一个64位Long保证高并发下ID全局唯一且趋势递增。MP在你不配置type时默认就是ASSIGN_ID新手容易忽略这一点数据库明明是自增实体里又不写TableId结果插入数据后主键是雪花数跟数据库对不上。注意AUTO依赖数据库自增insert后要想拿到主键MP会通过数据库的getGeneratedKeys机制回填到实体的id属性里所以insert之后直接读user.getId()是能拿到的别再去查一遍。3.3 TableField字段映射与自动填充TableField是花样最多的注解主要解决四类问题。第一字段名对不上。Java里叫phone数据库列叫mobileTableField(mobile) private String phone;第二实体类里存在数据库没有的字段一般是从其他地方拼进来的展示字段比如统计用的人数TableField(exist false) private Integer userCount;这个坑非常常见。不加existfalse的话MP自动生成的INSERT和SELECT都会带上这个列数据库直接报unknown column排查起来相当费劲。第三自动填充。createTime、updateTime这种字段每次都手写set很烦MySQL的数据库默认值又解决不了Java层的LocalDateTime类型问题。MP的做法是配合MetaObjectHandlerTableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;FieldFill有INSERT、UPDATE、INSERT_UPDATE、DEFAULT四个值。标了fill之后还要配一个小组件才能让MP在插入/更新时自动取值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()); } }第四字段名撞了SQL关键字。比如字段叫desc、order、level、type直接参与SQL会报语法错误。MySQL下用反引号处理TableField(desc) private String desc;其他数据库写法不同Oracle是双引号所以这个写法只在MySQL系里通用。3.4 TableLogic逻辑删除业务上越来越要求不物理删数据。MP的TableLogic就是干这个的TableLogic private Integer deleted;配置之后deleteById不再执行DELETE语句而是变成UPDATE user SET deleted 1 WHERE id ?。查询时所有自动生成的SQL都会带上deleted 0条件。这一套如果全靠手写每个Mapper方法都要加条件漏一个就是数据隐患。但这个设计有几个坑我实际都遇到过第一逻辑删除后的数据会越攒越多如果deleted字段没有索引按deleted过滤的效果会随数据量明显下降。建议在deleted字段上也建索引或者干脆把字段类型从tinyint改成datetime用删除时间代替删除标记删除时写入当前时间查询用IS NULL判断这样“何时删除”也有了记录。第二唯一索引冲突。最常见的是用户名唯一场景ID为1的张三被逻辑删了再插入一个叫张三的用户数据库唯一索引直接拒绝因为被删的记录还在表里只是标记deleted1。解决思路要么把deleted放进联合唯一索引要么用前面说的删除时间戳替代标记因为这个字段可以置NULLMySQL的联合唯一索引对NULL不生效。3.5 Version乐观锁高并发下防止数据覆盖MP用Version做乐观锁Version private Integer version;它的工作方式很好理解更新时把version当条件带进去同时把version加一UPDATE user SET username ?, version version 1 WHERE id ? AND version ?如果条件不成立说明更新期间数据被别人改过更新行数为0程序就可以决定重试或报错从而避免“最后写入覆盖前一次写入”的丢失更新问题。使用前提是必须配置乐观锁拦截器OptimisticLockerInnerInterceptor否则注解不生效。这块配置在第四章一起讲。注意Version字段要求每次更新前都拿到最新值。如果前端传上来的version是旧值乐观锁就会频繁触发表现为“明明改一下却总提示数据已被修改”。这通常是并发冲突真实存在但也可能是前端页面的version字段没跟着刷新。3.6 组合使用场景小结一个相对完整的实体类Data TableName(tb_user) public class User { TableId(value id, type IdType.AUTO) private Long id; TableField(user_name) private String username; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; Version private Integer version; TableField(exist false) private String extraInfo; }写注解时有个原则性问题注解是声明式的一旦写错影响是全局的。字段填充、逻辑删除、乐观锁这些注解都要求字段本身在业务上是稳定存在的如果只是“可能加个字段”就去标注解后面越改越乱。4. 配置文件里的那些参数4.1 application.yml 常用配置逐条讲我把一套相对完整的MP配置贴出来后面跟着的就是我的解释mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.example.mp.entity configuration: map-underscore-to-camel-case: true cache-enabled: false log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: id-type: auto table-prefix: tb_ logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0mapper-locations原生Mapper XML的路径。凡是自定义SQL比如复杂联表查询放在如mapper/UserMapper.xml里这个配置告诉MP去哪加载。type-aliases-package实体类包名。写上之后XML里的resultType可以直接写User这样的短类名不用写全限定名。map-underscore-to-camel-case下划线转驼峰。默认true可以不用写但建议显式写出来。cache-enabled二级缓存默认false。本地缓存一级缓存默认开启二级缓存涉及序列化和跨SqlSession问题建议保持false分布式环境下更别开。log-implSQL日志实现。开发阶段用StdOutImpl打印SQL生产环境要去掉。banner启动Logofalse关掉。id-type全局主键策略。如果全项目主键都是自增这里配auto实体类上不写type也会按自增处理。table-prefix全局表名前缀。表都叫tb_user、tb_order这种配了tb_所有实体类就都不用写TableName的value了。logic-delete-field全局逻辑删除字段名。下面两个值是逻辑删除值和未删除值1代表已删0代表未删。需要记住优先级实体类注解优先于全局配置。你全局配了id-type: auto但某个实体类写了TableId(type IdType.ASSIGN_ID)以注解为准。这个特性在迁移老项目时很有用局部特例通过注解改不污染全局。4.2 分页插件和乐观锁插件的正确打开方式MP的很多内置能力是靠拦截器实现的最有代表性的就是分页。BaseMapper里虽然有selectPage方法但不配拦截器你会发现page.getRecords()永远只有一条或者根本分不了页。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }DbType要按实际数据库填MySQL填MYSQLPostgreSQL填POSTGRE_SQLOracle填ORACLE。填错了分页SQL的方言生成就不对。我用过一次没填DbType的默认构造结果在MySQL上LIMIT语句生成得七扭八歪排查了很久。如果用到乐观锁额外加OptimisticLockerInnerInterceptor。顺序上一般分页拦截器放在前面乐观锁放后面官方默认建议这样排。多租户、防全表更新删除这类企业级能力也靠拦截器实现比如TenantLineInnerInterceptor做租户行级隔离BlockAttackInnerInterceptor阻止不带条件的UPDATE/DELETE。微服务多租户场景用得着我在第六章专门展开。4.3 日志、SQL打印与生产环境注意事项开发阶段一定要能看到SQL否则出了问题都不知道MP替你执行了什么。两个方式配置log-impl为StdOutImpl或者把mapper接口的日志级别调到debuglogging: level: com.example.mp.mapper: debug两种方式打印格式略有不同前者是MP默认的彩色格式后者走的是logback的debug通道二选一即可。生产环境记得把SQL日志关掉。一是日志量巨大、影响性能二是SQL里带着真实的查询参数敏感信息泄露风险不值得冒。我在一个练习项目里忘关压测的时候控制台日志直接打爆了磁盘教训惨痛。5. 实用小技巧条件构造器Wrapper5.1 QueryWrapper与LambdaQueryWrapper怎么选BaseMapper里的查询方法绝大多数都要带上Wrapper参数。Wrapper是MP的条件构造器相当于把WHERE后面的活用Java表达出来。QueryWrapper是最基本的写法QueryWrapperUser wrapper new QueryWrapper(); wrapper.eq(username, 张三) .like(phone, 138) .between(create_time, startTime, endTime) .orderByDesc(id); ListUser list userMapper.selectList(wrapper);QueryWrapper的问题在于列名是字符串写错一个字母编译器不会报错要等SQL执行时才炸出来。所以日常更推荐LambdaQueryWrapperLambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getUsername, 张三) .like(User::getPhone, 138) .orderByDesc(User::getId); ListUser list userMapper.selectList(wrapper);Lambda写法引用的是方法重名字段也不怕编译期就能拦截错误。代价是每次都要写User::getXxx稍微啰嗦但值得。5.2 UpdateWrapper只想更新部分字段时updateById默认只更新非null字段这是MP的一种安全策略传null不更新。但业务上有时候就非要更新为null或者只想更新个别列这时候用UpdateWrapperUpdateWrapperUser wrapper new UpdateWrapper(); wrapper.eq(id, 1L); wrapper.set(phone, null); // 强制把phone置空 wrapper.set(status, 0); userMapper.update(null, wrapper);update的第一个参数传null第二个参数是WrapperSQL就由Wrapper生成。这个用法很野但很常用特别是处理“把某个字段清空”的需求时比先查出实体再updateById省事得多。5.3 Wrapper使用的几个注意点第一字符串列名别硬编码。项目里如果全局搜eq(phone)重构字段名时根本搜不到Lambda写法相对安全。第二Wrapper条件拼太多SQL会很长。建议把公共查询条件抽取成方法比如带个“有效期”参数就拼between没有就不拼别把Wrapper写成一大坨。第三Wrapper里的set与eq要分清。set是更新列eq是条件列刚上手容易写反结果就是把整张表都更新了。UpdateWrapper如果eq条件没拼上生成的SQL不带WHERE配合BlockAttackInnerInterceptor会直接抛异常阻止执行属于越早配越好的防呆设计。6. 批量操作、通用Service与多租户能力6.1 批量插入的几种做法BaseMapper里没有批量insert方法这是MP被吐槽最多的一点。实际做法分几种如果数据量不大几十条用IService的saveBatchAutowired private UserService userService; ListUser userList ...; userService.saveBatch(userList);底层其实是循环调insert但框架帮你做了分批和事务管理。数据量再大几万条最推荐写自定义XML用foreach批量INSERTinsert idinsertBatch INSERT INTO user (username, password, phone) VALUES foreach collectionlist itemitem separator, (#{item.username}, #{item.password}, #{item.phone}) /foreach /insert另外JDBC层面有个优化参数JDBC URL后面加rewriteBatchedStatementstrue配了这个参数JDBC批处理才能真正合并SQL执行不然batchInsert是假批处理一条一条发性能提升非常有限。6.2 IService与ServiceImpl业务层模板MP提供的IService接口和ServiceImpl实现类是另一套增强。我现在的习惯是Controller直接注入Service接口业务逻辑写在IService的实现里单表CRUD方法Service里全都有不用自己写。public interface UserService extends IServiceUser { } Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { }这样Controller里就能用userService.getById、userService.list、userService.page这些方法。和BaseMapper的区别是IService面向业务层方法更偏业务语义比如saveBatch、listByIds、getOne。黑马课程day1后续的代码基本上就是ServiceMapper这样的组合。6.3 多租户与防全表更新微服务相关的拦截器微服务多租户场景TenantLineInnerInterceptor能自动给SQL追加租户条件TenantLineHandler tenantLineHandler new TenantLineHandler() { Override public Expression getTenantId() { // 从上下文取租户ID return new LongValue(TenantContext.getTenantId()); } Override public String getTenantIdColumn() { return tenant_id; } }; interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler));配置后所有自动生成的select/insert/update/delete都会带上tenant_id条件或插入租户值业务代码就不用每处手写tenant_id ?了。这个功能在单体改微服务时很实用但对初学者不友好建议先把基础跑通再碰它。BlockAttackInnerInterceptor是防呆利器禁止执行不带WHERE条件的UPDATE/DELETE。我见过不止一次因为忘写eq条件把整张表改掉的惨案配上它之后这种SQL直接抛异常保命。7. 实验记录与避坑清单7.1 常见报错与排查速查表现象可能原因处理方式启动报Failed to obtain JDBC Connection数据源配置或驱动类不对检查url/username/passwordMySQL 8驱动类用com.mysql.cj.jdbc.DriverselectById报table xxx.user doesnt existTableName没写或表名大小写不对检查实体类注解Linux下MySQL表名区分大小写实体类里有非表字段报unknown column缺existfalse给非表字段加TableField(exist false)selectPage不生效records取不出来分页插件没配配置MybatisPlusInterceptorPaginationInnerInterceptor乐观锁更新无效没配OptimisticLockerInnerInterceptor配置乐观锁拦截器updateById更新null字段不生效MP默认忽略null字段用UpdateWrapper.set显式更新insert后拿不到主键主键策略与数据库不匹配自增配AUTOinsert后读实体的id属性Spring Boot 3启动报错starter坐标不对换mybatis-plus-spring-boot3-starter批量插入很慢没合并SQL配rewriteBatchedStatementstrue或用foreach XML7.2 我实际踩过的三个坑第一个坑是关键字字段。有个实体类的字段叫type数据库里也有type列我以为没什么问题结果执行SQL直接报语法错误。排查了半天才发现type在MySQL里是保留关键字。后来团队定了规范字段命名先过一遍关键字列表实在躲不开的用TableField反引号处理。第二个坑是主键策略。不记得从哪个版本开始MP默认用ASSIGN_ID而我的表全是自增主键。第一次插入后看着id变成一长串雪花数还以为是并发写坏了最后发现就是没写TableId(type IdType.AUTO)。这个不算bug但是个特别容易踩的默认值陷阱。第三个坑更隐蔽逻辑删除加唯一索引。用户表username有唯一索引逻辑删了一个用户后再注册同名用户直接插入失败。当时在训练项目里排查了很久最后是把deleted字段从0/1改成删除时间戳默认为NULL联合唯一索引对NULL不生效问题解决。如果你的MySQL版本支持也可以用生成列把deleted转成非NULL值但麻烦不如时间戳方案通用。7.3 在SpringCloud微服务项目里的落地姿势回到课程本身。黑马SpringCloud课程day1用MP我在自己的微服务练习项目里也是这么落地的每个微服务模块引入mybatis-plus-boot-starter每个服务独立数据库独立建表规范实体类统一约定主键自增或雪花、逻辑删除、自动填充、乐观锁全部用注解不搞特例单表操作用IService和BaseMapper复杂联表查询才写自定义MapperXML分页插件每个服务都配DbType按各自数据库填公共配置沉淀到Nacos配置中心后续学到配置中心时正好把这类配置统一管理起来。这套玩法模板化之后新服务的数据库层半小时内就能搭完剩下时间都花在业务上。对一个以学习微服务为主要目标的人来说先把数据层从繁琐里解放出来真的很划算。最后再分享一个小经验如果你是跟着这套课程入门的别急着把MP所有注解和配置背下来。先把主键策略、逻辑删除、分页插件这三个点吃透后面课程里用到数据层的地方特别多这三个点会反复出现前期理解得越扎实后面就越省力。