分布式ID方案、雪花算法与时钟回拨问题

一、为什么需要全局唯一ID

传统的单体架构的时候,我们基本是单库然后业务单表的结构。每个业务表的ID一般我们都是从1增,通过AUTO_INCREMENT=1设置自增起始值,但是在分布式服务架构模式下分库分表的设计,使得多个库或多个表存储相同的业务数据。这种情况根据数据库的自增ID就会产生相同ID的情况,不能保证主键的唯一性。

如上图,如果第一个订单存储在 DB1 上则订单 ID 为1,当一个新订单又入库了存储在 DB2 上订单 ID 也为1。我们系统的架构虽然是分布式的,但是在用户层应是无感知的,重复的订单主键显而易见是不被允许的。那么针对分布式系统如何做到主键唯一性呢?

二、UUID

UUID是最直接的主键生成方案,也是面试中必须能够回答出来的基础策略。虽然UUID实现简单,但如果我们想在面试中脱颖而出,就需要深入分析UUID的弊端。UUID主要有两个明显的缺陷。第一个是长度问题,UUID通常占用36个字符,存储空间较大,不过在实际采用UUID的场景中,这个缺点通常不是主要考虑因素。第二个缺陷更为关键,那就是UUID不是递增的,这个弊端是面试时需要重点阐述的内容。

2.1 页分裂

要讲清楚UUID不是递增的弊端,我们需要先理解为什么数据库倾向于使用自增主键。这里的关键词是页分裂。

数据库的B+树索引结构中,数据按照主键大小有序存储在叶子节点上。当我们需要插入一条新记录时,如果这条记录的主键值恰好位于某个已满的叶子节点中间,就会触发页分裂操作。比如图中所示,当尝试在23之后插入25时,由于叶子节点已经放满,数据库不得不将这个节点分裂成两个节点,分别存储(20,21)和(22,23,25)。更严重的是,这种分裂可能会引发连锁反应,从叶子节点一直向上分裂到根节点,导致整个树结构都需要调整。

因此,UUID最大的缺陷在于它产生的ID不是递增的。我们倾向于在数据库中使用自增主键,是因为自增主键可以迫使数据库的B+树朝着一个方向增长,新数据总是追加到树的末尾,避免了中间节点的分裂,从而获得最佳的插入性能。而UUID生成的ID在整体上可以看作是随机的,这会导致数据频繁地插入到页的中间位置,引起更加频繁的页分裂操作。在极端情况下,这种分裂可能引发连锁反应,整棵B+树的结构都会受到影响,严重影响插入性能。

三、数据库步长自增方案

除了UUID方案,还有一种常见的方案也叫做自增,不过这种自增比较特殊,它是设置了步长的自增。

我们可以通过一个具体例子来说明这种方案。假设经过分库分表后,我们有16张表,那么可以让每张表按照不同的步长来生成自增ID。比如第一张表生成1、17、33、49这样的ID序列,第二张表生成2、18、34、50这样的ID序列,以此类推,每张表的起始值不同,但步长都是16。

这种方案的最大优势在于实现简单,应用层基本不需要做任何额外工作,只需要在创建表时指定好不同的起始值和步长即可。虽然生成的ID并不是严格全局递增的,但在单张表内部,ID肯定是递增的,这在一定程度上保证了插入性能。这个方案的性能主要取决于数据库本身的性能,应用层无需过多关注。

四、雪花算法

除了UUID和数据库自增,雪花算法是分布式场景下最经典的主键生成方案。需要注意的是,在当前的技术面试环境中,仅仅答出雪花算法可能已经不够突出,我们需要在理解雪花算法的基础上,找到更多的亮点。

雪花算法的核心思想并不复杂,关键在于分段设计。

雪花算法采用64位来表示一个ID,其中1位保留未使用,41位表示时间戳,10位作为机器ID,12位作为序列号。这种设计保证了ID的唯一性:时间戳是递增的,不同时刻产生的ID肯定不同;机器ID是不同的,同一时刻不同机器产生的ID肯定不同;同一时刻同一机器上,可以通过序列号来区分不同的ID。

基本解释清楚之后,我们可以从多个方向来展现技术深度,你可以根据自己掌握知识的程度来选择合适的方向。

