在客户端生成ID并不像看起来那么简单。在JPA和Hibernate中,我们可以使用UUID、自定义策略和专用ID生成服务器。
在客户端而不是数据库中生成ID是分布式应用程序的唯一选择。但是在这样的应用程序中生成唯一的ID是很困难的。正确地生成它们非常重要,因为JPA将使用ID来定义实体状态。最安全的选择是使用UUID和Hibernate的生成器,但是从自定义生成器到专用ID生成服务器有更多的选项。
在[前一篇文章],我们讨论了JPA实体的服务器生成ID。本文中描述的所有ID生成策略都基于一个基本原则:只有一个点负责生成ID:数据库。这个原则可能会成为一个挑战:我们依赖于一个特定的存储系统,因此切换到另一个存储系统(例如,从PostgreSQL到Cassandra)可能是一个问题。此外,这种方法不适用于分布式应用程序,因为我们可以在多个时区的多个数据中心上部署多个DB实例。
在这种情况下,基于客户端的ID生成(或者说,非基于DB的ID生成)进入了一个阶段。这种策略在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生成器的性能也是足够的。在我的电脑上(AppleM1max),每次操作大约需要500 ns,这就给了我们大约200万UUID每秒。
UUID:缺点
UUID几乎是ID值的最佳选择,但是有一些事情可能会阻止您使用它。
首先,UUID值比64位长ID占用更多的存储空间.如果我们需要精确的话,是空间的两倍。额外的64位可能看起来并不是一个重要的增加,但当谈到数十亿条记录时,这可能是一个问题。另外,我们应该记住外键,在那里我们需要复制ID值。因此,我们可能会增加一倍的ID存储消耗。
第二个问题是性能。有两个因素影响到这一点:
- UUID不会单调增加。
- 一些RDBMS将表或索引存储为B树。
这意味着当我们将新记录插入到表中时,RDBMS将其ID值写入索引或表结构的随机b树节点。由于大多数索引或表数据存储在磁盘上,随机磁盘读取的概率增加。这意味着数据存储过程中的进一步延迟。您可以在这篇文章.
最后,有些数据库只是不支持UUID作为数据类型,因此我们必须将ID值存储为varchar或字节数组,这对查询性能可能不是很好,并且需要在ORM方面进行一些额外的编码。
结语
如果我们不希望或不能使用数据库生成ID,那么UUID对于代理ID来说是一个很好的选择。这是一种众所周知的、可靠的获得独特价值的方法.另一方面,使用UUID可能会导致某些数据库中的性能问题。除此之外,我们还需要为这种数据类型提供更多的存储空间,这可能是大型数据集的一个问题。
专用ID生成服务器
当我们开始开发一个分布式应用程序时,我们可能会问自己:为什么我们不创建一个独立于数据库的特殊的ID生成工具呢?这是一个有效的论点。推特雪花是这样的一个很好的例子(虽然存档了)。我们可以在网络中设置多个专用的ID生成服务器,并从中获取ID。在雪花中使用的算法保证了全局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值,并允许我们定义equals()
和hashCode()
方法的实体-我们可以比较ID和计算他们的哈希码。
这种方法有什么问题吗?
首先,在像这样定义ID生成时,很难检查实体是新创建的还是持久化的。这对Hibernate来说不是问题。如果我们调用EntityManager#persist()
方法并传递具有现有ID的实体,Hibernate将返回Unique Constraint Violation
如果存在这样的pk,则出错。假设我们调用EntityManager#merge()
-Hibernate将执行SELECT
从数据库中,并根据其结果,将设置实体状态。但是,对于可能检查ID为NULL并假定实体不是新实体的开发人员来说,获取实体状态变得有点困难;我们可以在Internet上找到这样的代码示例。
这种假设可能会对分离实体造成意外的应用错误,例如试图存储对不存在实体的引用等。因此,我们需要就计算实体状态的算法达成一致。例如,我们可以使用@Version
如果存在字段,则为字段。
第二个问题-示例查询(QBE)。我们永远不要忘记,我们在每个实体中都有一个非空的全局唯一ID。因此,在为查询创建新实体时,必须始终手动删除ID。
第三个问题-单元测试。在我们的模拟中,很难保证一致的测试数据;每次,实体的ID都是不同的。要重写它,我们应该添加setter方法,但是它将使@Id
字段是可变的,因此我们需要以某种方式防止主代码库中的ID更改。
最后,每次获取实体时,我们都为新实体的实例生成一个值,然后ORM用从数据库中选择的ID值覆盖它。在这种情况下,ID值生成只是浪费时间和资源。
结语
使用字段初始化程序初始化ID很简单,但我们需要实现一些附加任务:
- 同意非空ID的实体状态检查算法。
- 确保在使用QBE功能时为ID设置NULL。
- 决定如何为单元测试提供一致的数据。
Hibernate发生器
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值要复杂一些,它包括Version字段、L1缓存、Persistable
(接口等)我们的单元测试也不会有任何问题。对于分离的实体,我们可以安全地假设一个具有null
ID尚未保存。
但我们需要定义正确的equals()
和hashCode()
方法。正如我们所看到的,ID是可变的;其他实体字段也是可变的。而可变字段会导致“不稳定”。equals()
和hashCode()
方法。您可以找到一个具有可变字段的“消失”实体的示例。关于Lombok使用的博客文章。我们会讨论equals()
和hashCode()
本文后面的实现;本主题与下一节中描述的案例相关。
结语
使用Hibernate生成器可以使我们从猜测实体的状态中解脱出来。此外,Hibernate在插入值之前承担了赋值的负担。但在这种情况下,我们需要equals()
和hashCode()
适用于新创建的具有空ID的实体。
定制工厂
当我们需要完全控制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()
实施
执行equals()
和hashCode()
JPA实体中的方法通常引起争论。有关于这个主题的各种文章,例如白龙, 弗拉德·米哈西,或桑本·詹森.
我们可以用@Id
字段或@NaturalId
要比较实体,但问题仍然存在--实体的性质是可变的。我们讨论了上面的各种ID分配方法,我们可以看到,即使对于“字段初始化器中的分配ID”,我们仍然必须使ID字段可变。
在下面的代码中,我们使用一个ID
字段作为实体标识符,但我们可以将其与一个自然ID字段(或多个字段)交换--方法将是相同的。对于JPABuddy,我们为这两种方法提供代码生成。让我们看看我们的解决方案。首先,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的实体是不相等的,除非它们是同一个对象。期间。它满足了所有对‘Eques()’方法的要求,在代码中很容易遵循,并且不会导致异常。
这个hashCode()
方法实现甚至更简单。对于同一类的所有实体,我们返回一个常量。它不会破坏“EquesandhashCode约定”,并适用于新的和存储的实体。
@Override
public int hashCode() {
return getClass().hashCode();
}
这里通常的问题是,“HashMap和HashSet中糟糕的性能如何?”在这里,我们可以引用弗拉德·米哈西的话: “您不应该在@OneToMany集中获取数千个实体,因为数据库端的性能损失比使用单个散列桶高出多个数量级。”
结语
在应用程序中生成实体ID是分布式系统的唯一选项,在全世界部署了多个应用程序和数据库实例。我们可以使用单独的ID生成服务器或应用程序中的ID生成(通常是UUID生成器)。这两种选择各有优缺点,但一般建议如下:
- 在大多数情况下,UUID工作良好,在ID长度、值生成速度和DB性能之间提供了很好的平衡。
- 如果我们需要满足对ID格式(长度、数据类型等)的特殊要求。或者值生成性能,那么我们必须考虑专用的ID生成服务器。
在ID分配算法方面,Hibernate生成器做得很好。使用标准生成器或自定义生成器可以简化代码库支持和ID生成过程调试。但是,我们需要记住正确的Equals()和hashCode()实现,因为这里有可变的ID。至于其他方案,我们可以增加以下几点:
- 直接ID字段初始化很容易实现。尽管如此,我们仍然需要记住诸如JPA实体状态定义(新的或保存的)、示例查询和模拟存储库时的单元测试等角落案例。此外,我们在实体获取的ID覆盖上浪费了一些资源。
- 实体生成工厂是最灵活的选择;我们控制代码中的一切。但是我们需要让所有开发人员使用这个API来创建实体。为了做到这一点,我们需要在所有使用我们的代码库的团队中强制执行特定的静态代码检查。