分布式 ID 生成方案:从数据库自增到雪花算法

分布式 ID 生成方案:从数据库自增到雪花算法

目录


自增 ID 的局限

在很多系统刚开始建设的时候,ID 并不是一个需要特别关注的问题。创建一张表,设置一个自增主键,数据库负责保证每条数据都有唯一编号,这种方式简单、稳定,而且几乎没有额外成本。

但随着业务规模扩大,系统从单体逐渐演变成多实例部署,甚至开始进行分库分表后,原本简单的自增 ID 就遇到了问题:

多个服务节点如何保证生成的 ID 仍然全局唯一?

单库场景下,所有数据写入都经过同一个数据库,自增序列天然是连续且唯一的。但到了分布式环境,每个数据库节点都有自己的自增序列,它们并不知道其他节点已经生成了哪些 ID。

例如:

复制代码
数据库A(用户表分片1)     数据库B(用户表分片2)
  id=1  张三                  id=1  李四
  id=2  王五                  id=2  赵六

对于数据库 A 来说,ID=1 是合法的;对于数据库 B 来说,ID=1 同样也是合法的。但放到整个系统来看,两个 ID 已经发生冲突。

有人可能会想到提前给不同节点划分 ID 范围,比如让 A 使用奇数,B 使用偶数,或者给每个数据库设置不同的起始值和增长步长。但这种方式随着节点数量变化会越来越难维护。新增机器、数据迁移、业务扩容,都可能导致原有规则失效。

因此,在分布式系统中,ID 的生成不能再依赖某一个数据库实例的自增能力,而需要设计一套独立的 ID 生成机制,让不同节点在没有互相协调的情况下,也能生成全局唯一的编号。

UUID

UUID 最大的优点是简单。应用节点之间不需要协调,本地直接生成就能用:

java 复制代码
String id = UUID.randomUUID().toString();
// 550e8400-e29b-41d4-a716-446655440000

零依赖、无网络开销、理论上不会重复。但简单也意味着牺牲了一些数据库场景下比较重要的特性。

做数据库主键性能差。 InnoDB 的主键索引本质是一棵 B+ 树,数据通常按照主键顺序插入。ID 不断递增时,新数据基本追加到索引尾部;而随机 UUID 会让数据插入位置更加分散,增加 B+ 树页分裂和索引维护成本。加上 UUID 是 36 个字符的字符串,比 8 字节的 Long 整数大好几倍,索引占用空间也更大。

可读性差。 出了问题排查时,user_id=1001user_id=550e8400-e29b-41d4-a716-446655440000 好认得多。

UUID 适合做请求追踪 ID、日志关联 ID 这类不需要排序、不做主键的场景。拿来做数据库主键,代价不小。

数据库号段

一种自然的思路是引入一个专门的 ID 服务,由它统一分配编号。但如果每次生成 ID 都访问数据库,本质上只是把压力从业务表转移到了发号表。

号段模式解决的就是这个问题:一次取一批,用完了再取下一批。

数据库只在号段用完时才被访问一次,平时 ID 生成全在内存里完成。

实际实现中,通常用两个号段交替:当前号段快用完时,提前加载下一个号段,避免用完时的数据库查询阻塞请求。美团的 Leaf 就是这个思路,用双 Buffer 保证在任何时刻都有可用的号段。

号段模式的 ID 有序递增,对数据库索引友好,也不依赖时钟。缺点是强依赖数据库,号段表挂了就发不了号(双 Buffer 和预加载可以缓解,但不能完全消除)。

Redis INCR

Redis 的 INCR 命令是原子操作,天然适合做分布式 ID 生成:

java 复制代码
public long generateId(String key) {
    return redisTemplate.opsForValue().increment(key);
}

Redis 单线程模型保证了原子性,不需要加锁,高并发下也不会重复。实现比号段模式简单得多,不需要维护号段加载逻辑。

但它有自己的问题。号段模式一次数据库交互拿一批 ID,之后全在内存里生成。Redis INCR 每次生成 ID 都要一次网络往返,QPS 很高时网络开销会成为瓶颈。另外 Redis 宕机了 ID 就发不出来了,虽然可以做主从切换,但切换过程中有短暂的不可用窗口。还有 Redis 的持久化策略 ------ 如果配置不当,故障恢复后计数器可能回退,导致 ID 重复,需要额外保证持久化策略的正确性。

Redis INCR 适合对性能要求不是极端高、已有 Redis 基础设施的场景。

雪花算法

Snowflake 是 Twitter 开源的分布式 ID 生成方案。它不依赖任何外部存储,本地直接生成,性能极高。

