Skip to content

maloong-zhang/snowflake

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

雪花算法ID生成服务

什么是雪花算法(Snowflake)

雪花算法(Snowflake)是由 Twitter 开发的一种分布式唯一 ID 生成算法,能够生成全局唯一的 64 位整数 ID,具有高效、有序和唯一的特点。生成的 ID 具备时间戳信息,并且能够保证在分布式环境下的唯一性。

雪花算法的生成 ID 结构是一个 64 位的整数,具体格式如下:

符号位 (1 bit) 时间戳 (41 bit) 数据中心 ID (5 bit) 机器 ID (5 bit) 序列号 (12 bit)
0 41 位时间戳 5 位数据中心 ID 5 位机器 ID 12 位序列号
  • 1 bit 符号位:最高位为符号位,始终为 0,表示正数。
  • 41 bit 时间戳:使用自定义起始时间开始的毫秒级时间戳,41 位可以表示 69 年的时间。
  • 5 bit 数据中心 ID:支持最多 32 个数据中心节点。
  • 5 bit 机器 ID:支持每个数据中心最多 32 台机器。
  • 12 bit 序列号:支持同一台机器每毫秒生成 4096 个唯一 ID。

雪花算法的特点

  • 时间排序:生成的 ID 基于时间戳递增,有序性非常好。
  • 分布式支持:通过数据中心 ID 和机器 ID 可以在分布式环境中保证唯一性。
  • 高性能:每个节点每毫秒可以生成最多 4096 个唯一 ID,性能高效。
  • 稳定性高:算法非常简单,不依赖于数据库等外部系统,可以在分布式场景下大规模部署。

使用雪花算法设计分布式 UUID 生成应用

系统设计思路

在分布式系统中,多个服务实例需要生成唯一的 ID。使用雪花算法可以设计一个分布式 UUID 服务,通过以下方式来保证全局唯一性和高效性:

  1. 时间戳:使用相对时间戳确保 ID 的时间有序。
  2. 数据中心 ID 和机器 ID:确保在分布式环境中,每个实例都有唯一的标识符,用以生成唯一 ID。
  3. 序列号:在同一毫秒内,序列号递增,避免 ID 冲突。

雪花算法的 Java 实现

public class SnowflakeIdGenerator {
    // 常量定义
    private final long twepoch = 1288834974657L; // Twitter 的起始时间戳

    private final long workerIdBits = 5L;        // 机器 ID 的位数
    private final long datacenterIdBits = 5L;    // 数据中心 ID 的位数
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);         // 最大机器 ID
    private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); // 最大数据中心 ID
    private final long sequenceBits = 12L;       // 序列号的位数

    private final long workerIdShift = sequenceBits;                    // 机器 ID 左移的位数
    private final long datacenterIdShift = sequenceBits + workerIdBits; // 数据中心 ID 左移的位数
    private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; // 时间戳左移的位数
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);      // 序列号掩码

    // 工作节点参数
    private long workerId;         // 机器 ID
    private long datacenterId;     // 数据中心 ID
    private long sequence = 0L;    // 序列号
    private long lastTimestamp = -1L;  // 上次生成 ID 的时间戳

    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException("Worker ID 超出范围");
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException("数据中心 ID 超出范围");
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    // 获取下一个 ID
    public synchronized long nextId() {
        long timestamp = timeGen(); // 获取当前时间戳

        if (timestamp < lastTimestamp) {
            throw new RuntimeException("系统时间倒退异常");
        }

        if (lastTimestamp == timestamp) {
            // 如果同一毫秒内,则增加序列号
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                // 如果序列号超过 4096,则阻塞到下一毫秒
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 如果时间戳变化了,则序列号重置
            sequence = 0L;
        }

        lastTimestamp = timestamp;

        // 生成唯一 ID,拼接时间戳、数据中心 ID、机器 ID 和序列号
        return ((timestamp - 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();
    }
}

设计分布式 UUID 应用

  1. 系统架构

    • 每个服务实例运行一个 SnowflakeIdGenerator 对象,用于生成唯一 ID。服务实例会被分配一个唯一的 数据中心 ID 和 机器 ID,通过配置或注册中心管理
    • 通过负载均衡器,客户端请求 UUID 时会被分发到任意服务实例,服务实例会调用 nextId() 生成唯一 ID。
  2. 实例化雪花算法

    // 在启动时,指定数据中心 ID 和机器 ID
    SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1L, 2L);
    
    // 生成唯一 ID
    long uniqueId = idGenerator.nextId();
    System.out.println("生成的唯一ID: " + uniqueId);
  3. 高可用设计

  • 数据中心和机器 ID 分配:使用配置中心或服务注册与发现工具(如 ZooKeeper、Consul)分配 数据中心 ID 和 机器 ID,确保每个实例的 ID 唯一。
  • 容错:如果某个服务实例发生故障,可以通过重新分配 数据中心 ID 和 机器 ID 来确保继续生成唯一 ID。
  • 负载均衡:通过负载均衡器(如 Nginx 或 Kubernetes Ingress)将生成 ID 的请求均衡分发给多个服务实例,确保负载均衡和高可用。

