下一代数据库主键ULID: 主键自带时间序告别随机碎片 写入性能飙升30%

ULID 兼容 UUID、字典序可排序、URL 安全,大幅提升数据库写入性能,是 UUID v4 的理想替代方案。

在当今高并发、高可用的互联网系统中,唯一标识符(Unique Identifier)是数据架构里最基础却又最常被忽视的一环。我们几乎每天都在用 UUID,但你真的了解它的短板吗?UUID 虽然通用、稳定、跨平台,但在高频写入、需要排序、或对数据库性能极度敏感的场景下,它其实是个“甜蜜的负担”。

今天要介绍的 ULID(Universally Unique Lexicographically Sortable Identifier),正是为解决这些问题而生的未来级 ID 方案。它不仅兼容 UUID,还能让 PostgreSQL 这类数据库的写入性能和索引效率直接起飞。更关键的是——它完全无需重构你的数据库表结构!

UUID 的四大“坑”,你踩过几个?

UUID(通用唯一识别码)自诞生以来,几乎成了分布式系统生成唯一 ID 的默认答案。

但我们必须正视它的几个本质缺陷:

第一,UUID v1/v2 依赖设备 MAC 地址,在现代云环境、容器化部署中根本不可行;
第二,UUID v3/v5 需要一个“唯一种子”,这在无状态服务中很难保证;
第三,现在最常用的 UUID v4 全靠随机数,虽然避免了隐私问题,却导致数据库索引严重碎片化——想象一下,每次插入新记录,B+树索引都要在随机位置分裂节点,这不仅拖慢写入速度,还大幅增加磁盘 I/O;
第四,UUID 太长(36位字符),且包含连字符和大小写混用,对人类极不友好,也不利于 URL 传递。

这些问题在中小型项目中可能还能忍,但在日活百万、写入量上亿的系统里,它们就是性能瓶颈的根源。

ULID:128 位的“时间+随机”精妙结构

ULID 的设计哲学非常清晰:保留 UUID 的 128 位长度和兼容性,但重新分配这 128 位的用途。它把前 48 位用于 Unix 时间戳(毫秒级精度),后 80 位用于加密安全的随机数。

这意味着:任何 ULID 都自带“出生时间戳”,天然具备时间顺序。更重要的是,因为时间戳在高位,ULID 在字典序(lexicographic order)下是可排序的!你不需要额外字段记录创建时间,只要按 ID 排序,就能得到插入顺序。

而且,ULID 使用 Crockford’s Base32 编码(排除 I、L、O、U 字母以防混淆),输出为 26 位纯大写字符串,无连字符、无特殊符号、URL 安全、肉眼友好。举个例子:01KANDQMV608PBSMF7TM9T1WR4 ——是不是比 UUID 好读太多?

为什么 ULID 能直接写入 PostgreSQL 的 UUID 列?

这可能是最让人惊喜的一点:你完全不需要改动数据库 schema!PostgreSQL 的 UUID 类型本质上是一个 128 位的二进制值。而 ULID 虽然通常以字符串形式展示,但其底层也是 128 位数据(48+80)。

在 Go 语言生态中,oklog/ulid 包实现了 database/sql/driver.Valuer 和 encoding.TextMarshaler 接口,这意味着当 pgx 驱动(PostgreSQL 的高性能 Go 驱动)遇到 ULID 类型时,会自动将其转换为符合 UUID 二进制格式的字节流。

因此,你可以直接把 ULID 插入到 UUID 类型的列中,PostgreSQL 不会报错,还能完美保留排序特性。这种“零侵入式升级”让 ULID 成为现有 UUID 系统平滑迁移的理想选择。

实战演示:用 Go + pgx + ULID 一键生成可排序主键

下面这段代码展示了如何用 Go 语言轻松集成 ULID 到 PostgreSQL 项目中。我们创建一张名为 ulid_test 的表,主键为 UUID 类型,然后分别插入 UUID v4 和 ULID 类型的记录。注意看,ULID 的插入完全不需要格式化字符串,直接传入 ulid.ULID 对象即可,驱动会自动处理底层转换。

package main

import (
  "context"
 
"fmt"
 
"os"

 
"github.com/google/uuid"
 
"github.com/jackc/pgx/v5"
 
"github.com/oklog/ulid/v2"
)

