对象关系映射框架 ORM

什么是 ORM

在传统的数据库操作中,我们通常需要直接编写SQL语句,与数据库进行交互。然而,这种方式在面对复杂的业务逻辑时,往往显得繁琐且容易出错。

为了解决这一问题,对象关系映射(Object-Relational Mapping,简称 ORM )应运而生。作为一种桥梁,ORM将面向对象的编程思想与关系型数据库的结构连接起来,极大地简化了开发流程

ORM,即对象关系映射,是一种编程技术,用于在面向对象编程语言和关系型数据库之间建立联系。

在ORM的框架下:

  • 类:对应着数据库中的一个表,类的属性则对应表中的字段,这就是通常说的,实体类
  • 对象:对应数据库中的一行数据,通过实例化类,我们可以操作数据库中的具体记录。
  • 方法:ORM 框架通常会提供一系列内置方法,开发者只需调用这些方法,就能完成数据库操作。

ORM的工作原理

ORM的核心在于 映射

它通过将类和对象的操作转化为底层的SQL语句,完成与数据库的交互。

那么,开发者在代码中定义模型类,就类似,表的创建。而 ORM 框架通过元数据来理解这些类如何映射到数据库。

  • 注解 / 装饰器:Hibernate 中使用 @Entity, @Column
  • XML / 配置文件:MyBatis 中或某些 .NET 框架常使用 XML 文件明确指定映射规则。
  • 约定大于配置

而且 ORM 框架通常维护一个会话(Session)上下文(Context)对象。它负责管理数据库连接,跟踪对象的状态变化。它能充当内存中数据对象和数据库之间的转换。

那么,查询是如何进行转换的呢?

当你在代码中发起查询时:

  • 面向对象查询:你使用语言特有的语法,或者 ORM 框架提供的特定语句或者方法
  • SQL 生成:ORM 引擎解析这些调用,根据映射元数据,动态生成对应的 SQL 语句。

而数据库执行 SQL 后返回结果集,也是由 ORM 来接收,ORM 接收结果集,它根据映射规则,自动实例化对应的类对象,将每一行数据填充到对象的属性中。最终返回给开发者的是一个个完整的对象,而不是原始的数据行。

Spring项目中整个持久层如何实现的

在 Spring Boot 项目中,持久层的实现通常基于 Spring Data JPA,其底层默认实现是 Hibernate

当然,持久层也不止是 Hibernate 来组成的,关键角色还有很多

  • Spring Data JPA:Spring 提供的抽象层,定义了一套标准的 Repository 接口规范,屏蔽了底层实现细节。
  • JPA (Java Persistence API):Java 官方定义的 ORM 规范接口,只有接口,没有实现,算是规范。
  • Hibernate:JPA 规范的最流行实现者,负责真正的 SQL 生成、对象映射和缓存管理。
  • ntityManager:JPA 的核心接口,由 Hibernate 实现(HibernateEntityManager),负责管理实体对象的生命周期和执行数据库操作。
  • 数据库连接池:通常使用 HikariCP(Spring Boot 默认),负责管理物理数据库连接。

而当你引入 spring-boot-starter-data-jpa 依赖后,Spring Boot 的自动配置机制(Auto Configuration)会立即介入:

  1. 扫描实体类:Spring Boot 会扫描带有 @Entity 注解的类,解析元数据
  2. 创建 EntityManagerFactory:这是重量级对象,线程安全,整个应用通常只有一个。它根据配置中的内容,来初始化 Hibernate 的核心服务。
  3. 创建 EntityManager:通常是请求作用域或事务作用域。它是轻量级的,非线程安全,用于执行具体的 CRUD 操作。
  4. 创建 TransactionManager:通常是 JpaTransactionManager,用于管理数据库事务,确保 ACID 特性。
  5. 生成 Repository 代理:这是 Spring Data JPA 最神奇的地方。你只需要定义一个接口继承 JpaRepository,无需写实现类。
    • 因为这里是 CGLIB 动态代理,继承了这个接口,就能通过动态代理的形式注入EntityManager,并将方法调用委托给底层的 JPA/Hibernate 逻辑。
  6. 连接管理:根据配置 spring.jpa.hibernate.ddl-auto等情况,Hibernate 会在启动时对比实体类元数据和数据库实际结构,自动执行 DDL 语句(建表、加列等)。

