分布式 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=1001 比 user_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;如果需要更高并发、更大规模的分布式生成能力,再引入雪花算法。很多时候,问题并不是现有方案性能不够,而是在业务还没有复杂到那个程度时,就提前引入了复杂的分布式方案,最终增加了系统维护成本。

相关推荐
倒头就睡的小比特2 天前
算法竞赛C++常用的STL
c++·算法
小羊没烦恼!2 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
猎头南楼2 天前
知识社区推荐系统实践:新用户冷启动与长短期兴趣建模的挑战 资深推荐算法工程师
人工智能·深度学习·算法·机器学习
这个DBA有点耶2 天前
MVCC深入:Read View、版本链与快照读——InnoDB并发控制的内核
数据库·mysql·架构
DBA_G2 天前
从地面到云霄:GBase数据库在民航三大场景的落地实践
数据库
自由能燃气设备2 天前
商用全预混低氮冷凝锅炉免费方案vs付费方案对比+选型避坑指南
大数据·数据库·人工智能
科创致远2 天前
科创致远 ESOP 系统核心效能与实战价值展示
大数据·数据库·人工智能·精益工程
旖旎夜光2 天前
力控面试题 01.01: 判定字符是否唯一(位运算) —— 题解
c++·学习·算法·leetcode·力控
wzdark2 天前
大规模并行计算中的负载均衡算法研究4
算法
Because_of_Her13 天前
并查集-听课笔记
笔记·算法·并查集