Skip to content

分布式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年
机器ID10位最多支持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 v7ULID
位数64位128位128位
格式整数带横线字符串Base32字符串
长度19位十进制36字符26字符
时间精度毫秒毫秒毫秒
时间有序
需要机器ID
时钟回拨风险✅ 有❌ 无❌ 无
逆运算时间需要知道epoch简单最简单
URL安全
JS精度问题✅ 有❌ 无❌ 无
官方标准✅ RFC 9562
信息泄露机器ID+并发量仅时间仅时间

性能对比

雪花算法UUID v7ULID
单次耗时~50ns~200ns~200ns
每秒生成~2000万~500万~500万
数据库索引⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
存储空间8字节16字节16字节

注:三者生成速度在实际业务中差距可忽略不计,接口耗时通常在10ms以上,ID生成仅需纳秒级别。

随机数碰撞概率

随机数位数碰撞概率(同一毫秒10亿次)
UUID v774位约 1/180亿
ULID80位约 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 v7

Q:UUID v7和ULID如何应对时钟回拨?

两者均依靠随机数保证唯一性,即使时钟回拨,随机数部分也能保证不重复。唯一影响是回拨期间ID不再单调递增,但实际影响可忽略不计。


七、总结

场景推荐方案
新项目通用IDULID
需要UUID格式兼容UUID v7
超高并发+极致性能雪花算法
分区表路由ULID
防止遍历攻击UUID v7 / ULID

雪花算法性能最强但运维复杂;UUID v7标准权威但略显冗长;ULID简洁现代且逆运算最方便。根据业务场景选择最适合的方案,而不是追求"最好"的方案。


参考:ULID Spec | RFC 9562 | Twitter Snowflake

最近更新