优缺点分析

优点

  1. 高性能:生成 ID 的过程只需简单的位操作,每毫秒可生成 4096 个 ID,性能非常高。
  2. 全局唯一:雪花算法能够在分布式环境中生成全局唯一的 ID,避免冲突。
  3. 有序性:生成的 ID 包含时间戳信息,因此具有有序性,在某些场景下可以按时间顺序排列。
  4. 无中心化:不需要依赖数据库或其他外部服务,完全在本地生成 ID。

缺点

  1. 时间依赖性:如果服务器的时间被调整回退,可能会导致生成重复 ID,因此时间必须被严格控制。
  2. 数据中心和机器 ID 的限制:雪花算法中数据中心 ID 和机器 ID 各占 5 位,最大支持 32 个数据中心和 32 台机器。如果分布式系统规模更大,可能需要调整位数。

使用Zookeeper管理WORKER_ID和DATACENTER_ID

ZooKeeper 是一个分布式协调服务,适合用来在多个节点之间动态分配 ID。可以为每个节点创建一个唯一的 ZNode 来分配和存储它的 WORKER_ID 和 DATACENTER_ID。当某个应用启动时,可以向 ZooKeeper 注册并获取唯一的 ID。

ZooKeeper 节点设计

  • root/worker-nodes:根路径用于存储所有工作节点。
    • worker-node-1:节点 1 的唯一标识符,存储 WORKER_ID 和 DATACENTER_ID。
    • worker-node-2:节点 2 的唯一标识符。

每个节点启动时会在 worker-nodes 下创建一个临时节点(Ephemeral ZNode),这样当节点宕机时 ZooKeeper 会自动删除它,保证 ID 唯一性和动态性 。

创建 ZooKeeper 客户端

我们需要在 Java 应用中通过 ZooKeeper 获取 WORKER_ID 和 DATACENTER_ID。可以使用 Apache Curator 框架,它提供了一个简化的 ZooKeeper 客户端 API。

  1. 添加依赖:

    <dependency>
        <groupId>org.apache.curator</groupId>
        <artifactId>curator-recipes</artifactId>
        <version>5.2.0</version>
    </dependency>
  2. 示例:通过 ZooKeeper 动态分配 ID

    import org.apache.curator.framework.CuratorFramework;
    import org.apache.curator.framework.CuratorFrameworkFactory;
    import org.apache.curator.retry.ExponentialBackoffRetry;
    import org.apache.curator.framework.recipes.nodes.PersistentNode;
    import org.apache.zookeeper.CreateMode;
    
    public class ZookeeperWorkerIdProvider {
        private static final String ZK_ADDRESS = "zookeeper:2181"; // ZooKeeper 的地址
        private static final String WORKER_NODE_PATH = "/worker-nodes"; // 节点路径
    
        private CuratorFramework client;
    
        public ZookeeperWorkerIdProvider() {
            client = CuratorFrameworkFactory.newClient(ZK_ADDRESS, new ExponentialBackoffRetry(1000, 3));
            client.start();
        }
    
        public long[] registerWorker() throws Exception {
            // 确保根路径存在
            if (client.checkExists().forPath(WORKER_NODE_PATH) == null) {
                client.create().forPath(WORKER_NODE_PATH);
            }
    
            // 创建一个临时顺序节点,为每个启动的工作节点分配唯一的 WORKER_ID 和 DATACENTER_ID
            String path = client.create()
                    .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
                    .forPath(WORKER_NODE_PATH + "/worker-node-", new byte[0]);
    
            // 从创建的路径获取 WORKER_ID 和 DATACENTER_ID
            String[] splitPath = path.split("-");
            long workerId = Long.parseLong(splitPath[splitPath.length - 1]) % 32; // 取最后的数字作为 WORKER_ID,32 是最大节点数
            long datacenterId = (Long.parseLong(splitPath[splitPath.length - 1]) / 32) % 32; // 计算 DATACENTER_ID
    
            return new long[] { workerId, datacenterId };
        }
    
        public void close() {
            if (client != null) {
                client.close();
            }
        }
    }

    此代码会通过 ZooKeeper 分配 WORKER_ID 和 DATACENTER_ID,并确保在应用程序重新启动或 ZooKeeper 节点发生故障时可以自动恢复 ID 分配。

  3. 在启动雪花算法应用时调用该类:

    public class SnowflakeApplication {
        public static void main(String[] args) throws Exception {
            ZookeeperWorkerIdProvider zkProvider = new ZookeeperWorkerIdProvider();
            long[] ids = zkProvider.registerWorker();
            long workerId = ids[0];
            long datacenterId = ids[1];
    
            SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(workerId, datacenterId);
            // 启动应用并使用 idGenerator 来生成 UUID
        }
    }

