第一次看见这个项目名时,我确实没忍住笑了一下。“一emmmm(标识符)”,六个字符七上八下的,像极了我们写代码时在键盘前卡住的样子——光标一闪一闪,脑子里全是“这到底该怎么设计”。但笑归笑,在这个行业待久了你就会明白,越是看着随意的名字,背后往往越是藏着说不完的话。所以今天这篇,我就把它当成一个正经的“标识符设计”项目来写,围绕“一emmmm”这个占位符外壳,聊聊技术人真正该较劲的东西:你的系统里,那些唯一标识符,到底是怎么“一不留神就写错”又“一不留神就出事故”的。
你可能会觉得,标识符不就一个ID吗?数据库自增、UUID、雪花ID,网上代码一抓一大把,有什么好写的。可真正碰到线上问题的人都知道,标识符选型选错了,比业务代码写错还难改,因为它几乎嵌在每一张表、每一条日志、每一个对外接口里。等你想换的时候,就像在一栋已经封顶的楼里换地基,动静大到没人愿意接这个活。所以这篇文章不打算教你怎么复制粘贴一段UUID生成代码,而是想把标识符这件事掰开揉碎:为什么它会出现“emmmm”这种犹豫,方案之间到底差在哪,什么规模选什么方案,踩过哪些坑以后才会长记性。无论你是刚接手新项目的初级开发,还是正在做技术方案评审的负责人,这篇的东西应该都能直接用上。
1. 抛开“一emmmm”的壳,我们先想清三件事
我见过太多团队在标识符上栽跟头,基本都有一个共同点:上来就聊技术方案,却没人先说清楚标识符要承担什么责任。你问项目里的人“你的ID是干嘛用的”,十有八九答不上来,只能说是“主键”。但主键只是数据库视角的约束,标识符真正扛着的是产品契约。
第一件事,你的标识符要不要对用户可见。如果ID出现在订单详情页、快递单号、短信链接里,那它就是一个公共身份,意味着别人可以通过它推测业务量、按顺序遍历你的订单、甚至拿别人的ID直接越权访问。很多程序员第一次意识到这件事,是发现自己的用户ID从1开始自增,然后在一次线上运营活动里,被同行用几百行脚本把所有订单拉了个遍。虽然现在接口普遍做了鉴权,但ID可枚举仍然是最容易被忽略的数据泄露面。反过来,如果ID只有系统内部用、永不出现在URL上,那自增主键其实是效率最高、最不需要操心的做法。
第二件事,标识符要在多大规模内保持唯一。单机单库,本地自增就行;多库多表,就得考虑全局唯一;一旦跨了微服务、按分片路由,你还得保证每片生成的ID拼在一起也不能重复。更麻烦的是“全局唯一”和“局部有序”常常同时被要求——比如做分库分表以后,数据库自增ID在每个库里会从1重新开始,合并数据时立即冲突。这时候你以为换个UUID就万事大吉,可UUID在MySQL的B+树索引里写入完全随机,页分裂多、缓存命中率下降,业务量一大,慢查询直接教做人。
第三件事,标识符能不能成为某种时间或业务线索。看过很多团队在ID里塞业务含义:区域编码两位、业务类型两位、年月日八位、最后几位随机数。这种设计看起来聪明,但后续几乎无一例外变成灾难。业务类型改个名字,历史ID语义就错乱了;区域合并了,新ID和旧ID对不上;跨部门协作时,A系统把一个业务前缀替换成另一个,下游所有解析逻辑全部得跟着改。老实说,标识符最好的状态反而是“无意义”的:它不表达任何东西,也就永远不会因为表达错误而翻车。时间信息可以放在ID里,但目的要纯粹,比如为了排序、为了定位分库,这属于技术语义,而不是业务语义。
所以你看,看到“标识符”三个字时先顿住的那个“emmmm”,不一定是坏事。那恰恰说明你隐约感觉到这里有不简单的地方。接下来所有的选择,都应该建立在这三件事的答案之上。
2. 四种主流方案横向对比,谁适合什么场景
我现在把市面常见的标识符方案拉一张表,列清楚,后文讲代码才能落地。这张表你可以直接存下来,下次评审方案时它就是第一道过滤网。
| 方案 | 唯一性范围 | 有序性 | 可再生性 | 可读性 | 适用场景 | 主要风险 |
|---|---|---|---|---|---|---|
| 数据库自增ID | 单库单表 | 递增 | 容易 | 强 | 内部表、单机应用、无分库诉求 | 多库冲突、可枚举泄露 |
| 数据库号段模式 | 全局 | 递增 | 容易 | 强 | 分库分表但能接受额外配置 | 依赖数据库高可用 |
| UUID / 标准UUID | 全局 | 无序 | 容易 | 弱 | 纯内部对象、离线合并、无索引压力 | 索引效率差、存储占用高 |
| UUIDv7 | 全局 | 趋势有序 | 容易 | 弱 | 新增系统且不想引入发号服务 | 兼容老系统困难 |
| 雪花ID及其变体 | 全局 | 时间趋势有序 | 较容易 | 弱 | 分布式服务、高并发写入 | 时钟回拨、位段分配固定 |
| 短随机串(Base62/Base36) | 概率唯一 | 无序 | 容易 | 中 | 邀请码、短连接、活动码 | 碰撞概率不可忽视,需去重 |
先解释几个关键概念。可枚举性指的是“你拿到一个ID,能不能猜出前后大概有哪些ID”。自增ID的枚举成本几乎为零,UUID的可枚举性则取决于随机源强度,基本可以认为不可枚举。有序性不是锦上添花的加分项,它对写入性能有直接影响:MySQL的InnoDB按主键聚簇存放,有序ID追加写入,无序ID则在索引中间插入,导致页面频繁分裂。这也是为什么哪怕你选了UUID,在很多大团队里也会被要求把索引结构调整成雪花ID或者UUIDv7。
ID的存储空间经常被人忽略。MySQL里一个bigint才8字节,而标准UUID是16字节,如果用作聚簇主键,它会被复制到每一个二级索引中,所有索引都会跟着变大。数据量到十亿级之后,多出的这8字节乘以几亿行,再乘以几个索引,多出来的磁盘和内存成本会非常可观。我见过一个项目因为主键选了字符串UUID,总数据量刚过2亿,所有索引加起来比原表数据还大,最后只能靠压数据来续命。所以选型这件事,真不是“哪个顺手用哪个”。
常见的UUID分那么多版本,很多人搞不清楚。UUIDv1基于MAC地址和时间戳,有隐私问题;UUIDv4就是纯随机;UUIDv7把时间戳放在高位,随机部分放在低位,让索引插入有趋势性。UUIDv7是目前比较推荐的折中方案,它既保留了“客户端直接生成、完全不依赖中心服务”的优雅性,又比UUIDv4对数据库索引更友好。标准里还有UUIDv8/9/10,本质就是允许自定义位段,如果你有大厂那样的成熟架构团队,可以考虑;大部分人不需要。
雪花ID则是另一条路线,它的核心是“把某个机构唯一的信息拼进时间线里”。因为要保证全局唯一,必须有中心化分配机器ID的动作;如果只在单个实例里生成,雪花ID变成了有排序功能的自定义自增ID,意义少了一半。所以选雪花ID的时候,要先想清楚:谁来分配workerId,分配完以后能不能持久化,机器时间如果被人工改回去怎么办。
3. 用代码落地一个生产级标识符系统
纸上谈兵聊完了,直接进入实操。我先从基础版本开始写,然后一步步把生产环境要考虑的细节补齐。
3.1 最快的方案:数据库自增和号段模式
如果你规模不大、又想要年轮般的壮丽有序,那数据库自增就是首选。建表加个自增列,数据库自己维护,并发安全它管,代码都不用想。但你要提前做一件小事:把auto_increment_offset和auto_increment_increment的配置理解清楚。这两个参数是MySQL为多主复制准备的,可以让你在不同实例上生成不重叠的自增值。比如两台机器,一个设置初始1、步长2,生成1、3、5;另一个初始2、步长2,生成2、4、6,这样多活也不撞车。
但自增ID的真问题在于,它是“当前库内”的临时编号,一旦业务拆分、历史数据归档、多库合并,就会有大量冲突和语义含糊。此时你可以考虑升级成号段模式:用一个中心表维护“当前最大ID”和“下一次分配步长”,业务方每次申请拿一段号码回来自己发。这个方案既保留了数值型ID的高性能,又能支撑分库分表。
-- 号段分配表结构 CREATE TABLE id_allocator ( biz_tag VARCHAR(64) NOT NULL COMMENT '业务标识', max_id BIGINT NOT NULL COMMENT '当前已分配的最大ID', step INT NOT NULL DEFAULT 1000 COMMENT '每次取号数量', updated_at DATETIME NOT NULL, PRIMARY KEY (biz_tag) ) ENGINE=InnoDB;取号的动作就是一个原子操作:UPDATE id_allocator SET max_id = max_id + step WHERE biz_tag = ?,然后拿新的max_id去内存里发step个号码。每次更新最多打一次数据库,剩下的交给本地内存,性能和全局唯一都兼顾了。这个小方案系统复杂度很低,如果没有特别强烈的“客户端直出ID”需求,我建议尽量从它开始。
3.2 UUIDv7:客户端直出,排队插入不吵架
当业务方分散在不同的服务里,谁都不想为生成一个ID去请求中心服务,UUIDv7就派上了用场。直接用Java的UUID库或Python3的uuid模块都无法生成UUIDv7(标准库还没有把这个版本纳入正式接口),但手动拼一个并不难。我把Python版本写出来,思路通用:
import os import time import uuid # 简易 UUIDv7 生成(基于 RFC 9562 的布局) def uuid7(): # 48 bit 毫秒级时间戳 ts = int(time.time() * 1000) & ((1 << 48) - 1) # 4 bit 版本号 ver = 0x7 # 12 bit 随机变体和 62 bit 随机数,这里为了展示使用 64 bit 随机 rand_a = int.from_bytes(os.urandom(2), 'big') & 0x0FFF rand_b = int.from_bytes(os.urandom(8), 'big') # 手工拼字节 raw = ( (ts << 16) | ((ver & 0x0F) << 12) | rand_a ).to_bytes(8, 'big') + (rand_b & ((1 << 64) - 1)).to_bytes(8, 'big') return str(uuid.UUID(bytes=raw)) print(uuid7())重点说下为什么UUIDv7要比UUIDv4强。UUIDv4的随机位分布在整个128bit里,生成结果像是无头苍蝇,插入数据库索引时,不巧的话会触发大量的索引页分裂。UUIDv7的高48bit是时间戳,相当于给每个ID一个大致顺序,新记录基本都往索引尾部追加,老记录很少被改到,页分裂率大幅降低。又保留了足够的随机位,能保证多客户端并发时碰撞概率极低。你可以把UUIDv7理解为“既享受客户端直出的自由,又不用背负聚簇索引粉碎的骂名”。
不过UUIDv7有个隐含问题:它需要依赖系统时钟,且没有内置解决时钟回拨的机制。进程发生一次时间倒跳,有可能生成比过去还小的ID,如果恰好建了唯一索引,写入直接报重复。解决办法是在进程内存里维护“最近一次生成的时间戳”,如果本次时间比它小,就沿用最近时间戳加随机数或序列号。实现不难,但一定要做,这不是理论问题,是云服务器NTP同步时真的会遇到的。
3.3 雪花ID:分布式场景下的配置艺术
在你需要高性能、可排序、还要全局唯一的场景里,雪花ID是绕不开的名字。它的经典定义是64位:1位符号位 + 41位毫秒时间戳 + 5位数据中心ID + 5位机器ID + 12位序列号。1毫秒内单机最高可以生成4096个ID,理论支撑每秒400万以上,实际受生产瓶颈限制,但也是极高并发场景的常客。
import time import random class Snowflake: def __init__(self, dc_id, worker_id, epoch=1700000000000): assert 0 <= dc_id <= 31, "dc_id 必须为 0-31" assert 0 <= worker_id <= 31, "worker_id 必须为 0-31" self.dc_id = dc_id self.worker_id = worker_id self.epoch = epoch self.sequence = 0 self.last_ts = -1 def gen(self): ts = int(time.time() * 1000) if ts < self.last_ts: # 时钟回拨:简单策略是抛出异常,业务上可等待或从备用时间跳变 raise RuntimeError("clock moved backwards") if ts == self.last_ts: self.sequence = (self.sequence + 1) & 0xFFF if self.sequence == 0: # 当前毫秒序列用完了,等到下一毫秒 while ts <= self.last_ts: ts = int(time.time() * 1000) else: self.sequence = 0 self.last_ts = ts return ( ((ts - self.epoch) << 22) | (self.dc_id << 17) | (self.worker_id << 12) | self.sequence ) snow = Snowflake(dc_id=1, worker_id=2) print(snow.gen())看着简单,生产级要求就麻烦了。41位时间戳可以支撑69年,但如果你的系统生命周期长,比如银行或者IOT设备,时间戳位段就要扩。经典的10位机器ID段,你分配给5位DC + 5位worker,这本身没有问题,但一旦定下来,想改就难——因为历史ID里已经编入了旧的分配方案,对ID做位解析的所有代码都得跟着变。我建议把机器ID看成硬件配置,用环境变量或配置中心下发,不要写死在代码里。
另一个生产关键点是序列号归零处理。经典实现里当序列号用光后,会自旋等到下一毫秒。这个方法简单高效,但遇到单点峰值极大时会产生CPU忙等。替代方案是准备一个小的内部队列,让上游请求先进队列,用异步线程慢慢发ID,避免阻塞调用方。不过大部分业务量其实到不了4096/毫秒,经典实现也够用,我只是提醒你不要连个压测都没做就自己发明等锁机制。
还有时钟回拨。我会在后文专门写排查经验,这里先说代码层面的防御:回拨幅度小时(比如几毫秒),可以在进程内存里保留最近一个ID作为基准,帮忙把时间“顶回去”;回拨幅度大时,最稳妥的选择是快速失败,让上游感知并切换到备用发号机或等待机器校时。绝不能让回拨后的时间戳继续生成ID,否则线上数据库唯一索引就会开始报警。
3.4 短码场景:邀请码和活动码的防碰撞技巧
顺带讲一下短码。很多人看到“标识符”就只想到主键,但邀请码、优惠券码、短链Key也是标识符的兄弟姐妹。这种场景的需求是:字符短,用户愿意手动输入,最好还能模糊排除0和O、1和l这类容易看错的字符。做法通常是把一个64位随机数做Base62编码,拼成6到8位的字符串,然后把字符表里易混淆的字母去掉。
import secrets ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" def short_code(n=6): code = '' # secrets.choice 是密码学安全的随机选择 for _ in range(n): code += secrets.choice(ALPHABET) return code print(short_code())这段代码写起来一分钟,但如果你真想在百万甚至千万级数据上做短码,必须想明白一件事:随机短码是有碰撞概率的,不能用“我随机出来的字符串应该不会重复吧”糊弄过去。8位62进制大概有2.18万亿种组合,听起来很大,但如果你生成1亿个码,碰撞概率已经高到肉眼可见的程度。上线前设计一张唯一索引表,每次落库时兜底,一旦检测到冲突就重新生成。别问我为什么这么醒目,问就是当年线上活动发码撞过车。
4. 常见问题与排查技巧实录
标识符相关的故障,线上出现时都特别磨人。我按真实踩坑频率排个序,从高到低说。
4.1 时钟回拨:最容易在凌晨出现的“幽灵冲突”
毛病出在服务器校时。你用的云主机时间通常由NTP维护,某天凌晨网络抖动,系统把时钟校准到UTC后,你生成的一批ID可能落在了上一批次时间戳之前。对雪花ID来说,这就是灾难:ID的数值顺序会倒退,数据库唯一索引立刻给你脸色看。日志里最常见的错误就是Duplicate entry,而且每次都是同一个“异常”的ID值。
排查套路分三步:第一,去所有应用节点看它们的系统时间是否完全一致,date -u对比,差异超过几毫秒就要警惕;第二,翻应用的启动日志,看NTP调时的时间段;第三,把生成ID的源码里对last_ts的判断加进监控指标,如果连续出现“本次生成时间小于上次生成时间”的计数,说明回拨被触发了。
修复方式根据回拨幅度来。几毫秒到几十毫秒的回拨,靠内存里维护“上次时间戳”并沿用旧时间戳生成即可;超过一秒的,建议直接抛异常并告警,让流量切到别的节点,人工检查NTP配置后再恢复。坚决不要用“把epoch往前调”这种永久性修改,那是用未来时间换过去时间,隐患更大。
4.2 把业务含义编码进ID,导致的可维护性灾难
这类问题不是报错,而是悄无声息地腐烂。有个系统在ID里编码了“渠道+城市+日期+序号”,单独看每个ID都很规整:渠道位用了3个字符,城市位用了4个字母。后来业务合并,渠道定义变了,城市被拆成两个,为了保持ID长度不变,他们只能牺牲掉新渠道的唯一性,把两个渠道挤到同一个编码段里。这套ID还被推给了下游十几个接口,下游全做了解码逻辑,最后没人再敢动这个字段。
我的建议很简单:ID永远不要成为业务字段的容器,业务字段请单独建列。如果你实在想保留可读性,那就把它放到业务码、业务号这类“非全局唯一”的字段里,主键仍然用一个无意义标识符。这样无论业务怎么演进,主键都稳如磐石,业务码是死是活都不影响链路。
4.3 基于UUID做分库分表路由,却忘了散列分布
有些系统为了均衡分片,会取UUID的hash值做分库键。听着合理,但真实场景中很难控制分布均匀。字符串UUID经过某种hash映射到32个分片后,小体量数据下各分片行数能差出3倍多。而雪花ID由于高位是时间,直接按它的分片也会让时间相邻的数据扎堆进同一片,业务查询偏斜。
解决分库分布问题,建议单独设计一个“分片键”,交给一致性哈希或者取模,不要把标识符本身和路由键绑定死。如果必须用ID做路由,那最好用ID的随机低位段(比如UUIDv7的低62bit)做取模,而不是用时间高位。这个细节我吃过闷亏,希望你们少走一次。
4.4 标识符寿命比你想的长得多,外部化前先想好
开发时一个ID可能只在数据库里转,后来日志要打,再后来要对客展示,最后变成对接第三方系统的唯一凭证。一旦ID出现在外部系统,它就有了契约属性,你再也没办法随便改类型、改长度、改生成规则了。所以方案评审时,永远要把“如果这个ID某天要暴露出去,会不会难受”当作一个必答问题。如果答案是“会”,宁可现在多花一天设计一个不开玩笑的方案,也不要在上线后花费几十天做数据迁移。
5. 一份可以照抄的标识符选型建议
我现在把自己在真实项目里最常给团队画的配置也贴出来,你可以直接当模板用。
- 单表、纯内部、无分库:就直接
BIGINT UNSIGNED AUTO_INCREMENT,限定内部服务间传递,不要写进对外接口。 - 分库分表、依赖数据库中心发号:用号段模式,服务本地缓存一批号,批量发放。
- 微服务数量不多、并发可控、想要客户端直出、能接受中心协调:用UUIDv7,并维护“上次生成时间”来抗回拨。
- 高并发、跨服务调用、对时间趋势有要求:用雪花ID,但要把机器ID交由配置中心统一分配。
- 邀请码、优惠券码、短链:6到8位Base62短码,配合数据库唯一索引兜底。
还有一个经常被忽略的建议:所有标识符字段,尽量都用整数类型存储,能不用字符串就不用字符串。字符串的索引比较走排序规则、大小写敏感问题也多,而且同样长度的字符串比整数浪费内存。即便是UUID,也建议存成BINARY(16),展示层再转成字符串。这个优化在数据量级大的时候收益非常明显。
回到开头那个让人停顿的“emmmm”。标识符这个领域,恰恰是需要在动手前好好停顿一下的。我在很多次事故复盘里都发现,根源并不在代码写得差,而在最开始把“标识符”理解成了“就是给表加个主键”。它其实和数据库、分布式、业务演进、外部契约都有千丝万缕的纠缠。既然已经停下来想过一遍了,那后面写起来,反而不会有太多回头路要走。如果你要在一堆技术方案里选一个“看起来差不多但将来不后悔”的方案,我希望上面的这些取舍能给你递上一块能踩稳的石头。