2025年9月17日·阅读约2分钟

PostgreSQL 中的 UUID 与 bigint:如何选择可扩展的 ID

PostgreSQL 中的 UUID 与 bigint:比较索引大小、排序、分片准备情况,以及 ID 在 API、Web 与移动应用中的流转方式。

PostgreSQL 中的 UUID 与 bigint:如何选择可扩展的 ID

为什么 ID 的选择比看起来更重要

每一行 PostgreSQL 表的数据都需要一种稳定的方式来再次被找到。这就是 ID 的作用:唯一标识一条记录,通常是主键,并且成为关系的粘合剂。其他表把它当作外键存储,查询时按它连接,应用通过它来表示“那个客户”“那张发票”或“那个工单”。

因为 ID 无处不在,选择不只是数据库的细节。它会在以后以索引大小、写入模式、查询速度、缓存命中率的形式出现,甚至影响产品工作,如分析、导入和调试。它还会影响你在 URL 和 API 中暴露什么,以及移动应用如何安全地存储和同步数据。

大多数团队最后会比较 PostgreSQL 中的 UUID 与 bigint。简单说,你是在两者之间选择:

  • bigint:一个 64 位数字,通常由序列生成(1、2、3…)。
  • UUID:一个 128 位标识符,通常看起来像随机字符串,或以时间排序的方式生成。

两者都没有在所有场景中都占优。bigint 往往更紧凑,对索引和排序友好。UUID 在需要跨系统全局唯一、希望对外 ID 更安全或预计数据会在多处创建(多个服务、离线移动或未来分片)时非常适合。

一个有用的经验法则:根据数据将如何被创建和共享来决定,而不是只看今天如何存储。

bigint 与 UUID 的基本概念(通俗版)

当人们比较 PostgreSQL 中的 UUID 和 bigint 时,他们在两种给行命名的方式之间选择:一个像计数器的小数字,或一个更长的全局唯一值。

一个 bigint ID 是 64 位整数。在 PostgreSQL 中通常通过 identity column(或旧式的 serial 模式)生成。数据库在底层维护一个序列,每次插入行时返回下一个数字。这意味着 ID 通常是 1、2、3、4……,简单、易读,并且对工具和报表友好。

一个 UUID(Universally Unique Identifier)是 128 位。你通常会看到它写成带连字符的 36 个字符,例如 550e8400-e29b-41d4-a716-446655440000。常见类型包括:

  • v4:随机 UUID。任何地方都能生成,但不按创建顺序排序。
  • v7:时间有序的 UUID。仍然唯一,但设计为大致随时间递增。

存储是最先出现的实用差异之一:bigint 使用 8 字节,而 UUID 使用 16 字节。这个尺寸差距会在索引中体现,并可能影响缓存命中率(数据库内存中能容纳的索引条目更少)。

还要考虑 ID 在数据库外出现的位置。bigint ID 在 URL 中短小易读,便于从日志或支持票据中识别。UUID 更长、难以手动输入,但更难被猜测,并且可以在客户端安全生成。

索引大小与表膨胀:会发生什么变化

在 bigint 与 UUID 之间最实际的差异是大小。bigint 是 8 字节;UUID 是 16 字节。听起来差距不大,直到你记得索引会多次重复存储你的 ID。

你的主键索引需要保持热(在内存中)才能保持快速。较小的索引意味着更多索引能放进 shared buffers 和 CPU 缓存,因此查找和连接需要更少的磁盘读取。使用 UUID 作为主键时,对于相同的行数,索引通常会明显更大。

二级索引会进一步放大差异。在 PostgreSQL 的 B-tree 索引中,每个二级索引条目也会存储主键值(以便数据库能找到对应行)。所以更宽的 ID 不仅膨胀了主键索引,也会膨胀你添加的每个其他索引。如果你有三个二级索引,UUID 比 bigint 多出来的 8 字节实际上会在四个地方出现。

