当一个128位的随机值作为B-tree索引的主键时,每一次插入都像是在图书馆的随机位置放一本书——图书管理员疲于奔命,书架越来越乱。这不是夸张的比喻:PostgreSQL的基准测试显示,使用UUID v4作为主键时,索引体积膨胀22%,插入速度下降34.8%。

问题远比表面看起来复杂:UUID的随机性破坏了索引的局部性,NTP时钟同步可能让Snowflake算法生成重复ID,而数据库自增ID在分布式环境中又成了单点瓶颈。选择主键方案,本质是在唯一性、有序性、性能和复杂度之间进行权衡。

B-tree的隐形代价:为什么随机写入如此昂贵

要理解ID选择对数据库性能的影响,必须先理解B-tree索引的工作原理。

B-tree是一种自平衡的树形数据结构,所有数据存储在叶子节点,叶子节点之间通过指针串联。在PostgreSQL和MySQL InnoDB中,主键索引(聚集索引)的叶子节点直接存储了整行数据。

索引的效率依赖于数据的物理局部性。当新数据按主键顺序插入时,它们总是添加到索引的末尾——这是一个顺序写操作,效率极高。但如果主键是随机的,每次插入都需要在索引树中随机找一个位置,可能触发页面分裂。

一个8KB的索引页能存储多少个主键?对于UUID(16字节),大约400个。当页面满时,数据库会将其分裂成两个页面,每个只填充50%。这意味着:

  • 存储空间浪费:理论上只需100GB的索引,实际可能占用200GB
  • I/O放大:读取同样的数据量,需要访问更多的页面
  • 缓存效率下降:更多的页面意味着更低的缓存命中率

一项针对PostgreSQL的基准测试揭示了惊人差距:插入1000万行数据时,UUIDv4比UUIDv7慢34.8%,索引体积大22%(约174MB)。时间有序的UUIDv7让索引保持紧凑,随机UUIDv4则让索引千疮百孔。

数据库自增ID:单点的诱惑与陷阱

面对UUID的性能问题,很多开发者会想起最简单的方案——数据库自增ID。

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    -- ...
);

在单机环境下,这确实是最优解。但在分布式系统中,自增ID的缺陷暴露无遗。

首先,它是一个单点。所有写入都必须经过同一台数据库获取ID,这台数据库成为整个系统的瓶颈。即使使用主从复制,主库的写入能力仍然限制了系统整体吞吐量。

Flickr团队在2010年提出了一种改进方案:部署两台数据库,一台生成奇数ID,另一台生成偶数ID。

Server 1: auto-increment-increment=2, auto-increment-offset=1 (1,3,5,7...)
Server 2: auto-increment-increment=2, auto-increment-offset=2 (2,4,6,8...)

这解决了单点问题,但带来了新的麻烦:

扩展困难。假设有N台服务器,步长必须设为N。当需要扩容到N+1台时,必须重新配置所有服务器的步长和偏移量,甚至需要停止服务。对于运行中的生产系统,这是一场噩梦。

ID不再单调递增。奇数ID和偶数ID交织,只能保证趋势递增,无法保证严格顺序。对于需要精确排序的业务(如版本控制、消息序列),这是不可接受的。

数据库压力依然存在。每获取一个ID都需要一次数据库读写,高并发场景下数据库仍然是瓶颈。

UUID的诞生与128位的承诺

UUID(Universally Unique Identifier)的设计初衷很简单:在没有中心协调的情况下生成全局唯一标识符。

UUID v4使用122位随机数(另有6位用于版本和变体标识),理论上有2^122个可能值。这是一个天文数字:如果每秒生成10亿个UUID,运行100亿年,碰撞概率仍然可以忽略不计。

这正是UUID的魅力所在——零协调、零依赖、零配置。任何节点在任何时刻都可以独立生成UUID,无需与其他节点通信。

但UUID v4的性能代价在数据库场景下变得难以忽视:

特性 UUID v4 UUID v7
时间有序
数据库插入性能
索引体积
全局唯一性
可排序性

2024年5月,IETF发布了RFC 9562,正式标准化了UUID v7。UUID v7将48位Unix毫秒时间戳放在高位,确保ID按时间递增:

| 48位时间戳 | 4位版本 | 12位随机 | 2位变体 | 62位随机 |

时间戳的存在让UUID v7具备了顺序性,新数据总是插入到索引末尾,避免了页面分裂的噩梦。基准测试显示,UUID v7的插入速度比UUID v4快约35%,索引体积小约22%。

Snowflake算法:时间戳的另一种用法

