做Java开发的朋友,几乎都跟这两种数据库访问工具打过交道:一边是JPA/Hibernate,一边是MyBatis。很多人觉得选一个就行,但真实项目里,两种一起用的情况其实并不罕见。公司老系统用的MyBatis,新模块想用JPA快一点;或者核心业务喜欢JPA的省事,报表查询又离不开MyBatis的动态SQL。这时候强制二选一,要么把老代码重写一遍,要么在新模块里受限于MyBatis的繁琐映射。与其纠结,不如设计一套合理的混合架构,让两者各干自己最擅长的活。

一、为什么需要混合使用

1.1 应用场景

先说场景,不然容易觉得混合是瞎折腾。最常见的三种情况:

第一,老项目迁移。老代码里全是MyBatis的Mapper和XML,业务逻辑已经跑得很稳。这时候突然把整个持久层改成JPA,成本和风险都很高。更理性的做法是保留现有MyBatis不动,新写的模块用JPA,让两套东西暂时共存,等以后有机会再逐步替换。

第二,查询场景差异明显。JPA在简单的增删改查、一对一、一对多映射上非常舒服,只要定义好实体类和接口,基本不用写SQL。但一旦遇到动态字段、多表连环join、子查询、分组统计,用JPA写起来就很别扭,要么拼JPQL,要么用Specification,最后还是绕不开SQL。这些场景换成MyBatis,直接在XML里写原生SQL,一行行列出来,代码好读,也容易调优。

第三,团队人员技能不同。有些老开发对MyBatis特别熟,新来的同事可能更熟Spring Data JPA。强行统一成一套,总有人要临时学东西,产出质量不一定靠谱。混合使用可以让每个人先在自己熟悉的区域干活,再慢慢对齐。

1.2 技术优缺点

简单总结一下两边的优缺点,这样选型的时候才有根据。

JPA/Hibernate的优点:

  • 实体映射和表结构关联度很高,一个@Entity注解就能让Java对象和数据库表对应起来。
  • 自带一级缓存、二级缓存,还有懒加载机制,提升简单查询效率。
  • 建表、更新表结构可以通过配置自动完成,开发期很省事。
  • 内置分页、排序、批量保存这些公共能力,代码量少。

JPA/Hibernate的缺点:

  • 动态SQL非常难写,复杂查询的代码又长又难读。
  • 自动生成的SQL有时候很“笨”,比如莫名其妙的join,性能不好排查。
  • 学习曲线偏陡,需要理解生命周期、事务边界、缓存机制一大堆概念。

MyBatis的优点:

  • SQL完全由开发者掌控,想怎么优化都行,没有黑魔法。
  • 动态SQL支持非常强大,<if>、<foreach>、<choose>这些标签很直观。
  • 跟复杂查询、存储过程配合很顺畅,DBA也喜欢看这种手写SQL。
  • 代码逻辑和SQL分离,Mapper接口加XML文件,职责清楚。

MyBatis的缺点:

  • 每张表的增删改查要自己写一堆重复SQL,开发慢。
  • 没有自动建表的能力,表结构变更要自己维护SQL脚本。
  • 对象关系映射比较弱,复杂对象继承、嵌套关联,需要自己写resultMap。
  • 一二级缓存需要手动配置,用不好容易脏读。

二、混合架构设计

2.1 总体思路:按模块划分,不在同一个文件里混用

混合使用不代表在同一个Service里一会儿调JPA一会儿调MyBatis,想怎么来就怎么来。那样会让数据访问规则乱成一锅粥。我的建议是:按业务模块划一条线,每个模块在数据访问层只选一个主力工具。比如“用户管理”这种标准的增删改查用JPA,“订单统计”这种报表查询用MyBatis。如果某个复杂查询确实需要同时访问两个模块的数据,那就在Service层分别调用两个模块的DAO,让Service来编排,而不是让两个DAO互相纠缠。

这样做的好处有两个:第一,代码归属清晰,新人一看就知道这个模块用JPA还是MyBatis;第二,事务边界比较干净,不会因为一个方法里同时使用两种持久化方案导致缓存或事务状态混乱。