外键和连接表也会感受到影响。引用你 ID 的任何表都会在自己的行和索引中存储该值。一个多对多连接表可能主要由两个外键加上一点开销组成,因此主键宽度翻倍会显著改变它的占用空间。

实际情况:

  • UUID 通常会让主键和二级索引更大,而且随着索引数量增加差异会成倍放大。
  • 更大的索引会带来更多内存压力,并在负载下增加页面读取。
  • 引用该键的表越多(事件、日志、连接表),尺寸差异就越重要。

如果一个用户 ID 出现在 usersordersorder_itemsaudit_log 中,同样的值会在这些表中被多次存储和索引。选择更宽的 ID 更多的是一个存储决策,而不仅仅是 ID 决策。

排序顺序与写入模式:顺序 vs 随机 ID

大多数 PostgreSQL 主键都基于 B-tree 索引。B-tree 在新行落在索引尾部时表现最佳,因为数据库可以以追加方式写入,避免大量重排。

顺序 ID:对存储友好且可预测

使用 bigint 的 identity 或序列,ID 会随时间增长。插入通常击中索引的最右端,因此页保持打包,缓存保持热,PostgreSQL 做更少额外工作。

即使你从不运行 ORDER BY id,这也会影响写入路径。写入时仍需以排序顺序把每个新键放到索引中。

随机 UUID:更分散,更多变动

随机 UUID(常见于 UUIDv4)会将插入分布到整个索引,这增加了页分裂的可能性,PostgreSQL 必须分配新的索引页并移动条目以腾出空间。结果是更多的写放大:更多索引字节被写入、更多 WAL 生成,并且后来需要更多后台工作(vacuum 和膨胀管理)。

时间有序的 UUID 则改变了这一点。像 UUIDv7 或其它基于时间的方案,让键大致随时间增长,恢复了大部分局部性,但仍是 16 字节并且在 API 中看起来像标准 UUID。

当你有高插入率、大表且表无法全部放入内存并且存在多个二级索引时,你会更明显地感受到这些差异。如果你对页分裂导致的写延迟波动敏感,避免在高写入表上使用完全随机的 ID。

示例:一个全天接收移动应用日志的繁忙 events 表,通常使用顺序键或时间有序的 UUID 会比完全随机的 UUID 运行更平稳。

你能实际感受的性能影响

Map your keys and foreign keys
Design tables, keys, and relationships visually before you write any code.
Open Data Designer

大多数现实世界的性能下降并不是“UUID 慢”或“bigint 快”。关键在于数据库为了回答你的查询必须触及多少数据。

查询计划主要关心是否能为过滤条件使用索引、是否能在主键上进行快速连接,以及表是否按物理顺序(或接近)排列以使范围读取便宜。使用 bigint 主键时,新行大致按顺序落入索引,因此主键索引倾向于保持紧凑且具有良好局部性。使用随机 UUID 时,插入会分散到索引中,可能造成更多页分裂和更凌乱的磁盘顺序。

读取操作通常是团队最先注意到的地方。更大的键意味着更大的索引,而更大的索引意味着内存中能容纳的页更少,从而降低缓存命中率并增加 IO,尤其是在像“列出订单并包含客户信息”这样的连接密集页面上。如果你的工作集不能放入内存,带有大量 UUID 的模式会更早把你压到那条边线上。

写入也会变化。随机 UUID 插入可能增加索引 churn,加重 autovacuum 压力,并在繁忙时段表现为延迟峰值。

如果你去做 UUID 与 bigint 的基准测试,要做到公平:相同的模式、相同的索引、相同的 fillfactor,并且插入行数足够大以超过内存(不要只用 1w 行)。衡量 p95 延迟和 IO,并测试热缓存与冷缓存场景。

如果你在 AppMaster 上基于 PostgreSQL 构建应用,这通常会先表现为列表页面变慢和数据库负载增加,而不是立刻看到“CPU 问题”。

面向外部系统时的安全性与可用性

如果你的 ID 会离开数据库并出现在 URL、API 响应、支持票据和移动屏幕上,那么选择会同时影响安全性和日常可用性。

