JPA 实体中客户端生成 ID 的终极指南

929 阅读15分钟

在客户端而不是数据库中生成 ID 是分布式应用程序的唯一选择。但在此类应用程序中生成唯一 ID 很困难。正确生成它们很重要,因为 JPA 将使用 ID 来定义实体状态。最安全的选择是使用 UUID 和 Hibernate 的生成器,但从自定义生成器到专用 ID 生成服务器还有更多选择。

image.png上一篇文章中,我们讨论了 JPA 实体的服务器生成 ID。本文中描述的所有 ID 生成策略都基于一个基本原则:有一个点负责生成 ID:数据库。这个原则可能会成为一个挑战:我们依赖于一个特定的存储系统,所以切换到另一个(例如,从 PostgreSQL 到 Cassandra)可能是个问题。此外,这种方法不适用于分布式应用程序,我们可以在多个时区的多个数据中心部署多个数据库实例。 

这些是基于客户端的 ID 生成(或者,更确切地说,非基于 DB)进入阶段的情况。这种策略在 ID 生成算法和格式方面为我们提供了更大的灵活性,并且本质上允许批量操作:ID 值在存储到 DB 之前是已知的。在本文中,我们将讨论客户端生成 ID 策略的两个基本主题:如何生成唯一 ID 值以及何时分配它。

生成算法

当涉及到分布式应用程序中的 ID 生成时,我们需要决定使用哪种算法来保证唯一性和声音生成性能。让我们看看这里的一些选项。

随机 ID 和时间戳——糟糕的想法

这是分散式 ID 生成的直接且简单的实现。让每个应用程序实例使用随机数生成器生成一个唯一 ID,就是这样!为了使它更好,我们可能会考虑使用复合结构 - 让我们将时间戳(以毫秒为单位)附加到随机数的开头,以使我们的 ID 可排序。例如,要创建一个 64 位 ID,我们可以使用时间戳的前 32 位和随机数的后 32 位。

这种方法的问题在于它不能保证唯一性。我们只能希望我们生成的 ID 不会发生冲突。对于大型分布式数据密集型系统,这种方法是不可接受的。除非我们是赌场,否则我们不能依赖概率法则。

结论

我们不应该为全球唯一的 ID 生成算法重新发明轮子。这将需要大量的时间、精力和几个博士学位。一些现有的解决方案解决了这个问题,可以在我们的应用程序中使用。

UUID:全球唯一

UUID 生成 – 是一种众所周知且广泛使用的分布式应用程序中的 ID 生成方法。几乎所有编程语言的标准库都支持这种数据类型。我们可以直接在应用程序代码中生成 ID 值,并且这个值将是全局唯一的(通过生成算法的设计)。与“传统”数字 ID 相比,UUID 具有一些优势:

  • 唯一性不依赖于数据表。我们可以在表或数据库之间移动带有UUID类型的主键的数据,不会有任何问题。
  • 数据隐藏。假设我们开发了一个 Web 应用程序,用户在登录时在其浏览器地址中看到以下片段userId=100:这意味着可能存在 ID 为 99 或 101 的用户。知道此信息可能会导致安全漏洞。

UUID 不可排序,但通常不需要按代理 ID 值排序数据;我们应该为此使用业务密钥。但是如果我们绝对需要排序,我们可以使用 UUID 子类型 - ULID,它代表“通用唯一的字典可排序标识符”。

Java 中随机 UUID 生成器的性能对于大多数情况也足够了。在我的电脑(Apple M1 max)上,每次操作大约需要 500ns,这为我们每秒提供大约 200 万个 UUID。

UUID:缺点

UUID 几乎是 ID 值的完美选择,但有些事情可能会阻止您使用它。

首先,与 64 位长 ID 相比,UUID 值消耗更多存储空间。两倍的空间,如果我们需要准确的话。额外的 64 位可能看起来不是一个重要的添加,但在谈论数十亿条记录时它可能是一个问题。此外,我们应该记住需要复制 ID 值的外键。因此,我们可能会使 ID 存储消耗翻倍。

第二个问题是性能。有两个因素影响这一点:

  1. UUID 不会单调增加
  2. 一些 RDBMS 将表或索引存储为 B 树

