fab2s / souuid
PHP中的简单有序UUID生成器
Requires
- php: ^7.1|^8.0
Requires (Dev)
- phpunit/php-timer: ^2.1
- phpunit/phpunit: ~8.0|~7.0
- ramsey/uuid: ^3.7|^4.0
- symfony/console: >=3.4
- webpatser/laravel-uuid: ^3.0|^4.0
Suggests
- ext-ctype: To use base62 and base36 formats
- ext-gmp: To use base62 and base36 formats
This package is auto-updated.
Last update: 2024-08-27 21:44:03 UTC
README
SoUuid
是一个使用PHP简单、高效生成有序UUID的工作提案。
虽然UUID已经有 明确定义的标准,但它们在作为DBMS主键使用时性能相当差。原因众所周知,归结为
- UUID长度为36个字符,这会显著增加索引大小,尤其是在InnoDB等数据库中,每个二级索引也会包含主键。
- 插入成本极高,因为UUID主键是随机的,因此在索引中高度分散。
您可以在 Percona数据库性能博客 或 MariaDb的知识库 上找到更多信息和分析。两者都包括在数据库级别处理此问题的解决方案。虽然它们侧重于MySQL,但与其他DBMS的问题类似:UUID缺乏顺序和UUID大小昂贵,顺序对于大量插入到PK至关重要。
PHP中已经以各种方式解决了顺序问题,其中大多数是作为RFC兼容实现的一些额外功能。在我看来,对于PHP来说,某些简单且更合适的方法可能会有些用处。因为首先,PHP限制在微秒级别,RFC实现必须人为地满足“官方”100ns间隔,这实际上削弱了UUID的唯一性,因为相同的RFC随机位现在保护了一个十倍宽的间隔以防止冲突。
当然,为了保持标准并牺牲一些性能和可用性是可以接受的,但总的来说,这似乎也很奇怪,要被这样一个低效的格式所束缚,它甚至不符合原始RFC定义的保证水平。然后,使用格里高利历作为时间起源只是没有什么意义。使用纪元时间似乎更合理、更方便处理。除非你打算永远将UUID绑定到某个物理MAC地址,而现在硬件已经成为商品,MAC地址经常更改,除了表示“进行了部署”之外没有其他意义。所以处理MAC地址格式以存储更有意义的ID似乎有些尴尬,尤其是在PHP中,因为MAC地址是一个相对遥远的信息。它也是将某些特定格式绑定到属于应用空间的事物(如工作者或工作ID)的一种限制。
虽然曾经将UUID永久绑定到某些物理MAC地址可能更有意义,但随着硬件成为商品以及MAC地址经常更改,这已经不再明显。因此,似乎有点尴尬,必须处理MAC地址格式以存储更有意义的ID,尤其是在PHP中,因为MAC地址是一个相对遥远的信息。它也是将某些特定格式绑定到属于应用空间的事物(如工作者或工作ID)的一种限制。
因此,总的来说,感觉有一些简单的改进空间,希望能帮助解决实际生活中的问题。
安装
SoUuid
可以使用composer安装
composer require "fab2s/souuid"
如果你想要安装php >=7.1.0版本,使用
composer require "fab2s/souuid" ^1
如果你想要安装php 5.6/7.0版本,使用
composer require "fab2s/souuid" ^0
除了将0.x版本进一步修改为1.x版本外,没有其他变化
实际应用中
无标识符
$uuid = SoUuid::generate(); $uuid->getBytes(); // 16 bytes binary string b"\x05d¦U<Ÿ¾\x00:F°(ÛEa\x07" $uuid->getHex(); // "0564a6553c9fbe003a46b028db456107" $uuid->getDateTime(); // DateTimeImmutable 2018-02-07 21:54:01.0 +00:00 $uuid->getMicroTime(); // 1518040440.938430 $uuid->getIdentifier(); // "" $uuid->getString(); // "0564a6553c9fbe-003a-46b0-28db-456107" $uuid->decode(); // lazy generated array representation /* array:4 [ "microTime" => "1518040440.938430" "dateTime" => DateTimeImmutable @1518040441 {#14 date: 2018-02-07 21:54:01.0 +00:00 } "identifier" => "" "rand" => "3a46b028db456107" ] */
字符串格式不匹配RFC模式,以防止任何混淆。但它仍然以所有方式满足RFC的存储要求,以便更好地便携:36个字符的字符串或16字节二进制字符串,也是最有效的选项。
带有标识符
$uuid = SoUuid::generate('w12345'); $uuid->getIdentifier(); // "w12345" $uuid->decode(); /* array:4 [ "microTime" => "1518040333.014178" "dateTime" => DateTimeImmutable @1518040333 {#14 date: 2018-02-07 21:52:13.0 +00:00 } "identifier" => "w12345" "rand" => "7cea2b" ] */
您获得二进制16
b"\x05d¦NÍÔ¢w12345|ê+"
从字符串构建
您可以从字符串、十六进制和二进制形式轻松实例化UUID
$uuid = SoUuid::fromString('0564a64ecdd4a2-7731-3233-3435-7cea2b'); $uuid = SoUuid::fromHex('0564a64ecdd4a27731323334357cea2b'); $uuid = SoUuid::fromBytes(b"\x05d¦NÍÔ¢w12345|ê+"); $uuid->decode(); /* array:4 [ "microTime" => "1518040333.014178" "dateTime" => DateTimeImmutable @1518040333 {#14 date: 2018-02-07 21:52:13.0 +00:00 } "identifier" => "w12345" "rand" => "7cea2b" ] */
Base62
SoUuid
支持基于gmp的base62
格式,这可以是一个方便的格式,用于向HTTP接口和URL公开。
SoUuid::generate()->getBase62(); // ABRxdU5wbCLM7E7QhHS6r $uuid = SoUuid::fromBase62('ABRxdU5wbCLM7E7QhHS6r');
Base62 SoUuids
在SoUuid有效时间框架内的长度可变,最多可达22个字符。如果您用0左填充到最大长度,它们就完全按微秒排序。
$orderedBase62Uuid = str_pad(SoUuid::generate()->getBase62(), 22, '0', STR_PAD_LEFT);
如果您现在开始生成,base62 UUID的长度将是21个字符,直到UTC 2398-12-22 05:49:06(base 62 zzzzzzzzz = 13 537 086 546 263 551 µsec或13 537 086 546纪元时间)。这应该有足够的时间来考虑左填充旧UUID,以防在那个时间点仍然需要保持一致的排序。
这使得base62
格式成为PK的第二大高效格式,仅次于原始二进制格式。以多5个(或如果你有2400年的计划,则是6个)字符为代价,您得到了一个更友好的格式,几乎可以在任何地方使用,无需进一步转换(与URL兼容等)除了大小写不敏感的地方。对于数据库管理系统,很容易确保PK字段是大小写敏感的(二进制或ascii_bin),但在Windows系统上的文件系统不区分大小写,这可能导致冲突。
在这种情况下,base36
格式可能是一个更好的选择。
Base36
秉承同样的精神,SoUuid
提供了base36
作为base62
的替代方案,同样基于gmp。
SoUuid::generate()->getBase36(); // bix20qgjqmi9hqxh0y9tao5u $uuid = SoUuid::fromBase36('bix20qgjqmi9hqxh0y9tao5u');
以增加最大长度25个字符为代价,格式变为不区分大小写。当适当填充时,它仍然在SoUuid整个时间框架内排序。
$orderedBase36Uuid = str_pad(SoUuid::generate()->getBase36(), 25, '0', STR_PAD_LEFT);
如果您现在开始生成,base36
UUID的长度将是24个字符,直到UTC 2085-11-09 15:34:00(base 36 zzzzzzzzzz = 3 656 158 440 062 975 µsec或3 656 158 440纪元时间)。这仍然留有足够的时间来考虑左填充旧UUID,以防在那个时间点仍然需要保持一致的排序。
总的来说,这使得base36格式在效率上作为PK的第三大。您得到了一个友好的有序格式,就像常规UUID格式一样便携(不区分大小写),与base62相比,多3个字节,与RFC格式相比,仍然保留了11个字节。
Laravel(很棒)
您可以直接在模型中使用SoUuidTrait
,以开始在模型中使用SoUuid作为主键。
默认情况下,它将使用经典字符串形式,但您也可以通过覆盖模型中的generateSoUuid
方法(或在使用此特性的抽象或特质中使用)来使用任何其他形式。
/** * @throws \Exception * * @return string */ public static function generateSoUuid(): string { // base62 example, could be any of the available forms return SoUuid::generate(static::generateSoUuidIdentifier())->getBase62(); }
请注意,您可以手动指定任何标识符,这可能比默认的
generateSoUuidIdentifier
更合适,后者将基于ModelName
中的每个大写字母(最多6个,例如从ModelName
中的mn
)生成。
迁移
无论哪种形式,数据库字段中携带SoUuid的最佳类型应该是大小写不敏感的ascii,并且与所选类型的长度匹配。
// Raw binary form $table->char('id', 16)->charset('ascii')->collation('ascii_bin')->primary(); // base62 unpaded, valid until 2398-12-22 05:49:06 UTC $table->char('id', 21)->charset('ascii')->collation('ascii_bin')->primary(); // base36 unpaded, valid until 2085-11-09 15:34:00 UTC $table->char('id', 24)->charset('ascii')->collation('ascii_bin')->primary(); // string form, valid until 4253-05-31 22:20:37 UTC $table->char('id', 36)->charset('ascii')->collation('ascii_bin')->primary();
所有这些都完全按微秒排序。
幕后
SoUuid
旨在简单、高效,并具有高度的防止冲突的保护。
配方相当基本,大部分灵感来源于原始RFC。
- 当前时间以微秒为单位存储在56位二进制格式中(7字节)。7字节比RFC的100ns公历时间少一个字节,但足以编码微秒时间戳,直到UTC 4253-05-31 22:20:37(或Unix纪元后的2^56微秒减1 µs)。
- 遵循RFC精神,接下来预留6个字节作为标识符,类似于RFC中的
node
参数,但这个标识符可以是任意6个或以下字节,不一定是类似于十六进制Mac地址的字符串。 - 再次像RFC那样,最终添加了一些随机字节,但由于我们在时间部分节省了一个字节,通过限制有效期限为未来的两千年并减少精度到微秒,我们可以再选择一个随机字节。
结果是16字节的二进制字符串,准备好用作主键,并按微秒排序。这意味着,只有同一微秒内生成的插入操作可能不会直接出现在主索引中。如果这种情况发生,最好是插入索引的顶部而不是中间或最坏的位置。
然后,在随机部分之前添加自定义标识符槽,因为这样做可以在扫描主索引上的自定义标识符时有所帮助,因为它们会在字符串中更早地被发现。
如果您不提供标识符,6个预留字节中的5个将被随机选择,并以空字节左填充以记住它是随机的。如果您使用少于6个字节作为标识符,则空字节右填充,并使用随机字节填充最终剩余的空隙。因此,您只需要担心不要在标识符中使用空字节,并限制它们的长度为6个字节。例如,"abc1"
将被编码为b"abc1\x00Ü"
(包括一个额外的熵字节或256更多的组合)在二进制UUID字符串中,并在解码时精确地检索为"abc1"
。
因此,总的来说,这意味着在最坏和疯狂的情况下,仅使用6个字节的唯一标识符时,在同一个微秒内发生冲突的概率为2^24(= 16 777 216)。不使用任何标识符,您将添加5个随机字节,总共达到8个随机字节(2^64种组合)以防止同一微秒内的冲突,这符合最佳的PHP RFC实现。当然,使用有意义的和高效的标识符,如工作ID,可以将冲突的概率降低到零。就像其他RFC实现一样,您仍然需要手动设置标识符,否则默认为随机字符串。
SoUuid
对使用的标识符没有意见,这6个字节可以是6个字母数字字符,如w99j42
,或者是一个大整数转换为16进制二进制。例如,4个字节可以编码一个十进制整数,最大为2^32 - 1或4 294 967 295,这对于限制在一个微秒内的冲突已经相当大了。使用完整的6个字节,您将得到2^48或281 474 976 710 656,但我想知道,您一次实际上有多少个UUID生成器/工作进程在使用?
hex2bin(base_convert("4294967295", 10, 16)); // b"ÿÿÿÿ"
您甚至可以使用一些这些字节来添加一点随机数。这可以是一个循环覆盖的合理最大整数值,并在同一主机上的工作进程之间通过apcu共享,或者甚至为每个服务器指定其工作进程使用的循环范围等。
有许多空间来实施特定情况下的工作方案。标识符细节归其生成器所有,同时仍然是标准的一部分,可以由每个SoUuid
生成器共享和使用。甚至可以将配方移植到其他语言中,如果您的UUID中至少有一部分是PHP生成的。您只需要以稍微较小的时窗为代价来换取更容易的标识符控制,这最终是防止冲突的最佳保证。
为什么要浪费一个整个的空字节呢?
嗯,正如你可能注意到的,一个空字节(\0
)被用来区分随机标识符和用户定义标识符。很明显,在这个目的上使用更少的空间可能会引起一些麻烦。但是,至少在没有标识符的情况下,SoUuid
仍然使用了8字节的熵来保护每个微秒,并且这至少与现有PHP实现的RFC所获得的最佳理论水平相匹配。
我说理论,因为这个意味着这些实现实际上收集微秒,当你查看细节时,这似乎并不明显。
这源于microtime(1)返回一个float
,确实受到php.ini中的precision
指令的限制,默认为14。
; The number of significant digits displayed in floating point numbers.
; https://php.ac.cn/precision
precision = 14
所以默认情况下你得到
microtime(1); // 1517992591.8068
而相同的微时间可以用实际的微时间精度作为字符串获取
microtime(0); // "0.80684200 1517992591"
所以这意味着在实践中,当你使用基于microtime(1)
的代码生成UUID时,可能会发现精度更低。与RFC相比,实际的时间窗口宽度是1000倍,用于防止冲突。
SoUuid
使用基于字符串的方法,实际上保持了微秒精度。
$timeParts = explode(' ', microtime(false)); /* [ "0.30693800", // there seems to always be two extra 0 after the µs "1517993957", ] */ $timeMicroSec = $timeParts[1] . substr($timeParts[0], 2, 6); // 1517993957306938
所以我的当前想法是,除了我不是一个XOR之外,这也许是一个好的起点。因为在需要生成大量UUID的情况下,你无论如何都需要正确使用标识符,而这已经变得更容易了。这个空间也可以在以后用来扩展和版本化SoUuid
格式(请成为我的1337
^_^)。
最终,如果你不想生成标识符,仍然想得到保护每个微秒的整个9字节熵,并将冲突概率降低到2^72 = 4 722 366 482 869 645 213 696的相当疯狂值,可以使用random_bytes(6)
作为默认标识符。
由于random_compat被包含为random_bytes()的polyfill,你甚至不必担心在PHP 7以下获得良好的随机性。
性能
自从这个问题被提出以来,我包含了一个简单的基准脚本,用于比较UUID生成时间与Webpaster/Uuid和Ramsey/Uuid。请注意,这两个库都擅长完成任务,可以在生产环境中信任。此外,UUID生成时间从未是真正的性能问题,因为在这里我们谈论的是每个100K UUID的几分之一秒,而分散的插入在实践中可能会花费数小时。发现与处理4个UUID版本并尽可能深入RFC的相比实现略慢也是相当正常的。主要观点仍然是相同的:慢的部分不是实现,而是RFC的缺乏秩序以及它的使用方式(字符串形式与二进制形式及排序,这里有一些基准)。
此外,比较并不完全公正,因为所有实现并不完全做相同的事情来得到一个可比较的UUID字符串。例如,Webpatser/Uuid
在二进制生成后立即预计算字符串表示,而SoUuid
则不。在SoUuid
中,十六进制和字符串形式是懒生成的。但这并不会减慢实际的“生成后插入”,因为二进制形式是其他形式派生的根源,并且它是作为最佳性能作为PK使用的。
无论如何,如果你还关心
$ composer install --dev
$ php bench
基准脚本在PHP 7以下未经过测试。
PHP 7.1.2
Benchmarking fab2s/SoUuid vs Ramsey/Uuid vs Webpatser/Uuid
Iterations: 100 000
Averaged over: 10
PHP 7.1.2 (cli) (built: Feb 14 2017 21:24:45) ( NTS MSVC14 (Visual C++ 2015) x64 )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2017 Zend Technologies
Windows NT xxxxx 10.0 build 16299 (Windows 10) AMD64
+----------------+----------+-----------+---------+
| Generator | Time (s) | Delta (s) | % |
+----------------+----------+-----------+---------+
| fab2s/SoUuid | 0.4533 | | |
| Webpatser/Uuid | 0.9050 | 0.4517 | 99.63% |
| Ramsey/Uuid | 1.5755 | 1.1221 | 247.52% |
+----------------+----------+-----------+---------+
Time: 29.43 seconds, Memory: 2.00MB
PHP 7.2.0
Benchmarking fab2s/SoUuid vs Ramsey/Uuid vs Webpatser/Uuid
Iterations: 100 000
Averaged over: 10
PHP 7.2.0 (cli) (built: Nov 28 2017 23:48:32) ( NTS MSVC15 (Visual C++ 2017) x64 )
Copyright (c) 1997-2017 The PHP Group
Zend Engine v3.2.0, Copyright (c) 1998-2017 Zend Technologies
Windows NT xxxxx 10.0 build 16299 (Windows 10) AMD64
+----------------+----------+-----------+---------+
| Generator | Time (s) | Delta (s) | % |
+----------------+----------+-----------+---------+
| fab2s/SoUuid | 0.3421 | | |
| Webpatser/Uuid | 0.6919 | 0.3498 | 102.26% |
| Ramsey/Uuid | 1.3497 | 1.0076 | 294.57% |
+----------------+----------+-----------+---------+
Time: 23.92 seconds, Memory: 4.00MB
从这个文档中我们可以得知的唯一有趣的事实是,PHP 7.2.0 相比 PHP 7.1.2 虽然速度更快,但代价是内存使用量增加。
要求
SoUuid
已在 php 7.1, 7.2, 7.3, 7.4, 8.0 和 8.1 上进行测试。
贡献
欢迎贡献,请勿犹豫,随时开启问题并提交拉取请求。
许可协议
SoUuid
是开源软件,受 MIT 许可协议 许可。