怎么解决雪花算法时间依赖性问题

雪花算法的核心问题之一是它对系统时钟的依赖,特别是在时钟回拨(Clock Drift)或者系统时间不准确的情况下,会导致生成重复的 ID 或系统异常。为了应对雪花算法的时间依赖性问题,常见的解决方法包括以下几种:

  1. 时钟回拨检测与等待(Clock Backward Detection & Waiting)

    问题描述

    当系统时钟回拨时,生成的时间戳会比之前的时间戳小,这样可能导致 ID 冲突,因为雪花算法依赖递增的时间戳来确保唯一性。

    解决方案

    • 在检测到时钟回拨时,等待系统时间赶上上次生成 ID 的时间戳,直到时钟恢复正常为止。
    • 这种方法通过强制等待,避免在时钟回拨期间生成重复的 ID。

    代码实现示例:

    public synchronized long nextId() {
        long timestamp = timeGen();
    
        if (timestamp < lastTimestamp) {
            long offset = lastTimestamp - timestamp;
            if (offset <= 5) {
                // 如果时钟回拨幅度小于 5 毫秒,等待时钟追赶
                try {
                    Thread.sleep(offset);
                    timestamp = timeGen();
                    if (timestamp < lastTimestamp) {
                        throw new RuntimeException("Clock moved backwards, refusing to generate id");
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            } else {
                throw new RuntimeException("Clock moved backwards, refusing to generate id for " + offset + " milliseconds");
            }
        }
    
        // 其他逻辑与正常的 Snowflake 算法相同
        return generateId(timestamp);
    }
    • 优势:简单有效,避免在回拨期间生成重复的 ID。
    • 劣势:在时钟回拨时需要等待,系统在此期间无法生成 ID,影响高可用性。
  2. 借助外部时间源(External Time Source)

    问题描述

    本地系统时间可能因为配置问题、系统负载或电池损耗而出现时间不准确,导致雪花算法依赖的时间戳错误。

    解决方案

    • 使用高精度的外部时间源来替代本地系统时钟,例如 NTP(Network Time Protocol)或 GPS。
    • 定期校正系统时钟,确保本地时间与标准时间保持一致,降低时钟回拨的几率。

    通过集成 NTP 协议,确保时钟不会严重漂移。例如:

    # 安装并启用 ntpd
    sudo apt-get install ntp
    sudo service ntp start
    • 优势:系统时间保持同步,极大减少时钟漂移问题。
    • 劣势:增加了对外部网络和时间源的依赖,且实时更新可能增加额外的系统开销。
  3. 使用逻辑时钟(Logical Clock / Hybrid Clock)

    问题描述

    完全依赖物理时钟时,容易受到系统时钟变化的影响。

    解决方案

    • 引入逻辑时钟,基于本地生成 ID 的速率来模拟递增的时间戳,确保系统生成的 ID 是有序的。
    • 当检测到系统时钟回拨时,逻辑时钟会继续递增,避免 ID 重复或错误。

    一种常见的实现方式是使用混合时钟(Hybrid Logical Clock),它结合了物理时钟和逻辑时钟,确保在物理时钟漂移时逻辑时钟能维持递增。

    private long logicalClock = 0;
    
    private synchronized long nextId() {
        long timestamp = timeGen();
    
        if (timestamp < lastTimestamp) {
            // 如果检测到时钟回拨,使用逻辑时钟
            logicalClock++;
            timestamp = lastTimestamp; // 使用上次的时间戳
        } else {
            logicalClock = 0; // 重置逻辑时钟
        }
    
        return generateId(timestamp);
    }
    • 优势:降低对物理时钟的依赖,在时钟回拨时仍能维持递增顺序。
    • 劣势:引入逻辑时钟的复杂性,可能在高并发场景下出现瓶颈。
  4. 调整生成 ID 的粒度

    问题描述

    雪花算法在同一毫秒内生成 ID 时,依赖时间戳 + 序列号(sequence)来确保唯一性。但当时间出现回拨问题时,生成 ID 会发生冲突。

    解决方案

    • 可以调整 ID 生成的粒度,比如使用纳秒级时间戳,而不是毫秒级时间戳。这样可以进一步减少冲突的可能性,尤其是在系统负载较高的情况下。
    • 此外,可以增加 sequence 位数以支持在同一毫秒内生成更多的 ID,减少时钟依赖带来的问题。
    private long timeGen() {
        return System.nanoTime() / 1000; // 转换为微秒级时间戳
    }
    • 优势:提高生成 ID 的精度,减少在并发生成时冲突的概率。
    • 劣势:时间粒度越高,系统时钟回拨造成的影响仍然存在,只是减少了冲突。
  5. 分布式锁方案

    问题描述

    如果多台服务器在时钟回拨的情况下生成 ID,可能导致不同服务器生成的 ID 冲突。

    解决方案

    • 通过引入分布式锁(如 Redis 锁、ZooKeeper 锁)来协调多个节点之间的 ID 生成顺序,避免 ID 冲突。
    • 每次生成 ID 时,通过锁机制确保只有一个节点在特定时间段内生成 ID,其他节点需要等待。

    虽然这种方法能够解决时钟回拨引发的冲突问题,但牺牲了一定的性能和效率,尤其是在高并发场景下,锁机制可能成为瓶颈。

    // 使用 Redis 分布式锁来同步多个节点的 ID 生成
    public synchronized long nextIdWithLock() {
        String lockKey = "snowflake-lock";
        boolean acquired = redisLock.tryLock(lockKey, 10); // 尝试获取锁
    
        if (!acquired) {
            throw new RuntimeException("Failed to acquire lock");
        }
    
        try {
            // ID 生成逻辑
            return nextId();
        } finally {
            redisLock.release(lockKey);
        }
    }
    • 优势:确保分布式环境下 ID 唯一性,防止多节点产生冲突。
    • 劣势:引入了分布式锁,会导致系统性能下降,尤其是在高并发场景下。
  6. 避免依赖时间戳的唯一性

    问题描述

    雪花算法依赖时间戳的唯一性,当时间戳出现回拨时,系统会出错。

    解决方案

    • 如果场景允许,可以将 ID 的唯一性设计为基于其他因子,如增加随机性或引入更多节点信息(如 IP 地址、进程 ID 等)。
    • 虽然这样会牺牲一定的有序性,但可以减少对时间戳的依赖,特别是对于不需要严格顺序的业务场景。

Zookeeper分布式锁

ZooKeeper 分布式锁是一种基于 ZooKeeper 的原子操作和一致性保障机制实现的分布式协调工具。它通过 ZooKeeper 的 临时节点 和 顺序节点 特性,实现多个客户端在分布式环境中的互斥访问,从而避免竞争条件和并发冲突。

ZooKeeper 分布式锁的实现原理

ZooKeeper 的分布式锁利用其以下几个特性:

  • 临时节点(Ephemeral Node):在客户端断开连接或失效时,临时节点会自动删除。通过这个特性,ZooKeeper 可以检测到某个客户端失效并自动释放锁。
  • 顺序节点(Sequential Node):每当一个节点创建时,它会在节点名称后附加一个单调递增的序号。ZooKeeper 利用这个特性来保证顺序性。

ZooKeeper 分布式锁的基本步骤

  1. 每个客户端尝试获取锁时,都会在 ZooKeeper 的某个锁目录(如 /locks)下创建一个临时顺序节点。
    • 例如,客户端 A 创建 /locks/lock-0001,客户端 B 创建 /locks/lock-0002。
  2. 判断当前客户端创建的节点是否是最小的节点。
    • 如果当前客户端创建的节点是最小的节点,则获得锁。
    • 如果不是最小节点,则监听比它小的那个节点(例如,客户端 B 创建 /locks/lock-0002 时,会监听 /locks/lock-0001)。
  3. 等待锁:
    • 当监听的节点(即比它小的那个节点)被删除时,说明持有锁的客户端释放了锁,此时当前客户端会被通知并尝试获取锁。
  4. 释放锁:
    • 客户端释放锁时,删除自己创建的临时顺序节点。
    • 删除节点后,ZooKeeper 会通知正在监听这个节点的下一个客户端,从而触发它去获得锁。

ZooKeeper 分布式锁的流程图示例

1. /locks
   ├── lock-0001   (Client A 创建)
   ├── lock-0002   (Client B 创建)
   └── lock-0003   (Client C 创建)

2. 竞争锁:
   - Client A 拥有最小的顺序号 lock-0001,所以获得锁。
   - Client B 监听 lock-0001,Client C 监听 lock-0002。

3. Client A 释放锁:
   - Client B 收到 lock-0001 被删除的通知,获得锁。
   - Client C 开始监听 lock-0002。

ZooKeeper 分布式锁的具体实现步骤

  1. 创建锁节点: 每个客户端尝试加锁时,会在 ZooKeeper 上的 /locks 目录下创建一个带序号的临时节点,例如 lock-00000001
  2. 判断最小节点: 客户端读取 /locks 目录下所有节点,判断自己创建的节点是不是最小的。
    • 如果是最小的,说明它获得了锁。
    • 如果不是最小的,监听比它小的那个节点。
  3. 监听节点: 如果当前客户端没有获得锁,它会监听比它小的那个节点。当比它小的节点被删除时,它会被通知去检查自己是否是最小节点,从而尝试获取锁。
  4. 删除节点释放锁: 持有锁的客户端在完成任务后,删除自己创建的临时节点,释放锁。
  5. 其他等待的客户端尝试获取锁: ZooKeeper 会通知下一个顺序节点,让它去尝试获取锁。

Java 示例代码

  1. 依赖引入(Maven)

    <dependency>
      <groupId>org.apache.curator</groupId>
      <artifactId>curator-recipes</artifactId>
      <version>5.2.0</version>
    </dependency>
    <dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.2.0</version>
    </dependency>
    <dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-client</artifactId>
    <version>5.2.0</version>
    </dependency>
  2. ZooKeeper 分布式锁实现

    import org.apache.curator.framework.CuratorFramework;
    import org.apache.curator.framework.CuratorFrameworkFactory;
    import org.apache.curator.framework.recipes.locks.InterProcessMutex;
    import org.apache.curator.retry.ExponentialBackoffRetry;
    
    public class DistributedLockExample {
    
        private static final String ZK_ADDRESS = "localhost:2181";
        private static final String LOCK_PATH = "/distributed-lock";
    
        public static void main(String[] args) {
            // 创建 ZooKeeper 客户端
            CuratorFramework client = CuratorFrameworkFactory.newClient(ZK_ADDRESS, new ExponentialBackoffRetry(1000, 3));
            client.start();
    
            // 创建分布式锁
            InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
    
            try {
                // 尝试获取锁
                if (lock.acquire(5, TimeUnit.SECONDS)) {
                    System.out.println("Lock acquired!");
                    try {
                        // 执行业务逻辑
                        performBusinessLogic();
                    } finally {
                        // 释放锁
                        lock.release();
                        System.out.println("Lock released!");
                    }
                } else {
                    System.out.println("Failed to acquire lock");
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                client.close();
            }
        }
    
        private static void performBusinessLogic() {
            System.out.println("Performing business logic...");
            // 模拟业务操作的时间消耗
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    关键步骤说明

    • Curator 框架:使用 Curator 是因为它封装了 ZooKeeper 的复杂 API,并提供了更简单的分布式锁实现(如 InterProcessMutex 类)。
    • InterProcessMutex:这是 Curator 提供的分布式可重入锁实现,内部使用 ZooKeeper 创建临时顺序节点来协调多个客户端的锁竞争。
    • 锁的获取和释放:客户端通过 lock.acquire() 获取锁,并在操作完成后通过 lock.release() 释放锁

ZooKeeper 分布式锁的优缺点

优点

  1. 高可靠性:ZooKeeper 提供了强一致性保证,确保分布式锁的正确性和可靠性。
  2. 自动故障处理:通过临时节点机制,ZooKeeper 能自动检测到客户端失效,并自动释放锁。
  3. 支持可重入锁:Curator 实现提供了可重入锁的支持,多个线程可以递归获取同一个锁。

缺点

  1. 性能开销:ZooKeeper 的强一致性特性会带来一定的性能开销,尤其是在高并发下,创建和删除节点的操作可能成为瓶颈。
  2. 单点故障问题:虽然 ZooKeeper 本身是一个分布式系统,但在集群不可用时,所有锁的获取和释放都将失败。
  3. 锁的粒度和吞吐量:ZooKeeper 的锁机制适用于粗粒度的分布式锁,不太适合细粒度、高吞吐量的场景。

应用场景

  • 分布式任务调度:在集群中确保只有一个节点执行某些任务。
  • 共享资源访问控制:防止多个节点同时修改共享资源,确保数据的一致性。
  • 分布式事务:在分布式系统中锁住某些资源,确保事务的原子性。

About

雪花算法ID生成集群

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published