☰
数据库主键生成方案深度对比:自增ID、UUID、雪花算法与号段模式
2026/10/17 9:17:01 网站建设 项目流程

前几天有同事问我:数据库主键到底用什么生成比较好?他说项目一直用自增ID,最近要分库分表,网上搜到的方案五花八门,越看越不知道怎么选。我太懂这种感觉了——数据库id生成方案听起来是个小话题,实际牵扯到唯一性、有序性、性能、可维护性,每一步决策都会在数据量变大之后加倍还回来。这篇文章我把自增ID、UUID、雪花算法、号段模式、Redis发号这几种主流方案逐个拆一遍,把“为什么”和“坑在哪”讲清楚,不整虚的。适合正在做表结构设计、分库分表改造,或者准备自研发号服务的人看。

1. 自增ID:单库很香,分表之后最先“造反”

1.1 自增ID为什么是“默认选项”

自增ID能成为绝大多数项目的默认选择,不是因为大家懒得思考,而是它在单库场景下的收益确实很明显。数据库原生支持,插入时不传值,引擎自己维护,主键天然递增;对InnoDB来说,主键是聚簇索引,递增主键意味着新数据总是追加在索引末尾,页分裂少,写入性能稳定。还有一个容易被忽略的好处:在事务里可以先插入一行拿到自增ID,再去写子表,连表操作和代码逻辑都省了一大截。

很多老系统的核心表就是自增ID跑了几千万行还不出问题,所以大家下意识觉得“既然它能撑住,为什么要换?”这个判断在单库单表时代没问题,但一旦进入分库分表,自增ID的短板会被迅速放大。另一个隐藏问题是对外暴露的ID连续且可预测,订单号、用户ID一旦泄露,别人顺着相邻ID就能推断你的业务规模,甚至写个脚本慢慢遍历。对安全敏感的业务来说,这是不能接受的。

1.2 分库分表后,自增ID的唯一性是怎么破的

分库后,每个库各自从1开始自增,A库和B库都可能生成ID=10086,两条记录合并到同一张总表中直接冲突。很多人会想到用auto_increment_offset和auto_increment_increment参数来错开:两个库,一个初始值为1、步长为2,生成1、3、5;另一个初始值为2、步长为2,生成2、4、6。单看结果,确实全局不冲突。

但坑在后面。假设后来要从2个库扩到3个库,步长必须从2改成3,而旧库里已经按“2”步长生成了一堆ID,初始值也要重新对齐,否则新节点生成的ID很可能跟旧数据撞上。实际操作中,这种调整往往要停服、导数据、改配置一起上,比迁移表还折腾。如果用全局自增表来兜底,所有库都到同一张表拿号,又会引入单点和性能瓶颈,本质上已经不是在用“自增”,而是在手搓一个极其简陋的发号器。这也是我为啥反复提醒:自增ID在单库是省心,在分库分表场景里不是“不够用”,而是“不敢用”。

1.3 自增ID的“可预测性”对业务意味着什么

我去年处理过一个安全工单,外部爬虫通过订单号的连续递增,把某电商系统的订单元数据拖走了一部分。虽然敏感字段有加密,但订单量、下单时间这些信息还是泄露了不少。自增ID作为业务主键自带这个风险:它把业务规模、增长节奏、甚至内部编号规则都暴露给了对手。

如果你的系统需要对外提供查询、分享、展示,建议至少把自增ID做一层“混淆”,比如映射成短链或换一种不可预测的对外编号,内部主键仍然自增。但注意,只要业务未来有分库分表计划,就别把自增当作终局方案。另外多说一句:bigint类型上限很大,不代表“用int还是bigint”是唯一要纠结的问题。自增ID的瓶颈从来不是位数,而是“谁来保证唯一”。单库由InnoDB保证,多库由谁保证?只要答案不清晰,出现重复主键只是时间问题。

2. UUID:看着免费,账单都在后面

2.1 为什么很多人踩了UUID的坑