2.2 数据源与事务统一

混合架构里最担心的是事务不一致。其实解决起来也简单:JPA和MyBatis共用同一个数据源(DataSource),配置一个事务管理器就可以了。为什么能共用?因为JPA底层的Hibernate也好,MyBatis的SqlSessionFactory也好,最终都是拿着一个java.sql.Connection在干活。既然Connection来自同一个数据库连接池,Spring就能把这个连接放到同一个事务里。

Spring Boot里面,只需要引入相关starter,然后配置好数据源,JPA的事务管理器会默认成为主事务管理器。你在Service方法上写上@Transactional,无论是JPA的Repository操作,还是MyBatis的Mapper操作,都会在同一个事务里提交或回滚。这里的关键是不要在配置里再自定义一个DataSourceTransactionManager去抢事务控制权,否则可能出现两个事务管理器打架的情况。

2.3 扫描路径分离

混合使用最容易踩的坑,就是Spring把不该扫描的接口给扫描进来了。JPA的Repository接口和MyBatis的Mapper接口老巢要分干净。比如JPA的仓库放在com.example.jpa.repo包下,MyBatis的Mapper放在com.example.mybatis.mapper包下。然后在启动类上分别告诉Spring去扫哪里:

  • @EnableJpaRepositories(basePackages = "com.example.jpa.repo")
  • @MapperScan("com.example.mybatis.mapper")

这样两边各扫各的,互不干扰。如果包路径重叠,可能出现MyBatis把JPA接口当成Mapper去代理,启动时报一堆奇怪的错误。

三、适配方案落地

下面用一个具体的Spring Boot项目来演示整个配置过程。这套示例都是同一套技术栈,没有引入其他乱七八糟的东西。

技术栈:Java + Spring Boot 2.7.8 + MySQL 8.0 + Maven

3.1 引入依赖

打开pom.xml,把JPA、MyBatis、MySQL驱动的依赖加进来。

<!-- pom.xml 关键依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.3.1</version>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

3.2 配置数据源

在application.yml里配置数据源和JPA相关属性。MyBatis不需要额外配置,因为MapperScan会负责启动。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/example_db?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update   # 开发期自动更新表结构
    show-sql: true
    properties:
      hibernate:
        format_sql: true

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.example.mybatis.dto

这里ddl-auto: update是开发期用来让JPA自动建表的,生产环境要给成none,否则很危险。

3.3 定义JPA实体和Repository

先写一个用户账号实体,让JPA管理。

// 技术栈:Java + Spring Boot 2.7.8
package com.example.jpa.entity;

import javax.persistence.*;

@Entity
@Table(name = "user_account")
public class UserAccount {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_name", nullable = false, length = 50)
    private String userName;

    @Column(name = "email", unique = true, length = 100)
    private String email;

    // 添加一个无参构造器,JPA规范要求
    public UserAccount() {
    }

