MyBatis 组件化实践:踩坑全记录
MyBatis 组件化实践:踩坑全记录
dong4j第一个坑:XML 去哪了
最先踩的坑很基础——MyBatis 的 Mapper XML 文件在打包成 jar 后消失了。
背景是我们在做一个日志组件,里面包含了 dao 接口和对应的 XML:
1 | src/main/java/com/xxx/log/service/dao/ |
有同事把 XML 和 dao 接口放在同一个包路径下,理由是”方便对照”。开发阶段一切正常,mvn spring-boot:run 跑起来 SQL 也能加载。但打包成 jar 给业务方引用时,运行提示 Invalid bound statement (not found)。
原因很简单:Maven 编译时只处理 src/main/java 下的 .java 文件,.xml 文件不会被拷贝到 target/classes。打包成 jar 后自然也没有。
修法有两种。第一种,把 XML 挪到 src/main/resources 下,保持相同包路径:
1 | src/main/java/com/xxx/log/service/dao/LogDao.java |
Maven 会把 resources 下的文件原样拷贝到 classpath,打包自然也会带上。这是标准做法。
第二种,在 pom.xml 中显式声明资源路径:
1 | <build> |
但第二种破坏了代码与资源的分离原则。长期维护下来,src/main/java 下什么都有,新人不知道哪些是代码哪些是资源。所以我选了第一种。
其实往深了想,”方便对照”这个需求可以用工具解决。IntelliJ IDEA 有 MyBatis 插件,可以从 dao 接口跳转到 XML,也可以反向跳转。而且免费的 MyBatisCodeHelper 或者付费的 MyBatis Log Plugin 都能做到。不需要用”把 XML 放在 java 目录下”这种反 Maven 约定的方式来解决问题。
第二个坑:组件里的 dao 扫不到
XML 问题解决后,遇到了第二个坑:业务方引了组件,但 dao 接口没被 Spring 扫描到。
Spring Boot 的默认包扫描范围是主启动类所在包及其子包。组件的包路径是 com.xxx.log.service.dao,而业务方的主启动类在 com.xxx.business 下,根本扫不到。
业务方解决方式是自己在启动类上加 @MapperScan:
1 |
|
功能性上是解决了,但破坏了组件的封装性。业务方需要知道组件内部有哪些 dao 包。如果将来组件内部重构,改了包名,所有引用方的启动类都要跟着改。
正确做法是组件自己提供自动配置:
1 |
|
再配上 Spring Boot 的 spring.factories 或者 AutoConfiguration.imports 文件:
1 | # META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports |
业务方引入依赖后,dao 自动被扫描,零配置接入。这才是组件应该有的体验。
但自动配置也有要注意的地方。如果业务方自己也有 @MapperScan,两者会不会冲突?答案是:不会。MyBatis-Plus 的 @MapperScan 支持多次声明,多个扫描路径会被合并。但前提是不要在不同层级重复扫描同一个包,否则可能导致同一个 Mapper 被注册两次。
第三个坑:间接依赖的 dao 找不到
接下来是一个更隐蔽的问题。xxx-manage-service 示例项目报了一个错:
1 | org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): |
排查过程是这样的:
项目的依赖链是 xxx-manage-service → infrastructure-service-starter → user-api-service-starter。DictDao 在 user-api-service-starter 中,是通过 infrastructure-service-starter 间接引入的。
虽然 user-api-service-starter 有自己的自动配置类,但某些场景下没有正确生效。排查发现是 spring.factories 文件中的自动配置类名写错了——少写了一个字母,Spring Boot 加载的时候静默跳过,没有报任何错。
这是个教训:自动配置的声明一定要和实际类名一致。Spring Boot 不会因为找不到自动配置类而启动失败,它只会跳过。所以在写 AutoConfiguration.imports 或者 spring.factories 时,建议用 copy-paste 类全限定名(来自 IDE 的 Copy Reference),不要手敲。
修好自动配置后还有一个小细节:业务方的 mapper-locations 配置需要匹配组件的包路径:
1 | mybatis-plus: |
关键是 classpath*: 前缀。因为组件的 XML 在 jar 包的 classpath 中,业务方代码在另一个 classpath 中,用 classpath* 才能扫描到多个 jar 中的 XML。
第四个坑:拦截器绑架
这个问题在之前的文章里已经提到过,但在 MyBatis 组件化这个话题下值得再说一遍。
MybatisAutoConfiguration 中同时装配了加密拦截器和分页拦截器:
1 |
|
条件注解是:
1 |
也就是说,不开加密就没有分页。这两个功能没有逻辑上的关联,纯粹是在同一个方法里创建的。
分开的改法也很直接:
1 | // 加密拦截器 —— 受配置控制 |
不止如此,加密拦截器本身也值得优化。它用反射检查每个入参是否有 @EncryptedTable 注解,每次查询/更新都做一遍:
1 | // 当前实现:每次操作都走反射 |
高频调用场景下,反射是瓶颈。加一层缓存:
1 | private final Map<Class<?>, Boolean> encryptClassCache = new ConcurrentHashMap<>(); |
同理,加密字段的反射查找也可以缓存:
1 | private final Map<Class<?>, List<Field>> encryptFieldCache = new ConcurrentHashMap<>(); |
改完之后,加密拦截器对数据库操作的性能影响降低了一个数量级。缓存的 key 是 Class 对象,内存占用极小,生命周期和 JVM 一致。
第五个坑:@Configuration 和 @AutoConfiguration 的微妙差别
这个问题不是 MyBatis 特有的,但确实是在排查 MyBatis 自动装配相关问题时踩出来的。
框架里有各种自动装配类,有的用 @Configuration,有的用 @AutoConfiguration。它们都能让 Bean 被 Spring 容器管理,但在 Spring Boot 3.x 中,两者的行为有了关键差异。
先看一个具体场景。2.1.0 版本的网关启动类是这样的:
1 |
|
用 exclude 排除了 AjCaptchaAutoConfiguration,在当时一切正常。但升级到 Spring Boot 3.x 后启动报错:
1 | java.lang.IllegalStateException: The following classes could not be excluded |
原因很简单:AjCaptchaAutoConfiguration 用的是 @Configuration,而不是 @AutoConfiguration。Spring Boot 3.x 不再把 @Configuration 视为自动装配类,exclude 属性不认它了。
修法是用 @ImportAutoConfiguration 的 exclude 来排除:
1 |
|
那 @Configuration 和 @AutoConfiguration 到底该怎么选?规则其实很清晰:
@AutoConfiguration用在 starter 组件中,配合META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件使用。它的加载时机由 Spring Boot 的自动配置机制控制,可以使用@ConditionalOnClass、@ConditionalOnMissingBean等条件注解来控制是否生效,也可以用@AutoConfigureBefore/@AutoConfigureAfter控制加载顺序。@Configuration用在微服务自身的配置类中,由@SpringBootApplication的组件扫描发现并加载。它不走AutoConfiguration.imports文件。
一个容易犯的错误是:在 starter 中用了 @Configuration 而不是 @AutoConfiguration,然后期望业务方用 exclude 排除它——在 Spring Boot 3.x 中这会失败。反过来,如果你的配置类只需要在特定微服务中生效,用 @Configuration 就够了,不需要写 AutoConfiguration.imports 文件。
实践中还有一个值得注意的点:@AutoConfiguration 是 Spring Boot 2.7 引入的,它和旧的 spring.factories 机制可以共存,但 AutoConfiguration.imports 是推荐的写法。如果你在维护一个同时支持 2.x 和 3.x 的组件,需要两套都保留。
第六个坑:逻辑删除 + 唯一索引 = 冲突
框架里用了 MyBatis-Plus 的逻辑删除功能,配置如下:
1 | mybatis-plus: |
数据不会被物理删除,delFlag 置为 1 就算删了。MyBatis-Plus 会自动在查询时加上 WHERE delFlag = 0,对业务代码透明。
但和唯一索引配合使用时,问题就来了。
以用户表为例,username 必须唯一。最直观的做法是加唯一索引:
1 | CREATE UNIQUE INDEX uni_username ON user(username); |
现在有一个用户 zhangsan,被逻辑删除后 delFlag = 1。如果业务上要再创建一个叫 zhangsan 的用户,唯一索引会直接报 Duplicate entry——因为 username = 'zhangsan' 的记录还在表里,只是被标记为删除了。
于是换成联合唯一索引:
1 | CREATE UNIQUE INDEX uni_username_delflag ON user(username, delFlag); |
这样 ('zhangsan', 0) 只能有一条——未删除的用户名依旧唯一。而 ('zhangsan', 1) 可以和 ('zhangsan', 0) 共存——已删除的同名用户不影响新用户创建。
但新的问题又出现了。如果数据库中已经存在被删除的 ('zhangsan', 1),而业务逻辑再次对这个用户执行删除操作,就会尝试将 delFlag 从 1 更新为 1——SQL 执行没问题,但联合唯一索引的约束是 (username, delFlag),两次删除会尝试写入相同的 ('zhangsan', 1'),触发唯一索引冲突。
根本原因是 delFlag 的值对于所有已删除记录都是相同的 1,违反了唯一性。解决思路是让每个已删除记录的 delFlag 值都唯一——用行 ID:
1 |
|
同时修改 DDL:
1 | -- 原来 |
这样一来,删除时 delFlag 被设置为该行的 id,每条已删除记录的 delFlag 都不同,联合唯一索引不会冲突。查询时,MyBatis-Plus 自动拼接 WHERE del_flag = 0,逻辑删除语义不变。
这引出了一个更普遍的问题:框架没有对 PO 实体做字段抽象。 delFlag、createTime、updateTime 这类公共字段,每个数据库实体都要写一遍。后期如果要加一个公共字段,所有 PO 都要改。合理的做法是抽一个父 PO 让所有实体继承:
1 |
|
另外,像 delFlag 这种只有 0 和非 0 两种状态的字段,用 bigint(8 字节)其实浪费了。建议 DDL 中用 tinyint(1 字节),但配合 @TableLogic(delval = "id") 时需要和 Java 类型一致——如果 delval 要取 id 的值(通常是 bigint),那字段类型就需要是 bigint。这是一个取舍:用 tinyint + delval = 1 可以省存储,但要承受上面说的联合唯一索引冲突问题。用 bigint + delval = "id" 占空间稍多,但消除了唯一索引冲突。我个人倾向后者——存储成本远低于 bug 修复成本。
Feign 接口的整理:一个被遗忘的角落
这七个坑之外,还有一个不算坑但值得提的——Feign 接口的组织。
框架中有多达 9 个 Feign 接口声明,分别对应不同的远程服务:
1 |
|
这些接口散落在不同的包中,没有一个统一的归纳文档。新同事要了解服务间的依赖关系,得把所有 Feign 接口的地方搜一遍。
在跨模块协作时,这个问题会被放大。A 组的服务改了接口签名,B 组引了对应的 Feign 接口但没有同步更新,运行时 404 或者参数错位。因为 Feign 接口只在编译期做类型检查,运行时的契约一致性是需要额外机制保证的。
理想情况下,Feign 接口应该由服务提供方以独立 jar 的形式发布——类似于 SDK。服务提供方改了接口,SDK 发一个新版本,消费方升级依赖,编译期就能发现不兼容的变更。但在项目型组织中,这种方式目前的性价比不高——服务数量不够多,单独发布 SDK 的维护成本可能超过收益。暂无完美方案,至少先把所有 Feign 声明整理到一个文档里,让服务依赖关系清晰可见。
这篇和下一篇(网关性能调优)是这个系列的收尾。网关那篇会聚焦实际问题——Nginx Cannot assign requested address 是怎么引发的、keepalive 怎么配、内核参数怎么调。都是上线后才知道的事。

















