UUID使用规范

随着 UUID 规范的日渐成熟, 目前已经可以在应用在大量日志库上面作为 主键(PrimaryKey) 使用

之所以考虑到不采用 自增ID(AUTO_INCREMENT) 就是为了方便分布式系统/多库数据合并等场景,
但因为早期规范存在缺陷现在还有一下问题要处理:

  • 早期 UUID(v1/v4) 版本是随机乱序生成, 配合 InnoDB 主键是聚簇索引会导致频繁页分裂和索引碎片

  • 采用字符串存储(CHAR(36)) 空间浪费巨大, 空间多占 2~3 倍会导致索引体积暴涨

  • UUIDv1 依赖 MAC + 时间, 可能会导致分布式多库合并出现冲突

只有在 UUIDv7 之后才修复以上问题, 目前 MySQL和MariaDB 之类方案可能因为版本导致没办法采用有序的 UUIDv7

目前不推荐直接采用 MySQL/MariaDB 内置的 UUID 类型, 需要为了考虑到兼容性采用 BINARY(16) 用二进制保存节约空间.

那么设计的表结构可能如下处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 登录日志
# MySQL 版本之后才有 UUID_TO_BIN, 为了保持兼容性需要在代码端自定义 UUID_TO_BIN 和 BIN_TO_UUID 方法
# 注意: 目前很多云数据库内部很多采用自定义魔改版本, 可能不提供这类自带函数
# 标准 UUID 本身 = 128 位(bit)的数字, 而 128 bit = 16 byte(字节), 所以可以直接采用 binary(16)
create table if not exists app_login_logs
(
id binary(16) not null comment '有序UUID标识',
uid bigint not null comment '用户ID',


# 创建信息
time bigint not null comment '用户创建时间, 毫秒级别的UTC时间戳',
ip varchar(64) not null comment '用户创建IP地址',
token varchar(32) not null default '' comment '用户最新生成的登陆凭证',

primary key (id)
) comment '应用登录日志, 采用 UUID_TO_BIN(UUID(), 1), 生成有序二进制, 通过 BIN_TO_UUID(id,1) 可以反序列化成 UUID'
engine = InnoDB
charset = utf8mb4
collate = utf8mb4_unicode_ci

因为内部 binary(16) 是作为 128 位(bit) 的数字, 所以作为主键会将其作为有序保存在内部从而不会导致页分裂和索引碎片

但是目前还没处理 UUIDv7 版本问题, 所以外部程序就需要按照最新版本 UUIDv7 来生成, 这里提供 PHP 版本的生成方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
/**
* 生成标准 UUIDv7 (时间有序 + 全球唯一)
* 用于 MySQL / MariaDB BINARY(16) 主键
* @return string 带横杠的 UUIDv7 字符串
*/
function uuid_v7(): string
{
// 1. 获取当前毫秒时间戳 (48bit)
$timestamp = (int)(microtime(true) * 1000);

// 2. 生成 74bit 随机数
$random = random_bytes(10); // 10字节 = 80bit

// 3. 时间戳转 6字节 二进制
$timeBin = hex2bin(str_pad(dechex($timestamp), 12, '0', STR_PAD_LEFT));

// 4. 拼接 + 设置版本号(0111=7) + variant(10xx)
$uuidBin = $timeBin
. chr(0x70 | (ord($random[0]) & 0x0F)) // 版本 7
. chr(0x80 | (ord($random[1]) & 0x3F)) // RFC 标准
. substr($random, 2);

// 5. 转成 UUID 字符串格式
$uuid = bin2hex($uuidBin);
return vsprintf('%08s-%04s-%04s-%04s-%012s', [
substr($uuid, 0, 8),
substr($uuid, 8, 4),
substr($uuid, 12, 4),
substr($uuid, 16, 4),
substr($uuid, 20, 12)
]);
}
?>

注意: 目前虽然生成 UUIDv7 但是还不能直接保存到 binary(16) 需要处理下二进制内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

/**
* 生成 UUID 有序二进制
* @param string|null $uuid
* @return string
*/
function uuid_to_bin(?string $uuid = null): string
{

$uuid = empty($uuid) ? uuid_v7() : $uuid;

// 去掉UUID里的横杠
$hex = str_replace('-', '', $uuid);

// 重排字节顺序 = MySQL swap_flag=1 的效果
$hex = substr($hex, 12, 4) . substr($hex, 8, 4) . substr($hex, 0, 8) . substr($hex, 16);

// 十六进制转二进制(最终16字节)
return hex2bin($hex);
}

/**
* 将有序二进制生成UUID
* @param string $bin
* @return string
*/
function bin_to_uuid(string $bin): string
{
$hex = bin2hex($bin);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 8, 8),
substr($hex, 4, 4),
substr($hex, 0, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}

大部分情况下很少会对 UUID 做复杂操作, 所以直接采用这种方式就能很好处理大部分日志库主键问题, 用数据模型处理下即可:

1
2
3
4
5
6
// 生成 16 字节有序二进制
$binaryId = uuid_to_bin();

// 直接插入 BINARY(16)
$pdo->prepare("INSERT INTO system_log(id, content) VALUES (?, ?)")
->execute([$binaryId, "测试日志"]);