PHP项目在MySQL使用顺序的UUID

分布式系统的数据库都离不开UUID。在RFC 4122标准中各版本的UUID都有存在一些缺点,简单概括就是长而乱序。其中乱序则是通过顺序的UUID来减少影响。

本文介绍什么是顺序的UUID,以及如何在PHP中生成。

标准UUID的缺点

长度为36位

太长基本是无解的。虽然转换成字节型,可以将长度从36位减少为16位,但是不具备可读性,查询出来后需要再转换为字符串才可读。不过我们可以去掉4个无意义的短横线,将长度减少为32位。

乱序

所有UUID都不是单调递增的。
在InnoDB中,如果将UUID作为主键插入,数据会散列在硬盘中。对比自增主键,数据库会更大、查询会更慢。另外,InnoDB所有的次要键都会包括主键,长度为36位(或16位)的UUID也仍将被包含在每一个次要键中,将大幅增加索引大小,意味着增加内存占用。
所以,UUID不适合作为InnoDB表的主键,建议只是将UUID作为作为次要键,使用唯一约束。

不过乱序则是通过顺序的UUID来减少影响。

什么是顺序的UUID

顺序的UUID,AKA按时间排序的UUID单调递增的UUID,是RFC 4122 Version 1 UUID的变种,在RFC 4122 Version 6草稿被提出。需注意的是该草稿并未通过,所以不能直接将其称为Version 6,并且在获取社区支持上可能不如官方标准。

顺序的UUID相对于Version 1加上了一个特性:新生成的值总是大于已生成的值。
这个特性可以减少插入UUID和重新编排索引的IO开销。

即便如此,顺序UUID的长度并未变化,意味着它仍不适合作为InnoDB表的主键。

如何生成顺序的UUID

这里推荐使用10k star的组件ramsey/uuid, 里面已实现了按时间排序的UUID

引入组件

1
composer require ramsey/uuid;

快速生成

1
2
3
4
5
6
use Ramsey\Uuid\Uuid;

$uuid = Uuid::uuid6();

$uuid->toString(); // 36位字符串
$uuid->getByte(); // 16位字节型

虽然组件文档很严谨将按时间排序的UUID归为非官方标准,但组件中还是沿用了草稿的version 6命名,所以本文具有时效性,如果RFC标准发生更新了,请以组件文档为主。

生成流程解析

这里以4.0.1版本为例(为便于阅读进行了重排版)作简单的解析。
组件遵循RFC 4122以及草稿的规范,uuid6()也是uuid()的变种,也是接收节点$node与时钟序列$clockSeq两个参数。

1
2
3
4
public static function uuid6(?Hexadecimal $node = null, ?int $clockSeq = null): UuidInterface 
{
return self::getFactory()->uuid6($node, $clockSeq);
}

这两个参数最后在vendor/ramsey/uuid/src/Generator/DefaultTimeGenerator.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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public function generate($node = null, ?int $clockSeq = null): string
{
if ($node instanceof Hexadecimal) {
$node = $node->toString();
}

$node = $this->getValidNode($node);

if ($clockSeq === null) {
try {
// This does not use "stable storage"; see RFC 4122, Section 4.2.1.1.
$clockSeq = random_int(0, 0x3fff);
} catch (Throwable $exception) {
throw new RandomSourceException(
$exception->getMessage(),
(int) $exception->getCode(),
$exception
);
}
}

$time = $this->timeProvider->getTime();

$uuidTime = $this->timeConverter->calculateTime(
$time->getSeconds()->toString(),
$time->getMicroseconds()->toString()
);

$timeHex = str_pad($uuidTime->toString(), 16, '0', STR_PAD_LEFT);

if (strlen($timeHex) !== 16) {
throw new TimeSourceException(sprintf(
'The generated time of \'%s\' is larger than expected',
$timeHex
));
}

$timeBytes = (string) hex2bin($timeHex);

return $timeBytes[4] . $timeBytes[5] . $timeBytes[6] . $timeBytes[7]
. $timeBytes[2] . $timeBytes[3]
. $timeBytes[0] . $timeBytes[1]
. pack('n*', $clockSeq)
. $node;
}

/**
* Uses the node provider given when constructing this instance to get
* the node ID (usually a MAC address)
*
* @param string|int|null $node A node value that may be used to override the node provider
*
* @return string 6-byte binary string representation of the node
*
* @throws InvalidArgumentException
*/
private function getValidNode($node): string
{
if ($node === null) {
$node = $this->nodeProvider->getNode();
}

// Convert the node to hex, if it is still an integer.
if (is_int($node)) {
$node = dechex($node);
}

if (!ctype_xdigit((string) $node) || strlen((string) $node) > 12) {
throw new InvalidArgumentException('Invalid node value');
}

return (string) hex2bin(str_pad((string) $node, 12, '0', STR_PAD_LEFT));
}

如果$clockSeq为null,则默认使用一个0~16383范围内的随机数。

对于默认的时钟序列,组件未遵循RFC标准的稳定存储,因为这部分需要额外的数据存储。如果你的节点在每微秒都有很高的生成频率,就需要自行维护。目前组件支持相同节点在每微秒生成16384个不重复的UUID,目前地球上应该还没出现这个频率以上的场景。

如果$node为null,将调用默认的nodeProvider获取。
vendor/ramsey/uuid/src/FeatureSet.php中定义了所有默认特性,默认的nodeProviderFallbackNodeProvider, 其实是一个集合,将按顺序尝试获取。其中集合中的SystemNodeProvider是获取系统的MAC地址,RandomNodeProvider是使用随机数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Returns a node provider configured for this environment
*/
private function buildNodeProvider(): NodeProviderInterface
{
if ($this->ignoreSystemNode) {
return new RandomNodeProvider();
}

return new FallbackNodeProvider(new NodeProviderCollection([
new SystemNodeProvider(),
new RandomNodeProvider(),
]));
}

如果你的分布式项目运行在Docker容器中,要注意:如果没有启动参数,按照Docker的默认分配机制,会导致每个PHP容器的MAC地址一致,而此组件默认使用MAC地址,所以最终导致节点一致,提高UUID的重复几率。

Refrences

[0] RFC 4122
[1] Storing UUID Values in MySQL
[2] ramsey/uuid doc