首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >别再在 PHP 里乱用 UUID 了:UUID、ULID、Sqid 到底怎么选?

别再在 PHP 里乱用 UUID 了:UUID、ULID、Sqid 到底怎么选?

作者头像
Tinywan
发布2026-09-15 14:31:44
发布2026-09-15 14:31:44
290
举报
文章被收录于专栏:开源技术小栈开源技术小栈

UUID、ULID 与 Sqid:实用深度解析

选择一个标识符看起来是个小决定,直到整个应用围绕它开始扩展。

一开始,自增整数感觉足够了。它小巧、快速、易读,而且每个关系型数据库都支持它。然后需求开始变化:

  • 你需要在不同的服务中创建记录,而不必向某个数据库索取下一个 ID
  • 你希望公开 URL 不暴露顺序的数据库 ID
  • 你希望 ID 大致按创建时间排序
  • 你需要在数据行存在之前就能生成标识符
  • 你需要跨系统的事件 ID、请求 ID、任务 ID 或导入 ID

通常就在这时,UUIDULID 和 Sqid 进入了讨论。

它们经常被当作解决同一问题的东西来讨论,但其实不然。它们在某些地方有重叠,但背后的设计模型不同:

  • UUID 是标准化的 128 位标识符,用于分布式唯一性。
  • ULID 是 128 位标识符,采用时间戳优先的布局,并具有紧凑的可排序字符串表示。
  • Sqid 是从非负整数生成的短 URL 安全 ID,通常来自已有的整数主键。

在本文中,我们将对它们的工作方式、带来的权衡,以及我在真实应用中何时使用它们进行一次实用的深度解析。示例将使用 PHP,但这些思路与语言和框架无关。

我们真正要解决的问题

在选择格式之前,我们需要明确标识符的职责。

一个标识符可以用于不同场景:

  • 数据库主键
  • 公开路由参数
  • 事件 ID
  • 链路追踪或关联 ID
  • 幂等性键
  • 临时令牌
  • 展示给用户的引用编号

这些职责有不同的要求。

例如,数据库主键应该紧凑、索引高效、永久稳定。公开 URL 的 ID 应该暴露安全、便于复制。事件 ID 应该无需集中协调即可全局唯一。安全令牌应该是不可猜测的,通常不应可解码。

因此,问题不应该是:

哪种 ID 最好?

更好的问题是:

系统中的这一部分需要标识符具备哪些特性?

一旦这样问,对比就清晰多了。

快速对比

让我们从高层面开始。

  • UUIDv4: 36 个字符,由随机性生成,不按时间排序,不可解码,最适合分布式不透明 ID。
  • UUIDv7: 36 个字符,由时间戳加随机性生成,按时间排序,部分可解码(因为时间戳是值的一部分),最适合分布式按时间排序的 ID。
  • ULID: 26 个字符,由时间戳加随机性生成,字典序可排序,部分可解码,最适合紧凑的可排序公开 ID。
  • Sqid: 长度可变,由数字生成,本身不按创建时间排序,可解码,最适合从数字输入生成的公开 ID。

这个对比已经告诉我们一件重要的事:

Sqid 也是生成的 ID,但它们的生成来源与 UUID 和 ULID 不同。

UUID 和 ULID 从时间、随机性或两者结合生成独立的标识符。Sqid 从你提供的数字生成确定性的、可逆的 ID。如果你的数据库行有一个整数 ID 12345,Sqid 可以把它变成类似 NkK9q 的东西。但那个数字仍然由该字符串表示。它不是加密,不是哈希,也不是安全边界。

这个区别会在文章中反复出现。

UUID:标准的主力

UUID 代表 Universally Unique Identifier(通用唯一标识符)

现代 UUID 规范是 RFC 9562,它取代了 RFC 4122。UUID 始终是 128 位。规范的文本表示就是大家熟悉的 36 字符十六进制加横线格式:

代码语言:javascript
复制
f81d4fae-7dec-11d0-a765-00a0c91e6bf6

字符串由五组十六进制字符组成,用横线分隔:

代码语言:javascript
复制
8-4-4-4-12

在底层,UUID 还包含 版本(version) 和 变体(variant) 位。版本告诉你这是哪种 UUID。

应用开发者通常关心的版本有:

  • UUIDv4: 随机或伪随机
  • UUIDv5: 使用 SHA-1 从命名空间和名称确定性生成
  • UUIDv6: 重新排序的基于时间的 UUID,以获得更好的数据库局部性
  • UUIDv7: 毫秒级 Unix 时间戳加随机数据

