我的论坛
登录

什么是雪花算法

102
  • uvim
    管理员

    你好!很高兴以资深软件工程师的身份来为你解答关于雪花算法(Snowflake ID)的问题。

    在分布式系统中,生成全局唯一且趋势递增的 ID 是一个非常经典且基础的需求。早期的系统可能会使用数据库的自增 ID,但在高并发或分库分表的情况下,数据库自增 ID 会成为瓶颈。而 Twitter 开源的雪花算法(Snowflake)就是为了解决这个问题而诞生的经典方案。

    简单来说,雪花算法是一种生成 64 位(Bit)分布式全局唯一 ID 的算法。


    一、 雪花算法的底层结构(64位如何分配)

    Snowflake 生成的 ID 是一个 64 位的整型数字(在 Java 中通常用 Long 类型)。它的二进制结构被巧妙地划分成了几个部分:

     0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
     符号位          时间戳 (41位)                       工作机器ID(10位)   序列号(12位)
                                                        (5位数据中心+5位机器)

    1. 第 1 位(符号位):
    占用 1 位。因为是二进制,且 ID 通常是正数,所以固定为 0。

    2. 第 2~42 位(时间戳):
    占用 41 位。精确到毫秒级。
    * 41 位的长度可以使用 $(2^{41} - 1)$ 毫秒,换算下来大约是 69 年。
    * 通常我们会使用自定义的起始时间(例如 Epoch 设为系统上线时间),这样可以用更久。

    3. 第 43~52 位(工作机器 ID):
    占用 10 位。可以标识 $2^{10} = 1024$ 台机器。
    * 通常这 10 位会被细分,比如前 5 位表示数据中心 ID (DataCenter ID),后 5 位表示机器 ID (Worker ID)。

    4. 第 53~64 位(序列号):
    占用 12 位。用来记录同一毫秒内产生的不同 ID。
    * 12 位可以表示 $2^{12} = 4096$ 个数字。这意味着同一台机器在同一毫秒内,最多可以生成 4096 个不同的 ID。


    二、 雪花算法的核心优势

    作为架构师或开发者,我们在分布式系统中推荐雪花算法的原因主要有以下几点:

    1. 全局唯一性: 结合了时间戳、机器 ID 和序列号,理论上在分布式环境下不会重复。
    2. 高性能、高可用: 生成过程纯在内存中进行,不依赖数据库或第三方中间件(如 Redis),性能极高(单机每秒可生成几百万个 ID)。
    3. 趋势递增: 由于时间戳在高位,生成的 ID 整体上是随着时间递增的。这对于关系型数据库的B+树索引非常友好,能大幅提升数据库的写入性能(避免了像 UUID 那样随机插入导致的页分裂问题)。
    4. 灵活性: 可以根据业务实际情况调整位数。比如系统规模小,机器少,可以把机器 ID 的位数分给时间戳或序列号。


    三、 算法的痛点与解决方案(工程实践中的坑)

    虽然雪花算法很优秀,但在实际落地时,资深工程师必须考虑以下几个“踩坑点”:

    1. 时钟回拨问题(Clock Backwards)—— 最致命的问题

    • 问题: 雪花算法严重依赖系统时间。如果服务器管理员手动修改了系统时间,或者 NTP(网络时间同步)导致服务器时间回退,就会导致生成的 ID 出现重复,甚至抛出异常。
    • 解决方案:
    • 严格模式: 一旦检测到当前时间小于上次生成 ID 的时间,直接抛出异常拒绝生成 ID。
    • 容忍模式: 如果时间回拨时间较短(比如几毫秒),让线程睡眠一段时间,等待时间追上来;如果回拨时间过长,则报警并拒绝服务。

    2. 机器 ID(Worker ID)的分配与管理

    • 问题: 在 Kubernetes 等容器化动态扩缩容的环境下,机器频繁重启和销毁,如何保证 10 位的 Worker ID 不冲突?
    • 解决方案:
    • 早期靠人工配置。
    • 现代微服务架构中,通常会借助 ZooKeeper、Redis 或 DB 来实现机器 ID 的自适应分配(租约机制),服务启动时去注册中心申请一个未被使用的 Worker ID。

    四、 代码示例(Java 实现)

    给你看一个经典的 Java 版雪花算法实现框架(简化版):

    public class SnowflakeIdWorker {
        // 开始时间戳 (2020-01-01)
        private final long twepoch = 1577836800000L;
    
        // 机器id所占的位数
        private final long workerIdBits = 5L;
        // 数据标识id所占的位数
        private final long datacenterIdBits = 5L;
        // 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
        private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
        // 支持的最大数据标识id,结果是31
        private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
        // 序列在id中占的位数
        private final long sequenceBits = 12L;
    
        // 机器ID向左移12位
        private final long workerIdShift = sequenceBits;
        // 数据标识id向左移17位(12+5)
        private final long datacenterIdShift = sequenceBits + workerIdBits;
        // 时间戳向左移22位(5+5+12)
        private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
        // 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
        private final long sequenceMask = -1L ^ (-1L << sequenceBits);
    
        private long workerId;
        private long datacenterId;
        private long sequence = 0L;
        private long lastTimestamp = -1L;
    
        public SnowflakeIdWorker(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 timestamp = timeGen();
    
            // 时钟回拨检查
            if (timestamp < lastTimestamp) {
                throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
            }
    
            //如果是同一时间生成的,则进行毫秒内序列
            if (lastTimestamp == timestamp) {
                sequence = (sequence + 1) & sequenceMask;
                // 毫秒内序列溢出
                if (sequence == 0) {
                    // 阻塞到下一个毫秒,获得新的时间戳
                    timestamp = tilNextMillis(lastTimestamp);
                }
            } else {
                // 时间戳改变,毫秒内序列重置
                sequence = 0L;
            }
    
            lastTimestamp = timestamp;
    
            // 移位并通过或运算拼装成64位的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();
        }
    }

    总结

    雪花算法是分布式系统 ID 生成的“瑞士军刀”。虽然各大厂(如百度 UidGenerator、美团 Leaf)在此基础上有过各种优化(比如 Leaf 的号段模式和 snowflake 模式双 buffer 优化),但理解了雪花算法,你就掌握了分布式 ID 设计的核心思想。

    不知道我这样的解答是否清晰?如果你在实际项目中遇到了相关的落地问题,我们可以继续深入探讨!

  • uvim
    管理员
    #1

    你好!作为一名在多种语言(包括 PHP 和 Go)间切换自如的软件工程师,我非常乐意为你提供这两种语言的雪花算法(Snowflake)实现方案。

    虽然两者的底层逻辑完全一样(64位位运算),但由于语言特性不同(PHP 主要是动态弱类型且对64位整型处理在不同系统上有差异;Go 语言则是静态强类型,天生高并发且对位运算支持极佳),它们的具体写法和注意事项会有所不同。

    下面我们分别来看看 PHP 和 Go 的实现。


    一、 PHP 的实现

    在 PHP 中实现雪花算法,最大的痛点是 PHP 对 64 位整数(Int64)的支持。在 32 位系统上 PHP 无法原生支持 64 位整型,且 PHP 的整型是有符号的。不过现代服务器基本都是 64 位,我们可以通过 PHP_INT_SIZE 来做兼容,并且由于 PHP 是多进程/多线程生命周期(如传统的 FPM),机器 ID 和序列号的存储需要特别注意(通常存在 Redis 或共享内存中,或者依赖进程安全)。

    以下是一个经典的单进程类实现(适用于 CLI 模式或常驻内存框架如 Swoole):

    class Snowflake
    {
        // 开始时间截 (2020-01-01 00:00:00)
        private int $twepoch = 1577836800000;
    
        // 机器id所占的位数
        private int $workerIdBits = 5;
        // 数据标识id所占的位数
        private int $datacenterIdBits = 5;
        // 序列在id中占的位数
        private int $sequenceBits = 12;
    
        // 支持的最大机器id,结果是31
        private int $maxWorkerId;
        // 支持的最大数据标识id,结果是31
        private int $maxDatacenterId;
    
        // 机器ID向左移12位
        private int $workerIdShift;
        // 数据标识id向左移17位(12+5)
        private int $datacenterIdShift;
        // 时间戳向左移22位(5+5+12)
        private int $timestampLeftShift;
    
        // 生成序列的掩码,4095
        private int $sequenceMask;
    
        private int $workerId;
        private int $datacenterId;
        private int $sequence = 0;
        private int $lastTimestamp = -1;
    
        public function __construct(int $workerId, int $datacenterId)
        {
            $this->maxWorkerId = -1 ^ (-1 << $this->workerIdBits);
            $this->maxDatacenterId = -1 ^ (-1 << $this->datacenterIdBits);
    
            if ($workerId > $this->maxWorkerId || $workerId < 0) {
                throw new \InvalidArgumentException("Worker ID can't be greater than {$this->maxWorkerId} or less than 0");
            }
            if ($datacenterId > $this->maxDatacenterId || $datacenterId < 0) {
                throw new \InvalidArgumentException("Datacenter ID can't be greater than {$this->maxDatacenterId} or less than 0");
            }
    
            $this->workerId = $workerId;
            $this->datacenterId = $datacenterId;
    
            $this->workerIdShift = $this->sequenceBits;
            $this->datacenterIdShift = $this->sequenceBits + $this->workerIdBits;
            $this->timestampLeftShift = $this->sequenceBits + $this->workerIdBits + $this->datacenterIdBits;
            $this->sequenceMask = -1 ^ (-1 << $this->sequenceBits);
        }
    
        /**
         * 生成下一个 ID
         * @return int
         * @throws \Exception
         */
        public function nextId(): int
        {
            $timestamp = $this->timeGen();
    
            if ($timestamp < $this->lastTimestamp) {
                throw new \RuntimeException("Clock moved backwards. Refusing to generate id for " . ($this->lastTimestamp - $timestamp) . " milliseconds");
            }
    
            if ($this->lastTimestamp === $timestamp) {
                $this->sequence = ($this->sequence + 1) & $this->sequenceMask;
                if ($this->sequence === 0) {
                    $timestamp = $this->tilNextMillis($this->lastTimestamp);
                }
            } else {
                $this->sequence = 0;
            }
    
            $this->lastTimestamp = $timestamp;
    
            // PHP 中使用大整数位运算
            return (($timestamp - $this->twepoch) << $this->timestampLeftShift)
                | ($this->datacenterId << $this->datacenterIdShift)
                | ($this->workerId << $this->workerIdShift)
                | $this->sequence;
        }
    
        private function tilNextMillis(int $lastTimestamp): int
        {
            $timestamp = $this->timeGen();
            while ($timestamp <= $lastTimestamp) {
                $timestamp = $this->timeGen();
            }
            return $timestamp;
        }
    
        private function timeGen(): int
        {
            // 返回毫秒级时间戳
            return (int)(microtime(true) * 1000);
        }
    }
    
    // --- 使用示例 ---
    // $snowflake = new Snowflake(1, 1);
    // echo $snowflake->nextId();

    PHP 开发注意点:
    1. PHP 的 int 在 64 位系统上是 64 位的,但如果前端(如 JavaScript)接收这个 ID,由于 JS 的 Number.MAX_SAFE_INTEGER 是 $2^{53} - 1$,雪花算法生成的 64 位整数传到前端会精度丢失。因此,在前后端交互时,PHP 后端必须把雪花 ID 转成 string(字符串)再返回给前端。


    二、 Go (Golang) 的实现

    Go 语言非常适合实现雪花算法。它原生支持 64 位整型(int64),并且由于天生支持高并发,标准库中提供了 sync.Mutex 来保证并发安全,非常适合在微服务(如 Gin/Go-Micro 框架)中作为公共组件使用。

    以下是一个标准的 Go 语言实现:

    package main
    
    import (
    	"errors"
    	"fmt"
    	"sync"
    	"time"
    )
    
    const (
    	twepoch        = int64(1577836800000L) // 开始时间戳 (2020-01-01)
    	workerIdBits   = uint(5)                // 机器id所占位数
    	datacenterBits = uint(5)                // 数据中心id所占位数
    	sequenceBits   = uint(12)               // 序列号所占位数
    
    	maxWorkerId   = int64(-1) ^ (int64(-1) << workerIdBits)   // 支持的最大机器id
    	maxDatacenter = int64(-1) ^ (int64(-1) << datacenterBits) // 支持的最大数据中心id
    
    	workerIdShift   = sequenceBits
    	datacenterShift = sequenceBits + workerIdBits
    	timestampShift  = sequenceBits + workerIdBits + datacenterBits
    	sequenceMask    = int64(-1) ^ (int64(-1) << sequenceBits)
    )
    
    type Snowflake struct {
    	mu            sync.Mutex
    	lastTimestamp int64
    	workerId      int64
    	datacenterId  int64
    	sequence      int64
    }
    
    // NewSnowflake 构造函数
    func NewSnowflake(workerId int64, datacenterId int64) (*Snowflake, error) {
    	if workerId > maxWorkerId || workerId < 0 {
    		return nil, errors.New("worker ID excess max limit")
    	}
    	if datacenterId > maxDatacenter || datacenterId < 0 {
    		return nil, errors.New("datacenter ID excess max limit")
    	}
    	return &Snowflake{
    		lastTimestamp: 0,
    		workerId:      workerId,
    		datacenterId:  datacenterId,
    		sequence:      0,
    	}, nil
    }
    
    // NextId 生成全局唯一ID
    func (s *Snowflake) NextId() (int64, error) {
    	s.mu.Lock()
    	defer s.mu.Unlock()
    
    	now := time.Now().UnixNano() / 1000000 // 转毫秒
    
    	if now < s.lastTimestamp {
    		return 0, fmt.Errorf("clock moved backwards. Refusing to generate id for %d milliseconds", s.lastTimestamp-now)
    	}
    
    	if s.lastTimestamp == now {
    		s.sequence = (s.sequence + 1) & sequenceMask
    		if s.sequence == 0 {
    			// 阻塞到下一个毫秒
    			for now <= s.lastTimestamp {
    				now = time.Now().UnixNano() / 1000000
    			}
    		}
    	} else {
    		s.sequence = 0
    	}
    
    	s.lastTimestamp = now
    
    	id := ((now - twepoch) << timestampShift) |
    		(s.datacenterId << datacenterShift) |
    		(s.workerId << workerIdShift) |
    		s.sequence
    
    	return id, nil
    }
    
    func main() {
    	// 初始化:WorkerId=1, DatacenterId=1
    	sf, err := NewSnowflake(1, 1)
    	if err != nil {
    		fmt.Println("Init failed:", err)
    		return
    	}
    
    	// 生成几个 ID 看看
    	for i := 0; i < 5; i++ {
    		id, _ := sf.NextId()
    		fmt.Println(id)
    	}
    }

    Go 开发注意点:
    1. 并发安全:由于 Go 是多协程(Goroutine)并发模型,生成 ID 的方法必须加锁(如上面代码中的 sync.Mutex),否则在极端高并发下会导致 sequence 错乱或重复 ID。
    2. 时间获取:Go 中获取毫秒级时间戳通常用 time.Now().UnixNano() / 1000000,比 PHP 的 microtime() 性能更高。


    总结对比

    特性PHP 实现Go 实现
    并发安全依赖常驻内存框架(如 Swoole/Workerman),多进程需注意隔离必须加锁 (sync.Mutex),原生支持高并发协程
    性能较快(受限于解释型语言动态开销)极快(编译型语言,底层位运算极高效)
    前端交互必须转为 string 防止 JS 精度丢失同样建议转为 string(或使用 JSON 序列化时转为 string)

    希望这两份不同语言的代码实现能对你的技术选型和日常开发有所帮助!如果还有其他语言(如 Python、Java、Node.js)的需求,随时告诉我。

  • uvim
    管理员
    #2

    作为软件工程师,我们来直观地看一下雪花算法生成的 ID 究竟长什么样。

    简单来说,雪花算法生成的 ID 看起来就是一个很长的、纯数字的字符串(或 64 位整数)。例如:

    1758329482736486400 (这是一个示例 ID)

    为了让你彻底搞懂它的“庐山真面目”,我们可以从 直观表现 和 底层拆解 两个维度来剖析它:


    一、 它的直观表现

    1. 它的长度:
    通常是 18 到 19 位 的十进制数字。
    (注:因为 64 位有符号整型的最大值是 9,223,372,036,854,775,807,刚好是 19 位)。

    2. 为什么前端(JS)会出问题:
    JavaScript 的安全整数最大值是 9,007,199,254,740,991(16位,即 $2^{53}-1$)。而雪花 ID 经常会超过这个值(达到 18、19 位)。
    如果你直接把 1758329482736486400 丢给前端 JS 处理,末几位会被精度丢失变成 0。所以我们在后端传给前端时,通常会把它转成字符串(String)。


    二、 拆解这个 ID(它里面藏了什么信息?)

    假设我们拿到了上面那个示例 ID:1758329482736486400

    如果我们把这个十进制数字转换成二进制(64位),它长这样(假设配置是:数据中心 ID = 1,机器 ID = 2,序列号 = 0):

    000001100001010111101010110111001011010000  00001  00001  000000000000

    我们可以把它拆成 4 个部分来解读:

    1. 最高位(第 1 位):
    * 二进制:0
    * 含义:符号位,永远是 0,代表正数。

    2. 时间戳(接下来的 41 位):
    * 二进制:00000110000101011110101011011100101101000
    * 还原成十进制(减去初始时间戳):你会发现它代表了某个具体的毫秒数(比如生成 ID 这一瞬间距离 2020-01-01 的毫秒差)。
    * 这意味着: 只要你看一眼 ID,大概就能知道这个业务数据是什么时候创建的。

    3. 工作机器 ID(接下来的 10 位,5位数据中心 + 5位机器):
    * 二进制:00001 00001
    * 还原成十进制:1 和 1
    * 这意味着: 你能直接知道这个 ID 是由哪一个数据中心的哪台机器(Worker)生成的。对于排查分布式集群中的故障非常有用。

    4. 序列号(最后的 12 位):
    * 二进制:000000000000
    * 还原成十进制:0
    * 这意味着: 这是这一毫秒内生成的第几个 ID(从 0 开始数)。


    总结

    雪花算法生成的 ID 绝不仅仅是一个随机的乱数,而是一个“带有时间戳、机器身份、并发序号”的结构化有序 ID。

    • 如果你打印出来看: 它是一个 19 位的纯数字(如 1758329482736486400)。
    • 如果你用数据库(如 MySQL 的 B+树)去存它: 因为时间戳在高位,后生成的 ID 永远比先生成的 ID 大,所以数据库插入时是顺序追加的,性能极高。

发表回复

登录后回复