本章主要介绍如何在Hibernate中为实体定义主键,以及JPA中主键的常用生成策略。此外对这些主键生成策略进行了一些分析,以方便在现实实现中选择一种合适的策略。
1. 为实体设置主键
在Hibernate(JPA)中,仅仅使用@Entity注解是无法完整描述一个持久化类的,Hibernate还要求一个持久化类必须指定一个主键。Hibernate要求使用@Id注解来表示一个类的某个属性被指定为主键。比如如下代码所示:
1 | package com.rainsia.hibernate.model; |
在上面的例子中,将Message类的id属性定义为了主键。Hibernate中使用主键有如下的注意事项:
- 主键值不能为空。
- 主键值必须是唯一的。
- 主键值一旦指定,那么不应该修改。因此不要为主键字段设置setter方法。
主键在Hibernate中有至关重要的意义。Hibernate不允许在创建了实体之后在次修改主键的值。如果不遵守Hibernate的这一要求,那么Hibernate的脏检查机制和缓存机制都将出现问题。默认情况下,主键必须人为指定,这为系统实现带来了巨大的困难:如何保证全局下键的唯一性?
在实际的实现中,自然主键(即实体本身就具有的属性作为主键)通常会有各种各样的问题,因此推荐使用代理主键。代理键不具有业务含义,仅用于标识一个唯一的实体。在大多数情况下,用户不会直接看到或者涉及到这些键。
2. 键生成器
使用代理主键的一大好处是可以让系统在创建实体时自动生成键值,从而避免人为指定主键的各种问题。在Hibernate中,要让系统自动生成键值,需要在主键上添加@GeneratedValue注解,并且配置合适的生成策略(算法)。
2.1. 使用JPA键生成器
在JPA中,可以通过@GeneratedValue的strategy属性来配置一种主键生成的策略。strategy的类型是JPA的枚举类型GenerationType,其取值如下:
- GenerationType.AUTO:这是默认值。Hibernate会根据配置文件中hibernate.dialect所配置的方言来选择一种最佳策略。(可移植性差,不推荐使用)
- GenerationType.SEQUENCE:以序列(有顺序的数字)的形式生成主键。Hibernate会在底层数据库中生成一个名称为HIBERNATE_SEQUENCE的表来辅助生成序列,该表中有一个列next_val用于记录下一个要生成的主键值。在具体执行insert语句之前,实体的主键就会生成并可以访问。默认情况下,所有实体共享一个序列,因此每个实体的实例的序列号可能不是连续的,但通常这不是问题。
- GenerationType.IDENTITY:使用数据库的自增长列作为主键。只有在执行insert的时候才会生成主键的值,并且只有在数据被插入数据库之后才能获取到主键的值。(事务没有提交之前都无法获取主键值,不推荐使用)
- GenerationType.TABLE:以序列的形式生成主键,使用一个辅助表HIBERNATE_SEQUENCES来生成序列。默认情况下和SEQUENCE行为类似,HIBERNATE_SEQUENCE表有两个列,sequence_name和next_val,默认情况下sequence_name为default,next_val记录了下一行数据的序列号。和SEQUENCE类似,所有实体共享一个序列,因此每个实体的实例的序列号可能不是连续的。可以使用@TableGenerator注解来改变其行为,例如为每一个实体单独提供一个序列、使用高/低位序列生成算法等。
2.1.1. 用SEQUENCE生成主键
首先,在实体类的主键列上,标注生成方法为SEQUENCE。
1 | package com.rainsia.hibernate.model; |
一旦创建了实体并调用了持久化之后(在实际insert语句执行之前),便可以访问正确访问其主键值。直接看代码演示:
1 | package com.rainsia.hibernate; |
执行程序之后,会产生一个HIBERNATE_SEQUENCE的表,其数据如下图所示:
其中只有一列next_val,用于记录下一条数据的主键值是多少。在调用em.persist()方法的时候,Hibernate会为正在持久化的实体获取next_val的值,并使得next_val的值自增。同时Hibernate也考虑到并发插入的情况,所以在获取next_val值的时候使用了锁,例如下面代码中的第四行使用了for update锁:
1 | select |
因此,可以认为即使是在并发插入的时候,要获取一个键值也只能等上一个键值获取并更新(增加1)完成之后才能够获取,因此可以保证实体实例的主键值不会重复。
值得注意的是,这个序列是所有实体共享的,如果交叉插入不同的实体实例,那么每一个实体的实例所获得的主键值将是不连续的。为了展示这个特性,我们再创建一个Message2实体,其代码和Message一样:
1 | package com.rainsia.hibernate.model; |
然后在persistence.xml文件中添加新的实体:
1 | …… |
然后,交叉插入两个实体:
1 |
执行该程序,如下图所示,可以看到两个表的序列号,并不连续:
每个实例的主键值将是整个数据库唯一的,而不是每个表唯一的。不同的实体之间也不会产生主键冲突。如果你担心为整个数据库创建一个序列可能会造成主键值溢出的问题,那么你可以不用太担心。我们使用Long类型作为主键值,在Java中,这是一个64位带符号的数据类型,其取值范围是$-\frac{1}{2}2^{64}$到$\frac{1}{2}2^{64}-1$之间。按照每毫秒产生一个id值计算,一个Long类型的序列可以持续生成键值大约3亿年。这几乎足以满足大部分的系统需求。
2.1.2. 定制SEQUENCE
如果要改变默认的HIBERNATE_SEQUENCE表的名字,或者对SEQUENCE做其他的定制。可以使用@SequenceGenerator注解来定制SEQUENCE的行为。
@SequenceGenerator有六个属性:name、catalog、schema、sequenceName、initialValue和allocationSize。
- name是必须的,用于指定一个生成器的名字,例如@SequenceGenerator(name=”id_gen”)。相应的,在主键之上的@GeneratedValue注解的属性也应该变为@GeneratedValue(generator=”id_gen”)。
- catalog和schema指定了序列的名称空间以避免重名。
- sequenceName指定了sequence的名称,如果底层依赖的数据库不支持序列,那么Hibernate将会使用表的形式记录序列,则sequenceName的值即指定了表名。其默认值为hibernate_sequence。
- initialValue指定了序列的初始值。其默认值为1。
- allocationSize指定了Hibernate缓存的序列值的数量,每次缓存值用完,才需要再次数据库系统中再次获取新的序列值。其默认值为50。
前4个属性都比较好理解,allocationSize参数的意义并不是在分配主键值的时候使用间隔分配的方式,而是指每隔多少个值才访问一次数据库获取新的主键值。例如如下实体定义:
1 | package com.rainsia.hibernate.model; |
程序中声明了一个序列,名称为messageGen,该序列名称为id_sequence(注意在MySql和MaridaDB等不支持不支持序列的DBMS中,Hibernate会使用一个表id_sequence来模拟序列的行为)。id_sequence序列初始值为5,缓存10个序列值,即Hibernate分配10个键值之后,才需要向数据库再次请求。
下面的程序将持久化30个Message实体的实例:
1 | package com.rainsia.hibernate.app; |
其完整的执行过程如下:
从中可以看出,在数据库表刚刚建立的时候,序列表被初始化为初始值:
1 | …… |
在持久化第一个实体的实例时,Hibernate会调用序列的自增功能两次(原因待考证):
1 | …… |
由于序列初始值是5,设置的序列递增值是10,因此第一次更新序列值后,序列的下一个值变为15;第二次更新序列值后,序列的下一个值变为25。此后,每持久化10个实例,序列的下一个值自动更新10。 其生成的主键值,从初始值5开始,并且连续:
此时,数据库中序列表的内容将变为下一个序列组的开始值45:
从中可以看出,现在程序只要每持久化10个实体之后才需要从数据库重新获取序列值,降低了访问数据库的频率。那么,是不是allocationSize越大越好呢?因为这样就可以以尽量低的频率去访问数据库。例如,如果allocationSize是1000的话,我们是不是持久化1000个对象才需要访问一次数据库来获取下次序列的起点。
2.1.3. 重启服务器后序列值的获取
可以考虑这样一种情况:当Hibernate缓存的序列缓存值还没用完的时候,系统重启了,由于Hibernate无法记忆自己的缓存值,必须从数据库的序列表中获取下一次序列的起始值,那么没用完的序列缓存之就浪费了,这同时造成了主键的不连贯。直接看代码:
首先将persistence中数据库表的生成模式改为update,这样每次重启程序的时候,就不会删除整个数据库表,重新创建:
1 | …… |
上次运行程序时,其初始值为5,插入25个实例之后,其最终主键最大值为5+25-1=29。然后我们再次运行程序,可以看到Message表的情况:
可以看出,其主键值跳过了29,而直接从36开始。其原因在于未使用的序列缓存值一直存在于Hibernate系统中,当重启之后,Hibernate也只能从序列表中恢复序列,因此未使用的序列缓存便无法继续使用。由于持久化第一个实体实例时,序列的值被增加了两次(原因不明),因此实际的缓存值应该要减掉一次间隔值:第一次程序运行完成后,序列表的next_val值为45,实际上下次分配的起始值为35。
从这个例子中可以看出,allocationSize并不是越大越好。如果allocationSize值过大并且程序频繁重启,那么在多次重启程序后,很多缓存的序列值并无法使用,最终序列空间将被耗尽。
2.1.4. 两个实体配置同一个序列的不同设置
下面考虑另外一种特殊情况,两个实体都配置了同一个序列,但是两个实体配置的序列生成器的设置不一样,在这种情况下Hibernate将有什么表现。
Message2实体类的键值生成器改为如下设置:
1 | package com.rainsia.hibernate.model; |
然后交叉插入两种不同的实体实例:
1 | package com.rainsia.hibernate.app; |
运行程序后,可以发现,同一个序列表当中出现了两个不同的列:
数据的插入情况如下:
可以看出,如果两个序列生成器,指向同一个表,但是初始值和间隔值均不同,插入数据生成的主键将产生一些奇怪的表现。这种做法实际上是违背JPA标准的。
JPA specification (JSR 338, 11.1.48):
The scope of the generator name is global to the persistence unit (across all generator types).
因此不要通过两个类中申明的序列生成器,配置同一个序列名但是却使用两种不同的配置。但是可以使用两个不同的序列生成器,配置相同的序列名、初始值和间隔值。或者一种更好的方式是使用Hibernate的全局序列生成器。
2.1.5. 全局序列生成器
JPA当中的序列生成器,只能配置到具体的实体类上,无法配置一个生成器供所有类同时使用。然而Hibernate提供了这样的功能,可以将通用生成器@GenericGenerator配置到package-info.java文件中,然后在所有实体类中都可以使用同一个生成器,以避免在不同实体类中配置生成器冲突的情况。
在Hibernate中,@GenericGenerator有三个属性:name, strategy和parameters。
- name标识了序列的名字。
- strategy需要制定一种键生成器策略。Hibernate中提供了大量的键生成器策略。和序列相关的主要是sequence和enhanced-sequence。推荐使用enhanced-sequence,其使用和JPA的SequenceGenerator,可以对生成的序列进行严格的控制。
- parameters是一个数组,用于对选定的strategy进行详细的配置。不同的strategy具有不同的参数。对于enhanced-sequence,其配置参数可以参考:enhanced-sequence。其主要可配置参数有:sequence_name(序列或辅助表的名字)、initial_value(序列初始值)、increment_size(递增值)、value_column(数据列的名字,默认为next_value)。
以下程序展示了@GenericGenerator配置序列的例子:
1 | /** |
随后,需要将整个package(package-info.java)加入到实体配置中:
1 | <persistence version="2.1" |
然后在定义实体的主键时,引用定义的序列生成器:
1 | package com.rainsia.hibernate.model; |
最后想往常一样创建并持久化实体:
1 | package com.rainsia.hibernate.app; |
其运行结果和之前的例子一致:
控制台的输出:
从中可以看出,其运行过程完全和之前一致。但是却是使用的全局序列。全局序列只要在包中定义一次,在所有实体中都可以共享。
如果序列的名字比较复杂,例如:”my_unique_id_fancy_generator_start_from_five”,那么在很多地方引用的时候容易输错,我们可以引入字符串常量来避免出错的概率:
1 | package com.rainsia.hibernate.model; |
然后在引用序列名的地方,直接使用常量Constants.ID_GEN:
1 | /** |
1 | package com.rainsia.hibernate.model; |
至此,我们已经找到一种比较合适的主键生成方式,并且推荐了基于这种生成方式的一种最佳实践。