这几年做后端服务,数据访问层几乎是每个项目的核心战场。如果你长期只用单一ORM,大概也遇到过这种纠结:JPA的确省心,但复杂查询和SQL调优让人头疼;MyBatis-Plus的SQL可控性很强,但常规增删改查又觉得啰嗦。我之前陆续接过几个老项目改造的活儿,它们有的用JPA,有的用MyBatis,后来新系统需要同时对接老库和新业务,干脆就在同一个SpringBoot工程里把JPA和MyBatis-Plus放到了一起,走了双ORM的路子。这篇就把这段时间的实践思路、配置要点、协作模式和踩过的坑整理出来,给同样被这个问题卡住的朋友一个参考。
1. 为什么要把JPA和MyBatis-Plus放在同一个项目里
先说结论:这不是为了炫技,也不是推崇某种“全家桶”方案,而是因为在真实的业务场景中,两者的优势正好互补。我在不少团队里见过类似的争议:支持JPA的人说它对象建模能力强,开发效率高;支持MyBatis-Plus的人说它SQL可控性强,查询优化自由,学习曲线也平缓。两边都有道理,但很多情况下,你未必需要“二选一”。
1.1 单ORM的局限性与双ORM的适用场景
单一使用JPA时,最头疼的问题出现在复杂报表查询和动态条件拼装上。虽然JPA提供Specification和QueryDSL这类方案,但为了一个多表关联加动态条件的统计接口,写出来的代码复杂度远超想象。另外JPA对数据库存储过程、窗口函数、JSON字段这类特性的支持都比较间接,一旦SQL需要精细调优,你还是得回到原生SQL。而MyBatis-Plus在这一点上就从容很多,它在XML里写SQL几乎不受限制,SQL语句所见即所得,也方便DBA直接评审。
反过来,如果项目只用MyBatis-Plus,很多简单的CRUD场景虽然能用BaseMapper快速实现,但代码中仍然需要维护实体类与表结构的一一对应关系。一旦表结构变动,涉及面非常广。相比之下,JPA的自动建表和ORM映射能力在项目初期建模阶段效率极高,尤其适合那些业务模型快速演进的内部系统。
然后真正让我决定使用双ORM的触发点是:业务系统需要维护一套核心主数据,同时承担高并发的查询接口和复杂的报表统计。主数据的维护逻辑用JPA来做非常顺手,因为它的关系映射与级联操作能大幅减少代码量;而报表统计和多表联查则用MyBatis-Plus,确保SQL可控、可优化。这个组合在技术层面也在同一个事务边界里共存,并不冲突。
1.2 JPA负责什么,MyBatis-Plus负责什么
在实践中的分工策略,可以总结为一句话:写多读少、模型复杂的模块用JPA,读多写少、SQL复杂的模块用MyBatis-Plus。
具体拆分如下:
- JPA承担领域模型清晰、操作路径短、级联关系复杂的部分。比如订单聚合、用户权限树、账号信息这类强实体关系,用@Entity标注后,通过Repository直接CRUD,配合@Transactional管理事务。
- MyBatis-Plus承担报表、列表分页、多表关联查询、动态条件SQL、复杂更新语句等场景。尤其在写大SQL时,直接用@Select注解或XML文件,SQL可读性和执行计划可控性都很高。
这种双ORM架构还有一个额外的好处:在老系统迁移时,如果旧库用MyBatis或MyBatis-Plus,新代码可以无缝复用大部分SQL经验,而不必强行翻译成JPQL。我当时接手的项目里就保留了上百条历史SQL,全部通过MyBatis-Plus的XML配置直接复用,极大降低了迁移成本。
2. 从两个ORM的设计哲学看双轨方案的底层逻辑
既然要在一个工程里养两套持久化技术,就有必要从设计哲学上理解它们,否则组合时很容易产生“两个都想用,两个都用不好”的问题。JPA是对象关系映射的代表,核心思路是让开发者尽量少碰SQL,以实体对象为出发点,将数据库表结构抽象成Java对象的世界。MyBatis-Plus更贴近“半自动映射”,核心思路是把SQL的编写权交还给开发者,框架只做参数封装与结果集的映射。
2.1 JPA的Object Mapping本质:以对象为中心
JPA的设计起点是“领域模型”。你在Java代码里定义实体类,用注解描述表名、字段、关联关系,剩下的插入、更新、删除、查询,框架都会根据方法名或注解自动生成SQL。这在做“订单-明细-支付记录”这种强关联模型时尤其舒服,只需要在Order实体上配置@OneToMany和@ManyToOne,通过Repository里的findByCustomerId等方法就能把数据拿全。
但代价也很明显。JPA生成的SQL有时候不是最优的,尤其是N+1问题。在我自己的项目里就遇到过一次典型场景:查询列表时需要展示每条订单的客户名称,直接使用JPA的findAll,结果发现每条订单都会额外发起一条客户查询,总共上千条SQL,数据库连接池压力陡增。后来还是得借助@EntityGraph或者直接退回到查询DTO的方式,绕开JPA默认的关联抓取策略。所以JPA虽然开发效率高,但“控制力”是它天然的短板。
2.2 MyBatis-Plus的SQL Mapping本质:以SQL为核心
MyBatis-Plus的基本思路和JPA正好相反。它是把“SQL本身”作为一等公民,所有的数据操作要么是你手写的SQL语句,要么是BaseMapper提供的通用方法。好处在于,查询效率、索引利用、分页写法都完全可控;坏处在于,一旦数据库表结构变化,手写SQL和实体类需要同步更新,维护成本稍微高一些。
同时MyBatis-Plus在条件构造器上做得很顺手,比如QueryWrapper和LambdaQueryWrapper,可以在Java代码中动态拼条件,又保留SQL的直觉感。这种介于“全自动”和“纯手写”之间的状态,让它在很多国产项目里有很高的接受度。和实践配合之后,你会发现拿它做报表查询、统计、多表join,远比JPA舒服得多。
理解了这两套设计哲学,你就能明白为什么双ORM不是“重复造轮子”,而是针对不同操作类型各自选择合适的工具链。
3. 双ORM工程搭建:依赖注入与包结构设计的第一次碰撞
环境搭建阶段往往是双ORM最容易出问题的地方,因为SpringBoot的自动配置会同时扫描两个持久化框架的组件,一不小心就会出现Bean冲突、连接池被重复创建、事务管理器互相覆盖之类的问题。我先把搭建要点写清楚,再逐个解释为什么这么设计。
3.1 依赖精简方案与配置项说明
我基于SpringBoot 2.7.x版本实践,核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-extension</artifactId> <version>3.5.3</version> </dependency>如果你的项目需要分页插件,还要引入mybatis-plus的jsqlparser依赖,或者直接用mybatis-plus-boot-starter的自动分页配置。连接池方面我推荐HikariCP,SpringBoot默认自带,没必要额外引入其他连接池。
在配置层面,最需要关注的是重复扫描和事务管理器隔离。我的配置长这样:
spring: datasource: url: jdbc:mysql://localhost:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none show-sql: true properties: hibernate: format_sql: true autoconfigure: exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration注意这里有个容易踩坑的点:如果你不排除DataSourceAutoConfiguration,SpringBoot会自动装配一个默认的DataSource,但是当你引入了JPA和MyBatis-Plus两套starter之后,它们各自的自动配置类也会尝试创建SqlSessionFactory和EntityManagerFactory。如果DataSource不一致,后面就会出现事务不生效或连接被莫名占用的问题。更稳妥的做法是显式配置一个DataSourceBean,然后让JPA和MyBatis-Plus都引用同一个DataSource,避免一个工程里暗中存在两个连接池。
3.2 包结构划分与扫描器隔离
包结构设计直接决定了Spring能否正确区分哪些类属于JPA、哪些属于MyBatis-Plus。我的建议是物理隔离,而不是逻辑隔离。一个可落地的结构如下:
com.example.project ├── modules │ ├── order │ │ ├── domain # JPA实体类 │ │ ├── repository # JPA Repository接口 │ │ ├── mapper # MyBatis-Plus Mapper接口 │ │ ├── service │ │ └── controller │ ├── user │ │ ├── domain │ │ ├── repository │ │ ├── mapper │ │ └── ...在主启动类上,必须显式声明扫描路径,像这样:
@SpringBootApplication @EnableJpaRepositories(basePackages = "com.example.project.**.repository") @MapperScan("com.example.project.**.mapper") public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }这里有个容易大意的地方:包扫描时如果两个框架都看到了对方管理的接口,就会抛异常。比如MyBatis-Plus的MapperScan如果扫描范围过大,把JPA的Repository也纳入进去,启动时一定会报找不到SQL语句的错。JpaRepository扫描同理。所以最稳妥的办法是接口所在的包名严格区分,比如repository包归JPA管,mapper包归MyBatis-Plus管,互不越界。
3.3 事务管理器的配置陷阱
双ORM最大的配置坑其实在事务管理器。如果默认使用JPA的事务管理器,那么你在MyBatis-Plus的Service方法上标注@Transactional,事务其实不会生效,因为MyBatis-Plus操作的Connection根本不在JPATransactionManager管辖范围;反过来也一样。
我的解决方案是让两个框架共享同一个PlatformTransactionManager,有条件的做法是定义一个主事务管理器,名为transactionManager,并把JPA和MyBatis-Plus的工厂都设置成使用同一个数据源。关键代码如下:
@Bean @Primary public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } @Bean public EntityManagerFactory entityManagerFactory(DataSource dataSource) { LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); factory.setDataSource(dataSource); factory.setPackagesToScan("com.example.project"); factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); return factory.getObject(); } @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setTypeAliasesPackage("com.example.project.domain"); return factory.getObject(); }因为两者最终都指向同一个DataSource,所以@Transactional注解在实际执行时能统一到同一个事务里。严格来说,这不算“分布式事务”,只是比默认配置更可靠的一种折中方案。如果你的项目里数据源本身有多个,并且需要跨库事务,那建议提前引入分布式事务方案,而不是在双ORM这里硬扛。
4. 双ORM协作模式:从实体映射到复杂查询的完整链路
框架搭好之后,真正决定行不行的,是两类API在业务代码里怎么协同。这里我围绕最常见的业务链路——从持久化实体到多条件分页查询——拆解整个协作过程。
4.1 实体与表的双向映射:JPA注解与MyBatis-Plus注解的共存
同一张表可以被JPA实体和MyBatis-Plus实体同时映射,这不算重复代码,反而能减少模型污染。在实践里,我更推荐一种做法:对核心主数据表,建立JPA领域实体;对查询聚合场景,建立MyBatis-Plus专用的查询DTO。
比如订单主表,我用JPA实体维护:
@Entity @Table(name = "t_order") public class OrderEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "order_no", nullable = false, length = 64) private String orderNo; @Column(name = "customer_id") private Long customerId; @Column(name = "amount") private BigDecimal amount; @Column(name = "status") private Integer status; }而在MyBatis-Plus的Mapper实体里,我会用独立的DTO类,只保留查询要用的字段:
@TableName("t_order") public class OrderQueryDTO { @TableId(value = "id", type = IdType.AUTO) private Long id; @TableField("order_no") private String orderNo; @TableField("customer_name") private String customerName; @TableField("amount") private BigDecimal amount; }这里有一个重要经验:不要把JPA的外键关联和MyBatis-Plus的自动填充混在同一个实体上。比如OrderEntity里配置了@ManyToOne关联CustomerEntity,MyBatis-Plus做插入或更新时,它根本不知道这个关联的存在,如果查询返回的结果集里包含关联字段,一定要在查询DTO里显式列出,否则结果集映射会直接丢字段。我早期就因为在JPA实体上懒加载了一个关联对象,然后在MyBatis-Plus查询返回时踩到了序列化异常,排查半天才发现是关联字段没在查询SQL里带出来。
4.2 业务Service层如何优雅地切换两套方案
Service层的设计建议是:对外暴露统一接口,内部按场景选择底层实现。不必为了“统一”而硬造一个抽象DAO层,那样反而增加理解成本。
一个订单查询服务可能同时依赖JPA的Repository和MyBatis-Plus的Mapper,我一般这样组织:
@Service public class OrderQueryService { private final OrderRepository orderRepository; // JPA private final OrderMapper orderMapper; // MyBatis-Plus public OrderDetail getOrderDetail(Long orderId) { // 主数据走JPA Optional<OrderEntity> entity = orderRepository.findById(orderId); // 聚合统计走MyBatis-Plus OrderStatVO stat = orderMapper.selectOrderStat(orderId); // 组装返回 return buildDetail(entity.get(), stat); } }这种写法既保留了JPA在CRUD和对象关系上的效率,也能让复杂统计SQL保持在MyBatis-Plus的掌控中。重点是要把JPA和MyBatis-Plus的调用逻辑分清楚,不要在同一个方法里同时修改同一份数据,以免事务边界模糊导致预期之外的锁竞争。
4.3 分页查询的差异:JPA的Pageable与MyBatis-Plus的IPage
分页是双ORM协作中最典型的一个对比场景。JPA的分页一般通过Pageable实现:
Pageable pageable = PageRequest.of(page, size, Sort.by("createTime").descending()); Page<OrderEntity> result = orderRepository.findByStatus(status, pageable);这种方式对简单列表非常友好,但遇到动态条件多、SQL复杂的情况,JPA分页会变得特别繁琐,unless你愿意写Specification。
换成MyBatis-Plus就清晰很多:
Page<OrderQueryDTO> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<OrderQueryDTO> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(OrderQueryDTO::getStatus, status) .like(StringUtils.hasText(keyword), OrderQueryDTO::getOrderNo, keyword) .orderByDesc(OrderQueryDTO::getCreateTime); IPage<OrderQueryDTO> result = orderMapper.selectPage(page, wrapper);我建议在项目里做一个约定:所有需要动态条件、多表JOIN、聚合统计的分页查询一律走MyBatis-Plus;所有基于主键的单表查询、级联持久化操作优先走JPA。这样团队开发时不用纠结选哪套工具,凭场景就能判断。
4.4 查询复杂事务边界的处理策略
双ORM协作时,最危险的操作就是在一次事务里同时通过JPA和MyBatis-Plus对同一条数据执行写操作。你可能会想:反正两个框架用的是同一个DataSource,事务管理器也统一了,应该没事。但实际执行中,JPA的EntityManager有二级缓存,如果先通过JPA更新了实体,紧接着又用MyBatis-Plus去更新同一行,MyBatis-Plus的SQL是直接打到数据库的,绕过了JPA的缓存,这就会产生数据不一致问题。
我的处理策略是:写操作尽量固定在JPA,只有在明确需要使用复杂SQL做批量更新时才切换到MyBatis-Plus,并且写完立即调用EntityManager的clear方法,避免脏缓存被后续JPA查询读到。简单来说,同一事务中要减少双写,写路径越单一越安全。
5. 实测避坑:从启动失败到数据不一致的完整排查链路
双ORM不是配完就能高枕无忧的。下面这些坑,我基本都在真实项目里撞过,并且都记录了完整的排查过程。写在这里,既算复盘,也能帮后面接手的人早点躲开。
5.1 启动报错:MyBatis-Plus扫描到了JPA的Repository接口
有一次我调整包结构,把通用Mapper接口放到了全局的repository包下,结果启动时报出:
Invalid bound statement (not found): com.example.project.repository.OrderRepository.selectPage看到这个报错的第一反应是“分页插件没配”,后来才发现是@MapperScan扫描范围太大,把JPA的OrderRepository接口也当成了Mapper接口处理。MyBatis-Plus会尝试为OrderRepository生成动态代理,但它里边并没有任何SQL语句定义,自然报“not found”。
排查链路是这样的:先看报错接口,确认它不属于mapper包;再检查启动类的@MapperScan路径;最后检查是否误用了同一个接口名。修法很简单,把@MapperScan精确到mapper包路径即可。这里我要强调一个习惯:包路径的粒度一定要精细,不要贪图方便用通配符扫整个工程。
5.2 事务失效:JPA事务管理器和MyBatis-Plus的连接不一致
早期项目里我在一个Service方法中先用JPA保存订单,然后又调用MyBatis-Plus Mapper更新库存。观察数据库后发现,如果库存更新抛异常,订单却已经被提交了。这就是典型的事务边界失效。
排查后发现原因:最初我只配置了JPA的事务管理器,没有显式定义DataSourceTransactionManager,导致MyBatis-Plus执行时没有加入全局事务。后来按第三节里的Bean配置统一了事务管理器,问题就消失了。所以这里的原则很简单:双ORM环境下,事务管理器必须有且只有一个主管理器,并且该管理者必须基于同一个DataSource创建。
5.3 查询结果数据错位:实体字段顺序和数据库列顺序不一致
这个问题非常隐蔽。我在一个遗留表上建了MyBatis-Plus实体,发现查出来的数据里有几列发生错位。排查过程是从打印SQL开始的,SQL里查询的列顺序完全正确,但结果集里某个字段总是取到其他字段的值。最后定位到问题是该表的数据库列在历史上变更过顺序,而MyBatis-Plus在做结果集自动映射时,如果既没有显式的@TableField注解,也没有开启mapUnderscoreToCamelCase,就可能按照数据库列的物理顺序而不是按名称映射。
解决办法很简单:所有实体字段都加上@TableField注解,明确指定数据库列名;同时开启以下配置确保驼峰转下划线:
mybatis-plus: configuration: map-underscore-to-camel-case: true这个坑在纯JPA项目中不太会出现,因为JPA始终以注解和属性名为准。但MyBatis-Plus默认的行为有时会按“结果集列的元数据顺序”进行映射,所以混合场景下一定要把字段映射显式写清楚。
5.4 N+1查询在双ORM中的隐藏放大器
JPA的N+1问题老生常谈,但在双ORM项目里有新的表现:如果你用JPA查出主订单列表,再在循环里调用MyBatis-Plus查客户信息,每条订单多一次SQL,那么本质上是“JPA查列表+MP查关联”的双重N+1,数据库压力很容易瞬间拉满。
我的解决方案是分两步走:第一步,先通过JPA批量查出订单主数据列表,同时用@EntityGraph把必要的关联一次性抓出来;第二步,需要更细的聚合信息时,用MyBatis-Plus的批量查询,把客户ID集合直接喂给IN查询,或者干脆写一个连表SQL一次查完。这样循环里的每条SQL就变成了一次批量查询,性能显著提升。
5.5 逻辑删除与自动填充在两个框架里的处理差异
MyBatis-Plus提供逻辑删除和自动填充功能,JPA则依赖Hibernate的@SQLDelete或@PreUpdate回调。如果你在同一张表上进行双ORM协作,很容易出现一边执行了逻辑删除,另一边却还能查到数据的情况。
这里有个实际案例:订单表开启了MyBatis-Plus的逻辑删除,配置了logic-delete-field,后来用JPA的Repository去查订单历史记录,发现被逻辑删除的数据仍然被查出来——因为JPA根本不认MyBatis-Plus的逻辑删除机制。要解决这个问题,建议明确约定:对于开启了逻辑删除的表,所有查询都必须走MyBatis-Plus;如果确实需要JPA接管该表,那么JPA侧的查询条件必须手动加上deleted=0。自动填充字段同理,JPA的@PrePersist能处理的字段,MyBatis-Plus的insert填充也能处理,但不要在两张实体的更新方法里各自维护,否则会出现“同一字段两套逻辑互相覆盖”的怪象。
6. 双ORM项目里的工程规范与代码分层约定
双ORM不是“配好了就完了”,它必须对应一套工程规范,否则随着人员迭代,代码很快会变成“能跑但没法维护”的状态。我从实践中提炼了几条约定,基本约束住了混乱的边界。
6.1 实体、DTO、VO的角色边界
双ORM项目里最常见的问题之一就是实体混乱。很多人的项目里,同一个字段在这样的实体里叫orderNo,在另一个实体里叫order_no,然后到处做转换。我的团队现在遵循这样的约定:
- JPA实体(domain包):专注于数据库表结构映射,保护业务状态,字段命名建议与Java规范一致,使用驼峰命名。
- MyBatis-Plus查询DTO(query包):专注于查询条件与结果集的映射,允许和数据库列名保持一致,尽量少做业务逻辑。
- VO(view包):面向接口输出,不绑定任何持久化框架,字段命名和格式根据前端需求决定。
比如订单查询接口,Controller返回OrderVO,Service内部先把JPA的OrderEntity和MyBatis-Plus的OrderQueryDTO组装成OrderVO。这样换数据库、换ORM时,影响范围能限制在domain和query包,VO和Controller的代码几乎不用动。
6.2 事务方法命名规范与粒度控制
我要求团队里所有跨框架写操作的事务方法,命名上统一带Tx后缀,方便Review代码时一眼识别边界。比如placeOrderTx()方法,明确表示这里是跨JPA和MyBatis-Plus写操作的入口,事务粒度以这个方法为边界。在这个方法内,不允许再嵌套开启子事务或进行长循环写操作。
另外,事务方法的粒度和锁的粒度要匹配。如果事务里既要调用远程接口又要写数据库,那么远程调用的时间会占用数据库连接,高并发下很容易拖垮连接池。所以在双ORM环境下,事务方法里只允许执行数据库操作,外部调用尽量放在事务边界之前或之后。
6.3 监控与日志设计的差异化思路
双ORM意味着日志输出有两套体系:JPA可以通过show-sql和format_sql控制SQL日志,MyBatis-Plus则依赖mybatis-plus.configuration.log-impl。建议在生产环境把两套SQL日志都关闭,改用分析工具或慢查询日志来做监控,避免日志文件快速膨胀。
如果需要定位某一次请求到底走了哪套ORM,简单做法是在Service层的入口打印操作类型,比如“查订单详情:JPA.findById | MyBatis-Plus.selectPage”。这个日志对排障特别有效,尤其是当同一个业务接口内部既有JPA查询又有MP查询时,一眼就能看出请求链路分布。
提示:双ORM项目里尽量不要开Hibernate的ddl-auto: update。自动建表虽然方便,但MyBatis-Plus的表注解经常会和Hibernate生成的表结构产生偏差,比如字段类型、索引名、注释差异,一旦上线后表结构不一致,排查成本极高。更稳妥的做法是使用Flyway或Liquibase统一管理数据库脚本,两套框架只负责读写,不负责建表。
7. 从单ORM平滑迁到双ORM的路线图
并不是每个项目一开始就是双ORM,更多情况是已有的单ORM系统需要引入第二种框架。如果你正打算做这个迁移,我建议按下面的路线图推进,不要一上来就大范围替换。
7.1 第一步:选一个“安全”模块做试点
不要动核心交易链路,先挑一个查询频率高但模型简单的模块,比如字典表、配置表、日志查询。把这个模块的查询层改成MyBatis-Plus或JPA,再跑一段时间的全量回归测试,观察性能与稳定性。这样可以确认两套框架在同一工程里的事务配置是否可靠,比如事务管理器是否统一、分页插件是否正常、逻辑删除是否一致。
7.2 第二步:统一数据源与事务管理器
迁移阶段最容易翻车的点,就是新引入的框架自动创建了一个新的SqlSessionFactory或EntityManagerFactory,造成两个连接池。所以在引入第二个ORM依赖时,第一件事就是把数据源配置梳理清晰,确保两套框架使用同一个DataSource实例。同时检查启动类上的@MapperScan和@EnableJpaRepositories扫描路径,避免重叠。
7.3 第三步:按读写比例逐步切换
试点模块跑通之后,再按业务模块逐个切换。切换时要重点关注三件事:分页查询的SQL是否一致、动态条件构造的语义是否一致、返回字段的null值处理是否一致。JPA在查询不存在的数据时通常返回Optional或null,MyBatis-Plus的查询单条方法则倾向于返回null;如果原接口明确返回空对象而非null,这一点的兼容性需要提前处理,否则前端会直接暴露空指针异常。
7.4 第四步:沉淀团队内部规范
迁移完成后,把“哪些场景用JPA、哪些场景用MP”整理成团队文档,并配一个最小可运行的双ORM示例模板。以后新项目直接抄这个模板起步,能少走很多弯路。团队文档的价值被严重低估,很多时候遇到的坑,别人也遇到过,只是没有记录下来。
8. 对“双ORM”方案的更多思考与补充
网上有一种观点认为,双ORM是架构的复杂度来源,能不用就不用。这话有一定道理,但并不是全对。真正的问题不在“是否使用双ORM”,而在于“是否给各个模块划清了边界”。如果你能守住五件事:包扫描隔离、事务管理器统一、实体职责分离、逻辑删除口径一致、查询场景分工明确,双ORM带来的收益实则很明显。
我见过不少纯JPA项目因为一次复杂统计报表,不得不用原生的EntityManager写StringBuilder拼JPQL,维护体验相当痛苦;也见过纯MyBatis-Plus项目在核心业务模型大量前,代码里堆满了LambdaQueryWrapper的链式调用,少了一点对象关系的自然表达。双ORM恰恰是对这类困境的一种务实回答。
如果是小项目、个人项目、内部工具,完全没必要上双ORM,单用JPA或单用MyBatis-Plus都能搞定,复杂度还能更低。但如果是中大型业务系统、老库迁移、报表与CRUD并存、团队协作人数较多的项目,双ORM是一个值得认真评估的备选方案。
最后再分享一个个人经验:如果你打算在项目里引入两套持久层技术,最好在技术选型评审阶段就把它作为一条明确的架构决策记录下来,包括分工原则、命名约束、事务边界、禁止事项。因为一旦进入开发期,每个人都会有自己的“顺手工具”偏好,没有书面约定,代码风格很快就会散掉。把这个约定提前定好,后面能省掉大量争执和重构时间。
这套双ORM的配法我已经在几个中型项目里稳定运行了不短的时间,线上表现符合预期。如果你正准备在自己的项目里搭类似的架构,我的建议是从最小配置开始,先跑通一个JPA查询和一个MyBatis-Plus查询,再逐步扩展,而不是一次性把所有模块都接进去。稳一点,踩的坑会少很多。