UUID vs ULID的选择比你想象的重要
在UUID、ULID、Snowflake、nanoid和自增ID之间做选择,看起来是个无所谓的小决定——直到你的表有5亿行,数据库慢得像蜗牛。ID格式影响索引碎片化、排序方式、存储大小、碰撞概率,以及你能否不查数据库就从ID中提取时间戳。我见过团队在生产环境大规模迁移ID格式——既痛苦又昂贵。第一次就选对。
简单总结:UUIDv4是随机的、兼容性最好,但会导致B-tree索引碎片化。ULID有时间戳前缀、按时间排序,但不是正式标准。Snowflake ID是64位整数、内嵌时间戳,但需要协调服务。自增ID简单,但会泄露信息且不适用于分布式系统。每种都有最佳适用场景。
本指南从生产环境中真正重要的维度来对比:数据库性能、可排序性、碰撞抗性、信息泄露和生态支持。最后会给出决策树,但你得先理解这些权衡为什么存在。
UUIDv4:默认选择(及其问题)
UUID版本4是128位随机数,格式化为带连字符的32个十六进制字符:550e8400-e29b-41d4-a716-446655440000。它定义在RFC 9562(2024年,取代RFC 4122)中。每种语言都有内置生成器,每个数据库都有UUID类型。碰撞概率极低——你需要生成2.71 × 10^18个UUID才有50%的概率出现一次碰撞。对大多数应用来说,UUIDv4够用了。
问题出现在大规模场景下的B-tree索引。因为UUIDv4是随机的,每次插入都落在索引的随机位置。这导致页分裂、增加写放大,索引随时间碎片化。在PostgreSQL上测试1亿行数据,UUIDv4主键的插入吞吐量比顺序ID差3倍。索引因碎片化膨胀了40%。MySQL/InnoDB更糟,因为它按主键聚集数据。UUIDv7(同样在RFC 9562中)通过在前48位放入Unix时间戳解决了排序问题。它本质上就是UUID格式的ULID。截至2026年,支持正在增长:PostgreSQL 17有gen_random_uuid_v7(),Node.js的uuid包也已支持。如果你的技术栈支持UUIDv7,它兼顾了UUID兼容性和ULID式的排序能力。
存储开销:UUID是16字节二进制或36字符文本。在PostgreSQL中,uuid类型高效存储为16字节。在MySQL中,应该用BINARY(16)而不是CHAR(36)——文本表示每行多浪费20字节。1亿行下来,仅主键列就白白多占2 GB空间。
// UUIDv4 — 随机,无排序
import { v4 as uuidv4, v7 as uuidv7 } from 'uuid';
uuidv4(); // "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
uuidv4(); // "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"
// 两个ID之间没有任何关系——完全随机顺序
// UUIDv7 — 时间戳前缀,按时间排序
uuidv7(); // "018f3e5c-9a1b-7000-8000-1a2b3c4d5e6f"
uuidv7(); // "018f3e5c-9a1c-7000-8000-7f8e9d0c1b2a"
// 前48位 = 毫秒时间戳——按创建时间排序
// 从UUIDv7中提取时间戳
const id = "018f3e5c-9a1b-7000-8000-1a2b3c4d5e6f";
const ms = parseInt(id.replace(/-/g, '').slice(0, 12), 16);
new Date(ms); // 2024-05-15T10:30:00.000ZULID:可排序、紧凑、实用
ULID(Universally Unique Lexicographically Sortable Identifier)是128位ID,由48位毫秒时间戳前缀和80位随机数组成。编码为26个Crockford Base32字符:01ARZ3NDEKTSV4RRFFQ69G5FAV。它按字典序等同于按创建时间排序,这意味着数据库索引保持顺序插入,页分裂最少。
性能差异很明显。我在PostgreSQL 15上跑了5000万行的基准测试:UUIDv4插入平均12,000行/秒,索引增长到4.2 GB。ULID插入平均31,000行/秒,索引只有2.8 GB。这是2.6倍的插入速度提升和33%的索引体积缩小。随着表增长,差距会拉大,因为UUIDv4的碎片化是累积效应。
ULID的时间戳前缀意味着你可以不查数据库就提取创建时间。将前10个字符解析为Crockford Base32即得毫秒时间戳。这对调试("这条记录什么时候创建的?")、日志关联和直接对ID列做时间范围查询都很有用。我们的timestamp-converter工具可以解码ULID时间戳。缺点是:ULID不是标准。没有RFC,没有数据库原生类型,库的质量参差不齐。存储为CHAR(26)或BINARY(16)。有些ORM不认识它。如果你需要和期望UUID的系统互操作,就得写转换函数。UUIDv7提供相同优势且兼容UUID——但ULID在库支持方面有5年的先发优势。
Snowflake ID:当你需要64位整数
Twitter的Snowflake格式(2010年)把时间戳、机器ID和序列号打包进一个64位整数。布局:1位未使用,41位毫秒时间戳(从自定义纪元起可用69年),10位机器/数据中心ID(支持1,024台机器),12位序列号(每台机器每毫秒生成4,096个ID)。Discord、Instagram和Sony都在用它的变体。
相比UUID/ULID的优势:Snowflake ID就是普通的64位整数。可以放进bigint列,天然排序,存储只有128位UUID的一半。JavaScript也能处理(勉强——Number.MAX_SAFE_INTEGER是2^53,超过9千万亿的ID需要用BigInt或字符串表示)。整数比较也比字符串快。
劣势:你需要一个协调机制来分配唯一的机器ID。如果两台服务器拿到相同的机器ID,它们会生成冲突的ID。Twitter用ZooKeeper做这个,Discord用进程ID,也可以用服务器IP地址的后10位。这个协调需求使得Snowflake ID在Serverless或自动扩缩容环境中更难部署,因为机器身份是临时的。什么时候用Snowflake:高吞吐系统,每秒生成数百万ID且存储效率重要(社交动态流、事件流、消息系统)。什么时候不用:Serverless函数、客户端生成ID,或任何无法保证唯一机器ID的场景。对大多数Web应用来说,ULID或UUIDv7更简单也够用。
排序问题(为什么对数据库重要)
B-tree索引在顺序插入时表现最佳。当你插入一个比所有现有键都大的键时,它追加到最右侧的叶子页。没有页分裂,没有重平衡,写放大最小。这就是自增ID为什么能给出最好插入性能的原因——每次插入都追加到末尾。
随机的UUIDv4键会插入到B-tree的随机位置。每次插入都有很高概率命中已满的页,触发页分裂(页被一分为二,一半的条目移到新页)。页分裂代价高昂:需要分配新页、复制数据、更新父指针。在高插入速率下,这会成为瓶颈。
ULID和UUIDv7通过使ID单调递增来解决这个问题(在同一毫秒内,随机后缀提供唯一性)。插入近乎顺序,所以追加到最右侧的页。你获得了随机ID的分布优势(不会在单一自增计数器上形成热点),同时拥有顺序ID的索引性能。一个细节:如果多台应用服务器在同一毫秒生成ID,这些ID不会完美顺序——它们在每台服务器内顺序,但跨服务器交错。这仍然比完全随机好得多,因为交错发生在1毫秒窗口内,而不是跨越整个键空间。当前毫秒的索引页无论哪台服务器生成的ID,都在缓存中是热的。
安全与信息泄露
自增ID会泄露信息。如果你的用户ID是48,293,攻击者就知道你大约有48,293个用户。如果他们注册账号得到ID 48,294,就知道两次请求之间没有人注册。竞争对手可以定期创建账号来追踪你的增长速度。这就是为什么大多数公开API使用不透明ID。
UUIDv4什么都不泄露——它是纯随机的。ULID和UUIDv7会泄露创建时间戳(毫秒精度)。这是否重要取决于你的威胁模型。对于社交媒体帖子,知道它创建于2026-06-04T10:30:00Z可能无所谓——帖子本来就有可见时间戳。但对于保密的草稿文档,通过URL泄露创建时间可能不太合适。
Snowflake ID泄露时间戳和机器ID。机器ID位可以暴露你的基础设施拓扑(有多少台服务器,哪个数据中心处理了请求)。内部系统没问题,公开接口需要考虑这些信息是否会帮助攻击者。如果你需要完全不透明、零信息泄露的ID,使用UUIDv4或nanoid(更短的随机ID)。接受索引性能代价,或者用一个单独的内部顺序ID做索引,对外暴露随机ID。很多系统两者兼用:内部bigint主键用于join和索引,加一个公开UUID用于API响应和URL。
实用建议(决策树)
单数据库,1000万行以下,无分布式需求:用自增bigint。简单、快速,所有ORM都支持。如果需要对外不透明的ID,加一个UUID列。别为一个不需要的系统过度工程化。
分布式系统,需要多服务器无协调生成ID:用UUIDv7(如果你的技术栈支持——PostgreSQL 17+、现代UUID库)。否则用ULID。两者都给你时间戳排序和可忽略的碰撞概率,不需要任何协调服务。客户端或服务端生成都可以。
高吞吐系统(>10万次插入/秒),存储敏感:用Snowflake ID(前提是你能管理机器ID分配)。64位大小使你的索引存储比128位UUID减半。每台机器每毫秒4,096个ID的限制在实践中很少成为问题。需要最大兼容性:用UUIDv4。每个数据库、每个ORM、每个API框架都理解UUID。性能损失只在大规模下才重要(数千万行且写入负载重)。读多写少的场景或较小的表,UUIDv4完全没问题。我们的uuid-generator工具可以生成v4和v7两种变体供测试。
迁移策略(选错了怎么办)
从自增迁移到UUID/ULID(转微服务或需要客户端生成ID时常见):添加新ID列,为现有行回填,更新所有外键和应用代码使用新列,然后删除旧列。分阶段进行——不要对生产数据库搞一步到位的大迁移。
从UUIDv4迁移到ULID/UUIDv7以提升性能:你不能简单地重新编码现有ID,因为它们会得到随机时间戳。方案有:保留现有行的UUIDv4 ID,只对新行用ULID(随着旧行删除,索引会逐渐变得更有序),或者用新ID全量重写(需要更新所有引用)。
任何ID迁移最安全的方式:过渡期双写。同时写入新旧两种ID,API同时提供两种(查找时两种都接受),确认所有客户端都用了新格式后再切换。这避免了停机,但临时加倍了ID存储。从三次ID迁移中我学到的教训:最难的部分不是数据库——是找到ID出现的每一个地方。日志文件、埋点事件、外部合作伙伴集成、缓存响应、消息队列、错误追踪服务。开始之前先把所有消费方梳理清楚。那种"应该一周搞定"的迁移总是要拖一个月,因为你不掌控的系统里有被遗忘的引用。