对于大多数现代应用工作,实际选择通常在 UUIDv4 和 UUIDv7 之间。

UUIDv4

UUIDv4 是经典的随机 UUID。

在保留所需的版本和变体位后,它使用 122 个随机位。这给了它巨大的标识符空间,这也是它多年来成为分布式系统默认答案的原因。

在 PHP 中,使用 ramsey/uuid,生成方式如下:

代码语言:javascript
复制
use Ramsey\Uuid\Uuid;

$orderId = Uuid::uuid4()->toString();

echo $orderId; // 8f2d7e76-7f41-4c43-9d67-9d2b4b2c8b98

你也可以验证字符串格式:

代码语言:javascript
复制
use Ramsey\Uuid\Uuid;

if (!Uuid::isValid($orderId)) {
    throw new InvalidArgumentException('Invalid order ID.');
}

UUIDv4 的主要优势是简单:生成随机字节,设置 UUID 位,你就得到了一个无需与数据库或集中服务通信即可创建的 ID。

这非常适合:

  • 公开实体 ID
  • 事件 ID
  • 导入 ID
  • 分布式写入
  • 客户端生成的 ID
  • 排序不重要的关联 ID

但 UUIDv4 有一个著名的缺点:它不按创建时间排序

如果你将 UUIDv4 用作数据库的聚集主键,新插入的数据会随机分布在索引中。对于 B 树索引,这可能意味着更多的页分裂、更差的局部性,以及相比顺序或按时间排序的标识符更多的索引开销。

这并不意味着 UUIDv4 总是不适合数据库。它意味着你应该了解存储和索引成本,而不是假设所有 ID 都像整数一样行为。

UUIDv7

UUIDv7 旨在解决一个常见的现代问题:我们想要 UUID,但也想要更好的时间排序行为。

RFC 9562 将 UUIDv7 定义为具有以下结构的 UUID:

  • 开头是 48 位毫秒级 Unix 时间戳
  • 版本和变体位
  • 74 位可用于随机性,或单调计数器与随机性的混合

重要之处在于布局。因为时间戳位于 ID 的最高有效端,UUIDv7 值会随时间按字节顺序和字符串顺序自然排序。

使用 ramsey/uuid,看起来像这样:

代码语言:javascript
复制
use Ramsey\Uuid\Uuid;

$eventId = Uuid::uuid7()->toString();

echo $eventId; // 0194f21a-7d7b-7333-9f4d-1e6f6c7b6b71

这使得 UUIDv7 成为新系统的强大默认选择,这些系统希望拥有 UUID 的语义和更好的数据库局部性。

其思维模型是:

UUIDv7 为你提供了 UUID 的分布式唯一性故事,同时具有更适合追加密集型系统的排序形态。

但仍有一些细节需要考虑。UUIDv7 会暴露大致的创建时间。对于事件、日志、订单和许多公开资源来说,这通常没问题,但在时间本身敏感的领域,这可能很重要。

另外,“可排序”并不意味着“完美序列号”。在同一毫秒内生成的多个 ID 仍需要随机性或计数器来避免冲突并保持单调性。好的库会处理这一点,但这个区别很重要。

ULID:紧凑且字典序可排序

ULID 代表 Universally Unique Lexicographically Sortable Identifier(通用唯一字典序可排序标识符)

与 UUID 一样,ULID 也是 128 位。其布局为:

代码语言:javascript
复制
01AN4Z07BY      79KA1307SR9X4MV3
|----------|    |----------------|
 timestamp          randomness
  48 bits             80 bits

时间戳是毫秒级 Unix 时间。随机部分是 80 位。规范字符串是 26 个字符,使用 Crockford 的 Base32 字母表:

代码语言:javascript
复制
01ARZ3NDEKTSV4RRFFQ69G5FAV

这给 ULID 带来了一些不错的特性:

  • 比规范 UUID 字符串更短
  • URL 安全
  • 设计上不区分大小写
  • 按时间字典序排序
  • 仍然只有 128 位

在 PHP 中,使用 robinvdvleuten/ulid,生成方式如下:

代码语言:javascript
复制
use Ulid\Ulid;

$articleId = Ulid::generate();

echo (string) $articleId; // 01JGY7TV4J5XJ6BQWQ9S7F0X9Z

你也可以读取 ULID 所表示的时间戳:

代码语言:javascript
复制
use Ulid\Ulid;