2010年,Twitter面对着每秒数万条推文的增长压力。他们需要一个能够全局唯一、大致有序、高性能的ID生成系统。Snowflake算法由此诞生。

Snowflake将64位整数划分为三部分:

| 1位符号位 | 41位时间戳 | 10位机器ID | 12位序列号 |
  • 时间戳(41位):毫秒精度,可使用约69年
  • 机器ID(10位):最多支持1024个节点
  • 序列号(12位):每毫秒最多生成4096个ID

这种设计实现了几个关键目标:

趋势递增:时间戳在高位,同一节点生成的ID自然有序。

高性能:单节点每秒可生成400万个ID,延迟在亚毫秒级别。

无协调:每个节点独立生成ID,只要机器ID不重复,就能保证全局唯一。

但Snowflake有一个致命依赖:系统时钟

时钟回拨:Snowflake的阿喀琉斯之踵

Snowflake的核心假设是系统时钟单调递增。但现实中,时钟可能因NTP同步、闰秒调整或人为设置而回退。

2017年的闰秒事件给全球分布式系统上了一课。当时,部分Linux系统因NTP同步出现时钟回退,依赖Snowflake的系统面临ID重复的风险。

时钟回拨有三种常见场景:

NTP阶梯式调整:当检测到时钟偏差较大时,NTP可能直接将时钟向前或向后拨动。向后拨动意味着可能出现与之前相同的时间戳。

NTP渐进调整(slew模式):NTP通过调整时钟频率来平滑修正偏差。这种方式更安全,但修正速度较慢,可能需要数小时。

人为调整:运维人员手动修改系统时间,可能造成大幅度回退。

美团在开发Leaf系统时总结了处理时钟回拨的几种策略:

拒绝策略:检测到时钟回拨后,直接拒绝服务,返回错误。这是最保守的做法,但会牺牲可用性。

等待策略:如果回拨幅度较小(如5毫秒以内),线程等待时钟追上。这适合小幅回拨,但大幅回拨会长时间阻塞服务。

摘除节点:发现时钟异常后,自动将节点从服务发现中移除,同时报警。这是美团Leaf采用的方案——牺牲一个节点,保全整个系统的正确性。

// 发生了回拨,此刻时间小于上次发号时间
if (timestamp < lastTimestamp) {
    long offset = lastTimestamp - timestamp;
    if (offset <= 5) {
        // 时间偏差小于5ms,等待两倍时间
        wait(offset << 1);
        timestamp = timeGen();
        if (timestamp < lastTimestamp) {
            throwClockBackwardsEx(timestamp); // 仍然回拨,抛异常
        }
    } else {
        throwClockBackwardsEx(timestamp); // 大幅回拨,直接报错
    }
}

根本解决方案是降低对时钟的依赖。美团Leaf-snowflake在启动时将自己的时间戳写入ZooKeeper,并与其他节点的时间戳进行比对校验。如果本机时间偏差超过阈值,拒绝启动并报警。

百花齐放:其他ID生成方案

Instagram方案:数据库内的Snowflake

2012年12月,Instagram分享了他们的ID生成方案。与Twitter Snowflake类似,他们将64位分为三部分:

| 41位时间戳 | 13位逻辑分片ID | 10位序列号 |

创新之处在于利用PostgreSQL的PL/PGSQL在数据库内部生成ID,而不是独立的ID服务:

CREATE OR REPLACE FUNCTION insta5.next_id(OUT result bigint) AS $$
DECLARE
    our_epoch bigint := 1314220021721;
    seq_id bigint;
    now_millis bigint;
    shard_id int := 5;
BEGIN
    SELECT nextval('insta5.table_id_seq') % 1024 INTO seq_id;
    SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO now_millis;
    result := (now_millis - our_epoch) << 23;
    result := result | (shard_id << 10);
    result := result | (seq_id);
END;
$$ LANGUAGE PLPGSQL;

ID中直接编码了分片ID,方便数据路由。这种方案减少了一次网络往返,但仍然依赖数据库。

KSUID:时间有序的160位标识符

Segment公司开发了KSUID(K-Sortable Unique IDentifier),使用160位:

| 32位时间戳(秒) | 128位随机数 |

时间戳使用秒级精度,而不是毫秒,简化了实现。128位随机数提供了远超UUID v4的安全性(UUID v4只有122位随机)。

KSUID的优势:

  • 字符串友好:使用Base62编码,生成27字符的字符串,无连字符
  • 字典序可排序:字符串本身按创建时间排序
  • 无协调:完全依赖随机数,不需要分配机器ID