核心思想是把一个 64 位的 Long 整数拆成几段,每段有不同的含义:

41 位时间戳提供了约 69 年的使用周期,对于绝大多数业务系统已经足够。12 位序列号意味着同一毫秒同一机器最多生成 4096 个 ID。5 位数据中心 + 5 位机器 ID,最多支持 1024 个节点。

生成逻辑:取当前时间戳,如果和上一次相同,序列号自增;如果不同,序列号归零。序列号溢出时,等下一毫秒再生成。

复制代码
nextId():
    timestamp = 当前时间戳

    if timestamp < lastTimestamp:
        时钟回拨,抛异常或等待恢复

    if timestamp == lastTimestamp:
        sequence++
        if sequence > maxSequence:
            等待下一毫秒,重新获取 timestamp
    else:
        sequence = 0

    lastTimestamp = timestamp

    return (timestamp - epoch) << 22
         | datacenterId << 17
         | workerId << 12
         | sequence

位运算的拆解:<< 22 把时间戳移到最高位,<< 17<< 12 分别给数据中心和机器 ID 留出位置,最后 12 位留给序列号。OR 操作把各段拼成一个完整的 64 位整数。

雪花算法纯本地生成,没有网络开销,ID 整体递增,对数据库索引友好。缺点是对系统时钟有依赖。

时钟回拨

雪花算法最大的隐患是时钟回拨。

线上环境里,更常见的是机器时间同步异常。例如某台机器因为时间同步配置异常,时钟突然回拨几秒,雪花算法会立即拒绝生成 ID,最终表现为业务请求失败。

正常情况下服务器时间是单调递增的,但 NTP 同步、闰秒调整、虚拟机迁移这些场景都可能导致时间"往回跳"。

处理方式通常有两种:

小幅回拨,等待恢复。 检测到回拨幅度在几毫秒以内时,sleep 一小段时间再重试。大部分 NTP 同步造成的小幅回拨都能扛过去。

java 复制代码
if (timestamp < lastTimestamp) {
    long offset = lastTimestamp - timestamp;
    if (offset <= 5) {
        Thread.sleep(offset << 1); // 等待回拨恢复
        timestamp = System.currentTimeMillis();
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨过大,拒绝生成ID");
        }
    } else {
        throw new RuntimeException("时钟回拨过大,拒绝生成ID");
    }
}

大幅回拨,直接报警。 回拨几秒以上通常意味着运维问题,这时候比兜底更有意义的是报警,让运维介入排查根因。

也有方案在 64 位中预留几位做"回拨计数器",每次检测到回拨就加一,这样即使时间回退也不会重复。代价是留给时间戳或序列号的位数变少了。

方案对比

方案 性能 趋势递增 适合作为主键 运维成本 适合场景
数据库自增 适合 单机部署,数据量不大
UUID 一般 追踪 ID、日志关联
数据库号段 适合 业务主键,已有数据库
Redis INCR 适合 已有 Redis 基础设施
雪花算法 极高 适合 大规模分布式系统

小结

分布式 ID 的设计是在业务规模和系统复杂度之间做取舍。对于早期系统,数据库自增通常已经足够;当业务发展到分库分表阶段,可以考虑号段模式或者 Redis INCR;如果需要更高并发、更大规模的分布式生成能力,再引入雪花算法。很多时候,问题并不是现有方案性能不够,而是在业务还没有复杂到那个程度时,就提前引入了复杂的分布式方案,最终增加了系统维护成本。

相关推荐
king_linlin2 小时前
算法基础——算法复杂度
c语言·开发语言·数据结构·算法
纪伊路上盛名在2 小时前
NVML ERROR_ RM has detected an NVML_RM version mismatch
linux·数据库·gpu·驱动
nVisual2 小时前
01-环境监控集成方案
运维·服务器·开发语言·网络·数据库·数据中心布线·综合布线管理软件
早点睡啊Y2 小时前
精读 LangChain 官方文档(三):
服务器·数据库·langchain
king_linlin2 小时前
数据结构——顺序表(附图文讲解|超详细)
c语言·数据结构·算法
GAOJ_K3 小时前
老旧产线弧形导轨换新:同规格替换避坑选型思路
人工智能·算法·机器学习·制造·导轨偏磨
zzzzzz3103 小时前
当老板说「数据库里加个字段就行了」时,我在想什么——一个后端开发的奇葩需求大赏
数据库·人工智能·产品经理
Jerry7 小时前
LeetCode 189. 轮转数组
算法
Jerry8 小时前
LeetCode 739. 每日温度
算法