这意味着当我们向表中插入新记录时,RDBMS 会将其 ID 值写入索引或表结构的随机 b 树节点。由于大部分索引或表数据存储在磁盘上,随机磁盘读取的概率增加。这意味着数据存储过程的进一步延迟。您可以在本文中找到有关此主题的更多信息。

最后,有些数据库不支持 UUID 作为数据类型,所以我们必须将 ID 值存储为 varchar 或字节数组,这可能对查询性能不利,并且需要在 ORM 端进行一些额外的编码。

结论 

如果我们不想或不能使用数据库生成 ID,UUID 是代理 ID 的不错选择。这是一种众所周知的、可靠的获取独特价值的方法。另一方面,使用 UUID 可能会导致某些数据库出现性能问题。除此之外,我们需要更多的存储空间来存储这种数据类型,这对于大型数据集来说可能是个问题。

专用 ID 生成服务器

当我们开始开发分布式应用程序时,我们可能会问自己:为什么我们不创建一个独立于数据库的特殊 ID 生成工具?这是一个有效的观点。Twitter Snowflake是此类工具的一个很好的(尽管已存档)示例。我们可以在我们的网络中设置多个专用的 ID 生成服务器并从中获取 ID。Snowflake 中使用的算法保证了全局 ID 的唯一性,并且它们是“大致按时间排序的”。性能也不错:每个进程每秒至少 10k ids,响应率 2ms(加上网络延迟)。

另一方面——我们需要设置和支持额外的服务器。除此之外,我们需要进行网络调用以获取 ID,并为此 - 在我们的应用程序中编写一些额外的代码。对于 Hibernate,它将是一种自定义 ID 生成策略。众所周知,所有我们编写一次的代码,都需要永远支持或者删除,所以在大多数情况下添加自定义ID生成策略代码意味着额外的工作。

结论 

如果我们需要一个独立的高性能 ID 生成设施,我们可能需要设置一个专用的 ID 生成服务器。但是要使用单独的 ID 生成服务器,我们应该准备投入一些额外的精力来支持我们的基础设施和应用程序代码中的专用服务器(容器)以获取 ID。

何时分配 ID 值?

这个问题虽然简单,但在您使用基于客户端的 ID 生成时可能会影响您的应用程序代码。在决定这个话题时,我们需要考虑:

  • JPA 实体比较算法。
  • 单元测试代码复杂度。

对于 ID 值的生成和分配,我们有以下选项:

  • 在实体创建时初始化 ID 字段。
  • 使用 Hibernate 的生成器。
  • 为新实体生成实现我们的工厂。

我们将使用 UUID 数据类型作为示例来讨论这些选项,但原则适用于上面讨论的所有 ID 生成算法和数据类型。

字段初始化

生成值最直接的方法是直接使用字段初始化器:

@Id 
@Column(name = "id", nullable = false) 
private UUID id = UUID.randomUUID();
```
```
这保证了一个非空的 ID 值,并允许我们轻松地为实体定义`equals()`和`hashCode()`方法——我们可以比较 ID 并计算它们的哈希码。

这种方法有什么问题吗?

首先,在定义这样的 ID 生成时,很难检查实体是新创建的还是持久的。这对 Hibernate 来说不是问题。如果我们调用该`EntityManager#persist()`方法并传递一个具有现有 ID 的实体,`Unique Constraint Violation`如果存在这样的 PKHibernate 将返回错误。假设我们调用`EntityManager#merge()`- Hibernate 将从`SELECT`数据库执行 a,并根据其结果设置实体状态。但是对于可能会检查 ID 是否为 null 并假设实体不是新实体的开发人员来说,获取实体状态变得有点困难。我们可以在网上找到这样的代码示例。 

这种假设可能会导致分离实体的意外应用程序错误,例如尝试存储对不存在实体的引用等。因此,我们需要就算法达成一致来确定实体状态。例如,`@Version`如果该字段存在,我们可以使用它。

