ULID 是什么?详解这个可排序的唯一标识符
每插入一个随机的 UUIDv4 作为主键,它都会落在数据库索引中一个无法预测的位置。这样做上几百万次,索引就会碎片化、缓存频繁失效、写入变慢。ULID 解决了这个问题,又不必放弃你喜欢 UUID 的那些特性:你依然可以在任何地方生成它,不需要中央协调器,但它会按时间顺序落位,而不是四处散落。
那么,一个 26 个字符的字符串是怎么做到按时间自排序的?这正是它全部的诀窍所在,动手之前先把这点搞清楚。
ULID(Universally Unique Lexicographically Sortable Identifier,全局唯一、字典序可排序标识符)是一个 128 位的标识符,写成 26 个 Crockford Base32 字符。前 10 个字符编码毫秒级时间戳,后 16 个字符编码随机位,因此当作普通字符串比较时,较晚创建的 ULID 总是排在较早的之后。它是一个可排序的唯一标识符,可以离线生成。
本指南会把它拆开来讲:逐字符解码它的结构、证明它确实可排序、数据库收益背后的 B-tree 数学,以及对内嵌时间戳所泄露信息的诚实审视。你可以在阅读时打开 ULID 生成器,对照一个真实的值跟着操作——生成一个、解码它、把它转换成 UUID。
ULID 是什么?
ULID(Universally Unique Lexicographically Sortable Identifier,全局唯一、字典序可排序标识符)是一个 128 位的标识符,被设计为比 UUID 更可排序、更紧凑的替代方案。它写成 26 个 Crockford Base32 字符:前 10 个字符保存一个 48 位的时间戳,单位是自 Unix epoch 以来的毫秒数;其余 16 个字符保存 80 位随机部分。因为时间排在最前面,所以这个字符串会按时间顺序排序。
最后这个特性正是这种格式存在的原因。UUIDv4 是完全随机的,这对唯一性来说很好,但意味着相隔一秒创建的两个 ID 之间毫无关联。ULID 保留了无需协调、随处生成的模式,并在此之上加上了时间排序,因此一整列 ULID 天然就按创建时间排序,不需要任何额外处理。
下面一眼看清这种格式:
| 属性 | 值 |
|---|---|
| 位数 | 128 |
| 编码 | 26 个 Crockford Base32 字符 |
| 布局 | 48 位时间戳 + 80 位随机部分 |
本文余下部分会补全每个部分的工作原理。编码和可排序性各占一节,下文会讲到 Base32 和排序的证明。先从布局说起。
ULID 的结构:48 位时间 + 80 位随机部分
ULID 的 26 个字符可以干净地分成两半。前 10 个字符是时间戳,后 16 个字符是随机部分。把经典示例摆出来,边界一目了然:
01ARYZ6S41 TSV4RRFFQ69G5FAV
└────────┘ └──────────────┘
10 chars 16 chars
48-bit ms 80-bit random
timestamp
两个部分,两项职责。一个记录何时,另一个保证唯一性。下面分别解码。
48 位时间戳(前 10 个字符)
前面的 10 个字符编码一个 48 位整数:ULID 创建那一刻的自 Unix epoch 以来的毫秒数。直接取规范里的经典示例:
01ARYZ6S41 -> 1469918176385 ms -> 2016-07-30T22:36:16.385Z
这是一次真实、可逆的解码——把 01ARYZ6S41TSV4RRFFQ69G5FAV 粘进解码器,你会精确地得到 2016-07-30T22:36:16.385Z。时间部分是纯粹的数据,不是哈希,所以读取它没有任何代价。
有个小细节常常把人绊倒:ULID 的第一个字符总是在 0 到 7 之间。一个 Crockford 字符保存 5 位,而 48 不是 5 的倍数——时间戳占据 10 个字符能携带的 50 位中的低 48 位,使得第一个字符的最高 2 位永远为零。两个零位把这个字符的值上限封在 7。如果你看到某个 ULID 以 8 或更高的字符开头,那它就是非法的。
80 位随机部分(后 16 个字符)
其余 16 个字符携带 80 位随机部分,唯一性正是来自这一半。这些位应当来自一个密码学安全的来源——浏览器里的 crypto.getRandomValues,而不是 Math.random。这个区别很重要:Math.random 的可预测程度足以让攻击者猜出或碰撞出值,而 CSPRNG 不会。
80 位有多大空间?大约 1.2 × 10²⁴ 种可能取值,而且这是每毫秒的量。即便你在单个毫秒内生成几百万个 ULID,两个抽到相同 80 位的概率也小到可以忽略不计。与时间戳不同,这一半不携带任何可解码的含义——它是噪声,唯一的目的就是让每个 ULID 各不相同。
Crockford 的 Base32:为什么 ULID 去掉了 I、L、O 和 U
ULID 用 Crockford 的 Base32 编码,这是一个 32 个符号的字母表:数字 0–9 加上去掉四个字母后的 A–Z。
0123456789ABCDEFGHJKMNPQRSTVWXYZ
缺失的字母是 I、L、O 和 U。其中三个被去掉是因为它们看起来像数字——I 和 L 像 1,O 像 0——这样一来,人在屏幕上读 ULID 时就不会把字母认成数字。反过来,输入也更宽容:合规的解码器会把 I 和 L 映射回 1、把 O 映射回 0,并把整个字符串当作大小写不敏感来处理。U 是单独排除的,目的是避免无意中拼出冒犯性的词。
位数运算是另一个原因。每个 Base32 字符编码 5 位,而一个十六进制字符只编码 4 位。把 128 位按每字符 5 位打包,你需要 26 个字符;把同样的 128 位按每字符 4 位打包——也就是 UUID 的方式——你需要 32 个字符,再加上四个连字符,共 36 个字符。所以 ULID 比 UUID 明显更短,而且没有连字符,可以直接放进 URL、文件名或请求头而无需转义。
Crockford 的 Base32 是一个 32 个符号的字母表(0–9 和 A–Z 去掉 I、L、O、U),每个字符编码 5 位。ULID 用它把 128 位打包成 26 个大小写不敏感、URL 安全的字符。关键在于,这个字母表是升序排列的,正是这一点让编码后的字符串与原始位以相同的方式排序。
为什么 ULID 按时间排序
很多文章会告诉你 ULID 按时间排序,但很少有人讲清楚为什么,所以这里给出真正的论证。它建立在你已经知道的两个事实之上:时间戳是整个值中最高位的部分,而 Crockford 的字母表是升序排列的。
把这两点放在一起,你就得到一条等价链:
string compare == 128-bit integer compare == creation-time compare
从左往右读。逐字符比较两个 ULID(字符串排序就是这么做的)得到的结果,与比较它们底层的 128 位整数得到的结果相同,因为字母表保持顺序,更「高」的字符总是意味着更大的值。比较这些 128 位整数得到的结果,又与比较创建时间得到的结果相同,因为时间戳位于最高位,所以它在比较中占主导;随机的尾部只在同一毫秒内打破平局。字符串顺序、位顺序和时间顺序,是同一种顺序。
来个快速演示。两个相隔一毫秒生成的 ULID:
01ARYZ6S41... (created at T)
01ARYZ6S42... (created at T + 1 ms)
第十个字符从 1 跳到 2,一次普通的文本排序就会把第二个排在第一个之后,不需要时间戳列,也不需要特殊的比较器。实际收益可以浓缩成一行:ORDER BY id 就能按时间顺序返回行,不需要额外的索引。
把 ULID 用作数据库主键:B 树局部性
主键场景正是 ULID 发挥价值的地方。大多数关系型数据库把主键索引存成一棵 B 树,而一个新键落在树中的哪个位置,决定了这次插入有多昂贵。
随机的 UUIDv4 每次插入都落在不可预测的位置:
UUIDv4: 每个新键都瞄准一个随机的叶子页。这个页往往是满的,于是引擎要分裂它、把一半的行复制到别处,并弄脏树中各处的页。在几百万行的规模上,这会让索引碎片化、把有用的页从缓冲缓存里挤出去,并拖慢插入吞吐。(关于硬核的索引页分裂数据——在写密集表上通常有 2–10× 的差距——见对比指南。)
而以时间为前缀的 ULID 每次都落在末尾:
ULID: 因为高位是时间戳,每个新键都比上一个大,所以它会追加到索引的右边缘或附近。插入保持顺序、页分裂几乎消失、索引保持紧凑,而对某个时间窗口的范围扫描会读取一段连续的页。
你得到了 UUID 那种无需协调的生成方式,同时又有自增整数那样的插入局部性——而且不会暴露一个可猜测的顺序计数器,因为随机的尾部仍然隐藏着确切的下一个值。
存储提示: 把这 128 位存成 16 个二进制字节——PostgreSQL 里的 uuid 列、MySQL 里的 BINARY(16)——而不是一个 26 个字符的文本字段,那样既浪费空间又会让索引膨胀。只在人或 URL 看得到的边界处才编码成 Base32 字符串。生成器的「转换」标签页正是为此提供了把 ULID 转换成 UUID的功能,因为两种形式是同样的 128 位。
单调 ULID:毫秒内的严格顺序
可排序性的证明有一个诚实的缺口:在单个毫秒内,普通的 ULID 并非严格有序。它们共享同样的 10 个字符的时间前缀,但它们的 80 位随机尾部是各自独立抽取的,所以同一毫秒内的两个 ULID 哪个排在前面,基本上就是抛硬币。对大多数用途来说这没问题。但当你需要在亚毫秒速率下也保持严格顺序时,就不行了。
单调生成弥补了这个缺口。规则很简单:某一毫秒内的第一个 ULID 照常获得全新的随机部分,而该毫秒内之后的每一个 ULID,都是取上一个 80 位随机值并把它加一(按大端整数处理,必要时进位到更高位)得来的。因此每一个值都严格大于它前面的那个。
你可以在一批于同一毫秒内生成的 ULID 中看到这一点——只有最后一个字符在变:
01KVT0F720ZK9N4T2QX7VR8WMC
01KVT0F720ZK9N4T2QX7VR8WMD
01KVT0F720ZK9N4T2QX7VR8WME
…WMC < …WMD < …WME,这是有保证的。每当行的创建速度可能快过毫秒时钟的跳动时,这一点就很重要:高吞吐插入、事件日志、紧密循环中的消息 ID。当时钟前进到下一毫秒时,生成会回到全新的随机部分,循环重新开始。
ULID vs UUID:何时用哪个
大多数人真正带着来的问题是 ULID vs UUID。这里给出聚焦的对比——拿 ULID 对上你现实中真正会权衡的两个 UUID 版本。(关于包含 Snowflake 和 NanoID 在内的完整五方决策矩阵,见 ULID、UUID 与 Snowflake 的完整对比。)
| 属性 | ULID | UUIDv4 | UUIDv7 |
|---|---|---|---|
| 长度 | 26 字符 | 36 字符 | 36 字符 |
| 编码 | Crockford Base32 | 带连字符的十六进制 | 带连字符的十六进制 |
| 按时间可排序? | 是 | 否 | 是 |
| 内嵌时间戳? | 是(48 位毫秒) | 否 | 是(48 位毫秒) |
| 已标准化? | 社区规范 | RFC 9562 | RFC 9562 |
| 最适合 | 短的可排序 ID | 不透明的随机 ID | UUID 格式下的可排序 ID |
用文字来说:当你想要最短、URL 安全、可排序的字符串时,选 ULID。当你想要一个不透明、完全随机、不内嵌时间的标识符时,选 UUIDv4——比如一个公开令牌,你宁可不暴露它的创建时间。当你需要时间排序但又必须留在标准 UUID 格式内、让版本位和变体位处在它们固定的位置、并能放进原生 uuid 列时,选 UUIDv7。
三者都是 128 位,所以 ULID ↔ UUID 的转换是双向无损的。ULID 与 ulid vs uuid v7 之间的关系比看上去更近:UUIDv7 本质上就是 IETF 对 ULID 所开创的那个时间前缀思路的标准化版本。如果你对 UUID 还不熟悉,先从基础知识开始,再回来看这个对比。
隐私权衡:ULID 会泄露它的创建时间
内嵌的时间戳既是一个特性,也是一处泄露,取决于谁来读这个 ID。任何持有 ULID 的人都可以一步解码出时间戳,得知该记录创建的确切毫秒——根本不需要访问你的数据库。
在你自己的系统内部,这纯粹是好处:即时审计、免费排序、轻松调试。但放在一个面向公众的标识符上,它就是一处真实的信息披露。创建时间本身可能就是商业敏感信息,而随时间采样的一小撮 ULID 会泄露你的创建速率,也就是你每秒生成多少订单、账户或消息,这正是竞争对手和爬虫喜欢去估算的那类东西。
平心而论,这比 UUIDv1 是更窄的泄露,后者历史上会内嵌生成机器的 MAC 地址;ULID 只暴露时间,从不暴露硬件身份。但仍然要权衡它。简单的缓解办法:把 ULID 留在内部,对那些顺序无关紧要的面向公众的 ID,发放一个完全随机的 UUIDv4。
使用 ULID 的常见陷阱
ULID 的大多数麻烦是一小撮可以避免的工程决策,而不是格式本身的 bug。反复出现的有这些:
- 假定同一毫秒的普通 ULID 是有序的。 它们共享时间前缀,但有各自独立的随机尾部,所以它们的顺序是未定义的。修复: 当你需要在亚毫秒速率下严格排序时,使用单调模式。
- 把 ULID 存成 26 个字符的文本。 那既浪费空间又会让索引膨胀。修复: 把这 128 位存成 16 个字节(
uuid/BINARY(16)),只在边界处编码成 Base32。 - 期待 ULID→UUID 的转换会被识别为 v4 或 v7。 转换只是把同样的位重新编码,它不会设置 UUID 的版本和变体字段,所以检查这些字段的库不会看到一个带标记的版本。修复: 把结果当作一个不透明的 128 位值,或者在你需要那个标记时生成一个真正的 UUIDv7。
- 用
Math.random填充随机部分。 它是可预测的,而且可能碰撞。修复: 始终使用像crypto.getRandomValues这样的 CSPRNG。 - 没有权衡时间戳泄露就公开暴露 ULID。 见上面的隐私一节。修复: 内部用 ULID,面向公众的 ID 用随机的 UUIDv4。
- 手动键入
I、L、O或U到 ULID 里。 这些字母不在字母表中,重新键入也容易出错。修复: 复制 ULID,不要重新键入它们。
常见问题
ULID 像 UUID 那样是官方标准吗?
不是。ULID 是一份发布在 GitHub 上的社区规范,不是 IETF RFC。它被广泛实现且稳定,但背后没有标准机构。如果你需要一个标准化的、按时间排序的标识符,UUIDv7(RFC 9562)在官方 UUID 格式内应用了同样的思路。
ULID 有多少个字符,为什么它比 UUID 短?
26 个字符,相比之下 UUID 是 36 个。ULID 使用 Crockford Base32,每个字符打包 5 位;UUID 的十六进制每个字符只打包 4 位,还加了四个连字符。所以同样的 128 位在 Base32 下需要更少的字符——而且没有一个字符需要 URL 转义。
两个 ULID 有可能碰撞吗?
实际上几乎不可能。在一毫秒内,一个 ULID 有 80 位随机位——大约 1.2 × 10²⁴ 种可能性——所以即便每毫秒生成几百万个,碰撞的概率也小到可以忽略不计。唯一的要求是用一个密码学安全的 RNG 来填充随机部分;Math.random 会让这个保证失效。
我可以把 ULID 存进 PostgreSQL 或 MySQL 吗?
可以。ULID 是 128 位,所以把它转换成 UUID 形式,存进一个 uuid 列(PostgreSQL)或 BINARY(16)(MySQL),然后只在边界处渲染成 Base32 字符串。没有原生的 ULID 列类型,但 UUID 表示同样只花 16 个字节,并能让索引保持紧凑。
ULID 区分大小写吗?
经典形式是大写的,但 Crockford Base32 在输入上是大小写不敏感的:解码器会以同样的方式读取小写字母,并把 I/L 映射为 1、把 O 映射为 0。为了避免在相等性检查和索引中出意外,在存储或比较之前先归一化到单一大小写。
48 位的时间戳会用尽吗?
很长很长一段时间内都不会。48 位的毫秒数要到公元 10889 年计数器才会溢出,所以对任何真实的应用来说,时间戳部分实际上是面向未来无忧的。早在这种格式空间用尽之前,你就会先把系统、语言和数据库都换掉了。
我能在浏览器或移动端不用服务器就生成 ULID 吗?
能——这正是一个核心好处。ULID 不需要中央协调器,所以任何节点、边缘 worker、浏览器或设备都可以用它的时钟加一个安全 RNG 生成一个。之后,在不同机器上创建的值仍然能按时间一起排序,因为时间戳就活在 ID 本身里。
结语
ULID 解决了一个具体而真实的问题——随机键让你的索引碎片化——同时又不剥夺去中心化的生成能力。它的工作机制值得记在心里:
- 一个 ULID 是 48 位毫秒时间戳 + 80 位随机部分,编码成 26 个 Crockford Base32 字符。
- 它按时间排序,因为时间戳是最高位的部分,而字母表保持顺序——字符串顺序等于时间顺序。
- 这种排序给了 B 树随机 UUIDv4 所缺乏的插入局部性,让写入保持快速、索引保持紧凑。
- 当你需要为同一毫秒内生成的 ID 保证严格顺序时,使用单调模式。
- 在面向公众的标识符上暴露 ULID 之前,权衡一下时间戳泄露。
- 当你必须留在标准 UUID 格式内时,改选 UUIDv7。
当你准备好动手用它时,打开 ULID 生成器,完全在你的浏览器里生成、解码和转换 ULID——没有服务器、没有上传、不存储任何东西。