UUID几乎是所有从“自增ID焦虑”中逃离的人的第一站。它不需要数据库参与,任何节点都能生成,全球唯一,看起来很完美。但问题出在版本上。最常见的UUID v4是纯随机生成的,而数据库主键一旦是随机的,对InnoDB聚簇索引几乎是灾难。

打个比方:你手里有一本按编号排序的图书目录,每天都有几万本新书带着随机编号塞进来。管理员为了保持编号顺序,得把书架里的书全部往后挪,经常还要腾出新的空间。UUID作为主键也一样:新数据可能落在任意一个数据页中间,存储引擎要把原有数据向后移动,导致页分裂、页碎片、随机IO飙升,索引越来越大,写入越来越慢。数据量小的时候根本感觉不到,到了千万行以上,性能差距会非常明显。

2.2 被忽略的存储和索引膨胀

UUID通常以字符串形式存储,一个varchar(36)平均要占36字节,而bigint只占8字节。这还不是最致命的。InnoDB的二级索引叶子节点会保存主键值,主键越长,每个二级索引文件就越大。如果一张表有四五张二级索引,UUID带来的额外空间可能超过业务字段本身。

索引大了,内存能缓存的页就少了,磁盘IO和排序成本同步上升。很多团队做OLTP系统时,光看表结构觉得UUID没问题,等线上出现了慢查询才发现索引膨胀已经失控。我习惯在评审时算一笔账:假设一张表2亿行,两个二级索引,主键从bigint换成varchar(36),仅主键字段和二级索引的冗余复制,额外空间就是2亿×(36-8)×3这个量级。这样一算,UUID的“免费”其实很贵。

2.3 什么场景还能凑合用UUID

UUID并不是一无是处。如果ID完全在客户端生成、数据离线合并、或者系统本身对写入顺序不敏感,它反而省掉了回源和协调的开销。比如同步客户端埋点数据、多端离线表单、跨系统数据整合,这些场景用UUID合理。

但如果是数据库主键,尤其是MySQL InnoDB的核心业务表,我强烈不建议用UUID v4。真要用,也得选UUID v7这种带时间戳的有序版本,或者把UUID转成binary(16)存储,能缓解一部分页分裂和空间问题。但整体性能和后面要讲的雪花算法、号段模式相比,还是差一截。

3. 雪花算法:高并发下的事实标准,时钟回拨是必修课

3.1 64位结构拆解:为什么是趋势递增

雪花算法大概是目前互联网系统里应用最广的分布式ID方案。核心是一个64位的long型整数,标准划分是:1位符号位、41位毫秒时间戳、10位机器ID、12位序列号。41位时间戳可以表示约69年,序列号12位意味着同一毫秒同一个节点最多生成4096个ID,整体单节点每秒理论可生成40多万个ID,绝大多数业务根本用不满。

它最大的好处是“趋势递增”。因为时间戳在数字的高位,ID整体会随着时间变大,但不同节点的ID不一定严格连续,所以叫趋势递增。对数据库来说,只要主键大体有序,聚簇索引的写入就不会像UUID那样频繁页分裂。对业务来说,ID从大到小排序基本等于按创建时间倒序,很多列表页和数据归档场景直接受益。

这里有个细节:不要傻乎乎把时间戳从1970年开始算。最好选一个公司或项目上线时间作为自定义纪元,比如2023-01-01。这样看似“浪费”了已经过去的时间,实际是把69年的有效期尽量往后拉,让系统能活更久。如果写死从1970年开始,系统大概率在下个世纪某个时间点突然罢工,那时候再改ID结构就痛苦了。

3.2 时钟回拨:唯一性第一,可用性第二

雪花算法依赖系统时钟,最经典的问题是NTP时钟同步导致机器时间回拨。假设节点A上一次生成ID时记录到的时间戳是1000,时钟回拨后当前时间变成999,如果继续生成,新ID的时间戳会跟之前重复,一旦机器ID和序列号也一样,就产生了重复主键。

处理手段通常有三种。第一种是记录lastTimestamp,发现当前时间小于它时,自旋等待,直到时钟追平。这对几毫秒的微小回拨有效。第二种是回拨幅度较大时直接拒绝生成、抛异常并告警,宁可让发号服务短时不可用,也不要返回重复ID。第三种是在序列号上做文章,回拨时借用序列号的扩展空间来区分,实现复杂度高,小团队不太建议。