第二个问题——[示例查询(QBE)](https://blog.frankel.ch/hibernate-query-by-example/#beyond-jpa-the-query-by-example-feature)。我们永远不应忘记,我们在每个实体中都有一个非空的全局唯一 ID。因此,在为查询创建新实体时,我们必须始终手动删除 ID。

第三个问题——单元测试。在我们的模拟中,很难保证一致的测试数据;每次,一个实体的 ID 都会不同。要覆盖它,我们应该添加 setter 方法,但它会使`@Id`字段可变,因此我们需要以某种方式防止主代码库中的 ID 更改。

最后,每次我们获取一个实体时,我们都会为新实体的实例生成一个值,然后 ORM 用从数据库中选择的 ID 值覆盖它。对于这种情况,生成 ID 值只是浪费时间和资源。

#### **结论**

使用字段初始化器进行 ID 初始化很简单,但是我们需要实现一些额外的任务:

1.  同意非空 ID 的实体状态检查算法。
1.  确保我们在使用 QBE 功能时将 ID 设置为 null。
1.  决定如何为我们的单元测试提供一致的数据。

### 休眠生成器

Hibernate 使用生成器为 JPA 实体分配 ID。我们在上一篇文章中谈到了序列生成器,而 Hibernate 为我们提供的远不止这些。例如,它以一种特殊的方式处理 UUID 主键。如果我们在下面的代码中定义 ID 字段,Hibernate 将自动使用它`UUIDGenerator`来生成 UUID 值并将其分配给该字段。

@Id @Column(name = "id", nullable = false) @GeneratedValue private UUID id;

Hibernate 中有更多的标准生成器;@GenericGenerator我们可以通过在注解中指定相应的类来使用它们。您可以在文档中找到有关生成器的更多信息

如果我们想以 Hibernate 不支持的方式生成 ID 值,我们需要开发一个自定义 ID 生成器。为此,我们需要实现一个IdentifierGenerator接口或其子类,并在@GenericGenerator注解参数中指定此实现。生成器代码可能如下所示:

public class CustomIdGenerator implements IdentifierGenerator { 
 
   @Override 
   public Serializable generate( 
              SharedSessionContractImplementor session,  
              Object object) 
              throws HibernateException { 
      //Generate ID value here 
   } 
}
```

\
```
而在一个 JPA 实体中,我们需要以这种方式声明该字段以使用上面定义的生成器:

@Id @GenericGenerator(name = "custom_gen", strategy = "org.sample.id.CustomIdGenerator") @GeneratedValue(generator = "custom_gen") private Integer id;

当我们使用 Hibernate 的生成器时,我们不会遇到实体状态定义的问题;我们依赖于 ORM。(其实Hibernate的方式比单纯的ID值检查要复杂一点,它包括版本字段、L1缓存、Persistable接口等)。我们也不会在单元测试方面遇到任何问题。对于分离实体的情况,我们可以安全地假设具有nullID 的实体尚未保存。

但是我们需要定义适当的equals()hashCode()方法。正如我们所见,ID 是可变的;其他实体字段也是可变的。可变字段导致“不稳定”equals()hashCode()方法。您可以在我们关于 Lombok 使用的博客文章中找到一个带有可变字段的“消失”实体的示例。我们将在本文后面讨论equals()和实现;hashCode()该主题与下一节中描述的案例相关。

结论 

使用 Hibernate 生成器使我们免于猜测实体的状态。此外,Hibernate 承担了在插入之前分配值的负担。但是对于这种情况,我们需要为新创建的具有空 ID 的实体实现equals()并适当地实现。hashCode()

定制工厂

当我们需要完全控制 JPA 实体创建过程时,我们可能会考虑创建一个特殊的工厂来生成实体。这个工厂可能提供一个 API 来为实体创建分配一个特定的 ID,为审计目的设置一个创建日期,指定一个版本等。在 Java 代码中,它可能看起来像这样:

@Autowired 
private JpaEntityFactory jpaEntityFactory; 
 
public Pet createNewPet(String name) { 
   return entityFactory.builder(Pet.class) 
      .whithId(100) 
      .withVersion(0) 
      .withName(name) 
      .build(); 
}
```
```
这样的工厂使 JPA 实体创建的过程保持一致且易于管理——只有一个 API 可以做到这一点,而我们是唯一负责它的人。因此,在模拟单元测试中生成预定义实体时,我们不会遇到问题。

但这里也有一个缺陷:我们必须强制所有开发人员使用我们的工厂来创建实体。这个任务可能有点挑战性。我们需要在 CI 管道中设置代码检查,如果我们检测到“非法”实体创建,甚至可能会导致构建失败。为了帮助开发人员,我们应该引入自定义 IDE 检查,以便在开发期间发现和检测此类情况。

#### **结论**

自定义工厂是 JPA 实体生成和初始化最灵活的方式,但需要一些努力来支持它。工作量将取决于工厂的功能复杂性。

## `Equals()`和`hashCode()`实施

JPA 实体中的实现`equals()`和`hashCode()`方法通常会引起热议。有很多关于这个主题的文章,例如来自[Baeldung](https://www.baeldung.com/jpa-entity-equality)、[Vlad Mihalcea](https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/)或[Thorben Janssen](https://thorben-janssen.com/ultimate-guide-to-implementing-equals-and-hashcode-with-hibernate/)的文章。

我们可以使用`@Id`字段或`@NaturalId`比较实体,但问题仍然存在 - 实体本质上是可变的。我们在上面讨论了 ID 分配的各种方法,我们可以看到,即使对于“在字段初始化器中分配 ID”,我们仍然必须使 ID 字段可变。

在下面的代码中,我们使用单个`ID`字段作为实体标识符,但我们可以将其与自然 ID 字段(或多个字段)互换——方法是相同的。对于 JPA Buddy,我们为这两种方法提供代码生成。让我们看看我们的解决方案。首先,实体的`equals()`方法。`Pet`

@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false; Pet pet = (Pet) o; return getId() != null && Objects.equals(getId(), pet.getId()); }

如您所见,我们假设两个没有 ID 的实体不相等,除非它们是同一个对象。时期。它满足 'equals()' 方法的所有要求,在代码中易于遵循,并且不会导致异常。

hashCode()方法实现更加简单。我们为同一类的所有实体返回一个常量。它不会破坏“equals 和 hashCode 约定”并且适用于新的和存储的实体。

@Override 
public int hashCode() { 
   return getClass().hashCode(); 
}
```
```
这里常见的问题是,“HashMapHashSet 的糟糕性能怎么办?” 在这里,我们可以引用 Vlad Mihalcea 的话: *“您永远不应该在 @OneToMany 集中获取数千个实体,因为数据库端的性能损失比使用单个散列存储桶高多个数量级。”*

## 结论

应用程序中的实体 ID 生成是在全球部署了多个应用程序和数据库实例的分布式系统的唯一选择。我们可以使用单独的 ID 生成服务器或应用内 ID 生成(通常是 UUID 生成器)。这两种选择各有利弊,但一般建议是:

1.  在大多数情况下,UUID 工作正常,并在 ID 长度、值生成速度和数据库性能之间提供了良好的平衡。
1.  如果我们需要满足关于 ID 格式(长度、数据类型等)或值生成性能的特殊要求,那么我们必须考虑专门的 ID 生成服务器。

至于 ID 分配算法,Hibernate 生成器做得很好。使用标准生成器或自定义生成器可简化代码库支持和 ID 生成过程调试。但是我们需要记住正确的 equals() 和 hashCode() 实现,因为我们这里有可变 ID。至于其他选项,我们可以添加以下内容:

1.  直接 ID 字段初始化很容易实现。尽管如此,当我们模拟存储库时,我们仍需要记住诸如 JPA 实体状态定义(新的或已保存的)、示例查询和单元测试等极端情况。此外,我们在实体获取上的 ID 覆盖上浪费了一些资源。
1.  实体生成工厂是最灵活的选择;我们控制代码中的所有内容。但是我们需要让所有的开发者都使用这个 API 来创建实体。我们需要在所有使用我们的代码库的团队中强制执行特定的静态代码检查以执行此操作。

在本系列的下一篇文章中,我们将讨论复合 ID:我们为什么需要它们,如何实现和使用它们,以及复合 ID 实现的不同方法的优缺点。