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

在客户端而不是数据库中生成 ID 是分布式应用的唯一选项。但是在此类应用程序中生成唯一 ID 是很困难的。正确生成它们至关重要,因为 JPA 将使用 ID 来定义实体状态。最安全的选择是使用 UUID 和 Hibernate 的生成器,但还有更多选项,从自定义生成器到专用 ID 生成服务器。
在这里插入图片描述
在上一篇文章中,我们讨论了 JPA 实体的服务器生成的 ID。本文中描述的所有 ID 生成策略都基于一个基本原则:有一个点负责生成 ID:数据库。这个原则可能会成为一个挑战:我们依赖于一个特定的存储系统,因此切换到另一个存储系统(例如,从PostgreSQL到Cassandra)可能是一个问题。此外,此方法不适用于分布式应用程序,在这些应用程序中,我们可以在多个时区的多个数据中心上部署多个数据库实例。

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

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

随机 ID 和时间戳 – 糟糕的想法
这是去中心化 ID 生成的简单而幼稚的实现。让每个应用程序实例使用随机数生成器生成一个唯一的 ID,就是这样!为了让它变得更好,我们可以考虑使用复合结构 - 让我们将时间戳(以毫秒为单位)附加到随机数的开头,以使我们的 ID 可排序。例如,要创建一个 64 位 ID,我们可以使用时间戳的前 32 位和随机数的最后 32 位。

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

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

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

唯一性不依赖于数据表。我们可以在表或数据库之间移动带有 UUID 类型主键的数据,不会有任何问题。
数据隐藏。假设我们开发了一个 Web 应用程序,用户在登录时在其浏览器地址中看到以下片段:.这意味着可能存在 ID 为 99 或 101 的用户。知道这些信息可能会导致安全漏洞。userId=100
UUID 不可排序,但通常不需要按代理项 ID 值对数据进行排序;为此,我们应该使用业务密钥。但是,如果我们绝对需要排序,则可以使用 UUID 子类型 – ULID,它代表“通用唯一的词典可排序标识符”。

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

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

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

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

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

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

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

专用 ID 生成服务器
当我们开始开发分布式应用程序时,我们可能会问自己:为什么不创建一个独立于数据库的特殊 ID 生成工具?这是一个有效的观点。Twitter Snowflake是这种工具的一个很好的例子(尽管已存档)。我们可以在我们的网络中设置多个专用的 ID 生成服务器并从中获取 ID。Snowflake 中使用的算法保证了全局 ID 的唯一性,并且它们“大致按时间顺序排列”。性能也很好:每个进程每秒至少 10k ID,响应速率 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 值,并允许我们轻松定义实体和方法——我们可以比较 ID 并计算它们的哈希代码。equals()hashCode()

这种方法有什么问题吗?

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

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

第二个问题 – 示例查询 (QBE)。我们永远不应该忘记,我们在每个实体中都有一个非空的全局唯一 ID。因此,在为查询创建新实体时,我们必须始终手动删除 ID。

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

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

结论
使用字段初始值设定项进行 ID 初始化很简单,但我们需要实现一些额外的任务:

就非 null ID 的实体状态检查算法达成一致。
确保在使用 QBE 功能时将 ID 设置为 null。
确定如何为单元测试提供一致的数据。
休眠发电机
Hibernate 使用生成器为 JPA 实体分配 ID。我们在上一篇文章中谈到了序列生成器,而 Hibernate 为我们提供了更多功能。例如,它以特殊方式处理 UUID 主键。如果我们像下面的代码一样定义 ID 字段,Hibernate 将自动使用它来生成 UUID 值并将其分配给该字段。UUIDGenerator

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

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

如果我们想以 Hibernate 不支持的方式生成 ID 值,我们需要开发一个自定义 ID 生成器。为此,我们需要实现一个接口或其子类,并在 annotation 参数中指定此实现。生成器代码可能如下所示: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 缓存、接口等)。我们在单元测试方面也不会有任何问题。对于分离的实体,我们可以放心地假设具有 ID 的实体尚未保存。Persistablenull

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

结论
使用 Hibernate 生成器可以让我们从猜测实体的状态中解放出来。此外,Hibernate 还承担了在插入值之前分配值的负担。但对于这种情况,我们需要对具有 null 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 实体中的实现和方法通常会引起激烈的争论。关于这个主题的文章有很多,例如来自 Baeldung、Vlad Mihalcea 或 Thorben Janssen 的文章。equals()hashCode()

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

在下面的代码中,我们使用单个字段作为实体标识符,但我们可以将其与自然 ID 字段(或多个字段)互换——方法相同。对于 JPA Buddy,我们为这两种方法提供代码生成。让我们看一下我们的解决方案。首先,实体的方法。ID 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 约定”,并且适用于新的和存储的实体。hashCode()

@Override 
public int hashCode() { 
   return getClass().hashCode(); 
}

这里通常的问题是,“HashMap 和 HashSet 的糟糕性能怎么办”?在这里,我们可以引用 Vlad Mihalcea 的话:“你永远不应该在一个 @OneToMany 集中获取数千个实体,因为数据库端的性能损失比使用单个哈希桶高出多个数量级。

结论
对于分布式系统而言,在应用程序中生成实体 ID 是唯一选项,这些系统在全球范围内部署了多个应用程序和数据库实例。我们可以使用单独的 ID 生成服务器或应用内 ID 生成(通常是 UUID 生成器)。这两种选择都有其优点和缺点,但一般建议是:

在大多数情况下,UUID 工作正常,并在 ID 长度、值生成速度和数据库性能之间提供了良好的平衡。
如果我们需要满足有关 ID 格式(长度、数据类型等)或值生成性能的特殊要求,那么我们必须考虑专门的 ID 生成服务器。
至于 ID 分配算法,Hibernate 生成器做得很好。使用标准生成器或自定义生成器可简化代码库支持和 ID 生成过程调试。但是我们需要记住正确的 equals() 和 hashCode() 实现,因为我们这里有可变的 ID。至于其他选项,我们可以添加以下内容:

直接 ID 字段初始化易于实现。尽管如此,当我们模拟存储库时,我们仍然需要记住一些极端情况,例如 JPA 实体状态定义(新的或已保存的)、示例查询和单元测试。此外,我们还在实体提取的 ID 覆盖上浪费了一些资源。
实体生成工厂是最灵活的选择;我们控制代码中的所有内容。但是我们需要让所有开发人员都使用这个 API 来创建实体。为此,我们需要在所有使用我们的代码库的团队中强制执行特定的静态代码检查。
在本系列的下一篇文章中,我们将讨论复合 ID:为什么需要它们,如何实现和使用它们,以及实现复合 ID 的不同方法的优缺点。

  • 21
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小徐博客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值