4.1 灵活调整分段设计

第一个方向是深入讨论每个字段的含义和长度,关键点是根据实际需求自定义各个字段的含义和长度。

大多数情况下,如果自己设计类似的算法,每个字段的含义和长度都是可以灵活控制的。比如时间戳的41位可以调整得更短或更长,39位也能表示十几年,对于大多数业务场景来说已经足够。机器ID虽然名称上是机器ID,但实际上指的是算法实例,而不是物理机器。比如一台物理机器可以部署多个进程,每个进程的机器ID是不同的;或者进一步细分,机器ID的前半部分表示物理机器,后半部分可以表示该机器上用于产生ID的进程、线程或协程。甚至机器ID也可以不表示机器,而是引入特定的业务含义。序列号的长度同样可以根据实际并发需求进行调整。

总结来说,雪花算法可以看作是一种设计思想,借助时间戳和分段机制,我们可以自由切割ID的不同比特位,赋予其不同的含义,灵活设计符合自己业务场景的ID生成算法。

4.2 序列号耗尽的处理策略

无论怎么设计雪花算法,序列号长度都有可能不够用。比如标准的12位序列号,在并发量极高的场景下,有可能在某个特定时刻,同一台机器上的序列号全部用完。

显然,理论上确实存在这种可能性,所以我们需要准备解决方案。解决思路其实并不复杂。如果12位不够用,可以增加序列号的位数,这部分位数可以从时间戳中拿出来。如果还不够,可以让业务方等待到下一个时间戳,时间戳变化后自然又可以生成新的ID了,这实际上是一种变相的限流机制。

一般来说,可以考虑加长序列号的长度,比如缩减时间戳的位数,将节省出来的位数分配给序列号。当然也可以更直接地将64位的ID扩展为128位,甚至更多,这样序列号就可以有三四十位,即便是超大规模的系统也不可能用完。不过,彻底的兜底方案还是要有的。我们可以考虑引入类似限流的做法,在当前时刻的ID已经耗尽之后,让业务方等待下一个时间戳。由于时间戳通常是毫秒级的,业务方最多只需要等待一毫秒。

4.3 数据堆积问题的解决

假设有这样一个场景:你的分库分表策略是按照ID对64取模来进行的,如果业务非常低频,以至于每个时刻都只生成了尾号为7的ID,那么是不是所有数据都会分到同一张表中呢?

确实会出现这种情况,不过解决方案也很简单。第一种方案是在每个时刻使用随机数作为序列号的起点,而不是每次都从0开始计数。第二种方案是使用上一个时刻的序列号作为起点,比如上一个时刻的序列号只增长到5,那么下一个时刻的序列号就从6开始。如果上一个时刻的序列号已经很大了,就可以退化为从0开始。

看起来第一种方案比较合理常规,但是相比之下第二种实际上更加可控,性能也更好。

因为在低频场景下,很容易出现序列号几乎没有增长的情况,从而导致数据在经过分库分表后只落到某一张表中。为了解决这个问题,可以让序列号部分不再从0开始增长,而是从一个随机数开始增长。还有一个策略是序列号从上一时刻的序列号开始增长,但如果上一时刻序列号已经很大了,就可以退化为从0开始增长。这样比随机数更可控,性能也更好。

五、时钟回拨问题

时钟回拨是指系统时钟由于某种原因(如人为调整、NTP同步错误等)突然倒退,这可能导致雪花算法生成的ID重复。处理时钟回拨的常见策略包括:

  • 记录上一次生成ID的时间戳:每次生成ID时,比较当前时间戳与上一次的时间戳,如果检测到回拨,则拒绝生成ID或等待时间追上。
  • 使用逻辑时钟:逻辑时钟保证总是递增,不依赖系统时钟。但需要额外的机制来同步和持久化逻辑时钟。

5.1 Java实现雪花算法

以下是雪花算法的Java实现,包括处理时钟回拨的逻辑:

java 复制代码
java复制代码
public class SnowflakeIdGenerator {  
// 起始时间戳(2020-01-01 00:00:00 的 Unix 时间戳)  
private final long twepoch = 1577836800000L;  
// 机器ID所占的bit数  
private final long workerIdBits = 10L;  
// 数据中心ID所占的bit数  
private final long datacenterIdBits = 10L;  
// 支持的最大机器ID数量,结果为1024 (这个位数的机器ID最多1024个(0-1023))  
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);  
// 支持的最大数据中心ID数为1024  
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);  
// 序列在ID中占的位数  
private final long sequenceBits = 12L;  
// 机器ID向左移12位  
private final long workerIdShift = sequenceBits;  
// 数据中心ID向左移22位  
private final long datacenterIdShift = sequenceBits + workerIdBits;  
// 时间戳向左移22位  
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;  
// 生成序列的掩码,这里位运算保证只取12位  
private final long sequenceMask = -1L ^ (-1L << sequenceBits);  
private long workerId;  
private long datacenterId;  
private long sequence = 0L;  
private long lastTimestamp = -1L;  
public SnowflakeIdGenerator(long workerId, long datacenterId) {  
if (workerId > maxWorkerId || workerId < 0) {  
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));  
        }  
if (datacenterId > maxDatacenterId || datacenterId < 0) {  
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));  
        }  
this.workerId = workerId;  
this.datacenterId = datacenterId;  
    }  
// 产生下一个ID  
public synchronized long nextId() {  
long currentTimestamp = timeGen();  
if (currentTimestamp < lastTimestamp) {  
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - currentTimestamp));  
        }  
if (currentTimestamp == lastTimestamp) {  
// 如果在同一毫秒内  
            sequence = (sequence + 1) & sequenceMask;  
if (sequence == 0) {  
// 阻塞到下一个毫秒  
                currentTimestamp = tilNextMillis(lastTimestamp);  
            }  
        } else {  
            sequence = 0L;  
        }  
        lastTimestamp = currentTimestamp;  
return ((currentTimestamp - twepoch) << timestampLeftShift) |  
                (datacenterId << datacenterIdShift) |  
                (workerId << workerIdShift) |  
                sequence;  
    }  
// 阻塞到下一个毫秒,直到获得新的时间戳  
private long tilNextMillis(long lastTimestamp) {  
long timestamp = timeGen();  
while (timestamp <= lastTimestamp) {  
            timestamp = timeGen();  
        }  
return timestamp;  
    }  
// 获取当前时间戳  
private long timeGen() {  
return System.currentTimeMillis();  
    }  
public static void main(String[] args) {  
SnowflakeIdGenerator idWorker = new SnowflakeIdGenerator(1, 1);  
for (int i = 0; i < 10; i++) {  
long id = idWorker.nextId();  
            System.out.println(id);  
        }  
    }  
}

六、小结

雪花算法通过时间戳、机器ID和序列号的组合,在分布式环境下生成全局唯一的64位ID。本文介绍了雪花算法的原理、处理了时钟回拨问题的策略,并提供了Java实现。这种算法不仅高效,而且保证了ID的有序性,是大数据量系统中常用的分布式ID生成方案。

Tips: 为了大家快速高效的学习,已经将文章提交到了git仓库,涵盖后端大部分技术,以及后端学习路线,仓库内容会持续更新,建议 Star 收藏 以便随时查看https://gitee.com/bxlj/java-article

相关推荐
墨染点香41 分钟前
LeetCode 刷题【179. 最大数】
算法·leetcode·职场和发展
失忆已成习惯.1 小时前
西农数据结构第四次实习题目参考
数据结构·算法·图论
kyle~1 小时前
排序---堆排序(Heap Sort)
数据结构·c++·算法
yesyesido1 小时前
3D在线魔方模拟器
科技·算法·3d·生活·业界资讯·交友·帅哥
是苏浙1 小时前
蓝桥杯备战day1
算法
汉克老师1 小时前
CCF-NOI2025第一试题目与解析(第二题、序列变换(sequence))
c++·算法·动态规划·noi
断剑zou天涯1 小时前
【算法笔记】KMP算法
java·笔记·算法
程序员东岸1 小时前
《数据结构——排序(下)》分治与超越:快排、归并与计数排序的终极对决
数据结构·c++·经验分享·笔记·学习·算法·排序算法
java1234_小锋1 小时前
Kafka与RabbitMQ相比有什么优势?
分布式·kafka·rabbitmq