当你的 Service 层调用 Repository 的方法时,整个持久层的工作流如下:

  1. 方法拦截与解析

    1. 方法的调用被 Spring 生成的 Repository 代理对象拦截。
    2. Spring Data JPA 解析方法名,将其转换为内部的查询条件对象。如果是自定义的 @Query 注解,则直接解析 JPQL 或 SQL 字符串。
  2. 获取 EntityManager 与开启事务

    1. 如果方法上有 @Transactional 注解,JpaTransactionManager 会检查当前线程是否已有事务。
      • 若无,则从连接池获取一个物理连接,关闭自动提交,开启一个新事务
      • 若有,则加入现有事务。
    2. 当前的 EntityManager 会被绑定到当前线程ThreadLocal,确保在同一事务中多次调用使用的是同一个持久化上下文
  3. SQL 生成与执行

    这部分是 Hibernate 的核心

    1. Hibernate 接收查询请求,根据实体类的映射元数据,将 JPQL 翻译成特定数据库方言的 SQL 语句。
    2. 使用 JDBC 绑定参数,做防注入,然后通过 JDBC Driver 将 SQL 发送给数据库执行。
  4. 结果集映射

    1. 数据库返回 ResultSet。Hibernate 遍历结果集,根据映射规则实例化 Java Entity 对象,然后将列值填充到对象属性中。
    2. 这些新加载的对象会被放入当前的 Persistence Context(一级缓存)中,并标记为 受管状态(Managed)。这意味着 Hibernate 开始追踪这些对象的任何变化。
  5. 变更追踪

    如果在事务中修改了对象属性,你不需要调用 update() 方法。

    1. 因为当事务准备提交时,Hibernate 会触发一个检查,它会对比 Persistence Context 中对象的当前快照与原始加载时的快照的情况
    2. 如果发现了变化,就自动生成 Hibernate 语句,在事务提交前,批量执行这些生成的 SQL。
  6. 事务提交与资源的释放

    1. 如果所有操作成功,事务管理器提交事务,数据库永久保存更改。
    2. 如果发生异常,事务回滚,所有内存中的更改丢失,数据库不受影响。
    3. EntityManager 从线程解绑,物理连接归还给连接池。

    至此,结束一次完整的 ORM 持久业务

ORM 框架, JPA 和 Spring Data JPA 等之间的关系

先说一下结论,JPA 定标准,Hibernate 做实现,Spring Data JPA 搞自动化

经过上面的描述,我们都知道Java 持久层框架访问数据库的方式大致分为两种。

  • 一种以 SQL 核心,封装一定程度的 JDBC 操作,比如: MyBatis。
  • 另一种是以 Java 实体类为核心,将实体类的和数据库表之间建立映射关系,也就是我们说的 ORM框架,如:Hibernate、Spring Data JPA。

JPA

JPA的全称是 Java Persistence API, 即 Java 持久化 API,是 SUN 公司推出的一套基于 ORM 的规范

它为 Java 开发人员提供了一种对象的关联映射工具,来管理 Java 应用中的关系数据,JPA 吸取了目前 Java 持久化技术的优点,旨在规范、简化 Java 对象的持久化工作。很多 ORM 框架都是实现了JPA的规范,如:Hibernate、EclipseLink。

Java 在操作数据库的时候,底层使用的其实是 JDBC,而 JDBC 是一组操作不同数据库的规范。我们的 Java 应用程序,只需要调用 JDBC 提供的 API就 可以访问数据库了,而 JPA 也是类似的道理。

JPA 统一了 Java 应用程序访问ORM框架的规范

JPA为我们提供了以下规范:

  1. ORM映射元数据:JPA支持XML和注解两种元数据的形式,元数据描述对象和表之间的映射关系,框架据此将实体对象持久化到数据库表中

  2. JPA 的API:用来操作实体对象,执行CRUD操作,框架在后台替我们完成所有的事情,开发人员不用再写SQL了

  3. JPQL查询语言:通过面向对象而非面向数据库的查询语言查询数据,避免程序的SQL语句紧密耦合。