bigint ID 对人类友好。它们短小,你可以在电话中读出来,支持团队可以迅速识别类似“所有出错订单都在 9,200,000 附近”这样的模式,这能加速调试,尤其是在依赖日志或客户截图时。

UUID 在对外暴露标识符时很有用。UUID 难以猜测,所以像逐个尝试 /users/1/users/2/users/3 这类抓取方式不起作用。它也让外部更难推断你有多少记录。

陷阱是把“不可猜测”当成“安全”。如果授权检查薄弱,可预测的 bigint ID 会被快速滥用,但 UUID 仍然可能从共享链接、泄露的日志或缓存的 API 响应中被窃取。安全应来自权限检查,而不是隐藏 ID。

一个实用方法:

  • 在每次读写上强制所有权或角色检查。
  • 如果在公共 API 中暴露 ID,使用 UUID 或单独的公共 token。
  • 如果你想要对人友好的引用,保留内部的 bigint 供运维使用。
  • 不要在 ID 本身编码敏感含义(例如用户类型)。

示例:客户门户显示发票 ID。如果发票使用 bigint,而你的 API 只检查“发票是否存在”,某人可以遍历编号并下载别人的发票。先修复权限检查,然后再决定 UUID 是否能减少风险和支持负载。

在像 AppMaster 这样 ID 会在生成的 API 和移动应用之间流动的平台里,最安全的默认是:一致的授权机制加上客户端能可靠处理的 ID 格式。

ID 在 API 与移动应用中的流转

Keep integrations ID safe
Connect auth, payments, messaging, and integrations without breaking your ID format.
Add Modules

数据库中选择的类型不会仅停留在数据库内。它会泄漏到每个边界:URL、JSON 负载、客户端存储、日志和分析中。

如果你之后改变 ID 类型,破坏往往不是“只是一次迁移”。外键必须在各处改变,不仅仅是主表。ORM 和代码生成器也许会重新生成模型,但集成仍期望旧格式。即便是简单的 GET /users/123 端点,当 ID 变成 36 字符的 UUID 时也会变得棘手。你还需要更新缓存、消息队列以及任何以整数形式存储 ID 的地方。

对于 API,最大的问题是格式和验证。bigint 作为数字传输,但一些系统(和一些语言)如果把它作为浮点数解析会有精度问题。UUID 作为字符串传输,更安全,但你需要严格验证以避免“差不多是 UUID 的东西”进入日志和数据库。

在移动端,ID 经常被序列化和存储:JSON 响应、本地 SQLite 表,以及在离线时保存的离线队列。数字 ID 更小,但字符串形式的 UUID 更容易作为不透明的 token 处理。真正造成痛苦的是不一致:一层把 ID 存为整数,另一层把它当作文本,比较或连接时就会脆弱。

一些能避免麻烦的规则:

  • 为 API 选择一种规范表示(通常是字符串)并保持一致。
  • 在边界验证 ID 并返回清晰的 400 错误。
  • 在本地缓存和离线队列中存储相同的表示形式。
  • 在服务间使用一致的字段名和格式记录 ID。

如果你用生成栈构建 Web 与移动客户端(例如,AppMaster 生成后端与原生应用),稳定的 ID 协议更重要,因为它会成为每个生成模型和请求的一部分。

分片准备与分布式系统

“分片就绪”主要意味着你可以在多个地方创建 ID 而不破坏唯一性,并且将来在节点间移动数据时无需重写所有外键。

UUID 在多区域或多写入者设置中很受欢迎,因为任一节点都能生成唯一 ID 而不需中央序列。这减少了协调,使得在不同区域接受写入并稍后合并数据变得更容易。

bigint 仍然可行,但需要计划。常见选项包括为每个分片分配数值区间(分片 1 使用 1–1B,分片 2 使用 1B–2B)、运行带分片前缀的独立序列,或使用 Snowflake 式 ID(时间位加机器或分片位)。这些方法可以让索引比 UUID 更小并保留一定的排序性,但会增加必须执行的操作规则。

日常重要的权衡项:

  • 协调:UUID 几乎不需要;bigint 通常需要区间规划或生成服务。
  • 冲突:UUID 冲突极不可能;bigint 只有在分配规则永不重叠时才安全。
  • 排序:许多 bigint 方案大致按时间有序;UUID 通常是随机的,除非使用时间有序的变体。
  • 复杂性:分片 bigint 只要团队自律就能保持简单,但需要长期维护规则。

对很多团队来说,“分片就绪”其实意味着“可迁移”。如果你今天在单库上,选个让当前工作更简单的 ID。如果你已经在构建多写入者(例如通过 AppMaster 生成的 API 和移动应用),那么尽早决定各服务如何创建和验证 ID 很重要。

逐步指南:如何选择合适的 ID 策略

Stay flexible as IDs evolve
Change requirements later and regenerate clean code without technical debt.
Regenerate App

先把应用的真实形态说清楚。单个 PostgreSQL 数据库和一个地区的部署需求跟多租户、未来按区域拆分或必须离线创建并稍后同步的移动应用有很大不同。

接着诚实评估 ID 会出现在哪里。如果标识符仅在后端内部使用(任务、内部工具、管理面板),简单往往更优。如果 ID 会出现在 URL、与客户共享的日志、支持票据或移动深链中,则可预测性和隐私更重要。

把排序作为决策因素,而非事后补救。如果你依赖“最新优先”的 feed、稳定的分页或易于扫描的审计轨迹,顺序 ID(或时间有序 ID)会减少意外。如果排序不依赖主键,你可以把主键选择和排序需求分开,改为按时间戳排序。

一个实用的决策流程:

  1. 分类你的架构(单库、多租户、多区域、离线优先)以及是否可能合并来自多源的数据。
  2. 决定 ID 是对外公开的标识还是纯内部使用。
  3. 确认你的排序和分页需求。如果需要按插入顺序自然排序,避免纯随机 ID。
  4. 如果选择 UUID,有意地选一个版本:随机(v4)用于不可预测,时间有序用于更好的索引局部性。
  5. 及早确定约定:统一的文本形式、大小写规则、验证方式以及每个 API 如何返回和接受 ID。

示例:如果移动应用需要离线创建“草稿订单”,UUID 允许设备在服务器看到之前安全生成 ID。在像 AppMaster 这样的工具中也很方便,因为相同的 ID 格式能从数据库流到 API、Web 和原生应用而无需特殊处理。

常见错误与陷阱

Make paging predictable
Test cursor pagination and sorting rules using your real UI workflows.
Generate Web App

大多数 ID 争论失败的原因是人们为某一个理由选择了 ID 类型,然后被后果惊到。

常见一个错误是在高写入表上使用完全随机的 UUID,然后不理解为什么插入看起来峰值很多。随机值会把新行分散到索引中,导致更多页分裂和数据库在高负载下更多工作。如果表写入繁忙,在提交前考虑插入的局部性。

另一个常见问题是跨服务和客户端混用 ID 类型。例如,一个服务使用 bigint,另一个使用 UUID,你的 API 最后同时返回数字和字符串 ID。这常导致微妙的 Bug:JSON 解析器在大数字上丢失精度、移动端代码在不同屏幕把 ID 当数字或字符串处理不一致,或者缓存键不匹配。

第三个陷阱是把“不可猜测 ID”当成安全。即使用 UUID,也必须做权限检查。

最后,团队在没有计划的情况下晚期更改 ID 类型。最难的部分不是主键本身,而是附着在它上的一切:外键、连接表、URL、分析事件、移动深链和客户端持久化状态。

为避免痛苦:

  • 为公共 API 选择一种 ID 类型并坚持下去。
  • 在客户端把 ID 视为不透明字符串以避免数值边界问题。
  • 永远不要把 ID 的随机性当作访问控制手段。
  • 如果必须迁移,做好 API 版本化并为长生命周期客户端预留过渡时间。

