
选择一个标识符看起来是个小决定,直到整个应用围绕它开始扩展。
一开始,自增整数感觉足够了。它小巧、快速、易读,而且每个关系型数据库都支持它。然后需求开始变化:
通常就在这时,UUID、ULID 和 Sqid 进入了讨论。
它们经常被当作解决同一问题的东西来讨论,但其实不然。它们在某些地方有重叠,但背后的设计模型不同:
在本文中,我们将对它们的工作方式、带来的权衡,以及我在真实应用中何时使用它们进行一次实用的深度解析。示例将使用 PHP,但这些思路与语言和框架无关。
在选择格式之前,我们需要明确标识符的职责。
一个标识符可以用于不同场景:
这些职责有不同的要求。
例如,数据库主键应该紧凑、索引高效、永久稳定。公开 URL 的 ID 应该暴露安全、便于复制。事件 ID 应该无需集中协调即可全局唯一。安全令牌应该是不可猜测的,通常不应可解码。
因此,问题不应该是:
哪种 ID 最好?
更好的问题是:
系统中的这一部分需要标识符具备哪些特性?
一旦这样问,对比就清晰多了。
让我们从高层面开始。
这个对比已经告诉我们一件重要的事:
Sqid 也是生成的 ID,但它们的生成来源与 UUID 和 ULID 不同。
UUID 和 ULID 从时间、随机性或两者结合生成独立的标识符。Sqid 从你提供的数字生成确定性的、可逆的 ID。如果你的数据库行有一个整数 ID 12345,Sqid 可以把它变成类似 NkK9q 的东西。但那个数字仍然由该字符串表示。它不是加密,不是哈希,也不是安全边界。
这个区别会在文章中反复出现。
UUID 代表 Universally Unique Identifier(通用唯一标识符)。
现代 UUID 规范是 RFC 9562,它取代了 RFC 4122。UUID 始终是 128 位。规范的文本表示就是大家熟悉的 36 字符十六进制加横线格式:
f81d4fae-7dec-11d0-a765-00a0c91e6bf6
字符串由五组十六进制字符组成,用横线分隔:
8-4-4-4-12
在底层,UUID 还包含 版本(version) 和 变体(variant) 位。版本告诉你这是哪种 UUID。
应用开发者通常关心的版本有:
对于大多数现代应用工作,实际选择通常在 UUIDv4 和 UUIDv7 之间。
UUIDv4 是经典的随机 UUID。
在保留所需的版本和变体位后,它使用 122 个随机位。这给了它巨大的标识符空间,这也是它多年来成为分布式系统默认答案的原因。
在 PHP 中,使用 ramsey/uuid,生成方式如下:
use Ramsey\Uuid\Uuid;
$orderId = Uuid::uuid4()->toString();
echo $orderId; // 8f2d7e76-7f41-4c43-9d67-9d2b4b2c8b98
你也可以验证字符串格式:
use Ramsey\Uuid\Uuid;
if (!Uuid::isValid($orderId)) {
throw new InvalidArgumentException('Invalid order ID.');
}
UUIDv4 的主要优势是简单:生成随机字节,设置 UUID 位,你就得到了一个无需与数据库或集中服务通信即可创建的 ID。
这非常适合:
但 UUIDv4 有一个著名的缺点:它不按创建时间排序。
如果你将 UUIDv4 用作数据库的聚集主键,新插入的数据会随机分布在索引中。对于 B 树索引,这可能意味着更多的页分裂、更差的局部性,以及相比顺序或按时间排序的标识符更多的索引开销。
这并不意味着 UUIDv4 总是不适合数据库。它意味着你应该了解存储和索引成本,而不是假设所有 ID 都像整数一样行为。
UUIDv7 旨在解决一个常见的现代问题:我们想要 UUID,但也想要更好的时间排序行为。
RFC 9562 将 UUIDv7 定义为具有以下结构的 UUID:
重要之处在于布局。因为时间戳位于 ID 的最高有效端,UUIDv7 值会随时间按字节顺序和字符串顺序自然排序。
使用 ramsey/uuid,看起来像这样:
use Ramsey\Uuid\Uuid;
$eventId = Uuid::uuid7()->toString();
echo $eventId; // 0194f21a-7d7b-7333-9f4d-1e6f6c7b6b71
这使得 UUIDv7 成为新系统的强大默认选择,这些系统希望拥有 UUID 的语义和更好的数据库局部性。
其思维模型是:
UUIDv7 为你提供了 UUID 的分布式唯一性故事,同时具有更适合追加密集型系统的排序形态。
但仍有一些细节需要考虑。UUIDv7 会暴露大致的创建时间。对于事件、日志、订单和许多公开资源来说,这通常没问题,但在时间本身敏感的领域,这可能很重要。
另外,“可排序”并不意味着“完美序列号”。在同一毫秒内生成的多个 ID 仍需要随机性或计数器来避免冲突并保持单调性。好的库会处理这一点,但这个区别很重要。
ULID 代表 Universally Unique Lexicographically Sortable Identifier(通用唯一字典序可排序标识符)。
与 UUID 一样,ULID 也是 128 位。其布局为:
01AN4Z07BY 79KA1307SR9X4MV3
|----------| |----------------|
timestamp randomness
48 bits 80 bits
时间戳是毫秒级 Unix 时间。随机部分是 80 位。规范字符串是 26 个字符,使用 Crockford 的 Base32 字母表:
01ARZ3NDEKTSV4RRFFQ69G5FAV
这给 ULID 带来了一些不错的特性:
在 PHP 中,使用 robinvdvleuten/ulid,生成方式如下:
use Ulid\Ulid;
$articleId = Ulid::generate();
echo (string) $articleId; // 01JGY7TV4J5XJ6BQWQ9S7F0X9Z
你也可以读取 ULID 所表示的时间戳:
use Ulid\Ulid;
$articleId = Ulid::generate();
echo $articleId->toTimestamp(); // 1561622862
这种兼容性很有用,因为即使你的应用更喜欢 ULID 字符串,某些基础设施仍然是 UUID 形态的。
ULID 的时间戳优先布局使其在不同毫秒之间自然排序。但如果在同一毫秒内生成多个 ULID 会怎样?
ULID 规范描述了一种单调策略:当检测到同一毫秒时,可以递增随机部分,使下一个 ULID 排在上一个之后。
这会产生如下行为:
01BX5ZZKBKACTAV9WEVGEMMVRZ
01BX5ZZKBKACTAV9WEVGEMMVS0
01BX5ZZKBKACTAV9WEVGEMMVS1
时间戳部分相同,但随机部分向前移动。
这很重要,因为“基于时间戳”本身是不够的。如果你的生成器在同一毫秒内创建许多 ID,单调实现可以比该毫秒内纯随机性保留更有用的顺序。
与 UUIDv7 一样,这并不会把 ULID 变成业务序列号。它给你稳定的技术排序,而不是无间隙的发票编号。
现在让我们切换思维模型。
Sqid 是生成的 ID,但它们不是独立的 128 位分布式标识符。Sqid 是从一个或多个非负整数生成的短 URL 安全 ID。
使用官方 PHP 包:
use Sqids\Sqids;
$sqids = new Sqids();
$publicId = $sqids->encode([1, 2, 3]);
$numbers = $sqids->decode($publicId);
echo $publicId; // 86Rf07
var_dump($numbers); // [1, 2, 3]
当你的数据库已经使用整数 ID,但你想要不同的公开 ID 形态,而不是像下面这样的 URL 时,这非常有用:
/orders/12345
你可以暴露:
/orders/NkK9q
然后在边界处解码:
use Sqids\Sqids;
final readonly class PublicOrderId
{
public function __construct(private Sqids $sqids) {}
public function encode(int $orderId): string
{
return $this->sqids->encode([$orderId]);
}
public function decode(string $publicId): int
{
$numbers = $this->sqids->decode($publicId);
if (count($numbers) !== 1) {
throw new InvalidArgumentException('Invalid public order ID.');
}
return $numbers[0];
}
}
这保持了领域模型的简单。你的数据库仍然可以使用高效的整数键,而你的公开路由使用从这些键生成的 Sqid。
但我们需要诚实地面对权衡:
Sqid 是可逆的。任何拥有字母表并付出足够努力的人都可以将它们解码回数字。
官方文档对此非常明确:Sqid 不是加密,不应用于敏感数据。
有一个 Sqid 细节很容易被忽略。
由于算法设计,解码随机字符串有时可能产生相同的数字序列。如果你的应用需要知道某个 ID 是否是对应数字的规范 Sqid,你应该解码它,重新编码结果,然后比较。
use Sqids\Sqids;
function isCanonicalSqid(Sqids $sqids, string $id): bool
{
$numbers = $sqids->decode($id);
if ($numbers === []) {
return false;
}
return $sqids->encode($numbers) === $id;
}
这在公开 URL 应具有唯一规范形态时特别有用。如果有人访问了解码为相同内部 ID 的非规范形态,你可以拒绝它或重定向到规范 URL。
Sqid 还支持最小长度:
use Sqids\Sqids;
$sqids = new Sqids(minLength: 10);
echo $sqids->encode([1, 2, 3]); // 86Rf07xd4z
以及自定义字母表:
use Sqids\Sqids;
$sqids = new Sqids(
alphabet: 'FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE',
);
自定义字母表会改变输出,但不应被视为秘密。它是一种格式选择,是一种弱混淆层,而不是安全机制。
数据库行为是这个对比变得实用的地方。
想象三张表:
orders_with_uuid_v4(id CHAR(36) PRIMARY KEY)
orders_with_uuid_v7(id CHAR(36) PRIMARY KEY)
orders_with_integer_id(id BIGINT UNSIGNED PRIMARY KEY)
三者都可以工作。但它们的行为不同。
UUIDv4 值是随机的。如果用作主键,插入会落在索引各处。对于写入密集型表,这可能代价高昂。
UUIDv7 和 ULID 值是按时间排序的。新值通常落在索引末尾,这对 B 树局部性更友好。
整数 ID 紧凑且自然顺序。它们对于内部关系数据仍然非常出色,尤其是当系统不需要分布式 ID 生成时。
Sqid 不必改变数据库键。它们通常位于数据库之上,作为从内部数字生成的公开 ID:
flowchart LR
A[Public URL /orders/NkK9q] --> B[Decode Sqid]
B --> C[Integer ID 12345]
C --> D[Database lookup by primary key]
Sqid 通常最好用作从系统内部整数键生成的公开 ID。
这就是为什么我通常不把 Sqid 视为直接的主键策略。我把它们看作是为系统中已存在的数字标识符生成的公开 ID。
UUID 和 ULID 都是 128 位值。你可以将它们存储为字符串,但这并非唯一选择。
常见选择有:
CHAR(36),用于规范 UUID 字符串CHAR(26),用于 ULID 字符串BINARY(16),用于紧凑的 16 字节存储字符串存储便于检查和调试。二进制存储更小,对索引大小可能更好,但需要在应用边界进行一致的转换。
对于使用 ramsey/uuid 的 UUID,你可以获取二进制字节:
use Ramsey\Uuid\Uuid;
$uuid = Uuid::uuid7();
$string = $uuid->toString();
$bytes = $uuid->getBytes();
对于 ULID,常见的面向应用的格式是 26 字符的 Base32 字符串。如果你决定将 UUID 或 ULID 存储为 BINARY(16),请明确该转换并进行充分测试,因为存储细节不应泄漏到应用的其他部分。
我的实用建议很简单:如果表较小或中等规模,且开发者体验更重要,字符串存储就可以了。如果表很大、写入密集或索引大小很重要,请在确定设计之前对二进制存储和按时间排序格式进行基准测试。
这是许多 ID 选择容易出错的部分。
标识符不会自动成为秘密。
UUIDv4 在使用安全随机性生成时难以猜测,但一旦 UUID 出现在 URL 中,它仍然只是一个标识符。它不应替代授权。
UUIDv7 和 ULID 会暴露大致创建时间。这对调试和排序很有用,但也可能泄漏信息。如果暴露创建时间是个问题,请为该表面使用不同的公开 ID 或添加另一层。
Sqid 是明确可逆的。它们让数字看起来更好看,但不保护敏感数据。如果暴露解码后的数字会造成安全问题,Sqid 就是错误的工具。
这是一条好规则:
如果仅仅因为某人知道资源 ID 就会危险地访问该资源,问题不在于 ID 格式。问题在于缺少授权。
使用 ID 进行标识。使用权限、签名、过期时间或随机令牌来保证安全。
例如,密码重置令牌不应该是围绕用户 ID 的 Sqid:
// 敏感令牌不要用这个主意。
$token = $sqids->encode([$userId]);
它应该从安全随机字节生成:
$token = bin2hex(random_bytes(32));
那个令牌不是用来解码的。它应该是不可猜测的。
我喜欢的一种设计是将 内部身份 与 公开身份 分离。
例如,一个 orders 表可以有:
idid每种方法都有效,但它们传达了不同的设计选择。
当你想要快速的关系存储,且只需要更好的公开 URL 时使用。
$publicId = $sqids->encode([$order->id]);
这简单高效。权衡是公开 ID 可逆。
当你希望公开 ID 独立生成,不暴露数据库序列时使用。
use Ulid\Ulid;
$orderPublicId = (string) Ulid::generate();
这适合 API、分布式系统,以及可能在主数据库流程之外创建的记录。
当分布式生成足够重要,以至于你希望主键本身就是全局唯一时使用。
这可以简化外部引用,但会改变数据库存储和索引特性。不要仅仅因为 ID 看起来现代就做出这个决定。
这是我通常遵循的决策流程。
flowchart TD
A[Need an identifier] --> B{Do you already have an integer ID?}
B -->|Yes| C{Is this only for public presentation?}
C -->|Yes| D[Use Sqids if reversibility is acceptable]
C -->|No| E[Keep the integer internally]
B -->|No| F{Need distributed generation?}
F -->|No| G[An integer key may still be enough]
F -->|Yes| H{Need time ordering?}
H -->|Yes| I[Use UUIDv7 or ULID]
H -->|No| J[Use UUIDv4]
正确的 ID 格式取决于工作需求,而不是当前流行哪种格式。
用简单的话说:
最后一点重要到值得重复:随机令牌是不同类别。不要因为 Sqid、ULID 或基于时间戳的 ID 看起来随机,就把它们用作敏感秘密。
如果应用使用多种 ID 风格,我喜欢将该选择保留在边界处,而不是把包调用分散得到处都是。
例如:
interface PublicIdGenerator
{
public function generate(): string;
}
UUIDv7 实现可以是:
use Ramsey\Uuid\Uuid;
final readonly class UuidV7PublicIdGenerator implements PublicIdGenerator
{
public function generate(): string
{
return Uuid::uuid7()->toString();
}
}
ULID 实现可以是:
use Ulid\Ulid;
final readonly class UlidPublicIdGenerator implements PublicIdGenerator
{
public function generate(): string
{
return (string) Ulid::generate();
}
}
对于 Sqid,我不会使用相同的接口,因为 Sqid 需要一个输入数字。这种差异是设计的一部分,应该保持可见:
use Sqids\Sqids;
final readonly class PublicIntegerIdEncoder
{
public function __construct(private Sqids $sqids) {}
public function encode(int $id): string
{
return $this->sqids->encode([$id]);
}
public function decode(string $publicId): int
{
$numbers = $this->sqids->decode($publicId);
if (count($numbers) !== 1 || $this->sqids->encode($numbers) !== $publicId) {
throw new InvalidArgumentException('Invalid public ID.');
}
return $numbers[0];
}
}
这种小的分离可以防止一个常见的架构错误:因为所有 ID 格式都是字符串就假装它们可以互换。
它们不是互换的。它们带有不同的保证。
让我们通过指出一些常见错误来结束这个对比。
Sqid 让 ID 更好看。它们不会让数据保密。如果解码值会暴露敏感内容,就不要将该表面使用 Sqid。
UUIDv4 很棒,但对于新的写入密集型系统,UUIDv7 通常是更好的默认选择,因为它保持了 UUID 标准形态,同时改善了时间局部性。
ULID 包含时间戳。它们有用且紧凑,但不是秘密令牌。
难以猜测的 ID 不是授权系统。始终检查当前行为是否有权访问该资源。
二进制 UUID 存储可能更好,但也会增加转换复杂性。只有当存储和索引收益值得这种复杂性时才使用它。
UUID、ULID 和 Sqid 都有用,但它们的用途不同。
UUIDv4 是经典的分布式随机标识符。UUIDv7 是我在需要时间排序时会选择的现代 UUID。ULID 为你提供紧凑、可排序、URL 安全的 128 位 ID。Sqid 在你已有整数 ID 且想要更友好的公开表示时非常出色。
最重要的教训不是任何包的语法。而是理解你需要的特性:
先回答这些问题,正确的格式通常就会变得显而易见。
希望你喜欢这篇文章,如果喜欢,别忘了分享给朋友!!!再见!