
前几天有同事问我数据库主键到底用什么生成比较好他说项目一直用自增ID最近要分库分表网上搜到的方案五花八门越看越不知道怎么选。我太懂这种感觉了——数据库id生成方案听起来是个小话题实际牵扯到唯一性、有序性、性能、可维护性每一步决策都会在数据量变大之后加倍还回来。这篇文章我把自增ID、UUID、雪花算法、号段模式、Redis发号这几种主流方案逐个拆一遍把“为什么”和“坑在哪”讲清楚不整虚的。适合正在做表结构设计、分库分表改造或者准备自研发号服务的人看。1. 自增ID单库很香分表之后最先“造反”1.1 自增ID为什么是“默认选项”自增ID能成为绝大多数项目的默认选择不是因为大家懒得思考而是它在单库场景下的收益确实很明显。数据库原生支持插入时不传值引擎自己维护主键天然递增对InnoDB来说主键是聚簇索引递增主键意味着新数据总是追加在索引末尾页分裂少写入性能稳定。还有一个容易被忽略的好处在事务里可以先插入一行拿到自增ID再去写子表连表操作和代码逻辑都省了一大截。很多老系统的核心表就是自增ID跑了几千万行还不出问题所以大家下意识觉得“既然它能撑住为什么要换”这个判断在单库单表时代没问题但一旦进入分库分表自增ID的短板会被迅速放大。另一个隐藏问题是对外暴露的ID连续且可预测订单号、用户ID一旦泄露别人顺着相邻ID就能推断你的业务规模甚至写个脚本慢慢遍历。对安全敏感的业务来说这是不能接受的。1.2 分库分表后自增ID的唯一性是怎么破的分库后每个库各自从1开始自增A库和B库都可能生成ID10086两条记录合并到同一张总表中直接冲突。很多人会想到用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 什么场景还能凑合用UUIDUUID并不是一无是处。如果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) ) ENGINEInnoDB;取号时执行一次原子更新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两个bufferA作为当前发号区间当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是单线程原子操作天然适合做计数器用它发号的好处是性能极高、部署简单。最常见的玩法是结合日期维度生成业务IDkey为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订单、用户、交易类IDRedis发号依赖持久化和故障转移近似连续业务服务极低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发号是否出局。这三个问题的答案不同选出来的方案可能完全不同。别急着抄别人的架构先把自己的业务边界画清楚再回头看这些技术方案选择就会顺畅很多。