资讯详情

资讯详情

UUID 还是自增 ID?别让 AI 替你做数据库设计

自增 ID 不是落后的设计UUID 也不是分布式系统的万能答案。真正成熟的数据库设计应该从数据的生命周期、系统边界、索引成本和业务需求出发而不是跟随某种技术潮流。一、一个值得重新思考的问题如果你使用过 MySQL 或 PostgreSQL大概率写过这样的表CREATE TABLE orders ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL );这很自然。id自动增长查询简单索引友好调试时看一眼就知道哪条记录大概先创建。但现在让 AI 生成一个 SaaS 系统、微服务项目甚至一个简单的 CRUD 应用它可能会给你这样的结构CREATE TABLE orders ( id UUID PRIMARY KEY, order_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL );为什么很多 AI 生成的项目默认采用 UUID通常有几个原因训练语料中有大量微服务、云原生和分布式系统的设计案例。UUID 可以在数据库之外生成方便 API、客户端和多个服务共同创建实体。不需要协调多个数据库的自增序列。UUID 作为公开标识符不会直接暴露连续的数据库编号。这些理由都成立但它们并不意味着 UUID 对所有项目都更好。问题在于AI 经常把“某些场景需要 UUID”简化成“新项目就应该使用 UUID”。这是一个值得警惕的工程习惯。数据库主键是基础设施决策。它会影响索引、外键、存储空间、数据迁移、接口设计和未来的系统集成不应该仅仅因为某个技术方案更流行就直接采用。本文从数据库实现、性能、分布式架构、安全性和实际项目设计等方面把这件事讲清楚。二、首先明确自增 ID 和 UUID 到底是什么1. 自增 ID自增 ID 是由数据库的序列机制生成的数值标识符。例如idorder_nostatus1001ORD-001pending1002ORD-002delivering1003ORD-003completed1004ORD-004pending在 PostgreSQL 中可以使用IDENTITYCREATE TABLE orders ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_no TEXT NOT NULL );BIGINT是 64 位有符号整数通常占用 8 字节。数据库通过序列生成数字应用不必自行协调编号。需要注意自增序列不保证绝对连续事务回滚、并发插入等情况都可能造成编号间隙。2. UUIDUUID 是一种 128 位标识符标准文本形式通常为 36 个字符包括连字符。例如019a5d10-2a10-7c31-8f72-65c3a4d1b920它的优势是可以在多个系统中独立生成极大降低标识符碰撞的风险而不必依赖同一个数据库序列。PostgreSQL 原生支持 UUID 类型不需要将其存储为 36 字符的文本。CREATE TABLE orders ( id UUID PRIMARY KEY, order_no TEXT NOT NULL );UUID 不是一种单一的生成算法。常见版本的行为差异很大UUIDv4主要使用随机数据生成生成过程简单但插入位置通常较分散。UUIDv7在高位编码 Unix 毫秒时间戳剩余位包含随机数据或可选的单调性辅助信息整体上更适合按索引顺序插入。UUIDv7 的规范见 RFC 9562。这意味着讨论 UUID 性能时不能把 UUIDv4 和 UUIDv7 混为一谈。三、从各个维度比较优劣1. 数据库存储成本先看最直接的区别。对比项BIGINT 自增 IDUUID原始字段大小8 字节16 字节典型索引键大小较小较大外键存储8 字节16 字节人工阅读方便不方便是否需要数据库参与生成通常需要不需要多节点独立生成需要额外设计原生适合UUID 的原始二进制长度是 BIGINT 的两倍。而且主键并不是只在主表中存储一次。它还会进入关联表、外键列以及相关索引。假设一个系统有 1000 万条订单每条订单平均被 4 条关联记录引用。仅计算主键列和这 4 条引用列的原始数据BIGINT(1000万 \times 5 \times 8) 字节约 400 MB。UUID(1000万 \times 5 \times 16) 字节约 800 MB。这是一个简化的容量估算不包含索引结构、页开销、对齐、其他字段和缓存成本。实际差距还可能受到索引结构和插入方式的影响。对于只有几千或几万条数据的普通业务系统这种差异通常不值得单独决定主键类型。但对于拥有大量关联表、海量记录和严格内存预算的系统键的宽度确实值得认真考虑。自增 ID 在存储效率上有客观优势不能因为 UUID 更现代就忽略这一点。2. 索引性能自增 ID 为什么通常更友好数据库中的主键一般会对应一个唯一索引。以 PostgreSQL 的 B-tree 索引为例新记录插入的位置会受到主键值的影响。对于自增 ID1001 1002 1003 1004 1005新值通常大于之前生成的值索引插入位置相对集中。对于 UUIDv4c2f... 19a... e81... 47d...新值可能分散在索引的不同位置。随机插入可能带来更差的缓存局部性。更多不同索引页的访问。页分裂及相关维护成本增加。更大的索引工作集。这并不意味着每次 UUID 插入都会导致严重的页分裂也不意味着 UUIDv4 一定会让系统变慢。实际影响取决于数据规模、缓存命中率、并发量、索引结构和硬件。但是从索引局部性来看自增 ID 的确有优势。UUIDv7 改善了什么UUIDv7 把时间戳放在高位因此按 UUID 值排序时通常会将相近时间生成的 UUID 排在相近位置。它改善了 UUIDv4 的随机插入问题。但要注意UUIDv7 具有时间排序特征不等于它就是数据库自增序列。同一毫秒生成多个 UUID 时后续位可能是随机的未必严格反映每次生成的先后顺序。多个节点的时钟也可能存在偏差。因此可以粗略理解为自增 BIGINT通常有较好的索引局部性和单数据库序列顺序。UUIDv4没有天然的时间排序特征。UUIDv7有时间排序特征通常比 UUIDv4 更适合数据库索引。UUIDv7 的具体设计与排序行为可以参考 RFC 9562 第 5.7 节及第 6.11 节。3. 插入顺序自增 ID 的优势究竟有多大这是你提出的一个非常实际的问题。自增 ID 不仅能标识记录还能在很多业务场景中近似反映编号分配顺序。例如SELECT * FROM orders ORDER BY id DESC LIMIT 20;在一个简单的单库业务系统中这通常可以快速找到最近分配编号的一批记录。但这里必须区分三个概念编号分配顺序数据库序列分配 ID 的先后。事务提交顺序事务最终成功提交的先后。业务发生顺序订单实际创建、支付或配送等事件的发生顺序。它们并不等价。例如事务 A 先获得 ID 1001但迟迟没有提交。事务 B 后获得 ID 1002却先提交。事务 A 最后提交。此时 ID 顺序与事务提交顺序不同。如果系统允许回填 ID、导入历史数据或调整序列ID 与创建时间的关系还会更加复杂。因此自增 ID 可以帮助你近似观察编号分配顺序却不能作为严格的业务时间线。如果你需要知道订单什么时候创建、什么时候进入配送、什么时候完成应该明确保存created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()如果需要准确追踪业务状态变更则应记录独立的订单事件或状态历史表。不要让主键承担时间戳、业务流水号和事件排序这三个不同职责。4. 分布式系统UUID 真正有价值的地方这是 UUID 最有说服力的优势。假设系统 A 和系统 B 各自有一个 PostgreSQL 数据库两边都能独立创建订单。系统 A 创建id 1001系统 B 也创建id 1001两个 ID 在各自数据库中都合法但同步时会发生主键冲突。你当然可以通过其他方式解决例如A 使用奇数 IDB 使用偶数 ID。A 使用一个编号区间B 使用另一个编号区间。使用带节点标识的分布式 ID 算法。设置独立的全局 ID 服务。这些方案都能工作但需要额外规则和维护成本。如果双方独立生成 UUID系统 A019a...a101 系统 B019a...b902双方几乎不可能生成相同的 UUID。无需为每条新记录向中心服务申请编号。这正是 UUID 在分布式系统中的价值。不过要注意UUID 的唯一性是基于生成算法及随机性等条件获得的工程保证并不是数学意义上的绝对不碰撞。数据库仍然应该保留主键或唯一约束作为最后防线。另外UUID 只解决标识符碰撞问题不解决业务数据冲突问题。如果 A 将订单状态改成“已取消”B 将同一订单改成“配送中”即使 UUID 完全不同或正确匹配同一订单系统仍然需要处理并发修改、状态机和业务裁决。标识符唯一不代表数据一致。5. 系统扩展与数据迁移如果未来只有一套系统、一个主数据库自增 ID 通常非常自然。如果未来可能出现以下情况UUID 的优势就会逐渐明显多套系统独立创建数据。手机客户端或离线设备需要先生成记录再同步到服务端。服务拆分后多个服务各自创建实体。数据库合并、跨区域同步、异步导入。需要在数据库事务提交前就确定一个稳定的实体标识符。自增 ID 并非无法支持这些场景只是需要设计编号协调机制。UUID 的价值是把这部分协调成本降下来。还有一个实际问题如果系统现在使用自增 ID未来才发现需要 UUID能不能迁移可以但不能简单地把所有主键字段从 BIGINT 改成 UUID 就结束了。你需要考虑所有外键及关联表。API 和缓存中的 ID。消息队列和事件记录中的引用。历史数据和离线数据。其他系统已经保存的旧 ID。因此哪怕内部使用自增 ID也可以从项目初期就设计一个稳定的外部标识符为未来集成保留空间。6. 安全性UUID 不等于安全这是另一个经常被误解的地方。自增 ID/orders/1001 /orders/1002 /orders/1003容易被猜测。UUID/orders/019a5d10-2a10-7c31-8f72-65c3a4d1b920不容易被顺序枚举。但 UUID 并不是访问控制机制。如果用户可以读取其他用户的订单只要他拿到对应 UUID照样可能访问。真正的安全措施应该是身份认证。对象级权限检查。租户隔离。数据访问策略。API 请求的授权校验。在多租户系统中不能因为订单 ID 很难猜就跳过tenant_id和资源权限检查。UUID 可以减少简单的顺序枚举风险但不能替代授权。7. API 设计与开发体验从开发者体验来看自增 ID 很友好{ id: 1001, status: pending }UUID 则更长{ id: 019a5d10-2a10-7c31-8f72-65c3a4d1b920, status: pending }UUID 可能让日志、接口调试和人工沟通更麻烦。例如订单 1001 出了问题通常比订单 019a5d10-2a10-7c31-8f72-65c3a4d1b920 出了问题更容易沟通。当然成熟的系统往往有order_no这样的业务单号内部主键并不需要直接展示给用户。这里最重要的不是让所有 ID 都易读而是区分数据库主键。API 资源标识符。业务单号。事件 ID。请求追踪 ID。它们可以使用不同的格式不需要强行统一。8. 数据库和语言生态UUID 的使用成本也与技术栈有关。PostgreSQL 原生支持 UUID 类型Go 生态也有成熟的 UUID 库。MySQL 等数据库同样支持 UUID但存储形式、生成方式、索引行为和默认函数有所不同。如果把 UUID 存成CHAR(36)或普通文本通常会比原生 UUID 或紧凑二进制存储占用更多空间。所以不能简单地说“UUID 在所有数据库中都一样”。使用 UUID 时应优先使用数据库原生 UUID 类型或合适的紧凑二进制表示而不是不加思考地存成 36 字符的字符串。四、综合优劣对照表维度自增 BIGINTUUIDv4UUIDv7存储成本优较高较高索引局部性通常很好通常较差通常较好单库实现简单度优较简单较简单多节点独立生成需要协调优优近似时间排序编号分配顺序不支持支持时间排序严格业务事件排序不支持不支持不支持人工阅读优较差较差客户端离线创建需要额外机制优优顺序枚举防护较弱较强较强数据库迁移与跨库合并需要编号策略更方便更方便这里的“优”不是说其他方案不能实现而是表示在该维度上更自然、更省成本。五、为什么 AI 经常推荐 UUID我认为这不仅是技术问题也与 AI 生成代码的方式有关。1. AI 倾向于给出可迁移的通用方案UUID 可以同时适用于单体系统、多服务系统、离线客户端和跨库同步。对 AI 来说推荐 UUID 是一种容易解释、容易复用的默认选择。但是通用性不等于最优性。对于一个单体应用所有订单都在同一个数据库创建未来也没有跨系统同步需求UUID 可能只是增加了存储和调试成本。2. AI 更容易识别架构层面的收益而忽略数据库内部成本“可以在多个系统独立生成”是非常容易描述的优势。但索引页局部性、关联表键宽度、缓存工作集、查询计划和实际性能需要结合数据库实现和负载分析。因此生成式代码很容易优先选择架构上看起来灵活的设计却忽略了更基础的成本。3. 现代技术案例对 AI 的选择有影响近年来微服务、SaaS、云原生和事件驱动架构的技术文章大量讨论 UUID。这些案例会让 UUID 成为 AI 容易采用的方案。但 AI 的推荐并不是性能测试的结论也不等于某种设计已经成为所有数据库项目的最佳实践。AI 应该帮助我们降低设计成本而不是替代工程判断。让 AI 生成数据库表结构时最好明确要求它比较主键方案并说明数据是否跨数据库同步。是否需要离线创建。数据规模和写入频率。是否需要公开资源标识符。数据库类型和索引特点。是否有既定的业务单号。如果这些信息都没有给出直接让 AI 生成完整数据库结构本身就存在设计不充分的问题。六、实战方案为什么我更倾向于“双标识符”现在讨论一种更实用的设计。假设我们正在开发一个 SaaS 系统内部有订单、用户、组织、配送任务等多张表。我不一定会要求所有表都以 UUID 作为主键。我更愿意考虑内部使用BIGINT自增主键。额外使用 UUID 作为全局稳定标识符。API、跨系统同步和事件引用使用 UUID。内部关联和高频数据库查询使用 BIGINT。例如CREATE TABLE orders ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, public_id UUID NOT NULL UNIQUE, order_no VARCHAR(64) NOT NULL UNIQUE, status VARCHAR(32) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );这里有三个不同的标识符字段职责id数据库内部主键public_id跨系统稳定标识符、API 资源标识符order_no面向业务人员的订单编号这种设计的好处是内部数据库可以保留紧凑的数值主键同时拥有一个可以在系统之间独立生成的稳定标识符。如果业务需要两个独立系统创建同一类订单那么public_id应在创建订单时生成并随着订单同步到另一端。不能让另一端重新生成一个新的 UUID否则双方就无法通过它识别同一笔订单。1. 外键应该引用哪个字段如果使用双标识符可以让内部关联继续引用idCREATE TABLE order_items ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id), product_name TEXT NOT NULL );而跨系统事件使用public_id{ event_type: order.updated, order_id: 019a5d10-2a10-7c31-8f72-65c3a4d1b920, status: delivering }接收端根据public_id找到自己的本地订单再更新对应记录。这样内部关联仍然使用 BIGINT外部系统不需要知道本地自增 ID。不过双标识符也有代价每张需要外部标识符的表增加一个字段。增加唯一索引。创建和查询逻辑需要明确区分两种 ID。数据迁移和导入必须保证public_id稳定。开发规范必须防止误把内部 ID 当作跨系统 ID。所以双标识符并非没有代价。对于很小的应用如果不需要跨系统引用直接使用自增 ID 通常更简单。2. 两套独立系统同步订单时怎么选这是一个非常典型的场景。如果系统 A 和系统 B 都能独立创建订单我会考虑以下方案方案 AUUID 作为订单主键。两边直接共享订单 UUID。架构简单数据库不需要再维护额外的外部标识符。适合数据规模不大、系统之间需要频繁共享实体标识符的项目。方案 BBIGINT 内部主键 UUID 全局标识符。两边各自生成本地自增 ID同时创建一个全局 UUID。例如系统本地 idpublic_idA1001019a...a101B2056019a...a101两边的id不同但public_id相同代表同一笔订单。对于你的双向同步场景这种设计很值得考虑内部保留自增 ID 的便利跨系统同步则使用 UUID。需要注意如果订单最初在 A 创建B 只是接收它那么 B 应保留 A 生成的public_id而不是重新生成。如果 A、B 独立创建的是两笔不同订单则它们应该拥有不同的public_id。七、如果选择 UUID应该使用哪个版本如果确定需要 UUID我一般不会在没有理由的情况下继续默认使用 UUIDv4 作为数据库主键。如果数据库和语言生态支持UUIDv7 通常是更值得考虑的选择。原因很简单可以在数据库之外生成。不依赖单个数据库的自增序列。具有时间排序特征。相比随机 UUIDv4通常有更好的索引局部性。但也要注意UUIDv7 的时间排序不等于严格的全局生成顺序。系统时钟不准确或回拨会影响时间排序特征。同一毫秒生成的多个 UUID 不一定严格按生成顺序排列。如果需要严格的业务事件顺序应该使用独立的事件序列或明确的排序机制。另外如果数据库内只需要一个主键使用 UUIDv7 并不自动意味着需要再增加一个 BIGINT。双标识符方案的价值在于把内部数据库标识和外部稳定标识分开而不是因为 UUID 性能不好就一定要同时保存两个 ID。八、什么时候我会直接选择自增 ID以下场景我会毫不犹豫地考虑BIGINT自增主键单体业务系统主要使用一个数据库。后端服务统一创建数据。不需要离线客户端预先创建实体。不需要跨数据库合并相同实体。数据量较大内部关联和索引成本值得优化。业务单号与内部主键已经明确分离。例如一个内部管理系统所有订单都由服务端创建订单通过业务编号对外展示那么自增 ID 就是非常合理的默认方案。不需要为了“未来可能分布式”提前为每一张表增加 UUID。未来真的需要分布式能力时可以为需要跨系统标识的实体增加外部 ID而不是从第一天就让整个数据库承担额外成本。九、什么时候我会优先选择 UUID以下场景我更倾向于 UUID多个独立系统可以创建同一类实体。需要跨库、跨服务共享标识符。客户端或离线设备需要在同步前生成 ID。事件、消息或资源需要在数据库事务之外被引用。不希望多个数据库协调自增序列。需要让实体在迁移和合并时保持稳定的全局标识。例如跨系统订单同步、离线任务创建、多个服务共同创建业务对象等场景。这时 UUID 带来的简化可能远大于多出的几个字节。十、最后的结论我不赞成把 UUID 和自增 ID 看成先进与落后的关系。它们解决的是不同的工程问题自增 ID用很低的存储成本和简单的数据库机制提供稳定的本地行标识。UUID用更宽的标识符换取跨系统独立生成和共享标识的便利。UUIDv7在 UUID 的分布式生成能力之外改善了时间排序和数据库索引局部性。双标识符把数据库内部主键与外部稳定标识分离适合同时重视内部效率和跨系统集成的项目。如果是一个简单的单库 CRUD 系统我会优先考虑自增 BIGINT。如果是两套独立系统双向同步订单我会优先考虑 UUID或者内部 BIGINT 加外部 UUID。如果是一个规模较大、内部关联密集、同时有明确分布式标识需求的系统我会认真评估双标识符而不是简单地让 UUID 替代所有自增主键。最后还有一个原则比选哪种 ID 更重要不要让主键承担它不该承担的职责。主键负责标识记录业务单号负责业务识别创建时间负责时间记录事件序号负责严格的事件排序权限系统负责访问控制。把这些职责分清楚才是真正成熟的数据库设计。而在 AI 时代这一点反而更重要代码生成可以越来越快但系统设计不能因为生成得快就省略必要的判断。参考资料PostgreSQL 官方文档UUID 数据类型PostgreSQL 官方文档Identity ColumnsIETFRFC 9562 — Universally Unique IDentifiers (UUIDs)
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →