fab2s/souuid

PHP中的简单有序UUID生成器

1.0.2 2022-08-03 13:08 UTC

This package is auto-updated.

Last update: 2024-08-27 21:44:03 UTC


README

Build Status QA Total Downloads Monthly Downloads Latest Stable Version Maintainability Scrutinizer Code Quality PRs Welcome License

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支持基于gmpbase62格式,这可以是一个方便的格式,用于向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/UuidRamsey/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 许可协议 许可。