持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情
在客户端而不是数据库中生成 ID 是分布式应用程序的唯一选择。 但在此类应用程序中生成唯一 ID 很困难。正确生成它们很重要,因为 JPA 将使用 ID 来定义实体状态。最安全的选择是使用 UUID 和 Hibernate 的生成器,但从自定义生成器到专用 ID 生成服务器还有更多选择。
本文中描述的所有 ID 生成策略都基于一个基本原则:数据库负责生成ID。这个原则可能会成为一个挑战:我们依赖于一个特定的存储系统,所以切换到另一个(例如,从 PostgreSQL 到 Cassandra)可能是个问题。此外,这种方法不适用于分布式应用程序,我们可以在多个时区的多个数据中心部署多个数据库实例。
这些是基于客户端的 ID 生成(基于非DB)进入阶段的情况。这种策略在 ID 生成算法和格式方面为我们提供了更大的灵活性,并且本质上允许批量操作。ID 值在存储到 DB 之前是已知的,在本文中,我们将讨论客户端生成 ID 策略的两个基本主题:如何生成唯一 ID 值以及何时分配它。
生成算法
当涉及到分布式应用程序中的 ID 生成时,我们需要决定使用哪种算法来保证唯一性和生成性能。让我们看看这里的一些选项。
随机 ID 和时间戳
这是分散式 ID 生成的直接且简单的实现。让每个应用程序实例使用随机数生成器生成一个唯一 ID。为了使它更完善,我们可能会考虑使用复合结构——让我们将时间戳(以毫秒为单位)附加到随机数的开头,以使我们的 ID 可排序。例如,要创建一个 64 位 ID,我们可以使用时间戳的前 32 位和随机数的后 32 位。
这种方法的问题在于它不能保证唯一性。我们只能希望我们生成的 ID 不会发生冲突。对于大型分布式数据密集型系统,我们不能依赖概率法则,这种方法是不可接受的。
UUID
UUID是一种众所周知且广泛使用的分布式应用程序中的 ID 生成方法。几乎所有编程语言的标准库都支持这种数据类型。我们可以直接在应用程序代码中生成 ID 值,并且这个值将是全局唯一的(通过生成算法的设计)。与“传统”数字 ID 相比,UUID 具有一些优势:
- 唯一性不依赖于数据表。我们可以在表或数据库之间移动带有UUID类型的主键的数据,不会有任何问题。
- 数据隐藏。假设我们开发了一个 Web 应用程序,用户在登录时在其浏览器地址中看到以下片段
userId=100:这意味着可能存在 ID 为 99 或 101 的用户。知道此信息可能会导致安全漏洞。
UUID 不可排序,但通常不需要按代理 ID 值排序数据;我们应该为此使用业务密钥。但是如果我们绝对需要排序,我们可以使用 UUID 子类型 - ULID,它代表“通用唯一的字典可排序标识符”。
UUID:缺点
首先,与 64 位长 ID 相比,UUID 值消耗更多存储空间,两倍的空间。
第二个问题是性能。有两个因素影响这一点:
- UUID 不会单调增加
- 一些 RDBMS 将表或索引存储为 B 树
这意味着当我们向表中插入新记录时,RDBMS 会将其 ID 值写入索引或表结构的随机 b 树节点。由于大部分索引或表数据存储在磁盘上,随机磁盘读取的概率增加。这意味着数据存储过程的进一步延迟。
专用 ID 生成服务器
当我们开始开发分布式应用程序时,我们可能会问自己:为什么我们不创建一个独立于数据库的特殊 ID 生成工具?这是一个有效的观点。Twitter Snowflake是一个很好的工具。我们可以在我们的网络中设置多个专用的 ID 生成服务器并从中获取 ID。Snowflake 中使用的算法保证了全局 ID 的唯一性,并且它们是“大致按时间排序的”。性能也不错:每个进程每秒至少 10k ids,响应率 2ms(加上网络延迟)。
另一方面——我们需要设置和支持额外的服务器。除此之外,我们需要进行网络调用以获取 ID,并为此在我们的应用程序中编写一些额外的代码。对于 Hibernate,它将是一种自定义 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如果存在这样的 PK,Hibernate 将返回错误。
假设我们调用EntityManager#merge()- Hibernate 将从SELECT数据库执行 a,并根据其结果设置实体状态。但是对于可能会检查 ID 是否为 null 并假设实体不是新实体的开发人员来说,获取实体状态变得有点困难。我们可以在网上找到这样的代码示例。
这种假设可能会导致分离实体的意外应用程序错误,例如尝试存储对不存在实体的引用等。因此,我们需要就算法达成一致来确定实体状态。例如,@Version如果该字段存在,我们可以使用它。
第二个问题——示例查询(QBE)。我们永远不应忘记,我们在每个实体中都有一个非空的全局唯一 ID。因此,在为查询创建新实体时,我们必须始终手动删除 ID。
第三个问题——单元测试。在我们的模拟中,很难保证一致的测试数据;每次,一个实体的 ID 都会不同。要覆盖它,我们应该添加 setter 方法,但它会使@Id字段可变,因此我们需要以某种方式防止主代码库中的 ID 更改。
最后,每次我们获取一个实体时,我们都会为新实体的实例生成一个值,然后 ORM 用从数据库中选择的 ID 值覆盖它。对于这种情况,生成 ID 值只是浪费时间和资源。
休眠生成器
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 的实体尚未保存。
定制工厂
当我们需要完全控制 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 检查,以便在开发期间发现和检测此类情况。