如果你使用像 AppMaster 这样的代码生成平台,保持一致性更重要,因为相同的 ID 类型会从数据库模式流到生成的后端、Web 和移动应用中。

决策前的快速检查表

感觉卡住时,不要从理论出发,从产品一年后的样子和 ID 会去哪些地方开始。

询问自己:

  • 最大表在 12 到 24 个月内会有多大,你会保留多少历史?
  • 你是否需要大致按创建时间排序的 ID 以方便分页和调试?
  • 是否有多个系统会同时创建记录,包括离线移动或后台任务?
  • ID 是否会出现在 URL、支持票据、导出或客户共享的截图中?
  • 每个客户端(Web、iOS、Android、脚本)是否能以相同方式处理 ID,包括验证和存储?

回答这些问题后,再检查基础设施。如果使用 bigint,确保你在每个环境(尤其是本地开发和导入场景)都有清晰的 ID 生成方案。如果使用 UUID,确保 API 合同和客户端模型一致处理字符串形式的 ID,并且团队能熟练阅读和比较它们。

一个现实测试:如果移动应用需要在离线时创建订单并稍后同步,UUID 通常能减少协调工作。如果应用大多在线且你想要更小的索引,bigint 通常更简单。

如果你在 AppMaster 上构建应用,尽早决定很重要,因为 ID 约定会流经 PostgreSQL 模型、生成的 API 以及 Web 和原生移动应用。

一个现实的示例场景

Decide your ID strategy fast
Model your PostgreSQL schema and test bigint vs UUID choices in a real app.
Try AppMaster

一家小公司有一个内部运维工具、一个客户门户和一个现场人员的移动应用。三者都通过同一个 API 访问相同的 PostgreSQL 数据库。整天都会创建新记录:工单、照片、状态更新和发票。

使用 bigint ID 时,API 载荷简洁且易读:

{ "ticket_id": 4821931, "customer_id": 91244 }

分页感觉自然:?after_id=4821931&limit=50。按 id 排序通常与创建时间一致,因此“最新工单”查询快速且可预测。调试也更简单:支持可以直接要求“ticket 4821931”,大多数人可以无误地输入这个数字。

使用 UUID 时,载荷会更长:

{ "ticket_id": "3f9b3c0a-7b9c-4bf0-9f9b-2a1b3c5d1d2e" }

如果使用随机 UUID v4,插入会散落到索引中,可能带来更多索引 churn,日常调试上会更常见到复制粘贴操作。分页常常转向基于游标的 token 而不是“after id”。

如果使用时间有序的 UUID,你能保留大部分“最新优先”的行为,同时仍避免在公共 URL 中暴露可猜测的 ID。

在实践中,团队通常会注意到四件事:

  • 人工输入 vs 复制粘贴 ID 的频率
  • “按 id 排序”是否与“按创建时间排序”一致
  • 游标分页是否干净且稳定
  • 在日志、API 调用和移动屏幕中追踪一条记录的难易程度

下一步:选个默认、测试并标准化

大多数团队纠结是因为想找完美答案。你不需要完美。你需要一个适合当前产品的默认值,以及一个快速验证它不会在未来造成伤害的方法。

你可以标准化的规则:

  • 想要最小的索引、可预测的排序和便于调试时,用 bigint。
  • 当 ID 必须在外部难以猜测、期望离线创建(移动端)或希望减少跨系统冲突时,用 UUID。
  • 如果未来可能按租户或区域拆分,优先选择在节点间工作的 ID 方案(UUID 或协调好的 bigint 方案)。
  • 选一个默认并尽量少做例外。对于大多数情况,一致性往往胜过对单表的微观优化。

在最终确定前,做一个小规模的验证:创建一个现实行大小的表,插入 100 万到 500 万行,比较(1)索引大小、(2)插入时间、(3)包含主键和几个二级索引的常见查询。在你真实的硬件和真实的数据形态上做比较。

