UUID、ULID 与序列 ID:了解它们在真实项目中如何影响索引、排序、分片以及安全的数据导出与导入。

一开始选 ID 看起来很无聊。然后你上线、数据增长,这个“简单”的决定会出现在各处:索引、URL、日志、导出和集成。
真正的问题不是“哪种最好?”,而是“将来你想避免哪种痛苦?”ID 很难更改,因为它们被复制到其他表、被客户端缓存,并被其他系统依赖。
当 ID 与产品演进不匹配时,你通常会在几个地方看到问题:
现在与将来的灵活性总是要权衡。序列整数易读且通常快速,但会泄露记录数并使合并数据集更难。随机 UUID 在跨系统唯一性上很强,但对索引更苛刻,人类在日志中也难以扫描。ULID 旨在实现全局唯一且近似时间排序,但它们在存储和工具支持上也有取舍。
一个有用的思路是:ID 主要是给谁看的?
如果 ID 主要供人类(支持、调试、运维)使用,较短且更易扫描的 ID 往往更合适。如果是给机器(分布式写入、离线客户端、多区域系统)使用,全局唯一和避免冲突更重要。
当人们争论“UUID vs ULID vs serial IDs”时,他们实际上是在选择每行数据如何获得一个唯一标签。这个标签影响以后插入、排序、合并和移动数据的难易程度。
序列 ID 是一个计数器。数据库发出 1、2、3……(通常存为 integer 或 bigint)。它易读、存储廉价,而且通常很快,因为新行落在索引末尾。
UUID 是一个 128 位的标识符,看起来像随机串,例如 3f8a...。在大多数设置下,它可以在不询问数据库下一个序号的情况下生成,因此不同系统可以独立创建 ID。代价是随机外观的插入会让索引更难维护,并且比简单的 bigint 占用更多空间。
ULID 也是 128 位,但设计成近似按时间排序。更新的 ULID 通常会在旧的之后排序,同时仍然保持全局唯一。你通常能拿到 UUID 的“任意生成”好处,同时排序行为更友好。
简单总结:
序列 ID 常见于单数据库应用和内部工具。UUID 出现在跨多个服务、设备或区域创建数据的场景。ULID 在团队想要分布式 ID 生成同时又关心排序、分页或“最新优先”查询时很受欢迎。
主键通常由索引支撑(往往是 B 树)。把索引想象成一本有序的电话簿:每一行需要在合适的位置插入条目以保持查找快速。
对于随机 ID(经典的 UUIDv4),新条目会散落在索引各处。这意味着数据库要触及更多索引页,更频繁地拆分页,并做额外写入。随着时间推移,你会看到更多的索引抖动:每次插入的工作量增加、缓存未命中变多、索引比预期更大。
对于大致递增的 ID(序列/bigint,或像许多 ULID 一样按时间排序的 ID),数据库通常可以在索引末尾附近追加新条目。这对缓存更友好,因为最近的页会保持热度,高并发写入时插入也更平滑。
关键长度很重要,因为索引条目不是免费的:
更大的键意味着每页能容纳的条目更少。这通常会导致更深的索引、每次查询需要读取更多页以及保持性能所需的内存更多。
如果你有一个不断写入的“events”表,随机 UUID 主键相比 bigint 主键会更快显得性能不足,尽管单行查找仍然可能看起来没问题。如果你预计写入量很高,索引开销通常是你最先注意到的差别。
如果你做过“加载更多”或无限滚动,你已经感受过 ID 无法很好排序的痛苦。当按某个 ID 排序能得到稳定、有意义的顺序(通常是创建时间)时,我们说这个 ID “排序良好”,分页就可预测。
对于随机 ID(如 UUIDv4),更新的行会被散布。按 id 排序不会匹配时间,你用“基于 id 的游标分页(例如:给我在这个 id 之后的条目)”会变得不可靠。你通常需要回退到 created_at,但必须小心使用。
ULID 旨在大致按时间排序。如果按 ULID 排序(作为字符串或二进制存储),新项通常会排在后面。这使得游标分页更简单,因为游标可以是最后看到的 ULID。
ULID 对于动态时间排序的 feed、更简单的游标以及比 UUIDv4 更少的随机插入问题很有帮助。
但当多台机器在同一毫秒生成大量 ID 时,ULID 不保证完美的时间顺序。如果你需要精确排序,仍应使用真实时间戳。
created_at 更可靠当你回填数据、导入历史记录或需要明确的平局规则时,按 created_at 排序通常更安全。
一个实用的模式是按 (created_at, id) 排序,其中 id 只作为平局键使用。
分片意味着把一个数据库拆分成多个更小的数据库,每个分片保存部分数据。团队通常在单库难以扩展或成为单点故障风险时才做分片。
你的 ID 选择会让分片变得容易或痛苦。
使用顺序 ID(自增 serial 或 bigint)时,每个分片都会生成 1, 2, 3...。相同的 ID 会在多个分片上出现。第一次你需要合并数据、移动行或构建跨分片功能时,就会遇到冲突。
你可以通过协调来避免冲突(中央 ID 服务,或为每个分片分配区间),但那会增加系统复杂度并可能成为瓶颈。
UUID 和 ULID 减少了协调需求,因为每个分片都可以独立生成 ID,重复的风险极低。如果你认为将来会把数据拆到多个数据库,这是反对纯序列的有力理由之一。
常见的折中做法是在 ID 前加分片前缀,然后在每个分片上使用本地序列。你可以把它存为两列,或打包成一个值。
这能工作,但会生成自定义 ID 格式。每个集成都必须理解它,排序不再天然表示全局时间,且在分片之间移动数据可能需要重写 ID(如果这些 ID 在外部被引用就会破坏引用)。
尽早问自己一个问题:你是否会需要把多个数据库的数据合并并保持引用稳定?如果会,从第一天起就计划全局唯一 ID,或为以后迁移预留预算。
导出与导入是 ID 选择从理论变成现实的地方。你克隆生产到暂存、恢复备份或合并两个系统的数据时,你会发现 ID 是否稳定且可移植。
对于序列(自增)ID,你通常无法把插入安全地 replay 到另一个数据库而指望引用不变,除非保留原始编号。如果你只导入部分行(比如 200 个客户及其订单),必须按正确顺序加载表并保持相同的主键。如果任何东西被重新编号,外键就会断裂。
UUID 和 ULID 在这方面更方便,因为它们不依赖数据库序列生成——你可以复制行并保留 ID,关系仍然匹配。这在你从备份恢复、做部分导出或合并数据集时非常有帮助。
举例:把 50 个账户从生产导出到暂存以调试一个问题。使用 UUID/ULID 主键,你可以导入这些账户和相关行(项目、发票、日志),所有引用仍指向正确的父对象。使用序列 ID,你常常需要建立一个翻译表(old_id -> new_id)并在导入时重写外键。
对于大批量导入,基础工作比 ID 类型更重要:
聚焦将来会让你受罪的点,你可以快速做出稳妥决定。
写下你最担心的未来风险。把它们具体化:拆分到多个数据库、与另一个系统合并客户数据、离线写入、环境间频繁拷贝数据等。
决定 ID 排序是否必须匹配时间。如果你希望“最新优先”无需额外列,ULID(或其他时间可排序 ID)是干净的选择。如果你愿意按 created_at 排序,UUID 和序列 ID 都可行。
估算写入量和索引敏感度。如果你预计大量插入且主键索引是关键瓶颈,序列 BIGINT 通常对 B 树索引最友好。随机 UUID 往往会引起更多抖动。
选一个默认并记录例外规则。保持简单:大多数表用一个默认,并写清楚何时例外(通常:对外公开的 ID 与内部 ID 不同)。
给出变更的余地。避免把含义编码到 ID 里,决定 ID 在数据库还是应用端生成,并把约束写清楚。
最大的问题是因为流行而选了某种 ID,然后发现它和你的查询、扩展或数据共享方式冲突。大多数问题在几个月后才显现。
常见失败:
123, 124, 125,人们可以猜测邻近记录并探测你的系统。需要早期处理的警示信号:
为大多数表选一个主键类型并坚持使用。在不同表混用类型(一个表用 bigint,另一个用 UUID)会让连接、API 和迁移更困难。
估算在预期规模下的索引大小。更宽的键意味着更大的主索引以及更多的内存和 IO。
决定如何分页。如果按 ID 分页,确保该 ID 的排序可预测(否则接受它不可预测)。如果按时间分页,索引 created_at 并始终如一地使用它。
在类生产数据上测试你的导入方案。验证你能在不破坏外键的前提下重建记录,并且重复导入不会默默生成新 ID。
写下你的冲突策略。谁生成 ID(DB 还是应用),以及两套系统离线创建记录后同步时会发生什么?
确保公开 URL 和日志不会泄露你关心的模式(记录数、创建速率、内部分片提示)。如果你使用序列 ID,假设他人可以猜到邻近 ID。
一位独立创始人上线了一个简单的 CRM:联系人、交易、笔记。一个 Postgres 数据库,一个 Web 应用,目标是尽快交付。
起初,序列 bigint 主键 是完美选择。插入快,索引整齐,日志中易读。
一年后,一个客户要求按季度导出用于审计,创始人开始从营销工具导入线索。原本仅在内部使用的 ID 现在出现在 CSV、邮件和工单中。如果两个系统都使用 1, 2, 3...,合并会很混乱。你可能需要添加来源列、映射表或在导入时重写 ID。
到了第二年,有了移动应用。它需要离线创建记录并在同步时上传。这时你需要能在客户端生成的 ID,并且希望在不同环境落地时低碰撞风险。
一个经常能经久不衰的折中方案:
如果你在 UUID、ULID 和序列 ID 之间犹豫,根据数据如何流动与增长来决定。
一句话推荐,按常见情况:
bigint 序列主键。混合往往是最好的答案。对永远不会离开你数据库的内部表(连接表、后台任务)使用序列 bigint,对用户、组织、发票以及可能被导出、同步或由其他服务引用的公开实体使用 UUID/ULID。
如果你在 Koder.ai (koder.ai) 上构建,值得在生成大量表和 API 之前决定 ID 模式。该平台的规划模式和快照/回滚功能可以让你在系统仍小且易变时更容易应用与验证模式更改。
从你想避免的未来痛点开始:来自随机索引写入导致的插入变慢、分页尴尬、危险的迁移,或者导入/合并时的 ID 冲突。如果你预计数据会在多个系统间移动或在多处创建,默认使用全局唯一 ID(UUID/ULID),并把时间排序相关的需求单独处理。
当你只有一个数据库、写入量大且 ID 仅在内部使用时,序列 bigint 是可靠的默认。它存储紧凑、对 B 树索引友好,并且在日志中易读。主要缺点是当需要合并来自多个系统的数据时容易冲突,且公开暴露会泄露记录数量。
当记录可能在多个服务、区域、设备或离线客户端生成且你希望在无需协调的情况下拥有极低的冲突风险时,选择 UUID。UUID 也适合公开的 ID,因为难以猜测。代价是更大的索引和相比顺序键更随机的插入模式。
当你希望 ID 能在任意地点生成且通常按时间排序时,ULID 很有用。这能简化游标分页,并减少 UUIDv4 常见的“随机插入”问题。注意 ULID 不是精确时间戳:在同一毫秒内多台机器生成大量 ID 时可能仍会出现乱序。
是的,尤其是在写入密集的表上使用 UUIDv4 风格的随机主键会有影响。随机插入会分散主键索引,导致更多页分裂、缓存抖动和随着时间增长的更大索引。你通常会先注意到持续写入速率变慢,以及更高的内存/IO 需求,而不是单行查询变慢。
因为随机 ID(如 UUIDv4)与创建时间无关,按 id 排序不会得到稳定的时间顺序,所以“在此 id 之后” 的游标不会产生可靠的时间线。可靠的修复方法是按 created_at 分页,并把 ID 作为平局键,例如 (created_at, id)。如果想单靠 ID 分页,选择像 ULID 这样的时间可排序 ID 更简单。
序列 ID 会在各分片上各自生成 1, 2, 3... 导致冲突。可以通过协调(为每个分片预留区间或使用中央 ID 服务)避免冲突,但这会增加运维复杂度并可能成为瓶颈。UUID/ULID 则降低了协调需求,因为每个分片可以独立生成 ID,重复的风险极低。
UUID/ULID 更安全:你可以导出行并在别处导入而不重写引用,这样关系保持完整。序列 ID 的部分导入通常需要一个翻译表(old_id -> new_id)并在导入时重写外键,这很容易出错。如果你经常克隆环境或合并数据,全局唯一 ID 会节省很多时间。
常见做法是使用两套 ID:一个紧凑的内部主键(序列 bigint),用于连接和存储效率;另一个是不可变的公开 ID(ULID 或 UUID),用于 URL、API、导出和跨系统引用。这样既能保持数据库性能,又能让集成和迁移更轻松。关键是把公开 ID 视为稳定值,绝不回收或重新解释它。
及早规划并在表与 API 中一致应用。在 Koder.ai (koder.ai) 中,最好在生成大量模式和端点前在规划模式里决定默认 ID 策略,然后使用快照/回滚在项目还小的时候验证变更。最难的不是创建新 ID,而是更新外键、缓存负载、日志和外部集成中仍引用旧 ID 的那些地方。