我个人的原则很简单:唯一性永远优先于可用性。一旦重复主键进了数据库,唯一键冲突会引发一系列连锁排查;而发号服务短时拒绝请求,顶多让调用方重试几次,影响面可控得多。

3.3 机器ID分配和序列号耗尽的细节

机器ID占10位,最多能区分1024个节点,但分配方式很容易埋雷。用IP末几位哈希,碰撞概率不小,尤其是在容器环境下,同一个Pod重启后IP都可能变。稳妥的做法是用ZooKeeper/etcd这类分布式协调组件做临时顺序节点,启动时注册、拿到全局唯一ID;或者本地文件持久化一份上次用的机器ID,再通过配置中心做启动校验。如果你的公司已经有配置中心,直接在配置里维护机器ID列表并做启动检查,成本也不高。

序列号在极端高并发下也可能在同一毫秒内用完。正规实现会在当前毫秒用完后自旋等待下一秒,而不是让序列号溢出覆盖。写代码时要注意自旋不能无限等下去,要加超时时间和告警。比如超过5秒还等不到下一秒,就该检查系统时钟是否故障了。很多初版实现只写了生成逻辑,没写超时保护,线上抖动时直接卡死线程池,这个细节非常容易被忽略。

3.4 能不能“改造”雪花算法

很多团队会想把业务语义塞进ID里,比如把10位机器ID拆成“业务线+机器”,或者加入自定义起始时间。这种定制本身没问题,但改之前要把账算清楚:时间戳位一旦被压缩,系统可用寿命会显著缩短;机器ID如果拆分不合理,扩容会直接受限。举例来说,41位时间戳够用69年,压成39位只剩17年,一个看起来“更高级”的ID结构,可能几年后就要重构整个发号方案。

我实际更建议先用标准结构把系统跑通,业务语义通过关联表或者ID前缀去解决,不要轻易动位数。每次遇到想动位的同事,我都会问一句:这几位被挪走以后,系统寿命少了多少年?多数人算完就放弃了。

4. 号段模式:数据库不发号,只发区间

4.1 一张发号表怎么把数据库压力降下来

号段模式的核心思路是扭转取号方式:不再让数据库每生成一个ID就写一次,而是让数据库一次性分配一段区间,业务服务在内存里发号。很多团队一听“号段”就以为很复杂,其实核心就是一张表。

比如这样一张表:

CREATE TABLE id_alloc ( biz_tag varchar(64) NOT NULL COMMENT '业务标识', max_id bigint NOT NULL COMMENT '当前已分配的最大ID', step int NOT NULL COMMENT '每次取号步长', updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (biz_tag) ) ENGINE=InnoDB;

取号时执行一次原子更新:

UPDATE id_alloc SET max_id = max_id + step WHERE biz_tag = 'order';

事务内把max_id取出来作为本批次的终点,服务端拿到的是(max_id - step, max_id]这个区间,全部存在内存里,调用方每次发号只是内存里加一,不碰数据库。步长设置为1000时,数据库写入量就降为原来的千分之一。

这里要注意,UPDATE和SELECT必须在同一个事务里并加行锁,否则两个节点可能拿到同一段号。MySQL里也可以用UPDATE ... SET max_id = LAST_INSERT_ID(max_id + step)这种技巧,一步拿到新的max_id,少一次查询。并发高的场景下,这个技巧能让取号事务的持有时间明显缩短。

4.2 双Buffer:把“号段耗尽”的尖刺抹平

单号段模式有个明显的毛刺:内存区间用完的那一瞬间,所有请求同时去数据库取号,DB的QPS瞬间飙高,取号服务也容易出现超时。双Buffer就是为了消除这个尖刺。原理不复杂,维护A、B两个buffer,A作为当前发号区间,当A剩余量低于阈值时,后台线程提前异步去数据库取下一段并填充B;等A耗尽立刻切换到B,再回头预加载A,循环往复。

