在Mysql中使用UUID作为主键的问题

通用唯一识别码(Universally Unique Identifiers,也称为 UUID)旨在让开发人员能够在不了解其他系统的情况下生成唯一的 ID,从而保证 ID 的唯一性。在分布式架构中,当有多个系统和数据库负责创建记录时,UUID 特别有用。你可能认为在数据库中使用 UUID 作为主键是个不错的主意,但如果使用不当,它们可能会严重影响数据库性能。

在本文中,你将了解在 MySQL 数据库中使用 UUID 作为主键的缺点。

不同版本的UUID

在撰写本文时,UUID 有五个官方版本和三个提议版本。让我们来看看每个版本,以便更好地理解它们的工作原理。

UUIDv1

UUID 版本 1 也被称为基于时间的 UUID,可分解如下:

UUIDv1 架构

虽然现代计算大多以 UNIX 纪元时间(1970 年 1 月 1 日)为基准,但 UUID 实际上使用的是 1568 年 10 月 10 日这个不同的日期,这一天是公历开始更广泛使用的日期。UUID 中嵌入的时间戳从这个日期开始以 100 纳秒为增量递增,然后用这个时间戳来设置 UUID 的 time_lowtime_midtime_hi 段。

UUID 的第三段包含版本号以及 time_hi 部分,且该段的第一个字符用于表示版本号。后续示例将展示,所有版本的 UUID 都是如此。保留部分也称为 UUID 的变体,它决定了 UUID 中各个位的使用方式。最后,UUID 的最后一段是节点(node),它是生成该 UUID 的系统的唯一地址。

UUIDv2

UUID 版本 2 相较于版本 1 做了一处改动,即将其结构中的 low_time 段替换为 POSIX 用户 ID。其理论依据是,这些 UUID 可以追溯到生成它们的用户账户。由于 low_time 段是 UUID 大部分可变性所在之处,替换这一段会增加冲突的可能性。因此,这个版本的 UUID 很少被使用。

UUIDv3和v5

UUID 的版本 3 和版本 5 非常相似。这两个版本的目标是能够以确定性的方式生成 UUID,这样在给定相同信息的情况下,就能生成相同的 UUID。这些实现使用两部分信息:一个命名空间(其本身也是一个 UUID)和一个名称。将这些值输入哈希算法,生成一个可以表示为 UUID 的 128 位值。

版本 3 使用 MD5 哈希算法,而版本 5 使用 SHA-1 哈希算法。

UUIDv4

UUID 版本 4 是随机生成的 UUID,不基于任何信息。它的生成是完全随机的,不依赖于时间、位置或其他任何因素。这使得版本 4 的 UUID 非常安全,因为它们不容易被预测或猜测。例外情况是 UUID 第三段的第一个位置,这个位置始终为 4,用于表示所使用的版本。

UUIDv4 架构

UUIDv6

UUID 版本 6 与版本 1 几乎完全相同。唯一的区别在于,用于捕获时间戳的位被翻转了,这意味着时间戳的最高有效部分会被优先存储。下图展示了这些差异。

UUIDv6 架构

创建 UUID 版本 6 的主要原因是生成一个与版本 1 兼容的值,同时由于时间戳的最高有效部分位于前面,使得这些值更易于排序。

UUIDv7

UUID 版本 7 同样是基于时间的 UUID 变体,但它集成了更常用的 UNIX 纪元时间戳,而非版本 1 所使用的公历日期。另一个关键区别在于,节点(基于生成 UUID 的系统的值)被随机数取代,这使得这些 UUID 更难以追溯到其来源。

UUIDv8

UUID 版本 8 是最新版本,它允许供应商进行特定实现,同时遵循 RFC 标准。与其他所有版本一样,UUIDv8 唯一的要求是在第三段的第一个位置指定版本号。

UUID与Mysql

使用 UUID(在大多数情况下)可以保证在架构中的所有系统间实现唯一性,因此你可能会倾向于将其用作记录的主键。需要注意的是,与自增整数相比,这样做存在一些权衡之处。

数据插入性能

在 MySQL 中,每当向表中插入一条新记录时,与主键关联的索引都需要更新,以确保表的查询性能良好。MySQL 中的索引采用 B+ 树的形式,这是一种多层数据结构,能让查询快速找到所需的数据。

下面的示意图展示了一个相对简单的 B+ 树结构,包含 6 个值从 1 到 6 的条目。如果有查询请求查找值 5,MySQL 会从根节点开始,然后从根节点得知需要遍历树的右侧来找到目标数据。

注意:为简单起见,这些示意图展示的是 B 树而非 B+ 树。关键区别在于,在 B+ 树中,叶子节点包含对实际数据的引用,而在 B 树中,叶子节点不包含。

B+ 树示例

如果向这个简单的 B+ 树中添加值 7 - 9,MySQL 会对右侧节点进行拆分并重新平衡树结构。这是因为 B+ 树需要保持自身的平衡特性,以维持高效的查询性能。当节点中的条目数量超过其容量时,就会触发节点拆分和树的重新平衡操作。

B+ 树重新平衡示例