func main() {
  ctx := context.Background()

  conn, err := pgx.Connect(ctx,
"postgres://...")
  if err != nil {
    panic(err)
  }
  defer conn.Close(ctx)

  _, err = conn.Exec(ctx, <code>
CREATE TABLE IF NOT EXISTS ulid_test (
  id UUID PRIMARY KEY,
  kind TEXT NOT NULL,
  value TEXT NOT NULL
);</code>)
  if err != nil {
    panic(err)
  }

  insertUUID(ctx, conn,
"1")
  insertUUID(ctx, conn,
"2")
  insertUUID(ctx, conn,
"3")
  insertUUID(ctx, conn,
"4")
  insertUUID(ctx, conn,
"5")

  insertULID(ctx, conn,
"1")
  insertULID(ctx, conn,
"2")
  insertULID(ctx, conn,
"3")
  insertULID(ctx, conn,
"4")
  insertULID(ctx, conn,
"5")
}

func insertUUID(ctx context.Context, conn *pgx.Conn, value string) {
  id := uuid.New()
  conn.Exec(ctx,
"INSERT INTO ulid_test (id, value, kind) VALUES ($1, $2, 'uuid')", id, value)

  fmt.Printf(
"Inserted UUID: %s\n", id.String())
}

func insertULID(ctx context.Context, conn *pgx.Conn, value string) {
  id := ulid.Make()

  conn.Exec(ctx,
"INSERT INTO ulid_test (id, value, kind) VALUES ($1, $2, 'ulid')", id, value)

  fmt.Printf(
"Inserted ULID: %s\n", id.String())
}

运行后你会看到类似这样的输出:

Inserted ULID: 019aaae4-be9c-d307-238f-be1692b3e8d7  
Inserted ULID: 019aaae4-be9d-011f-b82e-b870ca2abe9d  
...

查询结果震撼:ULID 自动按插入顺序排列!

执行这条 SQL:

select * from ulid_test where kind = 'ulid' order by id;  

你会发现返回的记录顺序和你插入的顺序完全一致!而如果你用 UUID v4 做同样操作,顺序则是完全随机的。这意味着:在日志系统、消息队列、用户行为追踪等需要“天然时间序”的场景中,ULID 不仅省去了 created_at 字段,还避免了额外的索引开销。数据库的 B+树索引会把新 ULID 持续追加到末尾,极大减少页分裂,显著提升写入吞吐量。对于 OLTP 系统来说,这简直是性能优化的“外挂”。

ULID 的极限能力:每毫秒 1.21e+24 个 ID,够你用到宇宙尽头

你可能会担心:如果系统在一毫秒内生成太多 ULID,会不会冲突?ULID 的 80 位随机空间意味着每毫秒可生成 2^80 ≈ 1.21 × 10^24 个唯一 ID。

这是什么概念?就算你每毫秒生成 10 亿个 ID,也需要超过 3800 万年才会用完!而且 ULID 实现了“单调递增模式”(monotonic mode):在同一毫秒内,如果多次调用生成器,它会自动将随机部分 +1,确保字典序严格递增。

这种设计既保证了唯一性,又避免了同一毫秒内 ID 乱序的问题。当然,如果你真能在一毫秒内生成超过 2^80 个 ID……那你可能已经统治了银河系,这个问题也不重要了。

警惕“热点写入”:ULID 的唯一软肋

凡事皆有两面。ULID 的时间有序性在带来索引优势的同时,也可能引发“热点写入”(hotspot writes)问题。

因为所有新记录都集中在索引的末尾,数据库的写入压力会集中在最新的数据页上。在极端高并发场景下(比如每秒数十万写入),这可能导致该数据页成为瓶颈,甚至引发锁竞争。不过,这种问题通常只出现在超大规模系统中(如 Twitter、支付宝级别),对于绝大多数应用,ULID 带来的性能提升远大于这点潜在风险。如果真遇到热点问题,也可以通过分片、写缓冲、或改用 UUID v7(带伪随机扰动)等方式缓解。

ULID 与 UUID v7 的殊途同归

有趣的是,ULID 的成功已经影响了官方标准。正在推进的 UUID v7 提案,其核心设计几乎就是 ULID 的翻版:时间戳 + 随机数,字典序可排序,兼容 UUID 格式。

这说明行业已经达成共识:传统 UUID v4 的时代正在过去,时间有序的 ID 才是未来。而 ULID 作为先行者,早已在 GitHub、GitLab、Stripe 等知名项目中落地实践。