适合用于数据库主键的最佳UUID工具库 - Vlad Mihalcea


在本文中,我们将了解哪种 UUID(通用唯一标识符)类型最适合具有主键约束的数据库列。
虽然标准的 128 位随机 UUID 是一个非常受欢迎的选择,但您会发现这非常适合数据库主键列。

通用唯一标识符 (UUID) 是一个 128 位伪随机序列,可以独立生成,无需单个集中式系统负责确保标识符的唯一性。
RFC 4122 规范定义了UUID 的五个标准化版本,它们由各种数据库函数或编程语言实现。
例如,UUID()MySQL 函数返回版本 1 UUID 编号。
并且 JavaUUID.randomUUID()函数返回版本 4 UUID 编号。
对于许多开发人员来说,使用这些标准 UUID 作为数据库标识符非常有吸引力,因为:

  • ids 可以由应用程序生成。因此不需要中央协调。
  • 标识符冲突的可能性极低。
  • id 值是随机的,您可以安全地将它发送到 UI,因为用户将无法猜测其他标识符值并使用它们来查看其他人的数据。

但是,出于多种原因,使用随机 UUID 作为数据库表主键不是一个好主意。
首先,UUID 很大。每条记录都需要 16 个字节作为数据库标识符,这也会影响所有关联的外键列。
其次,Primary Key 列通常有一个关联的 B+Tree 索引来加速查找或连接,B+Tree 索引按排序顺序存储数据。
然而,使用 B+Tree 索引随机值会导致很多问题:
  • 索引页面将具有非常低的填充因子,因为这些值是随机出现的。因此,一个 8kB 的页面最终将只存储几个元素,因此在磁盘和数据库内存中浪费了大量空间,因为索引页面可以缓存在缓冲池中。
  • 由于 B+Tree 索引需要重新平衡自身以保持其等距树结构,随机键值将导致更多的索引页拆分和合并,因为没有预先确定的填充树结构的顺序。

如果你使用的是 SQL Server 或 MySQL,那就更糟了,因为整个表基本上是一个聚集索引

事实上,几乎所有数据库专家都会告诉您避免使用标准 UUID 作为数据库表主键:

TSID – 按时间排序的唯一标识符
如果您计划将 UUID 值存储在主键列中,那么您最好使用 TSID(按时间排序的唯一标识符)。
TSID Creator OSS 库提供了一种此类实现,它提供了一个由两部分组成的 64 位 TSID:

  • 一个 42 位时间组件
  • 一个 22 位随机分量

随机成分有两部分:
  • 节点标识符(0 到 20 位)
  • 一个计数器(2 到 22 位)

tsidcreator.node引导应用程序时,系统属性可以提供节点标识符:
-Dtsidcreator.node="12"

节点标识符也可以通过环境变量提供TSIDCREATOR_NODE:
export TSIDCREATOR_NODE="12"

该库在 Maven Central 上可用,因此您可以通过以下依赖项获取它:

<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>tsid-creator</artifactId>
    <version>${tsid-creator.version}</version>
</dependency>

您可以创建一个Tsid最多可以使用 256 个节点的对象,如下所示:

Tsid tsid = TsidCreator.getTsid256();
从Tsid对象中,我们可以提取以下值:

64 位数值,
编码 64 位值的Crockford 的 Base32 字符串值,
存储在42-bit 序列中的纪元以来的 Unix 毫秒数
为了可视化这些值,我们可以将它们打印到日志中:

long tsidLong = tsid.toLong();
String tsidString = tsid.toString();
long tsidMillis = tsid.getUnixMilliseconds();
 
LOGGER.info(
    "TSID numerical value: {}",
    tsidLong
);
 
LOGGER.info(
   
"TSID string value: {}",
    tsidString
);
 
LOGGER.info(
   
"TSID time millis since epoch value: {}",
    tsidMillis
);

我们得到以下输出:

TSID numerical value: 388400145978465528
TSID string value: 0ARYZVZXW377R
TSID time millis since epoch value: 1670438610927

生成十个值时:

for (int i = 0; i < 10; i++) {
    LOGGER.info(
        "TSID numerical value: {}",
        TsidCreator.getTsid256().toLong()
    );
}

我们可以看到值是单调递增的:

TSID numerical value: 388401207189971936
TSID numerical value: 388401207189971937
TSID numerical value: 388401207194165637
TSID numerical value: 388401207194165638
TSID numerical value: 388401207194165639
TSID numerical value: 388401207194165640
TSID numerical value: 388401207194165641
TSID numerical value: 388401207194165642
TSID numerical value: 388401207194165643
TSID numerical value: 388401207194165644

避免同步
因为通过TsidCreator工具提供的默认TSID工厂带有一个同步的随机值生成器,所以最好使用一个自定义的TsidFactory,提供以下优化。

  • 它可以使用ThreadLocalRandom生成随机值,因此避免了同步块上的线程阻塞
  • 它可以使用少量的节点位,因此为随机生成的数值留下更多的位。

因此,我们可以定义下面的TsidUtil,它为我们提供了一个TsidFactory,在我们想要生成一个新的Tsid对象时使用。


public static class TsidUtil {
    public static final String TSID_NODE_COUNT_PROPERTY =
        "tsid.node.count";
    public static final String TSID_NODE_COUNT_ENV =
       
"TSID_NODE_COUNT";
 
    public static TsidFactory TSID_FACTORY;
 
    static {
        String nodeCountSetting = System.getProperty(
            TSID_NODE_COUNT_PROPERTY
        );
        if(nodeCountSetting == null) {
            nodeCountSetting = System.getenv(
                TSID_NODE_COUNT_ENV
            );
        }
 
        int nodeCount = nodeCountSetting != null ?
            Integer.parseInt(nodeCountSetting) :
            256;
 
        int nodeBits = (int) (Math.log(nodeCount) / Math.log(2));
 
        TSID_FACTORY = TsidFactory.builder()
            .withRandomFunction(length -> {
                final byte[] bytes = new byte[length];
                ThreadLocalRandom.current().nextBytes(bytes);
                return bytes;
            })
            .withNodeBits(nodeBits)
            .build();
    }
}

结论
使用标准 UUID 作为主键值不是一个好主意,除非第一个字节是单调递增的。
因此,使用按时间排序的 TSID 是一个更好的主意。它不仅需要标准 UUID 一半的字节数,而且更适合作为 B+Tree 索引键。
虽然 SQL Server 通过 提供按时间排序的 GUID NEWSEQUENTIALID,但 GUID 的大小为 128 位,因此它是 TSID 的两倍。
UUID 规范的第 7 版也存在同样的问题,它提供了按时间排序的 UUID。但是,它使用相同的规范格式(128 位),但格式太大了。每个引用外键列都会放大主键列存储的影响。
如果您所有的主键都是 128 位 UUID,那么主键和外键索引将需要大量空间,包括磁盘和数据库内存,因为缓冲池同时包含表和索引页。