ORM 框架

拿 Hibernate 框架举例说明了

Hibernate 作为一个 ORM 框架,它是一个 JPA 规范的具体实现。

Hibernate 是 Java 中的对象关系映射解决方案。对象关系映射或 ORM 框架是将应用程序数据模型对象映射到关系数据库表的技术。

而 Hibernate 不仅关注于从 Java 类到数据库表的映射,也有 Java 数据类型到 SQL 数据类型的映射。并且 Hibernate 可以自动生成 SQL 语句,自动执行

JPA 规范本质上就是一种 ORM 规范,所以它自身肯定不是一个 ORM 框架,因为 JPA 并未提供 ORM 实现,JPA仅仅定义了一些接口作为 ORM 框架的规范。

所以说,Hibernate 作为一个 ORM 框架的同时,它也是一种 JPA 的实现。

Spring Data

Spring Data 是 Spring 社区的一个子项目,主要用于简化数据(关系型&非关系型)访问,其主要目标是使得数据库的访问变得方便快捷。

它提供很多模板操作,用于简化在 Spring 项目中对各种数据库中数据访问对象的支持

  • Spring Data Elasticsearch
  • Spring Data MongoDB
  • Spring Data Redis
  • Spring Data Solr

等等

Spring Data JPA

这是 Spring 团队对 JPA 的二次封装

传统的 JPA 开发中,你需要写一个 DAO 接口,再写一个 DAO 实现类,在实现类里注入 EntityManager,然后手写 createQuery, setParameter, getResultList 等样板代码,很复杂就是了。

而 Spring Data JPA 是在 JPA 规范下提供的 Repository 层的再次封装,提供了更多可用的接口,然后底层通过 Hibernate 实现。

所以说,Spring Data JPA 开发中,你只需定义一个接口。无需实现类,Spring 启动时,会扫描这个接口。Spring Data JPA 会在运行时自动生成一个代理对象,注入到容器中。

而当你调用 userRepository.findByUsername("ErgouTree")类似这种方法时,Spring Data JPA 会解析方法名,自动构建 JPQL 查询,然后委托给底层的 JPA (Hibernate) 执行。

怎么还有人说?用了 Spring Data JPA 就不能用 Hibernate 的高级特性了????

哦牛逼,Spring Data JPA 的底层就是 Hibernate 的各种高级特性,所以说,肯定是允许你透过它直接使用底层的 EntityManager,或者在 @Query 注解中编写原生的 HQL/SQL,甚至可以直接注入 Hibernate 的 Session

N+1 问题

什么是 N+1 问题?

N+1 问题是指应用程序在执行一次数据库查询,用于获取主对象列表后,由于代码逻辑或 ORM 的默认加载策略(通常是懒加载),在遍历这个列表并访问每个对象的关联数据时,又针对每个对象单独发起了一次数据库查询(N 次查询)。

原本只需要 1 条 SQL 就能完成的任务,实际上执行了 1 + N 条 SQL 语句。

假设有一个博客系统:

  • Pos表
  • Comment 表
  • 一篇文章有多条评论(One-to-Many)

你需要列出所有文章,并显示每篇文章的评论数量。

那么,ORM 框架可能就会这样

1
2
3
4
5
6
7
8
9
10
// 1. 查询所有文章 (这是第 1 次查询)
List<Post> posts = postRepository.findAll();

// 2. 遍历文章,获取评论数
for (Post post : posts) {
// 如果 comments 配置为懒加载 (Lazy),这里会触发新的查询
// 这是第 2, 3, ..., N+1 次查询
int count = post.getComments().size();
System.out.println(post.getTitle() + ": " + count);
}

产生的 SQL 执行流就很可怕了

  1. SELECT * FROM posts; (1 次)

  2. SELECT * FROM comments WHERE post_id = 1; (第 1 篇文章)

  3. SELECT * FROM comments WHERE post_id = 2; (第 2 篇文章)

  4. SELECT * FROM comments WHERE post_id = 3; (第 3 篇文章) …

    SELECT * FROM comments WHERE post_id = N; (第 N 篇文章)

