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

为什么 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 出现在 users、orders、order_items 和 audit_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 运行更平稳。
你能实际感受的性能影响
大多数现实世界的性能下降并不是“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 与移动应用中的流转
数据库中选择的类型不会仅停留在数据库内。它会泄漏到每个边界: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 策略
先把应用的真实形态说清楚。单个 PostgreSQL 数据库和一个地区的部署需求跟多租户、未来按区域拆分或必须离线创建并稍后同步的移动应用有很大不同。
接着诚实评估 ID 会出现在哪里。如果标识符仅在后端内部使用(任务、内部工具、管理面板),简单往往更优。如果 ID 会出现在 URL、与客户共享的日志、支持票据或移动深链中,则可预测性和隐私更重要。
把排序作为决策因素,而非事后补救。如果你依赖“最新优先”的 feed、稳定的分页或易于扫描的审计轨迹,顺序 ID(或时间有序 ID)会减少意外。如果排序不依赖主键,你可以把主键选择和排序需求分开,改为按时间戳排序。
一个实用的决策流程:
- 分类你的架构(单库、多租户、多区域、离线优先)以及是否可能合并来自多源的数据。
- 决定 ID 是对外公开的标识还是纯内部使用。
- 确认你的排序和分页需求。如果需要按插入顺序自然排序,避免纯随机 ID。
- 如果选择 UUID,有意地选一个版本:随机(v4)用于不可预测,时间有序用于更好的索引局部性。
- 及早确定约定:统一的文本形式、大小写规则、验证方式以及每个 API 如何返回和接受 ID。
示例:如果移动应用需要离线创建“草稿订单”,UUID 允许设备在服务器看到之前安全生成 ID。在像 AppMaster 这样的工具中也很方便,因为相同的 ID 格式能从数据库流到 API、Web 和原生应用而无需特殊处理。
常见错误与陷阱
大多数 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 和原生移动应用。
一个现实的示例场景
一家小公司有一个内部运维工具、一个客户门户和一个现场人员的移动应用。三者都通过同一个 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 和原生移动应用。具体类型重要,但一旦有真实用户和多个客户端,一致性通常更重要。
常见问题
默认使用 bigint,当你只有单一 PostgreSQL 数据库、绝大部分写操作发生在服务端,并且你关心索引紧凑和可预测的插入行为时。需要在多个地方生成 ID(多个服务、离线移动端、未来分片)或不希望公开 ID 被轻易猜到时,选择 UUID。
因为 ID 会被复制到很多地方:主键索引、每个二级索引(作为行定位值)、其他表的外键列和连接表。UUID 是 16 字节而 bigint 是 8 字节,所以在整个模式中尺寸差会成倍放大,并可能降低缓存命中率。
在高写入量的表上会明显感到差异。随机 UUID(如 v4)会把插入分散到整个 B-tree,增加页分裂和索引 churn。如果想用 UUID 但又希望写入平稳,使用基于时间排序的 UUID 策略会让新键大多落在末尾。
通常体现为更多 IO,而不是 CPU 慢。更大的键意味着更大的索引,内存中能容纳的页更少,因而连接和查找可能触发更多磁盘读取。差异在大表、连接密集的查询和工作集超过内存时最明显。
UUID 可以减少像 /users/1 之类的简单猜测,但它们不能替代授权检查。如果权限检查有漏洞,UUID 依然可能被泄露和滥用。把 UUID 当作对外标识的一种便利手段,真正的安全来自严格的访问控制。
使用一个规范的表示并保持一致是关键。一个实用的默认是把 ID 在 API 请求和响应中作为字符串处理,即使数据库使用的是 bigint,因为这样可以避免客户端的数值精度问题并简化验证。无论选择什么,确保 web、移动、日志和缓存一致。
如果客户端把大整数作为浮点数解析,bigint 可能会出现精度丢失问题。UUID 作为字符串可以避免这个问题,但如果不严格验证也容易被错误使用。最安全的方法是保持一致:在所有地方使用同一种表示,并在 API 边界进行验证。
UUID 在多节点独立生成 ID 时最简单:几乎不需要协调。bigint 也能实现,但需要诸如每个分片分配数值区间、序列前缀或 Snowflake 式的生成器等规则,并且必须长期维护这些规则。如果要最简单的分布式策略,选择 UUID(最好是时间有序的变体)。
更改主键类型会影响的不仅仅是一列。你必须更新外键、连接表、API 协议、客户端存储、缓存的数据、分析事件以及任何以数字或字符串形式存储 ID 的集成。若需变更,规划渐进迁移:新增列并建立唯一索引、双写、分批回填、更新客户端并在切换一段时间后移除旧列。
可以这样做:在数据库内部保留紧凑的 bigint 主键,同时为对外暴露添加独立的公共 UUID(或 token)。这样既有利于内部调试和索引紧凑,又能避免外部枚举。关键是要早做决定并明确哪个是“对外 ID”,不要随意混用。


