努力了一下午,终于把最难的 id 写完了。如果你也做过分布式 ID、分库分表主键,或者被“全局唯一、趋势递增、高并发下不重复”这几个条件同时卡过,应该能理解这句话的重量。id 看起来就是一个字段,但把它从单机自增迁到分布式环境时,设计复杂度会突然涨一大截。这篇就把 ID 生成的方案选择、雪花算法实现、数据库分区、前端精度处理和常见排查方法完整梳理一遍,给同样被 id 折磨过的同学一个可以直接参考的清单。
先说结论:没有一个 ID 方案是万能的。数据库自增适合小规模单体应用,Redis INCR 适合短业务场景,UUID 胜在简单但索引性能一般,雪花算法适合分布式业务但要注意时钟回拨和精度丢失。如果你的系统也在做分库分表、消息幂等、日志追踪,这篇文章可以直接收藏。
1. 核心能力速览:常见 ID 生成方案对比
在做 ID 技术选型之前,先把主流方案的规格看一遍,后面才不会走弯路。
| 方案 | 唯一性 | 趋势递增 | 生成性能 | 外部依赖 | 可反解 | 适用场景 |
|---|---|---|---|---|---|---|
| 数据库自增 | 单库内唯一 | 强递增 | 中等 | 依赖数据库 | 弱 | 单库单表、后台管理 |
| Redis INCR | 全局唯一 | 强递增 | 高 | 依赖 Redis | 弱 | 短周期流水号、计数器 |
| UUID v4 | 全局唯一 | 无序 | 高 | 无 | 弱 | 客户端生成、临时标识 |
| UUID v7 | 全局唯一 | 趋势递增 | 高 | 无 | 弱 | 分布式系统索引友好 |
| 雪花算法 | 全局唯一 | 趋势递增 | 高 | 依赖时钟/机器ID | 强 | 分布式主键、订单号 |
| 号段模式 | 全局唯一 | 趋势递增 | 高 | 依赖数据库 | 中 | 分库分表批量发号 |
从这张表能看到的重点:如果你的系统对索引性能有要求,趋势递增比完全随机更重要。这就是为什么 UUID v4 在 MySQL 大表里做索引会相对吃亏,而雪花算法、UUID v7、号段模式更受后端欢迎。
另外,ID 设计还分为“业务可反解”和“业务不可反解”两类。雪花算法可以直接从 ID 中解析出时间戳和机器 ID,方便定位数据产生来源;UUID 和 Redis 自增则很难做到。具体选哪种,要看业务是否需要从 ID 反推时间、是否允许别人通过 ID 猜测业务量。
2. ID 设计为什么难:不只是“不重复”
很多人一开始想得很简单:我只要保证 ID 不重复不就行了?真正动手设计时才会遇到下面这些约束。
全局唯一只是底线。单机自增 ID 在单库下没问题,一旦分库分表,每个库各自维护自增值,同一个业务在不同分片里就会产生重复 ID。分布式环境下的“全局唯一”需要专门的发号器来做。
趋势递增很容易被忽略。MySQL InnoDB 的聚簇索引如果使用完全无序的 UUID,会产生大量随机写,导致页分裂,插入性能明显下降。如果 ID 是趋势递增的,新记录总能落到索引末尾,写入性能稳定得多。
高性能是硬指标。在高并发下单场景里,发号器需要吞吐量足够高,不能每次都走数据库事务。比如订单系统每秒钟可能产生几万个新 ID,如果每次都在数据库里 INSERT 后再拿自增 ID,数据库很容易被打满。
高可用更难。发号器本身不能单点故障。Redis、数据库、独立 ID 服务都要考虑降级和容灾。如果发号器挂了,整个写入链路都停了,影响范围远大于单表单机。
可反解和不可猜测是两种方向。可反解适合做订单号、审计日志,运营人员看到 ID 就能判断大致生成时间;不可猜测适合用户 ID、活动 ID,防止别人通过遍历 ID 获取未授权的数据。
再加上前端 JavaScript 对大整数的精度限制、跨语言传输时位数溢出、数据库主键类型容量、分区表查找性能这些隐藏问题,ID 设计就成了一个典型的“一看就会,一写就崩”的模块。
3. 常见 ID 方案拆解
3.1 数据库自增 ID
最传统的方式,适合单库单表、后台管理、内容表等低并发场景。
CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, PRIMARY KEY (id) );优点是完全不用写额外代码,MySQL 自动维护;缺点是分库分表后各库自增值可能重复,解决办法有设置不同初始步长、使用全局发号表、配合数据库号段方案等。另一个隐性问题:自增 ID 会暴露业务量,竞争对手通过订单号差值就能估算订单规模。
3.2 Redis INCR
用 Redis 的原子自增命令生成递增 ID:
INCR order_id_202602INCR 是原子操作,并发下不会重复。但 Redis 的持久化配置如果为 RDB 异步模式,极端宕机情况下存在计数回退的可能,需要根据业务容忍度评估。适合做短周期的流水号,比如每日订单号、验证码序号。
3.3 UUID 系列
UUID v4 是完全随机生成,优点是不依赖中心节点、客户端也能生成;缺点是作为数据库主键时索引性能一般,占用存储空间也更大。
UUID v7 则是在时间戳的基础上拼接随机位,保证趋势递增,同时保留分布式生成能力。它比 v4 对数据库索引更友好,属于比较新的选择,很多现代方案开始推荐 v7。
3.4 雪花算法(Snowflake)
雪花算法是 Twitter 开源的分布式 ID 生成算法,核心思路是把 64 位 ID 拆成时间戳、机器 ID、序列号三段。它的优点是纯本地生成,不依赖数据库和 Redis,吞吐量高,还能从 ID 中解析出时间。缺点是需要处理时钟回拨、机器 ID 分配和前端精度丢失。
3.5 号段模式
号段模式是“数据库自增 + Redis INCR”之间的折中方案:数据库记录当前已经发到哪个号段,业务服务每次取一批 ID 到本地内存,用完了再向数据库申请下一批。这样数据库压力很低,性能高,适合分库分表场景。美团 Leaf、百度 UidGenerator 等成熟方案本质上都是围绕号段和雪花算法做扩展。
3.6 硬件唯一 ID
在 IoT、嵌入式领域,还会用到芯片唯一 ID。STM32 芯片出厂时内部有一段不可修改的唯一 ID,可以用于设备标识、License 绑定。NAND Flash 也有厂商 ID 和颗粒信息,可以通过 Flash ID 识别颗粒型号。
这类硬件 ID 是“设备身份证”,与业务主键设计不同。它不追求递增,只要求全球唯一和不可篡改。如果项目涉及设备接入,建议把硬件 ID 单独存一列,并给业务系统生成独立的逻辑主键,不要把硬件 ID 直接当业务主键用。
4. 雪花算法实现与部署注意点
雪花算法的核心是 64 位的 bit 布局。常见分法是:
- 第 1 位:符号位,固定为 0
- 第 2~42 位(共 41 位):毫秒级时间戳,可用 69 年
- 第 43~52 位(共 10 位):机器 ID,可以拆成数据中心 ID + 机器 ID
- 第 53~64 位(共 12 位):同一毫秒内的序列号
下面给出一个 Python 版的标准实现,适合本地验证和二次改造。在正式项目里可以不用自己造轮子,但理解算法实现有助于排错。
import time class Snowflake: def __init__(self, datacenter_id, worker_id, epoch=1609459200000): self.datacenter_id = datacenter_id self.worker_id = worker_id self.epoch = epoch self.sequence = 0 self.last_timestamp = -1 self.datacenter_id_bits = 5 self.worker_id_bits = 5 self.sequence_bits = 12 self.max_datacenter_id = -1 ^ (-1 << self.datacenter_id_bits) self.max_worker_id = -1 ^ (-1 << self.worker_id_bits) self.max_sequence = -1 ^ (-1 << self.sequence_bits) if self.datacenter_id > self.max_datacenter_id or self.datacenter_id < 0: raise ValueError(f"datacenter_id 必须在 0~{self.max_datacenter_id} 之间") if self.worker_id > self.max_worker_id or self.worker_id < 0: raise ValueError(f"worker_id 必须在 0~{self.max_worker_id} 之间") self.timestamp_shift = self.datacenter_id_bits + self.worker_id_bits + self.sequence_bits self.datacenter_id_shift = self.worker_id_bits + self.sequence_bits self.worker_id_shift = self.sequence_bits def _current_timestamp(self): return int(time.time() * 1000) def _til_next_millis(self, last_timestamp): timestamp = self._current_timestamp() while timestamp <= last_timestamp: timestamp = self._current_timestamp() return timestamp def next_id(self): timestamp = self._current_timestamp() if timestamp < self.last_timestamp: # 时钟回拨,这里用抛异常快速失败;生产环境可考虑等待或走备用时钟 raise RuntimeError(f"时钟回拨 {self.last_timestamp - timestamp}ms") if timestamp == self.last_timestamp: self.sequence = (self.sequence + 1) & self.max_sequence if self.sequence == 0: timestamp = self._til_next_millis(self.last_timestamp) else: self.sequence = 0 self.last_timestamp = timestamp return ((timestamp - self.epoch) << self.timestamp_shift) | \ (self.datacenter_id << self.datacenter_id_shift) | \ (self.worker_id << self.worker_id_shift) | \ self.sequence关键的三个易错点:
第一,时钟回拨。如果服务器 NTP 校准后系统时间往回调了,按照原时间戳生成的 ID 可能跟之前重复。上面的实现选择直接抛异常,生产环境更稳妥的做法是记录回拨毫秒数并短暂等待,或者维护一套备用时钟源。
第二,机器 ID 分配。datacenter_id 和 worker_id 必须全局唯一,否则两个节点仍可能生成重复 ID。小规模系统可以在配置文件里手动分配,集群规模较大时需要通过 ZooKeeper、数据库或注册中心动态分配。
第三,序列号溢出。同一毫秒内如果生成超过 4096 个 ID,序列号会清零并等待下一毫秒。如果业务并发超过这个阈值,需要减少时间戳精度,或者增加序列号位数调整 bit 布局,或者直接改读算法方案。
雪花 ID 在 Go、Java、Python 里都是 64 位整型,但 JavaScript 的 Number 只能精确表示 2^53 以内的整数。浏览器端一旦拿到雪花 ID,很容易出现最后几位变成 0 的精度丢失。后文会单独说这个问题。
5. ID 生成器的测试与效果验证
写完发号器,先不要急着接入业务,按下面的验证流程走一遍,能省下后面不少排查时间。
测试目的:验证唯一性、趋势递增性、并发下是否重复、位段解析是否正确。
测试输入:通过配置不同的 datacenter_id 和 worker_id,连续生成多批 ID。
from snowflake import Snowflake def test_snowflake(): sf = Snowflake(datacenter_id=1, worker_id=1, epoch=1609459200000) total = 100000 id_set = set() # 验证唯一性 for _ in range(total): uid = sf.next_id() if uid in id_set: raise RuntimeError(f"重复 ID: {uid}") id_set.add(uid) # 验证递增性 ids = [sf.next_id() for _ in range(10000)] for i in range(1, len(ids)): if ids[i] <= ids[i - 1]: raise RuntimeError(f"递增性异常: {ids[i-1]} -> {ids[i]}") # 位段解析验证 first = sf.next_id() timestamp_part = (first >> 22) + 1609459200000 print(f"总数据量: {total}, 去重后: {len(id_set)}") print(f"生成 ID 示例: {first}") print(f"解析出的时间戳: {timestamp_part}") if __name__ == "__main__": test_snowflake()这里用一个很简单的去重逻辑判断 ID 是否重复。如果使用多进程或多实例测试,关键就不是“在同一个进程内不重复”,而是“不同进程分配不同 worker_id 后是否重复”。可以同时启动多个 worker,并把生成的 ID 汇总到一个外部存储里做去重。
判断成功标准:
- 数量级在十万到百万级别时,去重后数量仍等于生成总数。
- 时间排序后,ID 基本保持递增,波动只来自并发进程的乱序写入,而不是同一进程生成的乱序。
- 把 ID 右移 22 位后加回 epoch,能得到当前时间附近的时间戳。
批量任务场景:如果发号器要支持批量 ID,不建议循环调用单条接口,可以给发号器提供一个 batch 方法,一次申请多批 ID,减少 RPC 损耗。
def next_batch(batch_size=1000): return [self.next_id() for _ in range(batch_size)]如果生成器被多个业务线共用,建议把 batch 申请逻辑放在服务端,客户端只负责拿号,避免每个客户端都维护一套生成器状态。
性能观察:不需要精确压测,先关注两个指标。第一,单实例在 8 并发下生成 10 万条 ID 的耗时时长;第二,同一毫秒内序列号是否频繁回绕。如果发现大量 ID 的序列号位始终很小,说明当前时间戳精度和序列号容量配置偏保守,后续并发再涨可能不够用。
6. 数据库主键设计与 MySQL 分区
ID 生成出来,最终要落到数据库。主键字段选什么类型,直接影响存储和查询性能。
使用自增 ID 时,最常见的问题是把主键建成了INT,业务量上来后溢出,改成BIGINT时需要回填大量历史数据。建议新表直接使用BIGINT。雪花 ID 是 64 位有符号整数,MySQL 里对应BIGINT;Java 里如果使用Long,需要注意从前端传回时变成String处理。
如果选了雪花 ID 作为主键,还需要注意磁盘空间:BIGINT占 8 字节,UUID存为CHAR(36)占 36 字节,两者在二级索引中的空间差异会放大。MySQL InnoDB 的二级索引叶子节点会存主键值,主键越长,二级索引越占空间,查询也越慢。所以目前比较推荐的还是 8 字节的雪花 ID,而不是把 UUID 字符串直接当主键。
再看分区表。一个比较典型的场景是报警表,数据量很大,通常按时间分区:
CREATE TABLE alarm_log ( id BIGINT NOT NULL, alarm_time DATETIME NOT NULL, alarm_content VARCHAR(255), PRIMARY KEY (id, alarm_time) ) PARTITION BY RANGE (TO_DAYS(alarm_time)) ( PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')), PARTITION p202502 VALUES LESS THAN (TO_DAYS('2025-03-01')), PARTITION p202503 VALUES LESS THAN (TO_DAYS('2025-04-01')), PARTITION p_max VALUES LESS THAN MAXVALUE );这样做的好处是:删除过期数据时可以按分区 DROP 或归档,不用一条一条 DELETE;时间范围查询可以走分区裁剪。
但要注意:按 alarm_time 做 RANGE 分区后,只按 id 查询时通常不能直接利用分区裁剪。InnoDB 的每个分区都是独立的 B+ 树,查询时如果 WHERE 条件没有带上分区键,优化器需要扫描所有分区,性能往往不如不分区的表。而且上面这个 SQL 里,主键必须包含分区键 alarm_time,否则 MySQL 会报错“分区键必须包含在所有唯一键中”。
如果业务里既有按 id 的精确查询,又有按 alarm_time 的范围归档需求,可以考虑下面几种方案:
- 按 id 做 HASH 分区,适合按 id 均匀分布的数据。查询 id 时可以直接裁剪到对应分区,但按时间归档就不方便了。
- 主键改为联合索引,必须带 alarm_time 的查询走分区裁剪,只按 id 查的场景适当兜底。
- 单独维护一张路由表,记录 id 与 alarm_time、分区的映射关系,先查路由表再查目标分区。
- 按 id 和 alarm_time 联合分区,要求业务查询时尽量带上时间范围,否则分区裁剪效果有限。
从实际情况看,报警表这类数据更适合“id 作为业务键,alarm_time 作为分区键,查询尽量带时间范围”的设计。纯按 id 的高频查询,建议在应用层缓存或在另一张表里维护索引关系,不要指望 MySQL 自动做到完美的分区裁剪。
7. 前端与接口层的 ID 处理
ID 设计经常在“后端到前端”这一步出问题。最典型的就是雪花 ID 在 JavaScript 里精度丢失。
JavaScript 的Number是 64 位浮点数,能精确表示的最大安全整数是 9007199254740991,也就是 2^53 - 1。雪花 ID 直接超过这个范围,JSON 序列化后到浏览器端,最后几位可能变成 0。比如后端返回 7277244048610402305,前端看到可能是 7277244048610402000。
解决办法是将大整数 ID 序列化为字符串。
Spring Boot 场景下,可以给 Jackson 配置 Long 转 String:
{ "id": "7277244048610402305", "content": "test" }也可以使用 JSON 库自带的注解,例如 Java 的@JsonSerialize(using = ToStringSerializer.class),或者 Go 的json:"id,string"。核心原则是:后端接口返回给前端的 ID,一律按字符串处理;只有后端内部计算逻辑里才使用数值类型。
另一个常见前端问题是 localStorage 数据管理。很多业务系统会把列表数据缓存在 localStorage 里,删除某条记录时需要根据记录 ID 删除对应的键值。
const STORAGE_KEY_PREFIX = 'order_cache_'; function removeOrderCache(orderId) { const key = STORAGE_KEY_PREFIX + orderId; localStorage.removeItem(key); } function clearOrdersCache(orderIds) { orderIds.forEach((id) => { localStorage.removeItem(STORAGE_KEY_PREFIX + id); }); }这里有一个容易踩的坑:orderId从接口拿回来时是字符串,但如果代码里不小心做了数字运算,比如id + ''后拿去拼接,可能导致 key 不一致。建议所有 ID 在接口层统一用字符串格式,前端保存和删除都使用同一字符串,避免类型转换。
接口层的 ID 参数还需要做校验。前端传入的 ID 不能直接拼进 SQL 或查询条件,一方面要防止注入,另一方面要防止越权。比如用户 A 把自己订单 ID 改成用户 B 的订单 ID,如果后端只校验格式不校验归属,就存在越权风险。正确的做法是:每个 ID 查询都必须带上当前登录用户、租户、项目等隔离条件,让数据库在查询时自动把越权数据过滤掉。
如果你在做 OA 流程集成,比如获取流程实例 ID、任务 ID,也应该通过官方接口拿到返回值,而不是根据规则去猜 ID。任何通过遍历 ID 枚举资源的行为,在真实环境里都属于安全风险,只能在授权测试环境里验证。
8. 常见问题与排查方法
ID 相关的问题往往隐蔽,下面这张排查表覆盖了大部分高频坑。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 生成出来的 ID 有重复 | 两个实例分配了相同的 worker_id;时钟回拨;数据库自增分片冲突 | 检查机器 ID 配置,查看生成器日志 | 统一分配机器 ID,添加时钟回拨处理逻辑 |
| ID 长度超过 2^53,前端数字不对 | JavaScript Number 精度溢出 | 浏览器控制台打印返回值,比较最后几位 | 接口层把 ID 转为字符串返回 |
| MySQL 主键报 Duplicate entry | INT 溢出后回绕;自增步长配置错误;分区键与唯一键冲突 | 查看表结构、自增步长、分区定义 | 升级为 BIGINT,调整自增策略,检查分区键是否包含在唯一键中 |
| 按 id 查询非常慢 | 表按时间 RANGE 分区,查询没有裁剪到目标分区 | EXPLAIN 查看扫描分区数量 | 查询条件带上时间范围,或改用 HASH 分区、增加路由关系表 |
| 发号器启动后持续抛时钟回拨异常 | 系统时间被 NTP 回调 | 查看系统时间、NTP 状态 | 增加等待或备用时钟源,必要时人工干预 |
| API 接口返回 ID 后前端删除 localStorage 失败 | key 拼接时 ID 出现类型不统一 | 打印实际 key 和 ID 字符串 | 统一前端 ID 类型,使用字符串拼接 |
| 批量插入任务卡住 | 锁等待、主键冲突、分区数量过大 | 查看数据库锁等待状态和慢查询日志 | 分批提交,合理控制分区数量,冲突数据记录到日志 |
再补充两个容易混淆的概念。事件 ID 与业务主键不一样。Windows 系统日志里的事件 ID(如 nvlddmkm 153)是系统定义的事件编号,不是数据库生成的主键,排查时不能按业务主键逻辑去理解。硬件 ID 与业务 ID 也要分离。STM32、Flash 芯片的唯一 ID 是设备身份标识,业务系统不能直接把它作为唯一业务键,因为硬件 ID 可能在维修、更换芯片后发生变化,而且不具备递增性,数据库索引并不友好。
9. 最佳实践与使用建议
第一,先确认是否真的需要分布式 ID。如果系统只有一个 MySQL 实例,单表数据规模可控,用数据库自增 ID 就够了。过度设计分布式 ID 会增加系统复杂度和运维成本。
第二,保存一套最小可运行配置。无论你最后用雪花算法、号段模式还是 Redis INCR,建议把发号器的核心参数(epoch、机器 ID、序列号位数)集中放在一个配置文件中,方便统一调整。发号器代码单独成一个模块,不要散落在业务代码里。
第三,模型文件、输入素材、输出结果分目录管理。这句话虽然更多出现在 AI 项目中,但放到 ID 生成器这里同样适用:生成器配置文件、测试脚本、批量发号结果、异常日志,建议分开存储。后面排查问题时能省很多时间。
第四,批量任务必须加日志和失败重试。如果做批量补发 ID、批量迁移主键,不能只记录成功数据。每条失败记录要保留原始主键、失败原因、重试次数。迁移完成后做一轮唯一性与引用完整性校验,确保没有数据孤岛。
第五,接口服务要注意访问范围。发号器一旦做成独立服务,就需要考虑权限控制。不是所有内部服务都应该随意调用发号接口,建议按业务线申请 access key,并设置调用限额。这样即使某一方误操作,也不会打满整个发号器。
第六,安全与合规边界。涉及用户 ID、订单 ID、设备 ID、会话 ID 时,要遵守最小化原则。不要使用弱会话 ID,不要在无必要的情况下暴露可枚举的用户标识。做本地安全测试时,只能在授权靶场环境里进行,不能在真实站点或未授权系统上验证。涉及个人数据的批量导出、跨系统传 ID,需要先确认数据合规要求,避免隐私泄露。
10. 总结与下一步
ID 设计最值得先验证的点有三个:唯一性、递增性、前端精度兼容性。只要你把这三件事跑通,大部分业务场景都能覆盖。最容易踩的坑也有三个:雪花算法的时钟回拨、机器 ID 分配冲突、JavaScript 大整数精度丢失。这三个坑在项目上线前一定要压测和代码评审。
下一步建议按这个顺序推进:先给存量系统做一次主键类型检查,确认有没有 INT 溢出的隐患;然后写一套发号器单元测试,把唯一性和递增性验证固化到 CI 里;最后再考虑是否把当前方案升级为号段模式或独立发号服务。ID 这个模块写起来不复杂,但隐蔽问题不少,值得花一个下午认真打磨。