主键生成策略UUID

在分库分表环境下,由于表中数据同时存在不同数据库中,单服务时主键值所使用的自增长将无用武之地,各分区数据库自生成的ID无法保证全局唯一。所以需要单独设计全局主键,以避免跨库主键重复问题。

这里要说的是常见的主键生成策略之一UUID

UUID标准形式为36个字符(32 个 16 进制数字和 4 个连字符)占36字节,分为5段,形式为8-4-4-4-12的36个字符,例如:550e8400-e29b-41d4-a716-446655440000。若是二进制存储,无需连字符,为128位二进制,即16字节。

UUID是主键是最简单的方案,本地生成,性能高,没有网络耗时。但缺点也很明显,由于UUID非常长,会占用大量的存储空间;另外,作为主键建立索引和基于索引进行查询时都会存在性能问题,在InnoDB下,UUID的无序性会引起数据位置频繁变动,导致页分裂。

小知识点

十进制,每个数字需要4位二进制位来表示,因为 4 位二进制数可以表示 16 种不同的值(从 0000 到 1111),而十六进制,每个数字也需要4位二进制位来表示。十进制与二进制之间可以相互转换,但不像十六进制这样直接一个数对应 4 位二进制这么简洁的关系。
所以128位的uuid是32个字符,即32个数字。在二进制存储时,每个数字占4位二进制位,所以是128位16字节。字符串存储时,32个字符+4个连字符,共36个字符,每个字符一个字节,即需要36字节存储。

在InnoDB下,UUID的无序性导致页分裂的原因

UUID 是随机生成的无序字符串,作为主键时,新插入的数据无法确定其插入位置,可能会在 B+ 树索引的中间位置。这与自增主键每次插入数据都在索引的最后位置不同,自增主键插入时不需要频繁调整数据位置,而 UUID 插入时会导致数据页中的数据频繁移动,以腾出空间插入新数据。

比如,当插入无序的 UUID 主键时,若数据页已满,为维护 B+ 树的有序性,需要申请新数据页并进行页分裂,将部分数据移动到新页,调整数据页之间的关系,增加了数据重排的次数和存储开销。

由于InnoDB 使用 B+ 树作为索引结构,数据按照主键顺序存储在叶子节点。因为 UUID 无序,无法利用索引进行高效范围查询,每次分页查询都需扫描大量无用数据,导致随机读写严重,性能下降。随着数据量增加,分页查询的 I/O 操作增多,查询时间变长,严重时影响用户体验。

页分裂解决办法

1.若业务中有合适的业务字段(如时间字段),可先对该字段进行排序,再结合 UUID 主键进行分页查询。例如,按创建时间降序排序,每页获取一定数量的记录,同时记录每页最后一个记录的 UUID 值,下一页查询时以此 UUID 值为起点进行查询,如此可提高分页查询效率。

2.使用有序的 UUID 替代方案,采用如 UUIDv7 等基于时间戳的有序 UUID,其将时间戳置于前面,使生成的 UUID 相对有序。插入数据时能减少页分裂和数据移动,分页查询时可按 UUID 的顺序进行,提高查询效率,在一定程度上保留了 UUID 的唯一性和随机性特点。(在 UUID 前添加自然数字序号也可以使数据变得有序,时间顺序的序号更直观)。

或使用数据库自增主键生成全局唯一 ID 的中间件,兼具自增 ID 的有序性和分布式环境下的唯一性,便于分页查询。

3.使用Snowflake 算法,它是 Twitter 开发的一种分布式唯一 ID 生成算法。它通过结合机器节点、时间戳和序列号等信息来生成唯一的 64 - bit 整数 ID。其构成部分包括 1 位符号位(总是 0)、41 位时间戳、10 位机器节点、12 位序列号。其优点是,生成的 ID 是有序的,能够保证在全局范围内唯一,并且可以在一定程度上避免 UUID 的无序性带来的问题。场景,在分布式系统中,需要一个高效的、能够生成全局唯一且有序的 ID 的场景。

Golang 使用UUID的例子:

1
2
3
4
5
6
7
8
9
10
package main

import (
"github.com/google/uuid"
)
func main() {
// 生成新的UUID
newUUID := uuid.New().String()
fmt.Println(newUUID)
}