Appearance
分布式ID生成方案对比:雪花算法 vs UUID v7 vs ULID
在分布式系统中,如何生成全局唯一ID是一个绕不开的话题。本文深入对比三种主流方案:雪花算法、UUID v7、ULID,帮助你选择最适合自己业务的方案。
一、雪花算法(Snowflake)
起源
雪花算法由 Twitter 于 2010 年开源,专为分布式系统设计,能在不依赖数据库的情况下高效生成全局唯一 ID。
结构
雪花算法生成的是一个 64位整数,由四部分组成:
| 1位符号位 | 41位时间戳 | 10位机器ID | 12位序列号 || 部分 | 位数 | 说明 |
|---|---|---|
| 符号位 | 1位 | 固定为0,保证ID是正数 |
| 时间戳 | 41位 | 毫秒级时间戳减去epoch,可用约69年 |
| 机器ID | 10位 | 最多支持1024台机器 |
| 序列号 | 12位 | 同一毫秒内最多生成4096个ID |
核心原理
三部分通过位移 + 按位或拼装成一个64位整数:
go
ID = (timestamp - epoch) << 22 | workerId << 12 | sequence<< 22:时间戳左移22位,给机器ID(10位)和序列号(12位)腾出空间<< 12:机器ID左移12位,给序列号(12位)腾出空间- 按位或:三部分地盘不重叠,直接合并
Go 代码示例
go
package main
import (
"fmt"
"sync"
"time"
)
const (
workerBits uint8 = 10
numberBits uint8 = 12
workerMax int64 = -1 ^ (-1 << workerBits) // 1023
numberMax int64 = -1 ^ (-1 << numberBits) // 4095
timeShift = workerBits + numberBits // 22
workerShift = numberBits // 12
epoch int64 = 1704067200000 // 2024-01-01 起始时间戳
)
type Worker struct {
mu sync.Mutex
timeStamp int64
workerId int64
number int64
}
func NewWorker(workerId int64) *Worker {
return &Worker{workerId: workerId}
}
func (w *Worker) NextId() int64 {
w.mu.Lock()
defer w.mu.Unlock()
now := time.Now().UnixNano() / 1e6
if w.timeStamp == now {
w.number++
if w.number > numberMax {
for now <= w.timeStamp {
now = time.Now().UnixNano() / 1e6
}
}
} else {
w.number = 0
w.timeStamp = now
}
return (now-epoch)<<timeShift | (w.workerId<<workerShift) | w.number
}
func main() {
worker, _ := NewWorker(0), nil
fmt.Println(worker.NextId()) // 例:123456789012345678
}逆运算(解析时间)
go
func ParseSnowflakeTime(id int64, epoch int64) time.Time {
// 右移22位提取时间戳部分
timestamp := (id >> 22) + epoch
return time.UnixMilli(timestamp)
}优点
- 性能极高,单机每秒可生成约 400万个ID
- 纯内存位运算,无任何IO开销
- ID趋势递增,数据库索引友好
- 可逆运算出生成时间、机器ID、序列号
缺点
- 强依赖机器时钟,存在时钟回拨风险
- 机器ID需要人工或系统分配,运维成本高
- K8s等动态扩缩容场景下,workerId管理复杂
- ID信息可被逆推,存在业务信息泄露风险
- JavaScript中存在精度丢失问题(JS最大安全整数只有53位)
二、UUID v7
起源
UUID 标准诞生于1980年代,规定所有版本固定128位。v7 版本于 2024年5月正式发布为 RFC 9562 标准,很大程度上借鉴了 ULID 的设计思想。
结构
0190b271-c9e0-7000-8b2e-3b5a9f2c1d4e
├────48位时间戳────┤├4位版本┤├────74位随机数────┤| 部分 | 位数 | 说明 |
|---|---|---|
| 时间戳 | 48位 | 毫秒级时间戳 |
| 版本号 | 4位 | 固定为7 |
| 变体标识 | 2位 | 固定为10 |
| 随机数 | 74位 | 保证唯一性 |
Go 代码示例
go
package main
import (
"crypto/rand"
"encoding/binary"
"fmt"
"strings"
"time"
)
func GenerateUUIDv7() string {
now := time.Now().UnixMilli()
b := make([]byte, 16)
rand.Read(b)
// 前48位写入时间戳
binary.BigEndian.PutUint32(b[0:4], uint32(now>>16))
binary.BigEndian.PutUint16(b[4:6], uint16(now&0xFFFF))
// 版本号设为7
b[6] = (b[6] & 0x0F) | 0x70
// 变体标识设为10
b[8] = (b[8] & 0x3F) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func ParseUUIDv7Time(uuid string) time.Time {
clean := strings.ReplaceAll(uuid, "-", "")
timePart := clean[:12]
var ms int64
fmt.Sscanf(timePart, "%x", &ms)
return time.UnixMilli(ms)
}
func main() {
uuid := GenerateUUIDv7()
fmt.Println("UUID v7:", uuid)
fmt.Println("生成时间:", ParseUUIDv7Time(uuid).Format("2006-01-02 15:04:05.000"))
}逆运算(解析时间)
0190b271-c9e0-7000-8b2e-3b5a9f2c1d4e
第一步:去掉横线取前12位十六进制
0190b271c9e0
第二步:转十进制
0x0190b271c9e0 = 1718868600288 ms
第三步:换算时间
1718868600288 ms = 2024年6月20日 ✅优点
- 官方RFC标准,权威性强,生态兼容好
- 无需机器ID,开箱即用,天然支持分布式
- 时间戳有序,数据库索引友好
- 74位随机数,碰撞概率约180亿分之一
- URL安全(仅包含十六进制字符和横线)
- 天然应对时钟回拨(随机数保证唯一)
- 可逆运算出生成时间
缺点
- 字符串格式,存储占16字节,比雪花算法大一倍
- 含横线,某些场景存在歧义
- 生成速度比雪花算法慢约4倍(需生成随机数)
三、ULID
起源
ULID 由 Alizain Feerasta 于 2016年开源,是社区驱动的非官方标准。UUID v7 的设计很大程度上受到了 ULID 的影响。
结构
01HQ3V2KZP 8XQJT4N6RY5W0M3F
├─48位时间戳─┤├────────80位随机数────────┤
├──10个字符──┤├────────16个字符────────┤使用 Crockford Base32 编码(去掉了易混淆的 I L O U),共26个字符。
| 部分 | 位数 | 字符数 | 说明 |
|---|---|---|---|
| 时间戳 | 48位 | 10个 | 毫秒级时间戳 |
| 随机数 | 80位 | 16个 | 保证唯一性 |
Go 代码示例
go
package main
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
"time"
)
const base32Chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
func GenerateULID() string {
now := time.Now().UnixMilli()
// 时间戳部分:编码成10个Base32字符
timeChars := make([]byte, 10)
t := now
for i := 9; i >= 0; i-- {
timeChars[i] = base32Chars[t&0x1F]
t >>= 5
}
// 随机数部分:编码成16个Base32字符
randBytes := make([]byte, 10)
rand.Read(randBytes)
randNum := new(big.Int).SetBytes(randBytes)
randChars := make([]byte, 16)
mask := big.NewInt(0x1F)
tmp := new(big.Int)
for i := 15; i >= 0; i-- {
tmp.And(randNum, mask)
randChars[i] = base32Chars[tmp.Int64()]
randNum.Rsh(randNum, 5)
}
return string(timeChars) + string(randChars)
}
func ParseULIDTime(ulid string) time.Time {
ulid = strings.ToUpper(ulid)
timePart := ulid[:10]
var ms int64
for _, c := range timePart {
ms <<= 5
ms |= int64(strings.IndexRune(base32Chars, c))
}
return time.UnixMilli(ms)
}
func main() {
ulid := GenerateULID()
fmt.Println("ULID:", ulid)
fmt.Println("生成时间:", ParseULIDTime(ulid).Format("2006-01-02 15:04:05.000"))
}逆运算(解析时间)
01HQ3V2KZP8XQJT4N6RY5W0M3F
第一步:取前10位
01HQ3V2KZP
第二步:Base32解码得到毫秒时间戳
直接换算 = 2024年2月15日 ✅优点
- 逆运算最简单,前10位直接就是时间戳
- 字符串最短,只有26个字符
- 80位随机数,碰撞概率比UUID v7更低
- URL安全,大小写不敏感
- 字典序等于时间顺序,可直接排序
- 无需机器ID,开箱即用
- 天然应对时钟回拨
缺点
- 非官方标准,无RFC背书
- 不兼容UUID格式
- Base32编码理解成本略高
四、三者全面对比
特性对比
| 对比项 | 雪花算法 | UUID v7 | ULID |
|---|---|---|---|
| 位数 | 64位 | 128位 | 128位 |
| 格式 | 整数 | 带横线字符串 | Base32字符串 |
| 长度 | 19位十进制 | 36字符 | 26字符 |
| 时间精度 | 毫秒 | 毫秒 | 毫秒 |
| 时间有序 | ✅ | ✅ | ✅ |
| 需要机器ID | ✅ | ❌ | ❌ |
| 时钟回拨风险 | ✅ 有 | ❌ 无 | ❌ 无 |
| 逆运算时间 | 需要知道epoch | 简单 | 最简单 |
| URL安全 | ✅ | ✅ | ✅ |
| JS精度问题 | ✅ 有 | ❌ 无 | ❌ 无 |
| 官方标准 | ❌ | ✅ RFC 9562 | ❌ |
| 信息泄露 | 机器ID+并发量 | 仅时间 | 仅时间 |
性能对比
| 雪花算法 | UUID v7 | ULID | |
|---|---|---|---|
| 单次耗时 | ~50ns | ~200ns | ~200ns |
| 每秒生成 | ~2000万 | ~500万 | ~500万 |
| 数据库索引 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 存储空间 | 8字节 | 16字节 | 16字节 |
注:三者生成速度在实际业务中差距可忽略不计,接口耗时通常在10ms以上,ID生成仅需纳秒级别。
随机数碰撞概率
| 随机数位数 | 碰撞概率(同一毫秒10亿次) | |
|---|---|---|
| UUID v7 | 74位 | 约 1/180亿 |
| ULID | 80位 | 约 1/1208亿 |
五、如何选择?
新项目,无历史包袱
→ 优先选 ULID
需要兼容UUID生态,或对接第三方系统
→ 选 UUID v7
超高并发,对数据库索引性能极度敏感
→ 选 雪花算法(需解决机器ID管理问题)
需要逆运算时间用于分区表路由
→ ULID 最方便,前10位直接解析
已有雪花算法在用,担心JS精度问题
→ 后端返回时转成字符串传输六、常见问题
Q:为什么雪花算法在JavaScript中会丢失精度?
JavaScript 所有数字都是64位浮点数(IEEE 754),只有53位用于存储精确整数,而雪花算法生成的是64位整数,超出了JS的安全整数范围,末尾数字会被截断。
解决方案:后端返回时转成字符串。
go
// Go后端
c.JSON(200, gin.H{
"id": strconv.FormatInt(id, 10), // 转成字符串
})Q:雪花算法epoch设多少合适?
epoch应设为系统上线前的某个固定时间点,越接近上线时间越好。原因是 now - epoch 数值越小,41位时间戳剩余空间越大,可用年限越长。一旦确定不能修改。
Q:时钟回拨怎么处理?
小回拨(≤5ms) → 等待时钟追上
大回拨(>5ms) → 切换备用workerId
根本预防 → 开启NTP平滑同步,禁止手动改时间
一劳永逸 → 换用 ULID 或 UUID v7Q:UUID v7和ULID如何应对时钟回拨?
两者均依靠随机数保证唯一性,即使时钟回拨,随机数部分也能保证不重复。唯一影响是回拨期间ID不再单调递增,但实际影响可忽略不计。
七、总结
| 场景 | 推荐方案 |
|---|---|
| 新项目通用ID | ULID |
| 需要UUID格式兼容 | UUID v7 |
| 超高并发+极致性能 | 雪花算法 |
| 分区表路由 | ULID |
| 防止遍历攻击 | UUID v7 / ULID |
雪花算法性能最强但运维复杂;UUID v7标准权威但略显冗长;ULID简洁现代且逆运算最方便。根据业务场景选择最适合的方案,而不是追求"最好"的方案。
参考:ULID Spec | RFC 9562 | Twitter Snowflake