如果担心以后可能变更,规划一个让迁移变得平滑的流程:

  • 添加新 ID 列并创建唯一索引。
  • 双写:对新行同时写入两种 ID。
  • 分批为旧行回填新 ID。
  • 更新 API 和客户端以接受新 ID(在过渡期保留对旧 ID 的支持)。
  • 切换读端,待日志和指标稳定后再删除旧主键。

如果你在 AppMaster (appmaster.io) 上构建,早点做决定值得投入,因为 ID 约定会流经你的 PostgreSQL 模型、生成的 API 以及 Web 和原生移动应用。具体类型重要,但一旦有真实用户和多个客户端,一致性通常更重要。

常见问题

Should I use bigint or UUID as the primary key in PostgreSQL?

默认使用 bigint,当你只有单一 PostgreSQL 数据库、绝大部分写操作发生在服务端,并且你关心索引紧凑和可预测的插入行为时。需要在多个地方生成 ID(多个服务、离线移动端、未来分片)或不希望公开 ID 被轻易猜到时,选择 UUID。

Why do UUIDs make indexes and storage grow so much?

因为 ID 会被复制到很多地方:主键索引、每个二级索引(作为行定位值)、其他表的外键列和连接表。UUID 是 16 字节而 bigint 是 8 字节,所以在整个模式中尺寸差会成倍放大,并可能降低缓存命中率。

Will UUIDs slow down inserts compared to bigint?

在高写入量的表上会明显感到差异。随机 UUID(如 v4)会把插入分散到整个 B-tree,增加页分裂和索引 churn。如果想用 UUID 但又希望写入平稳,使用基于时间排序的 UUID 策略会让新键大多落在末尾。

Will I notice slower reads with UUIDs?

通常体现为更多 IO,而不是 CPU 慢。更大的键意味着更大的索引,内存中能容纳的页更少,因而连接和查找可能触发更多磁盘读取。差异在大表、连接密集的查询和工作集超过内存时最明显。

Are UUIDs more secure than bigint IDs in public APIs?

UUID 可以减少像 /users/1 之类的简单猜测,但它们不能替代授权检查。如果权限检查有漏洞,UUID 依然可能被泄露和滥用。把 UUID 当作对外标识的一种便利手段,真正的安全来自严格的访问控制。

What’s the best way to represent IDs in JSON APIs?

使用一个规范的表示并保持一致是关键。一个实用的默认是把 ID 在 API 请求和响应中作为字符串处理,即使数据库使用的是 bigint,因为这样可以避免客户端的数值精度问题并简化验证。无论选择什么,确保 web、移动、日志和缓存一致。

Do bigints cause issues in JavaScript or mobile apps?

如果客户端把大整数作为浮点数解析,bigint 可能会出现精度丢失问题。UUID 作为字符串可以避免这个问题,但如果不严格验证也容易被错误使用。最安全的方法是保持一致:在所有地方使用同一种表示,并在 API 边界进行验证。

What should I pick if I might shard or go multi-region later?

UUID 在多节点独立生成 ID 时最简单:几乎不需要协调。bigint 也能实现,但需要诸如每个分片分配数值区间、序列前缀或 Snowflake 式的生成器等规则,并且必须长期维护这些规则。如果要最简单的分布式策略,选择 UUID(最好是时间有序的变体)。

How painful is it to migrate from bigint to UUID (or the other way)?

更改主键类型会影响的不仅仅是一列。你必须更新外键、连接表、API 协议、客户端存储、缓存的数据、分析事件以及任何以数字或字符串形式存储 ID 的集成。若需变更,规划渐进迁移:新增列并建立唯一索引、双写、分批回填、更新客户端并在切换一段时间后移除旧列。

Can I use both: bigint internally and UUID externally?

可以这样做:在数据库内部保留紧凑的 bigint 主键,同时为对外暴露添加独立的公共 UUID(或 token)。这样既有利于内部调试和索引紧凑,又能避免外部枚举。关键是要早做决定并明确哪个是“对外 ID”,不要随意混用。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