    // getter 和 setter 就不逐个贴了,实际项目中可以用 Lombok 简化
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUserName() { return userName; }
    public void setUserName(String userName) { this.userName = userName; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

接着写JPA的Repository接口。Spring Data JPA会根据方法名自动生成查询逻辑。

package com.example.jpa.repo;

import com.example.jpa.entity.UserAccount;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserAccountRepository extends JpaRepository<UserAccount, Long> {

    // 根据邮箱精确查找用户,JPA会自动生成SQL
    UserAccount findByEmail(String email);
}

3.4 定义MyBatis Mapper和XML

现在搞一个订单统计功能,这个查询要关联两张表,还要分组统计。用MyBatis写原生SQL更顺手。

先定义一个DTO类,用来接收统计结果。

package com.example.mybatis.dto;

import java.math.BigDecimal;

public class OrderStatsDTO {

    private Long userId;
    private String userName;
    private Long orderCount;
    private BigDecimal totalAmount;

    // getter和setter
    public Long getUserId() { return userId; }
    public void setUserId(Long userId) { this.userId = userId; }
    public String getUserName() { return userName; }
    public void setUserName(String userName) { this.userName = userName; }
    public Long getOrderCount() { return orderCount; }
    public void setOrderCount(Long orderCount) { this.orderCount = orderCount; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
}

再写Mapper接口。注意这个接口放在com.example.mybatis.mapper包里,跟JPA的Repository包完全隔开。

package com.example.mybatis.mapper;

import com.example.mybatis.dto.OrderStatsDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface UserOrderMapper {

    // 统计某个用户在指定日期范围内的订单数量和总金额
    OrderStatsDTO countOrderByUser(@Param("userId") Long userId,
                                   @Param("startDate") String startDate,
                                   @Param("endDate") String endDate);
}

对应的XML映射文件放在src/main/resources/mapper/UserOrderMapper.xml里。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- 命名空间必须跟Mapper接口的完整类名一致 -->
<mapper namespace="com.example.mybatis.mapper.UserOrderMapper">

    <!--
      这里演示一个相对复杂的分组统计查询,
      MyBatis能让你把SQL完整地写出来,比JPA的JPQL更直白。
    -->
    <select id="countOrderByUser" resultType="com.example.mybatis.dto.OrderStatsDTO">
        SELECT
            u.id AS userId,
            u.user_name AS userName,
            COUNT(o.id) AS orderCount,
            COALESCE(SUM(o.amount), 0) AS totalAmount
        FROM user_account u
        LEFT JOIN user_order o ON o.user_id = u.id
        WHERE u.id = #{userId}
          AND o.create_time BETWEEN #{startDate} AND #{endDate}
        GROUP BY u.id, u.user_name
    </select>
</mapper>

3.5 启动类装配

在启动类上加上两个扫描注解,分别指定JPA和MyBatis的扫描路径。这样Spring就知道去哪里找Repository,去哪里找Mapper。

package com.example;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
@EnableJpaRepositories(basePackages = "com.example.jpa.repo")  // 只扫JPA仓库
@MapperScan("com.example.mybatis.mapper")                      // 只扫MyBatis Mapper接口
public class Application {

    public static void main(String[] args) {
        // 启动Spring Boot应用
        SpringApplication.run(Application.class, args);
    }
}

注意,@MapperScan和@Mapper注解其实选一个就行。我这里两个都用了,是为了让示例更清晰。实际项目中,如果你用了@MapperScan,就可以不用在每个Mapper接口上写@Mapper;要是只加了@Mapper而没加@MapperScan,Spring也能扫描到。但混合使用场景下,推荐用启动类上的@MapperScan统一管理,避免遗漏。

3.6 服务层调用示例

最后写一个Service,在同一个事务里,先通过JPA查用户基础信息,再用MyBatis查订单统计。两个操作共享同一个事务。

package com.example.service;

import com.example.jpa.entity.UserAccount;
import com.example.jpa.repo.UserAccountRepository;
import com.example.mybatis.dto.OrderStatsDTO;
import com.example.mybatis.mapper.UserOrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final UserAccountRepository userAccountRepository;
    private final UserOrderMapper userOrderMapper;

    // 构造器注入,这是Spring推荐的写法
    public OrderService(UserAccountRepository userAccountRepository,
                        UserOrderMapper userOrderMapper) {
        this.userAccountRepository = userAccountRepository;
        this.userOrderMapper = userOrderMapper;
    }

    /**
     * 查询用户订单统计信息。
     * 这里同时使用了JPA和MyBatis,但它们在同一个事务边界内。
     *
     * @param userId    用户ID
     * @param startDate 起始日期,格式 yyyy-MM-dd
     * @param endDate   结束日期,格式 yyyy-MM-dd
     * @return 统计结果,如果用户不存在则返回null
     */
    @Transactional(readOnly = true)
    public OrderStatsDTO getUserOrderStats(Long userId, String startDate, String endDate) {
        // 1. 调用JPA Repository,获取用户基本信息
        //    如果只是在SQL里join一下拿到userName,其实不用JPA也行。
        //    这里故意演示JPA的能力。
        UserAccount user = userAccountRepository.findById(userId).orElse(null);
        if (user == null) {
            return null;
        }

        // 2. 调用MyBatis Mapper,执行复杂统计SQL
        OrderStatsDTO stats = userOrderMapper.countOrderByUser(userId, startDate, endDate);

        // 3. 把JPA查到的用户名放进DTO,做一个简单的数据合并
        if (stats != null) {
            stats.setUserName(user.getUserName());
        }
        return stats;
    }
}

看到没有,Service方法上只要一个@Transactional(readOnly = true),JPA和MyBatis的操作就都在同一个事务里了。如果MyBatis的SQL抛异常,JPA那边的修改也会跟着回滚——当然了,这里因为是只读查询,所以没体现写入回滚,但你心里清楚就行。

这个例子充分展示了混合使用的日常姿势:简单实体操作走JPA,复杂统计走MyBatis,业务层统一协调。

四、注意事项

混合使用虽然解决了选择困难,但坑也不少。我把这些年遇到的坑总结一下。

第一,扫描路径必须物理隔离。JPA会尝试把EntityManager绑定到所有@Repository标记的类上,MyBatis也会尝试对所有接口生成代理。如果两个扫描器碰到同一个接口,轻则启动慢,重则直接启动失败。所以basePackages和mapperLocations这些路径,一定要在项目结构上就分开。

第二,事务管理器只能有一个主心骨。Spring Boot会自动配置JPA的事务管理器并标记为@Primary,这时候不要再去自定义一个DataSourceTransactionManager,否则Spring不知道该用哪个。如果你非要自定义,记得给JPA的事务管理器加上@Primary注解,防止事务分发错乱。

第三,分页插件不要重复引入。JPA自带Pageable分页,MyBatis常用PageHelper这类插件。如果一个分页功能同时交给两种技术,查询结果会非常诡异。建议每个模块固定用一种分页方式,别在同一个Service里混着用。

第四,二级缓存要小心。Hibernate的二级缓存和MyBatis的二级缓存是各自独立工作的。如果同一个表的数据一会儿被JPA读,一会儿被MyBatis改,那缓存里的数据就会互相“不知道”,很容易读到旧数据。解决方法是:对于同一张表,要么全走JPA,要么全走MyBatis,不要让两条通路同时频繁操作一张表。

第五,实体类和DTO别混用。JPA的实体类带着一堆ORM注解,直接拿给MyBatis用,可能会被Hibernate拦截或者出现奇怪的状态管理。最好让JPA只管实体类,MyBatis只管自己的DTO或者Map。MyBatis的SQL查询结果用resultType直接映射到Map也行,但更推荐定义清晰的DTO类。

第六,启动时表的顺序问题。如果JPA设置了ddl-auto: update,而MyBatis的SQL又要join这些表,最好确认JPA建表完成后再执行MyBatis的初始化。一般Spring Boot会等Hibernate启动完再创建MyBatis的SqlSessionFactory,所以问题不大。但如果在MyBatis的XML里写了大量测试数据插入,就要注意插入顺序依赖。

五、总结

JPA和MyBatis不是“有我无他”的仇人,它们完全可以配合得很好。JPA负责那些结构清晰、生命周期稳定的实体管理,把人从重复的CRUD里解放出来;MyBatis负责那些变动频繁、SQL优化空间大的复杂查询,给人足够的掌控感。

混合使用的核心点其实就三个:第一,按模块划分边界,别在同一个类里来回横跳;第二,共用一个数据源,用统一事务管理器把二者拉进一个事务;第三,扫描路径严格分离,不让Spring误认接口。

这套方案不一定要从第一天就全面铺开。你可以先在老系统旁边加一个JPA模块,让新代码受益,等老代码逐步老去,再考虑是不是要整体迁移成一套。记住,架构是服务的,不是用来当教条的。能解决实际问题的架构,才是好架构。