用双Buffer后,绝大多数请求根本感觉不到“取号”这个动作,数据库长期处于低频批量写入状态。我见过把双Buffer玩坏的例子:阈值设得太低,比如还剩10个号才触发预加载,而数据库一次取号要几十毫秒,这几十毫秒内剩余10个号瞬间被打光,预加载又没跟上,照样出现尖刺。

预加载阈值建议按“峰值QPS × 预估数据库耗时”再乘一个冗余系数来设。比如峰值5万QPS,一次取号耗时50毫秒,那阈值至少留2500个号,实际我通常会按3到5倍设置,宁可多预加载一点,也不要让业务在取号边缘等锁。

4.3 多实例部署与扩容到底怎么操作

号段模式天生适合多实例。多个发号服务实例共同使用同一张id_alloc表,靠biz_tag对应的行锁保证不会取到重叠区间。扩容时不需要像自增ID那样去改步长,新实例起来后正常去数据库取号就行。

但有两件事要注意。第一,所有实例最好使用相同的步长,否则不同实例取到的区间大小不一致,会加剧数据库锁竞争。第二,步长不能设得太大。如果步长过大,一次取号会消费大量ID,进程在区间发完前重启,剩下的号会被废弃,形成比较大的“空洞”。虽然业务一般能接受ID空洞,但空洞太大会让下游误以为数据有缺失。

给个经验值:步长可以按“峰值每秒ID数 × 60秒”先粗算。比如峰值5万QPS,一次取300万差不多能撑一分钟。再配合双Buffer预加载,数据库每秒也就一两次取号请求,非常轻松。等线上跑一段后看实际取号频率和数据库压力,再微调步长。

4.4 号段模式容易忽略的两个故障场景

第一个故障场景:发号服务内存里的ID没有落库,进程突然挂掉会丢弃一段未发完的号。数据库里的max_id已经推进到了区间的末尾,重启后只会跳过这一整段,不会重复。业务一般能接受,但你要提前告诉下游“ID会有空洞”,避免对方写死“ID必须连续”的校验逻辑。

第二个故障场景:数据库挂了怎么办?内存里的区间发完后,新请求会阻塞在取号上。我的处理方式是:缓存里至少预留一个buffer,一旦预加载失败就快速失败,返回“发号服务暂时不可用”,让调用方重试。宁可拒绝新请求,也不要让线程池无限阻塞被打满。这个和雪花算法的理念一致:重复ID是事故,短时不可用是故障,事故永远比故障严重。

5. Redis发号和现成组件:快是真的,先想清楚谁承担风险

5.1 Redis INCR为什么能发号

Redis的INCR/INCRBY是单线程原子操作,天然适合做计数器,用它发号的好处是性能极高、部署简单。最常见的玩法是结合日期维度生成业务ID:key为id:order:20250101,每天从1开始递增,最终拼出来的ID类似20250101000001,可读性非常好,对账也方便。

如果你做短链接、消息ID、日志流水,不是核心交易数据,用Redis发号完全够用,代码量还小。很多团队用Redis发号并不是因为复杂,而是因为它足够快,简单到一天就能上线。

5.2 持久化会成为大坑

Redis发号的隐患主要在持久化。默认RDB快照可能丢失最近一次的写入;开启AOF如果配置成everysec,理论上也最多丢一秒的数据。更麻烦的是主从切换:从节点实例的计数器可能落后于旧主节点,切换后继续INCR,有很大概率产生重复ID。

我之前帮人排查过一个线上问题,就是Redis主从切换后消息ID重复,导致下游消费去重出现异常。别指望Redis默认配置能保证严格唯一。如果业务要求“一旦发出就绝不重复”,要么放弃Redis方案,要么用更强的一致性手段把多实例发号逻辑锁起来,但这样复杂度会迅速上升,反而不如号段模式干净。

5.3 现成的发号组件:托管不如理解

开源社区有几种成熟方向:一类是号段加双Buffer的实现,一类是雪花算法加机器ID自动注册的实现,还有把ID做成可反解字符串的实现。这些组件解决了很多细节问题,比如时钟回拨检测、号段预加载、节点注册等。