$articleId = Ulid::generate();

echo $articleId->toTimestamp(); // 1561622862

这种兼容性很有用,因为即使你的应用更喜欢 ULID 字符串,某些基础设施仍然是 UUID 形态的。

ULID 单调性

ULID 的时间戳优先布局使其在不同毫秒之间自然排序。但如果在同一毫秒内生成多个 ULID 会怎样?

ULID 规范描述了一种单调策略:当检测到同一毫秒时,可以递增随机部分,使下一个 ULID 排在上一个之后。

这会产生如下行为:

代码语言:javascript
复制
01BX5ZZKBKACTAV9WEVGEMMVRZ
01BX5ZZKBKACTAV9WEVGEMMVS0
01BX5ZZKBKACTAV9WEVGEMMVS1

时间戳部分相同,但随机部分向前移动。

这很重要,因为“基于时间戳”本身是不够的。如果你的生成器在同一毫秒内创建许多 ID,单调实现可以比该毫秒内纯随机性保留更有用的顺序。

与 UUIDv7 一样,这并不会把 ULID 变成业务序列号。它给你稳定的技术排序,而不是无间隙的发票编号。

Sqid:从数字生成的 ID

现在让我们切换思维模型。

Sqid 是生成的 ID,但它们不是独立的 128 位分布式标识符。Sqid 是从一个或多个非负整数生成的短 URL 安全 ID。

使用官方 PHP 包:

代码语言:javascript
复制
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 时,这非常有用:

代码语言:javascript
复制
/orders/12345

你可以暴露:

代码语言:javascript
复制
/orders/NkK9q

然后在边界处解码:

代码语言:javascript
复制
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 细节很容易被忽略。

由于算法设计,解码随机字符串有时可能产生相同的数字序列。如果你的应用需要知道某个 ID 是否是对应数字的规范 Sqid,你应该解码它,重新编码结果,然后比较。

代码语言:javascript
复制
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 还支持最小长度:

代码语言:javascript
复制
use Sqids\Sqids;

$sqids = new Sqids(minLength: 10);

echo $sqids->encode([1, 2, 3]); // 86Rf07xd4z

以及自定义字母表:

代码语言:javascript
复制
use Sqids\Sqids;

$sqids = new Sqids(
    alphabet: 'FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE',
);

自定义字母表会改变输出,但不应被视为秘密。它是一种格式选择,是一种弱混淆层,而不是安全机制。

它们在数据库中的表现

数据库行为是这个对比变得实用的地方。

想象三张表:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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

UUID 和 ULID 都是 128 位值。你可以将它们存储为字符串,但这并非唯一选择。

常见选择有:

  • CHAR(36),用于规范 UUID 字符串
  • CHAR(26),用于 ULID 字符串
  • BINARY(16),用于紧凑的 16 字节存储

字符串存储便于检查和调试。二进制存储更小,对索引大小可能更好,但需要在应用边界进行一致的转换。

对于使用 ramsey/uuid 的 UUID,你可以获取二进制字节:

代码语言:javascript
复制
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:

代码语言:javascript
复制
// 敏感令牌不要用这个主意。
$token = $sqids->encode([$userId]);

它应该从安全随机字节生成:

代码语言:javascript
复制
$token = bin2hex(random_bytes(32));

那个令牌不是用来解码的。它应该是不可猜测的。

公开 ID 与内部 ID

我喜欢的一种设计是将 内部身份 与 公开身份 分离。

例如,一个 orders 表可以有:

  • 用于连接的内部整数 id
  • 用于 API 的公开 UUIDv7 或 ULID
  • 或者通过 Sqid 暴露在 URL 中的整数 id

每种方法都有效,但它们传达了不同的设计选择。

整数 ID 加 Sqid

当你想要快速的关系存储,且只需要更好的公开 URL 时使用。

代码语言:javascript
复制
$publicId = $sqids->encode([$order->id]);

这简单高效。权衡是公开 ID 可逆。

UUIDv7 或 ULID 作为公开 ID

当你希望公开 ID 独立生成,不暴露数据库序列时使用。

代码语言:javascript
复制
use Ulid\Ulid;

$orderPublicId = (string) Ulid::generate();

这适合 API、分布式系统,以及可能在主数据库流程之外创建的记录。

UUIDv7 或 ULID 作为主键

当分布式生成足够重要,以至于你希望主键本身就是全局唯一时使用。

这可以简化外部引用,但会改变数据库存储和索引特性。不要仅仅因为 ID 看起来现代就做出这个决定。