为什么会出现 N+1 问题?

N+1查询 问题是一种在应用程序与数据库交互时,极其常见、隐蔽、且极具破坏力的低效数据查询模式。

其本质在于程序为了获取一个 主对象 列表及其关联的 子对象 信息,错误地执行了 一次 用于查询主列表的查询,以及紧随其后的 N次 用于查询每个主对象所关联子对象的、额外的独立查询,从而总共向数据库发起了 N+1次查询。

这个问题主要涵盖以下五个关键点:它是一种极其低效的数据查询模式、通常发生在处理 一对多 或 多对一 的关联关系时、其本质是 一次 主查询引发了 N次 额外的关联查询、根源往往在于对象关系映射框架的懒加载机制、以及它通过 放大的 网络往返次数和数据库负载来拖垮性能。

根本原因在于 ORM 的懒加载 (Lazy Loading) 策略 与 业务代码的遍历逻辑 之间的冲突。

对于 Hibernate / JPA 中,@OneToMany@ManyToMany 关系的默认抓取策略(FetchType)通常是 LAZY

这意味着,当你加载主对象 Post 时,ORM 不会立即去查关联对象,而是生成一个代理对象。只有当你真正调用 getComments() 时,才会发送 SQL 去数据库加载真实数据。

开发者在循环中访问了关联属性,从而触发了额外的 SQL 查询。

开发者只关注了业务逻辑,而忽略了底层的数据加载机制,没有意识到需要在查询主列表时一次性把关联数据带出来。

如何检测并解决 N+1 问题?

N+1 问题往往在开发环境难以发现,只有在生产环境才会爆发。

但是怀疑到这个问题上了,找起来还是有办法的

  1. 开启 SQL 日志: 在 application.properties 中开启 Hibernate 的 SQL 输出:

    1
    2
    3
    4
    spring.jpa.show-sql=true
    spring.jpa.properties.hibernate.format_sql=true
    # 更详细地查看参数绑定
    logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

    观察控制台,如果你看到大量重复模式的 SQL(只是 ID 不同),基本就是 N+1。

  2. Hibernate Statistics

    开启 Hibernate 的统计功能,查看 getCollectionFetchCountgetQueryExecutionCount。如果获取集合的次数远大于查询实体的次数,说明有问题。

    1
    spring.jpa.properties.hibernate.generate_statistics=true
  3. APM 工具

    使用 SkyWalking 等链路追踪工具,它们能直观地展示一个请求中包含了多少个数据库调用片段。

那么,解决方案是变 N 次查询为 1 次查询

  1. 使用 JOIN FETCH

    在查询主对象时,显式告诉 JPA 把关联对象一起查出来。这会生成一条带有 LEFT JOIN 的大 SQL。

    1
    2
    @Query("SELECT p FROM Post p LEFT JOIN FETCH p.comments")
    List<Post> findAllWithComments();

    注意:分页 + JOIN FETCH 是禁忌。

  2. 使用 @EntityGraph

    尽管 JOIN FETCH 能在根源上避免 N+1,但是我确实不习惯写固定的 JPQL,所以说,可以使用 JPA 标准的 EntityGraph 动态指定本次查询需要加载的关联路径。

    1
    2
    3
    4
    5
    6
    7
    8
    // Repository 接口定义
    @EntityGraph(attributePaths = {"comments"})
    List<Post> findAll();

    // Service 层调用:
    EntityGraph<Post> graph = entityManager.createEntityGraph(Post.class);
    graph.addSubgraph("comments");
    List<Post> posts = repository.findAll(graph);
  3. 使用 Batch Size

    如果必须分页,或者关联数据量太大不适合一次性 JOIN FETCH,可以配置 Hibernate 使用 IN 查询进行批量加载。

    1
    2
    3
    @OneToMany(mappedBy = "post")
    @BatchSize(size = 10) // 每次最多查 10 个
    private List<Comment> comments;
  4. DTO 投影

    唯一真神,只查需要的数据不就完了

    只不过,得到的是 DTO 不是 Entity,请注意