这个过程被称为页分裂(page splitting),其目标是保持 B+ 树结构的平衡,以便 MySQL 能够快速找到所需的数据。对于顺序值,这个过程相对简单;然而,当算法中引入随机性时,MySQL 重新平衡树的时间会显著变长。在高流量(负载较重的数据库环境)的数据库中,由于 MySQL 试图保持树的平衡,这可能会影响用户体验。

消耗更多的存储空间

在 MySQL 中,所有主键都会被索引。默认情况下,一个自增整数每个值仅消耗 32 位存储空间。与之相比,若以紧凑的二进制形式存储 UUID,每个 UUID 在磁盘上会消耗 128 位空间,这已经是 32 位整数存储空间的 4 倍。

如果选择使用更便于人类阅读的基于字符串的表示形式,每个 UUID 会以 CHAR(36) 的形式存储,每个 UUID 会消耗高达 288 位的空间。这意味着每条记录存储的数据量将是使用 32 位整数时的 9 倍。

除了主键上创建的默认索引外,二级索引也会消耗更多的存储空间。这是因为二级索引会使用主键作为指向实际行的指针,这意味着它们需要与索引一起存储。根据在使用 UUID 作为主键的表上创建的索引数量,这可能会导致数据库的存储需求显著增加。

除了上述提到的存储空间消耗差异,页分裂(如前一节所述)也会对存储利用率和性能产生负面影响。InnoDB 假定主键会以数字或字典序的方式可预测地递增。若确实如此,InnoDB 会在创建新页面之前将页面填充至约 94% 的页面大小。但当主键是随机的时候,每个页面的空间利用率可能低至 50%。因此,使用包含随机性的 UUID 可能会导致需要过多的页面来存储索引。

在 MySQL 中使用 UUID 主键的最佳方法

如果确实需要在表中使用 UUID 作为记录的唯一标识符,你可以遵循以下几个最佳实践,以尽量减少这样做带来的负面影响。

使用二进制数据类型

虽然 UUID 通常以 36 个字符的字符串形式使用,但它们也可以用原生二进制格式表示。如果将其转换为二进制值,可以将其存储在 BINARY(16) 列中,这样每个值的存储需求就会降至 16 字节。这仍然比 32 位整数大很多,但肯定比将 UUID 存储为 CHAR(36) 要好。

create table uuids(
  UUIDAsChar char(36) not null,
  UUIDAsBinary binary(16) not null
);

insert into uuids set
  UUIDAsChar = 'd211ca18-d389-11ee-a506-0242ac120002',
  UUIDAsBinary = UUID_TO_BIN('d211ca18-d389-11ee-a506-0242ac120002');

select * from uuids;
-- +--------------------------------------+------------------------------------+
-- | UUIDAsChar                           | UUIDAsBinary                       |
-- +--------------------------------------+------------------------------------+
-- | d211ca18-d389-11ee-a506-0242ac120002 | 0xD211CA18D38911EEA5060242AC120002 |
-- +--------------------------------------+------------------------------------+

使用有序的 UUID 变体

使用支持排序的 UUID 版本可以使生成的值更具顺序性,从而减轻使用 UUID 带来的部分性能和存储方面的影响,避免前文所述的部分页分裂问题。即使在多个系统上生成 UUID,基于时间的 UUID(如版本 6 或 7)也能在保证唯一性的同时,让值尽可能保持顺序性。UUIDv1 是个例外,它的时间戳最低有效部分排在最前面。

使用Mysql内置的UUID方法

MySQL 支持在 SQL 中直接生成 UUID;不过,它仅支持 UUIDv1 值。虽然单独使用这些值并非最佳实践,但 MySQL 中有一个名为 uuid_to_bin 的辅助函数。该函数不仅能将字符串值转换为二进制,还可以使用 swap flag 选项,该选项会对时间戳部分重新排序,使生成的二进制值更具顺序性。

set @uuidvar = 'd211ca18-d389-11ee-a506-0242ac120002';
-- Without swap flag
SELECT HEX(UUID_TO_BIN(@uuidvar)) as UUIDAsHex;
-- +----------------------------------+
-- | UUIDAsHex                        |
-- +----------------------------------+
-- | D211CA18D38911EEA5060242AC120002 |
-- +----------------------------------+

-- With swap flag
SELECT HEX(UUID_TO_BIN(@uuidvar,1)) as UUIDAsHex;
-- +----------------------------------+
-- | UUIDAsHex                        |
-- +----------------------------------+
-- | 11EED389D211CA18A5060242AC120002 |
-- +----------------------------------+

使用其他类型的 ID

UUID 并非在分布式架构中提供唯一性的唯一标识符类型。鉴于 UUID 最早于 1987 年创建,其他专业人士有足够的时间提出不同的格式,例如雪花 ID(Snowflake IDs)、通用唯一词法 ID(ULIDs),甚至是纳秒 ID(NanoIDs)。

# Snowflake ID
7167350074945572864

# ULID
01HQF2QXSW5EFKRC2YYCEXZK0N

# NanoID
kw2c0khavhql

结论

在 MySQL 中使用 UUID 作为主键几乎可以保证分布式系统中的唯一性;不过,这也存在一些权衡之处。幸运的是,有多种可用的版本以及其他替代方案,你可以选择能更好应对这些权衡的选项。阅读本文后,在设计下一个数据库时,你应该能够更明智地选择 ID 类型。