但对中小团队来说,引入一个现成组件就多一套运维和排障成本。我更建议把源码读一遍,理解它的取号、缓存、回拨处理逻辑,再决定是直接用还是自己实现一个简化版。号段模式的核心代码其实很少,自己维护也不难,关键是加好监控指标:当前buffer剩余量、数据库取号耗时、取号失败次数。这几个指标比任何框架都重要。

5.4 Redis方案和号段方案怎么选

简单说,Redis发号适合“量大但对极端情况容忍度高”的场景,号段模式适合“要严格唯一、可回放、可解释”的场景。日志ID重复一两秒可能无所谓,但订单号、支付流水号重复就是事故。

很多团队会把两者结合:Redis做高频预分配,数据库落账做兜底,但这套组合复杂度并不低。在我实际经历中,大部分业务场景用号段模式就够了,Redis发号的定位应该是“辅助”,而不是“唯一真相源”。

6. 选型决策表与我的“组合拳”

6.1 一张表把方案摆平

我不喜欢给团队列一堆华丽指标,最后反而不知道怎么选。直接看这张表就够了:

方案唯一性保证有序性生成位置数据库写入压力外部依赖典型场景
自增ID单库内保证严格递增数据库每次插入一次写无单库小系统、内部表
UUID v4全局概率唯一无序客户端无无离线数据、轻量场景
雪花算法依赖时钟和机器ID趋势递增业务服务无配置中心/时钟海量日志、分布式核心
号段模式数据库行锁保证区间内连续、整体趋势递增发号服务低频批量写MySQL订单、用户、交易类ID
Redis发号依赖持久化和故障转移近似连续业务服务极低Redis日志、短链、非关键数据

6.2 我一般怎么选

如果是一个单库小系统,没有分表计划,我依然用自增ID,简单可靠,没必要为了“先进”而先进。如果有人告诉我这个系统明年就要分库分表,那我会直接上号段模式,第一天就用双Buffer,省得后面迁移。

中大型分布式核心表,订单、用户、支付流水这类,我首选号段模式。原因很简单:它不依赖系统时钟,不怕NTP回拨,数据库就是唯一真相源,出问题好排查。要求ID按日期可读、方便对账的,可以在号段基础上加日期前缀,比如20250101 + 8位序号,对运营同学非常友好。

日志、埋点、行为数据这类海量写、不要求强一致的,用雪花算法。因为它不需要每次发号都访问数据库,纯粹本地计算,性能最高。对外展示且担心被人遍历的,不要用自增ID,用雪花或号段生成的趋势递增ID,再配合混淆映射层。

6.3 我踩过的坑和事后改进

第一个坑:早期给某电商库存表直接用UUID v4做主键,还存varchar(36)。两千万行后,索引文件比业务数据还大,夜间批量写入经常锁等待。后来改成号段模式生成的bigint主键,同样的批量任务耗时从十几分钟降到两分钟以内。如果当初老老实实用有序bigint,根本不会有这趟折腾。

第二个坑:用雪花算法时没做时钟回拨检测,线上某个节点NTP回拨了200毫秒,导致一批订单ID重复,插入时出现唯一键冲突。查了大半天才定位到是时钟问题。从那以后,我把时钟回拨检测写成了框架级逻辑,一旦回拨超过阈值宁可拒绝发号,也要保证唯一性。

第三个坑:号段模式步长设得太小。一开始为了“少浪费ID”,步长只设了1万,结果高峰期取号请求排队,数据库出现明显毛刺。后来按峰值QPS重新算步长,并加了双Buffer预加载,数据库每秒只有一两次取号请求,稳得很。

6.4 选型前先回答三个问题

如果让我说一条最核心的实操建议,那就是:选型之前先回答三个问题——ID由谁生成?允不允许中间有空洞?能不能接受外部依赖?

由谁生成决定了要不要引入发号服务;允不允许空洞决定了能不能用号段和雪花;能不能接受外部依赖决定了Redis发号是否出局。这三个问题的答案不同,选出来的方案可能完全不同。别急着抄别人的架构,先把自己的业务边界画清楚,再回头看这些技术方案,选择就会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询