实用的决策流程

这是我通常遵循的决策流程。

代码语言:javascript
复制
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 格式取决于工作需求,而不是当前流行哪种格式。

用简单的话说:

  • 当你需要不透明分布式 ID 且不关心排序时,选择 UUIDv4
  • 当你想要标准 UUID 且时间局部性更好时,选择 UUIDv7
  • 当你想要紧凑、URL 安全、字典序可排序的 128 位 ID 时,选择 ULID
  • 当你已有数字且希望从中生成短公开 ID 时,选择 Sqid
  • 当值必须保密或不可猜测时,选择 随机令牌

最后一点重要到值得重复:随机令牌是不同类别。不要因为 Sqid、ULID 或基于时间戳的 ID 看起来随机,就把它们用作敏感秘密。

一个小型 PHP 抽象

如果应用使用多种 ID 风格,我喜欢将该选择保留在边界处,而不是把包调用分散得到处都是。

例如:

代码语言:javascript
复制
interface PublicIdGenerator
{
    public function generate(): string;
}

UUIDv7 实现可以是:

代码语言:javascript
复制
use Ramsey\Uuid\Uuid;

final readonly class UuidV7PublicIdGenerator implements PublicIdGenerator
{
    public function generate(): string
    {
        return Uuid::uuid7()->toString();
    }
}

ULID 实现可以是:

代码语言:javascript
复制
use Ulid\Ulid;

final readonly class UlidPublicIdGenerator implements PublicIdGenerator
{
    public function generate(): string
    {
        return (string) Ulid::generate();
    }
}

对于 Sqid,我不会使用相同的接口,因为 Sqid 需要一个输入数字。这种差异是设计的一部分,应该保持可见:

代码语言:javascript
复制
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 当作加密

Sqid 让 ID 更好看。它们不会让数据保密。如果解码值会暴露敏感内容,就不要将该表面使用 Sqid。

默认到处使用 UUIDv4

UUIDv4 很棒,但对于新的写入密集型系统,UUIDv7 通常是更好的默认选择,因为它保持了 UUID 标准形态,同时改善了时间局部性。

因为 ULID 看起来随机就假设它是秘密

ULID 包含时间戳。它们有用且紧凑,但不是秘密令牌。

使用公开 ID 但不进行授权

难以猜测的 ID 不是授权系统。始终检查当前行为是否有权访问该资源。

未经测量就优化 ID 存储

二进制 UUID 存储可能更好,但也会增加转换复杂性。只有当存储和索引收益值得这种复杂性时才使用它。

结论

UUID、ULID 和 Sqid 都有用,但它们的用途不同。

UUIDv4 是经典的分布式随机标识符。UUIDv7 是我在需要时间排序时会选择的现代 UUID。ULID 为你提供紧凑、可排序、URL 安全的 128 位 ID。Sqid 在你已有整数 ID 且想要更友好的公开表示时非常出色。

最重要的教训不是任何包的语法。而是理解你需要的特性:

  • 你需要在持久化之前生成 ID 吗?
  • 它需要按时间排序吗?
  • 它会公开暴露吗?
  • 它可以被解码是可以接受的吗?
  • 它是用来标识某物,还是用来保护某物?

先回答这些问题,正确的格式通常就会变得显而易见。

希望你喜欢这篇文章,如果喜欢,别忘了分享给朋友!!!再见!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-08-07,如有侵权请联系 cloudcommunity@tencent.com 删除
目录
  • UUID、ULID 与 Sqid:实用深度解析
  • 我们真正要解决的问题
  • 快速对比
  • UUID:标准的主力
  • UUIDv4
  • UUIDv7
  • ULID:紧凑且字典序可排序
  • ULID 单调性
  • Sqid:从数字生成的 ID
  • Sqid 与规范 ID
  • 它们在数据库中的表现
  • 存储 UUID 和 ULID
  • 安全与隐私
  • 公开 ID 与内部 ID
    • 整数 ID 加 Sqid
    • UUIDv7 或 ULID 作为公开 ID
    • UUIDv7 或 ULID 作为主键
  • 实用的决策流程
  • 一个小型 PHP 抽象
    • 常见错误
  • 把 Sqid 当作加密
  • 默认到处使用 UUIDv4
  • 因为 ULID 看起来随机就假设它是秘密
  • 使用公开 ID 但不进行授权
  • 未经测量就优化 ID 存储
  • 结论
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档