分布式系统里的 ID 要在互不协调的多个节点上各自生成,所以候选基本都是 128 位:UUIDv4 全是随机数,UUIDv7 和 ULID 前面放毫秒时间戳、后面放随机数。

三个里选哪个,绕不开一句常见的建议:主键最好递增。选型真正在选的只有两件事:键占几个字节,同一毫秒内有没有顺序。这两件事各换来什么、要付什么,取决于几个进程在写、数据靠什么分布。

一个写入者的 InnoDB 表上递增的收益最大:同一张 30 万行的表,主键从纯随机换成同一毫秒内严格递增,聚簇索引少近四成的页。四个实例各带计数器时只剩两成,键存成 CHAR(36) 时反而比纯随机多两成页;按主键范围分片的库上递增变成写热点;按 hash 分布的库不看这一项。

只有前两种形态是我自己量的:单节点 MySQL 能在一台笔记本上反复跑,而且它的 fill factor 规则手册里写着,可以拿实测去对。分片那两种按各家文档说,不报没测过的数。

环境是 MySQL 8.0.46(mysql:8.0 容器,innodb_buffer_pool_size=128M,binlog 开着)。建表脚本、灌数脚本和原始输出在 distributed-id-demo:八张表的页数在 results/2026-09-08-innodb.txtOPTIMIZE TABLE 前后的对照在 results/2026-09-08-innodb-reclaim.txt

一处口径先交代清楚:灌数用的键是脚本预先生成的,键里的毫秒时间戳每 500 行进一格,模拟每毫秒写 500 条,不是灌数时的真实速率。

三种 ID 的位段:时间戳在前,随机位在后#

三种 ID 都是 128 位,差别在这 128 位怎么切。随机位的宽度取自规范本身,不是谁的实测。

时间戳 随机位 文本形态 出处
UUIDv4 122 36 字符十六进制,带连字符 RFC 9562 §5.4
UUIDv7 48 位,Unix 毫秒 74(rand_a 12 + rand_b 62) 36 字符十六进制 RFC 9562 §5.7
ULID 48 位,Unix 毫秒 80 26 字符 Crockford Base32 ulid/spec

UUIDv7 的 74 位是 128 减掉 48 位时间戳、4 位 version、2 位 variant 剩下的。规范把它切成 rand_arand_b 两段,并且明确说这两段可以拿去放计数器换单调性,§6.2 给了三种做法:定长计数器(Fixed Bit-Length Dedicated Counter)、把随机位本身当计数器(Monotonic Random)、用更高精度的时间戳顶掉随机位(Method 3)。

ULID 那 80 位没有这个自由度,spec 直接规定同一毫秒内检测到时间戳没变,就把随机段按最低位加一并带进位。

主键只在两处影响存储:键宽和同一毫秒内的顺序#

主键落到 InnoDB 里不只是一列数据,它就是聚簇索引的键,整张表的行按它排在 B-tree 的叶子页上。一页 16 KiB,能装多少行由两件事决定:一行占多宽(键宽是其中一项),以及这一页最后被填到多满。

第二件事有个正式名字叫 fill factor(页填充率),规则写在 MySQL 手册讲 聚簇索引物理结构 的那节:

When new records are inserted into an InnoDB clustered index, InnoDB tries to leave 1/16 of the page free for future insertions and updates of the index records. If index records are inserted in a sequential order (ascending or descending), the resulting index pages are about 15/16 full. If records are inserted in a random order, the pages are from 1/2 to 15/16 full.

机制就在这一句里。主键递增时,新行永远落在最右边那一页的末尾,这一页填到 15/16 才开下一页。主键随机时,新行落进某个已经填满的页中间,这一页只能一分为二(page split),两半各自半满,而后续的行未必再回来把它们补满:

flowchart TB subgraph SEQ["键严格递增,插入点钉在最右边"] direction LR S1["页 1
15/16 满"] --- S2["页 2
15/16 满"] --- S3["页 3
正在填,新行都进这里"] end subgraph RND["键随机,插入点落在中间某页"] direction LR R1["页 1
15/16 满"] --- R2["页 2
已满,新行要插进来"] --- R3["页 3
15/16 满"] end subgraph SPL["页 2 撑爆后一分为二,两半都停在半满"] direction LR P1["页 1
15/16 满"] --- P2A["页 2a
1/2 满"] --- P2B["页 2b
1/2 满"] --- P3["页 3
15/16 满"] end RND --> SPL

这张图按手册那两个比例画的示意:同一批行,顺序插入三页装完,随机插入要四页。所以「主键随机」的代价不是抽象的碎片,是每页少装多少行。

于是候选方案要动的就是两个变量:

  • 键占几个字节:CHAR(36) 的 36 字节,还是 BINARY(16) 的 16 字节。同一页 16 KiB,键窄了每页就能多排几行。
  • 键在同一毫秒内是不是严格递增:决定这些页最后停在 15/16 还是 1/2。

回到「选哪种 ID」这个问题上。在同一套列定义下,主键能动的就这两处。把三种候选摊到这两列上:

主键方案 存文本 / 存二进制的键宽 同一毫秒内的顺序
UUIDv4 36 字符 / 16 字节 无序,122 位全是随机
UUIDv7,只填随机数 36 字符 / 16 字节 无序,前 48 位相同、后 74 位各自随机
UUIDv7 + rand_a 计数器 36 字符 / 16 字节 严格递增(§6.2 方法一
ULID 26 字符 / 16 字节 严格递增(spec 规定同毫秒加一带进位

三行 UUID 的键宽一模一样,因为 v4 和 v7 的区别只在这 128 位内部怎么切,切法不改变总长;ULID 省下的 10 个字符也只在文本形态上,转成二进制三者都是 16 字节。也就是说,「选 v4 还是 v7 还是 ULID」这一步本身几乎不动键宽,真正动键宽的是「存文本还是存二进制」这个决定。而顺序那一列里,只有加了计数器的 UUIDv7 和 ULID 拿得到递增性。

换成 UUIDv7 不等于主键有序#

「把 UUIDv4 换成 UUIDv7」听起来正好解决第二个变量,因为 v7 把 48 位毫秒时间戳放在最前面,字典序和时间顺序一致。但这个「一致」只到毫秒边界为止:同一毫秒内产生的那些 ID,前 48 位一模一样,后面 74 位仍然是各自独立的随机数,彼此之间没有顺序。

在每毫秒写几百条这个量级上,键仍然一块一块地散着落进 B-tree,只是散落的范围从整个键空间收窄成一个随时间右移的窗口。

递增性(规范里叫 monotonicity,§6.2 的标题就是 Monotonicity and Counters)要靠 §6.2 那几种做法,往 rand_arand_b 里放计数器。规范在这里用的是弱措辞:§5.7 说实现「MAY」用这些子字段装计数器,§6.2 对需要批量生成的实现说的是「SHOULD」,两处都不是 MUST。也就是说,一个只填随机数、同一毫秒内无序、但照样合规的 UUIDv7 实现是允许存在的。

允许存在不等于普遍存在,所以实际的库得去量。我在 demo 里拿 com.github.f4b6a3:uuid-creator 6.1.1 跑了一遍:它默认那个 TimeOrderedEpochFactory 单线程连出 200 万个,回退 0、相等 0,键是严格递增的;按相邻 ID 的差值反推,计数器占 26 位、剩下 48 位每个 ID 重掷,和它文档写的对得上(results/2026-09-08-uuid-creator.txt)。至少这一个库的默认实现已经把计数器做进去了。

结论落在一句话上:规范不要求,所以「我们换成 UUIDv7 了」推不出「主键有序了」,这一步得去翻自己那个库的实现,或者自己把计数器加上。而没有计数器的 UUIDv7 实测下来不但没比 UUIDv4 好,占的页还更多。

实验:八张表,只有主键不同#

八张表各 30 万行,除主键以外每一列的取值逐行相同:两种键宽 × 四种随机程度。纯随机是 UUIDv4;仅毫秒有序是只填随机数的普通 UUIDv7;严格递增把 rand_a 的 12 位做成计数器,一个写入者在写;4 个写入者那两张表把这 12 位拆成 4 位节点号加 8 位每节点计数器,模拟 4 个实例各自生成、轮流到达主库。全部从空表批量灌入。

主键 聚簇索引页数 每页行数 聚簇索引大小
CHAR(36) 纯随机 2921 102.7 45.6 MB
CHAR(36) 仅毫秒有序 3115 96.3 48.7 MB
CHAR(36) 严格递增,单写入者 1831 163.8 28.6 MB
CHAR(36) 4 个写入者各带计数器 3507 85.5 54.8 MB
BINARY(16) 纯随机 2277 131.8 35.6 MB
BINARY(16) 仅毫秒有序 2469 121.5 38.6 MB
BINARY(16) 严格递增,单写入者 1380 217.4 21.6 MB
BINARY(16) 4 个写入者各带计数器 1829 164.0 28.6 MB

三列里只有页数是量出来的:ANALYZE TABLE 之后读 mysql.innodb_table_stats.clustered_index_size。大小是页数 × 16 KiB,每页行数是 COUNT(*) ÷ 页数;页数含非叶子页,所以每页行数比真实的叶子页密度略低一点。整套重新生成键、重新灌了两遍:带计数器的四行逐个相同,纯随机和仅毫秒有序那几行最多差 3%,因为它们的尾巴每次都是新随机数。

一个写入者:递增少 39% 的页,键宽再省两成#

先看前六行,两个变量各自的量级分开看。键宽:同样随机程度下,BINARY(16)CHAR(36) 少 21% 到 25% 的页。递增性更大:BINARY(16) 从纯随机的 2277 页降到严格递增的 1380 页,少 39%,每页从 132 行涨到 217 行。两项叠起来,从 CHAR(36) 纯随机的 2921 页到 BINARY(16) 严格递增的 1380 页,少了一半多。

手册那段规则在这张表上能直接验。把严格递增那两行当成手册说的「顺序插入」,纯随机和仅毫秒有序四行的每页行数是它的 56% 到 63%(CHAR(36) 是 63% 和 59%,BINARY(16) 是 61% 和 56%)。而按手册给的区间,随机插入相对顺序插入应该落在 53%(1/2 比 15/16)到 100% 之间,实测贴着 53% 那个下界。这组灌数是一次性从空表灌到底的,那些半满的页没有后续写入回来补,随机插入的最坏情况几乎全部兑现。

仅毫秒有序那两行说明「毫秒粒度有序」拿不到「顺序插入」那一档:同一毫秒内的键仍然散着落,page split 的账照付。它甚至没比纯随机省,CHAR(36) 从 2921 涨到 3115 页、BINARY(16) 从 2277 涨到 2469 页,两次独立运行里方向一致(百分比不稳,两轮之间差几个点)。

OPTIMIZE TABLE 重建之后,八张表按键宽各自收敛到同一个页数:CHAR(36) 都是 2087 页,BINARY(16) 都是 1572 页。拿它当 InnoDB 自己认为的正常紧凑度,纯随机和仅毫秒有序的表比它多四到六成,CHAR(36) 四个写入者那张最差,多 68%;严格递增的两张反而比重建后还少 12%,因为重建走的是 sorted index build,按 innodb_fill_factor 默认的 100 主动留了 1/16 给未来增长。多出来的这四到六成落到运行时,就是 buffer pool 里装下的行少那么多,插入时要读进来、改掉、再写回的页也多那么多。

所以只有一个进程在写时,两个变量都能拿满的组合是:UUIDv7 当聚簇主键,rand_a 的 12 位做成同毫秒内的递增计数器,存成 BINARY(16)。计数器拿递增性,二进制存储拿键宽。选 v7 而不是 ULID 的理由只有一个:它还是 UUID,PostgreSQL 的 UUID 列、各语言现成的序列化都能直接吃,而 ULID 得自己决定是存 CHAR(26) 还是转成 16 字节。

代价是可读性。BINARY(16) 在 MySQL 客户端里 SELECT 出来是一串字节,看日志和手写 WHERE 都得过一层 BIN_TO_UUID / UUID_TO_BIN。用 UUID_TO_BIN 存 v7 时别开第二个参数 swap_flag:手册写着 time-part swapping 假定的是 v1,而 v7 的时间戳本来就在最前面,互换反而把 version 和随机的 rand_a 挪到头部,顺序就丢了。生成也得在应用侧做,UUID() 只生成 v1(我在 8.0.46 上查过版本位;swap_flag 打乱 v7 顺序这件事也在同一个脚本里验过,见仓库的 mysql/uuid_bin_swap.sql 和它的输出),写这篇时能查到的 9.x 手册里还是那句 assumes v1。

递增性在读那一侧还有一笔,拿上面量到的密度就能算出来,算的是一次查询要碰多少个页,不是延迟。主键时间有序时,「最近插入的 100 行」在聚簇索引末尾连成一段,按上面 BINARY(16) 严格递增那张表每页 217 行的密度,最多跨 2 个页,也不需要二级索引;主键纯随机时,键里没有时间信息,得走时间戳列的索引再逐个回表,100 个主键散在 2277 个页里,期望碰到 98 个不同的页(2277 × (1 - (1 - 1/2277)^100))。热数据能不能留在 buffer pool 里也跟着这个走:有序时 working set 是索引末尾的一段,随机时「最近的数据」在物理上就是整张表。Flickr 那篇 ticket server 拒绝 GUID 的理由除了体积就是这一条:“If you can’t keep your indexes in memory, you can’t keep your database fast."。

键宽在读侧也有账:InnoDB 的二级索引每条记录都带一份完整主键,手册直接说 “it is advantageous to have a short primary key”,主键多宽,每个二级索引都要按行数再付一遍。

四个写入者:收益减半,文本主键反而更差#

上面那个 39% 有个前提没写在表里:它是一个写入者的数。RFC 把 §6.2 那几种计数器做法的适用范围写得很明确:

Implementations SHOULD employ the following methods for single-node UUID implementations that require batch UUID creation or are otherwise concerned about monotonicity with high-frequency UUID generation

单节点。一个多实例的服务拿不到严格递增:同一毫秒里各实例的计数器互不知情,节点之间还有时钟偏差。所以我按最常见的那种切法补了一组:rand_a 的 12 位拆成 4 位节点号加 8 位每节点计数器,4 个实例轮流到达主库。这样键在毫秒内不再是一条递增的线,而是 4 段各自递增的区间交错着写。

结果分了岔。BINARY(16) 保住一半多:1829 页,比纯随机的 2277 少 20%,但比单写入者的 1380 多 33%。CHAR(36) 全丢了:3507 页,比纯随机还多 20%,是八张表里最差的一行。这两个数在两次重新生成键的独立运行里逐个相同。

「4 个实例各带节点号」也不是凭空造出来的布局,现成的几套多写入者发号方案给的也都是这种 k-sorted。Twitter 的 Snowflake 是 41 位时间戳、10 位机器号、12 位序列号,对自己的有序程度只承诺 k-sorted(“we’re promising 1s, but shooting for 10’s of ms”),理由写在同一页上:“machines generating ids should not have to coordinate with each other”。MySQL 生态里 auto_increment_increment / auto_increment_offset 的多主发号、Flickr 的 ticket server(两台 MySQL,一台发奇数一台发偶数)、Vitess 的 sequence(文档明说 “there is no strict guarantee about ordering”)都是 N 路等差数列交错到达。所以 1829 和 3507 对应的就是这类方案落到 InnoDB 上的形状。

分片之后:范围分片的库要换随机键,hash 分布的库不看这一项#

这条界线容易和「B+tree 还是 LSM」混在一起,其实是两件独立的事。

存储结构决定的是有没有 fill factor 这个问题:B+tree 原地把页劈成两半,所以有半满页;LSM(TiKV 底下是 RocksDBOceanBase 是 MemTable 加 SSTable)先写内存、再由 compaction 成批刷成新文件,没有「原地劈页」这个动作。分布方式决定的是递增会不会造热点,跟存储结构无关:按主键的字节序切成一段段来分(TiDB、CockroachDB),递增的键永远追加在最后那一段上;按 hash 分(YugabyteDB 的 YSQL 默认把主键第一列做 hash 分区Citus 按分布列的 hash 落 shard、OceanBase 的 hash 分区、Vitess 的 hash vindex),落到哪台机器由 hash 说话。CockroachDB 和 YugabyteDB 都是 PostgreSQL 兼容、都是 LSM,主键建议却相反,差别就只在这一条。

所以「现代分布式数据库都用递增主键吗」这个问题,答案既不是「都用」也不是「都不用」,分歧也不在 MySQL 和 PostgreSQL 之间:

数据靠什么分布 例子 主键递增的后果
不分布,一个写入者 单机 MySQL、Aurora MySQL 递增键少近四成的页,收益能拿满(本篇实测)
不分布,多个写入者 MySQL 多主、Vitess 一个 shard 内 只拿到 k-sorted,收益减半
按主键范围分片 TiDB、CockroachDB 写热点,官方让你主动打散
按 hash 分布 YugabyteDB 默认、Citus、OceanBase hash 分区 分布由 hash 定,递增与否不影响分布

范围分片那一行里,主键顺便决定了数据落在哪台机器,「有点顺序」从好处变成负担。TiKV 按 key 的字节序把键空间切成连续的 Region,主键递增意味着插入只能追加到末尾,写热点长在同一个 Region 上。TiDB 讲聚簇索引的那页用的词是「值相近」而不是「递增」:

there might be write hotspot issues when inserting a large number of primary keys with close values

这一句正好把 UUIDv7 圈进去:v7 的前 48 位是毫秒时间戳,同一段时间生成的 ID 共享前缀、字节序上彼此紧邻。所以「换成 UUIDv7」在这边同样不解决问题,而且代价换了一种:不是页数变多,是写全压在一个 Region 上。CockroachDB 的措辞 更直接:

Traditional approaches using monotonically increasing INT or SERIAL data types will create hotspots for both reads and writes in a distributed database like CockroachDB.

它推荐的默认做法就是 gen_random_uuid(),也就是 UUIDv4;不得不按顺序键建索引时,改用 hash-sharded index 把顺序流量摊到多个 range 上。键宽那一项在这边也算,而且门槛更严:TiDB 讲聚簇索引 的那页写着主键超过 64 位的表更占空间,有多个二级索引时尤其明显,16 字节的 UUID 是这条线的两倍。

按主键范围分片的库上,具体选择大致是这样(最后一行留给按 hash 分布的库做对照):

场景 怎么选 依据
整型主键 AUTO_RANDOM 之类的随机整型,而不是自增 TiDB 明说它就是用来替掉 AUTO_INCREMENT、把写热点打散的
一定要用 UUID UUIDv4,不要 UUIDv7,也不要加同毫秒计数器 时间戳前缀正好制造「close values」
已经用了 v7、又不想改 ID 方案 把主键设成非聚簇,配 SHARD_ROW_ID_BITS 打散 _tidb_rowid 代价是主键要另存一份索引,每行多一个 KV 对
需要按创建时间范围扫 单独放一列时间戳建索引,别指望主键给这个顺序 主键在这边的职责是打散,不是排序
按 hash 分布的库 分布交给 hash,主键可以照单节点那套来 落哪台机器由 hash 定,不由主键顺序定

这张表全按上面那几份文档写,没有一行是在分片库上量出来的。方向在上一轮 TiDB 压测里撞到过一次:连续递增的 id 带共享前缀,索引写全压在一个 Region 上,换成随机 UUID 才摊开(九个瓶颈里只有三个在数据库上),那篇没有对照数字。真要上线前,热点还是得在自己的集群上用监控看,不是靠读文档。

小结#

  • 选型在选的只有两件事:键宽(存 36 字符文本还是 16 字节二进制)和同一毫秒内有没有顺序。v4 / v7 / ULID 的 128 位切法不改变键宽,只有带计数器的 v7 和 ULID 拿得到顺序。
  • 一个写入者的 InnoDB 是上限:递增少 39% 的页,键宽再省两成出头。「换成 UUIDv7」本身不带顺序,规范里计数器只是 MAY 和 SHOULD,只填随机数的 v7 比 v4 还多占页,用哪个库得翻它的实现。
  • 四个写入者各带计数器:递增的收益减半,CHAR(36) 反而比纯随机多两成。RFC 把计数器限定在单节点,Snowflake、MySQL 多主、Flickr、Vitess 给的也都是 k-sorted。
  • 分片之后看数据靠什么分布,不看 B+tree 还是 LSM:按主键范围分片的(TiDB、CockroachDB)递增造写热点,v7 的时间戳前缀也算「值相近」;按 hash 分布的(YugabyteDB 默认、Citus、OceanBase hash 分区)不看这一项。
  • 落到具体方案:一个写入者用 UUIDv7 加 rand_a 计数器、存 BINARY(16);按主键范围分片的库用 UUIDv4 或 AUTO_RANDOM;需要按时间取数就另建时间戳列,别指望主键给顺序。

相关文章#

参考资料#