代价是ID更长(20字节 vs UUID的16字节),时间精度较低(秒级 vs 毫秒级)。

ULID:128位的可排序替代方案

ULID(Universally Unique Lexicographically Sortable Identifier)采用类似的设计:

| 48位时间戳(毫秒) | 80位随机数 |

总共128位,与UUID等长,可以直接存储在UUID字段中。80位随机数对于大多数场景已经足够——即使每毫秒生成2^80个ID,碰撞概率也极低。

ULID使用Crockford’s Base32编码,生成26字符的字符串,比UUID的36字符更短,且大小写不敏感。

美团Leaf:两种模式的混合方案

2017年4月,美团开源了Leaf系统,提供了两种模式:

Leaf-segment(号段模式):从数据库批量获取ID段,缓存在本地。例如,一次获取1000个ID,消耗完再获取下一批。这将对数据库的访问频率降低了1000倍。

Leaf-snowflake(雪花模式):标准Snowflake算法,但通过ZooKeeper管理机器ID,并在本地缓存,实现ZooKeeper的弱依赖。

两种模式可以根据业务需求切换:号段模式提供严格递增,雪花模式提供更高性能。

方案对比:没有完美方案,只有权衡

方案 长度 有序性 依赖 性能 适用场景
数据库自增 64位 严格递增 数据库 单机系统
UUID v4 128位 无序 中(生成) 非索引字段
UUID v7 128位 趋势递增 时钟 分布式数据库主键
Snowflake 64位 趋势递增 时钟+机器ID 极高 高并发分布式系统
KSUID 160位 趋势递增 需要字符串友好
ULID 128位 趋势递增 UUID替代
Leaf-segment 64位 严格递增 数据库 订单号、流水号
Leaf-snowflake 64位 趋势递增 时钟+ZK 极高 大规模分布式

选择ID生成方案时,需要回答几个关键问题:

是否作为数据库主键? 如果是,优先考虑时间有序的方案(UUID v7、Snowflake、ULID),避免随机插入导致的索引碎片。

是否需要严格递增? 订单号、流水号可能需要严格递增,这时号段模式是更好的选择。

是否有时间同步基础设施? Snowflake依赖精确的时钟同步。如果没有可靠的NTP服务,或对时钟回拨零容忍,考虑不依赖时钟的方案。

是否需要从ID中提取信息? Snowflake ID可以解码出时间戳和机器ID,便于故障排查。UUID v4则不包含任何有意义的信息。

性能要求如何? Snowflake单机可达每秒400万ID,号段模式受限于数据库吞吐量,UUID生成速度取决于随机数生成器。

实践建议:从问题出发

不要问"哪个ID方案最好",而要问"我的系统需要什么"。

单机应用:使用数据库自增ID,简单可靠。

分布式系统,ID作为主键:优先使用UUID v7或ULID,兼顾唯一性和索引友好性。PostgreSQL 18已经开始原生支持UUID v7。

超大规模,性能敏感:Snowflake或其变种(如Leaf-snowflake),但必须部署可靠的时间同步机制。

需要严格递增:号段模式(如Leaf-segment),接受对数据库的依赖。

简单优先:如果团队没有分布式系统运维经验,UUID v7是不错的选择——它不需要任何基础设施,开箱即用,性能也足够好。

ID生成看似简单,实则牵涉到分布式系统的核心挑战:时钟、协调、唯一性。每一种方案都是特定约束下的优化解。理解这些约束,才能做出正确的选择。


参考资料

  1. Davis, K., et al. “Universally Unique IDentifiers (UUIDs).” RFC 9562, May 2024.
  2. Twitter. “Snowflake: A Networked Service for Generating Unique IDs at Scale.” 2010.
  3. Instagram Engineering. “Sharding & IDs at Instagram.” December 2012.
  4. 美团技术团队. “Leaf——美团点评分布式ID生成系统.” April 2017.
  5. Segment. “KSUID: K-Sortable Globally Unique IDs.” GitHub Repository.
  6. Ulid Specification. “Universally Unique Lexicographically Sortable Identifier.”
  7. PostgreSQL Documentation. “B-tree Indexes.”
  8. Sinha, U. “PostgreSQL UUID Performance: Benchmarking Random (v4) and Time-based (v7) UUIDs.” May 2025.
  9. Flickr Engineering. “Ticket Servers: Distributed Unique Primary Keys on the Cheap.” 2010.
  10. Lamport, L. “Time, Clocks, and the Ordering of Events in a Distributed System.” CACM, 1978.