分布式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

相关推荐
洛水水13 小时前
【力扣100题】78.在排序数组中查找元素的第一个和最后一个位置
数据结构·算法·leetcode
江屿风13 小时前
C++图论基础拓扑排序算法流食般投喂
开发语言·c++·笔记·算法·排序算法
段一凡-华北理工大学13 小时前
工业领域的Hadoop架构学习~系列文章23:物流行业Hadoop应用实践 - 智能物流的数字化引擎
大数据·人工智能·hadoop·分布式·学习·架构·高炉炼铁
海棠AI实验室13 小时前
AI 时代文献综述:从检索到成稿的 RAG 五步法
windows·算法·自动化·llm·rag
H1785350909613 小时前
SolidWorks_基于草图的实体特征14_扫描扭转与控制
前端·人工智能·算法·3d建模·solidworks
黄金龙PLUS13 小时前
基于ARX结构的新型序列密码算法FlashLight
算法·网络安全·密码学·哈希算法·同态加密
洛水水13 小时前
【力扣100题】77.搜索二维矩阵
算法·leetcode·矩阵
Ze3G90nYt13 小时前
Redis 分布式锁进阶第一百三十一篇
数据库·redis·分布式
云烟成雨TD13 小时前
Spring AI Alibaba 1.x 系列【75】分布式智能体
人工智能·分布式·spring
仙俊红14 小时前
深入理解 ThreadLocal —— 从变量引用、强弱引用到 Spring Boot 实战
spring boot·python·算法