做后端这些年,几乎每个项目都会碰到同一个问题:分布式环境下怎么生成全局唯一的ID。尤其是分库分表之后,数据库自增主键完全失效,各个分片可能生成相同的号码,而订单号、支付流水号、日志ID又必须全局唯一。这个问题的标准答案里总绕不开一个词:雪花算法,也叫Snowflake。PHP这边没有内置的官方实现,不少团队是网上抄一段经典代码改改就上线,结果遇到并发重复、时钟回拨、进程间撞号这些坑,才回头认真研究底层。
这篇文章从原理讲起,给出一段能直接运行的PHP实现,再补上工程化改造的完整思路,包括文件锁、Redis发号、机器ID管理、时钟回拨容错等。适合正在维护PHP业务、又不想为生成ID引入重型中间件的后端同学;看完之后,你可以直接把手写代码落到自己的项目里。
1. 为什么分布式环境需要雪花算法:ID生成方案的选型对比
1.1 单库自增ID的局限:分库分表后第一个翻车的方案
单库单表时代,AUTO_INCREMENT是最省事的ID方案,MySQL自己维护递增,业务层不用操心。但一旦拆了库、分了表,问题立刻出现:每个分片都有自己的自增序列,A库生成1、2、3,B库也生成1、2、3。拿订单表来说,两个订单ID撞了,后面做分布式事务、对账、日志追踪全都乱套。
有人会说,那就用一个独立的发号表,所有订单先到这个表拿ID。这种做法在并发量不大的时候确实能用,但本质是把ID生成变成了一个高频单点写入,数据库压力一大,这里就是瓶颈。再往后你还得处理这个发号表的备份、切换、扩容,复杂度和收益完全不成正比。
自增ID还有个隐性需求:它往往是趋势递增的,对数据库主键索引和缓存都很友好。如果一定要保留这个特性,又要在分布式环境下全局唯一,那答案基本就指向雪花算法这一类方案了。
1.2 UUID为什么不是正确答案:无序、太长、没法排序
UUID的方案几乎所有人都会想到,但真踩过坑的人都知道它有多痛。第一,UUID是字符串,16字节,存储和索引都比bigint大不少;第二,UUID完全无序,作为主键插入时,B+树索引可能频繁触发页分裂,写入性能明显下降;第三,在日志、接口、监控里出现一长串36位字符串,肉眼根本分不清哪个是哪个。
更麻烦的是,很多业务场景需要按“创建时间”语义来排序。UUID是无序的,你得额外加一个create_time字段来排序;雪花ID本身带了时间戳,单看ID就能判断先后,省了一个字段的索引开销。
1.3 Snowflake方案的核心竞争力:紧凑、递增、去中心化
雪花算法本质上是用一个64位的整数,把“时间+机器+序列号”三部分信息压缩进去。它有几个特点非常贴合后端业务:
- 64位整数,在64位PHP里可以直接用int存储,MySQL对应
BIGINT,存储紧凑,索引高效。 - 因为高41位是毫秒时间戳,生成的ID整体上是趋势递增的,排序、分页都舒服。
- 不同机器通过机器ID区分,节点之间无需互相通信,天然适合微服务和多实例部署。
- 每一毫秒内通过序列号支撑高并发,单节点一毫秒就能生成4096个ID。
| 方案 | 是否趋势递增 | 是否去中心化 | 存储类型 | 单节点并发能力 | 主要问题 |
|---|---|---|---|---|---|
| 自增ID | 是 | 否 | bigint | 受限于数据库 | 分库分表后无法全局唯一 |
| UUID | 否 | 是 | char(36) | 高 | 无序、存储大、索引碎片化 |
| Redis INCR | 是 | 否 | bigint | 高 | 引入中间件,有网络开销 |
| 雪花算法 | 是 | 是 | bigint | 高 | 需处理时钟回拨和机器ID分配 |
2. 雪花算法的核心原理:64位ID是怎么拼出来的
2.1 64位布局拆解:每一位都有明确任务
标准的雪花算法把一个64位ID切成四段:
- 第1位:符号位,固定为0,保证整个ID是正数。
- 第2到42位:41位毫秒时间戳,记录从某个自定义起始时间(EPOCH)到当前时刻经过的毫秒数。
- 第43到52位:10位机器标识。常见拆法是5位数据中心ID加5位机器ID,最多支持32个数据中心、32台机器。
- 第53到64位:12位序列号,同一台机器同一毫秒内可以生成0到4095共4096个ID。
这四段加在一起刚好64位,正好对应64位系统里的一个long/int。PHP在64位环境下,PHP_INT_MAX约等于9.2×10^18,只要第一位符号位是0,整个ID就不会溢出int范围。
2.2 时间戳为什么占41位:69年是怎么算出来的
41位二进制能表示的最大值是2^41-1,也就是2199023255551毫秒。换算成年:
2199023255551 / 1000 / 3600 / 24 / 365 ≈ 69.7年所以从自定义的EPOCH起算,这套算法可以用大约69年。EPOCH可以选一个离当前比较近的日期,比如2021年1月1日,这样等于把“允许使用窗口”往后平移了几十年。现在很多实现选2010年前后作为EPOCH,那意味着到2080年前后会溢出,看起来很远,但在金融、政务等中长期系统里,这个时间点并不算遥不可及。
2.3 序列号的作用:给定毫秒内的“排队号”
引入序列号是为了支撑单节点在同一毫秒内的高并发。我的理解是:时间戳只能精确到毫秒,同一毫秒内如果只靠时间戳区分ID,那生成的所有ID都长一个样。序列号给这毫秒内发了4096个“号码牌”,从0排到4095,满了就等下一毫秒。
举个拼接例子。假设当前时间戳与EPOCH的差值是1毫秒,数据中心ID是1,机器ID是1,序列号是1,那么把各部分移位后做按位或:
1 << 22 | 1 << 17 | 1 << 12 | 1 = 4194304 | 131072 | 4096 | 1 = 4329473这个ID正好把时间、机器、序列号的信息全部编码进去。
2.4 序列号溢出怎么办:等待下一毫秒
当同一毫秒内的第4096个请求到来,序列号从4095加1后会回到0,这时候不能再继续生成,否则就会和这一毫秒初始的ID重复。正确处理是阻塞等待,直到系统时间进入下一毫秒,再把序列号清零重新开始。
这部分逻辑量不大,但非常关键。经典实现里用了一个掩码技巧:sequence = (sequence + 1) & 4095。当sequence是4095时,加1后变成4096,按位与4095后刚好是0,一次判断就能知道这一毫秒的额度已经用完。用位运算代替取模,效率高,代码也简洁。
2.5 时钟回拨:所有实现都绕不开的命门
雪花算法里的时间戳依赖系统时钟。如果服务器的时钟因为NTP校时、人工调整等原因往前跳,那么新生成的ID时间戳会小于之前生成过的ID,整个ID序列出现回退。更危险的是,如果时钟回拨到上一毫秒,序列号可能从0重新开始,此时生成的新ID有可能和回拨前同一毫秒生成的ID完全重复。
时钟回拨不是理论问题,我在实际项目中确实遇到过。运维调整服务器时间、虚拟化平台时钟漂移,都会触发。所以任何严谨的雪花实现,都必须把回拨检测和处理方案写进去。关于具体怎么处理,后面工程化改造章节会展开。
3. PHP实现落地:从零手写一个可用的雪花算法类
3.1 环境检查:不是64位PHP就没法玩
PHP的int类型在不同平台位数不同。在32位PHP里,PHP_INT_SIZE是4,最大整数是2147483647,而雪花算法生成的ID动辄几百万亿,直接溢出。所以类创建时第一件事就是检查环境:
if (PHP_INT_SIZE < 8) { throw new RuntimeException('Snowflake requires 64-bit PHP, current PHP_INT_SIZE=' . PHP_INT_SIZE); }这条检查一定要放在构造函数最前面,否则在32位环境里代码会表现出各种诡异的错误。
3.2 完整实现:一个标准的PHP雪花算法类
下面这段实现参考了经典的设计,我把关键步骤都加了注释。它适合单进程常驻内存的环境,比如CLI脚本、Swoole常驻进程,或者请求量不大、每个PHP-FPM进程内独立调用也不会冲突的场景。
class Snowflake { private const EPOCH = 1609430400000; // 2021-01-01 00:00:00 UTC 毫秒 private const DATACENTER_BITS = 5; private const WORKER_BITS = 5; private const SEQUENCE_BITS = 12; // 序列号掩码:2^12 - 1 = 4095 private const SEQUENCE_MASK = 4095; // 时间戳左移位数:机器位 + 序列号位 = 5 + 12 = 17,实际各段位移见 nextId() private const TIMESTAMP_SHIFT = 22; private const DATACENTER_SHIFT = 17; private const WORKER_SHIFT = 12; private $datacenterId; private $workerId; private $sequence = 0; private $lastTimestamp = -1; public function __construct($datacenterId, $workerId) { if (PHP_INT_SIZE < 8) { throw new RuntimeException('Snowflake requires 64-bit PHP.'); } $maxDatacenter = (1 << self::DATACENTER_BITS) - 1; $maxWorker = (1 << self::WORKER_BITS) - 1; if ($datacenterId < 0 || $datacenterId > $maxDatacenter) { throw new InvalidArgumentException('datacenterId must be in [0, ' . $maxDatacenter . ']'); } if ($workerId < 0 || $workerId > $maxWorker) { throw new InvalidArgumentException('workerId must be in [0, ' . $maxWorker . ']'); } $this->datacenterId = $datacenterId; $this->workerId = $workerId; } public function nextId() { $timestamp = $this->currentTimestamp(); if ($timestamp < $this->lastTimestamp) { throw new RuntimeException('Clock moved backwards, reject request'); } if ($timestamp === $this->lastTimestamp) { $this->sequence = ($this->sequence + 1) & self::SEQUENCE_MASK; if ($this->sequence === 0) { $timestamp = $this->waitNextMillis($this->lastTimestamp); } } else { $this->sequence = 0; } $this->lastTimestamp = $timestamp; return (($timestamp - self::EPOCH) << self::TIMESTAMP_SHIFT) | ($this->datacenterId << self::DATACENTER_SHIFT) | ($this->workerId << self::WORKER_SHIFT) | $this->sequence; } public function parse($id) { $timestamp = ($id >> self::TIMESTAMP_SHIFT) + self::EPOCH; $datacenterId = ($id >> self::DATACENTER_SHIFT) & ((1 << self::DATACENTER_BITS) - 1); $workerId = ($id >> self::WORKER_SHIFT) & ((1 << self::WORKER_BITS) - 1); $sequence = $id & self::SEQUENCE_MASK; return [ 'timestamp' => $timestamp, 'datacenterId' => $datacenterId, 'workerId' => $workerId, 'sequence' => $sequence, ]; } private function currentTimestamp() { return (int) floor(microtime(true) * 1000); } private function waitNextMillis($lastTimestamp) { $timestamp = $this->currentTimestamp(); while ($timestamp <= $lastTimestamp) { usleep(100); $timestamp = $this->currentTimestamp(); } return $timestamp; } }3.3 位移和按位或怎么理解:用拼接思路拆解
核心拼接逻辑其实就是把三块数字放进同一个64位容器里。我习惯把它想成“三份快递放进一个箱子里”:时间戳放在最高的41位,机器ID放中间,序列号放最低的12位。<<是左移,相当于把数字推到对应区域;|是按位或,相当于把三个区域拼在一起。因为三个区域互不重叠,按位或的效果和加法是一样的。
以timestamp - EPOCH左移22位为例。22位正好是数据中心5位加机器5位加序列号12位的总长度。这样时间戳在最终ID的高位,低22位被空出来给机器和序列号。机器ID左移17位,是因为它左边要清出5位给数据中心,右边要留出12位给序列号。序列号不需要移动,直接放在最低12位。
3.4 解析ID:把生成的数字还原回时间、机器、序列号
排查问题的时候,光看一个数字看不出任何信息。我在类里加了一个parse()方法,可以把ID反解出来:
$sf = new Snowflake(1, 1); $id = $sf->nextId(); print_r($sf->parse($id)); // Array // ( // [timestamp] => 1728000000123 // [datacenterId] => 1 // [workerId] => 1 // [sequence] => 0 // )解析思路和拼接完全相反:右移去掉低位,再按位与去掉不需要的高位。比如解析机器ID,就把ID右移12位,这时序列号被丢掉,机器ID落在最右边,再和5位掩码做按位与,把高位时间戳清掉,剩下就是机器ID。这个操作在排查撞号问题时是救命级的工具,强烈建议保留。
3.5 单进程验证:先排除自身逻辑问题
写完类以后,先做一个批量生成测试:
$sf = new Snowflake(1, 1); $ids = []; for ($i = 0; $i < 100000; $i++) { $ids[] = $sf->nextId(); } echo count(array_unique($ids)) . '/' . count($ids), PHP_EOL; // 100000/100000如果数量不对,要么是序列号溢出处理有问题,要么是拼接逻辑有重叠。这步过了,再谈后续的工程化问题。
4. 工程化改造:多进程安全、机器号分配与时钟容错
4.1 PHP-FPM多进程下的重复危局:进程内状态不是全局状态
上面这个类在单个PHP进程里跑,唯一性没有问题。但线上PHP绝大多数跑在PHP-FPM下,而PHP-FPM是多进程模型,每个worker进程各自持有一份$lastTimestamp和$sequence。两个worker进程同时接收到请求,恰好处于同一毫秒,各自的$sequence都从0开始自增。由于数据中心ID和机器ID相同,生成出来的时间戳、机器ID、序列号全都一样,于是出现了重复ID。
这个坑非常隐蔽,因为本地单进程测试永远测不出来,到线上并发一上来就暴露。当时排查的时候,我把日志里撞号的ID拿去parse(),发现时间戳相同、机器ID相同、序列号也相同,才意识到问题出在进程间状态隔离上。
4.2 文件锁方案:不加中间件解决多进程冲突
最简单的改造方案是用文件锁。生成ID前先锁定一个共享文件,所有PHP进程通过flock排队进入临界区,在临界区内读取上一次的时间戳和序列号,计算新ID后再写回去。这样进程之间就有了共享状态,避免了重复。
class SnowflakeWithFileLock { private const EPOCH = 1609430400000; private const SEQUENCE_MASK = 4095; private const TIMESTAMP_SHIFT = 22; private const DATACENTER_SHIFT = 17; private const WORKER_SHIFT = 12; private $lockFile; private $datacenterId; private $workerId; public function __construct($datacenterId, $workerId, $lockFile = '/tmp/snowflake.lock') { $this->datacenterId = $datacenterId; $this->workerId = $workerId; $this->lockFile = $lockFile; } public function nextId() { $fp = fopen($this->lockFile, 'c+'); if ($fp === false) { throw new RuntimeException('Cannot open lock file: ' . $this->lockFile); } if (!flock($fp, LOCK_EX)) { fclose($fp); throw new RuntimeException('Cannot acquire file lock'); } // 读取上一次生成时保存的“时间戳:序列号” rewind($fp); $content = stream_get_contents($fp); $lastTs = 0; $lastSeq = 0; if ($content !== false && trim($content) !== '') { $parts = explode(':', $content); $lastTs = (int) $parts[0]; $lastSeq = (int) $parts[1]; } $timestamp = $this->currentTimestamp(); if ($timestamp < $lastTs) { flock($fp, LOCK_UN); fclose($fp); throw new RuntimeException('Clock moved backwards: ' . ($lastTs - $timestamp) . 'ms'); } if ($timestamp === $lastTs) { $sequence = ($lastSeq + 1) & self::SEQUENCE_MASK; if ($sequence === 0) { $timestamp = $this->waitNextMillis($lastTs); $sequence = 0; } } else { $sequence = 0; } // 写回,供下一个进程使用 ftruncate($fp, 0); rewind($fp); fwrite($fp, $timestamp . ':' . $sequence); fflush($fp); flock($fp, LOCK_UN); fclose($fp); return (($timestamp - self::EPOCH) << self::TIMESTAMP_SHIFT) | ($this->datacenterId << self::DATACENTER_SHIFT) | ($this->workerId << self::WORKER_SHIFT) | $sequence; } private function currentTimestamp() { return (int) floor(microtime(true) * 1000); } private function waitNextMillis($lastTimestamp) { $timestamp = $this->currentTimestamp(); while ($timestamp <= $lastTimestamp) { usleep(100); $timestamp = $this->currentTimestamp(); } return $timestamp; } }文件锁方案的优势是零依赖,适合请求量中等、并发峰值不高的项目。缺点也很明显:锁等待会拖慢生成速度,文件IO本身也有开销。实测下来,在并发不高的接口场景里完全够用;但如果单机接口QPS很高,就用Redis方案。
4.3 Redis方案:用原子自增替代进程内序列号
如果你已经用了Redis,一个很自然的想法是:把序列号放到Redis里做原子自增。同一个毫秒内多进程都往同一个key上INCR,Redis单命令原子执行,天然不会撞号。
具体做法是让key带上当前毫秒时间戳:
$timestamp = (int) floor(microtime(true) * 1000); $key = 'snowflake:' . $datacenterId . ':' . $workerId . ':' . $timestamp; $seq = $redis->incr($key); $redis->expire($key, 2); // 这个key只在当前毫秒有用,短过期时间清理 if ($seq > 4095) { // 当前毫秒额度用完,阻塞到下一毫秒再重试 usleep(1000); return $this->nextId(); } $id = (($timestamp - $epoch) << 22) | ($datacenterId << 17) | ($workerId << 12) | ($seq - 1); // INCR 从 1 开始,序列号从 0 开始这个方案有两个小代价:每次生成多了一次Redis网络往返;Redis如果不可用,ID生成也就不可用。所以生产环境一般有降级开关,Redis故障时退回文件锁或本地序列号。
4.4 机器ID的管理:配置还是注册中心
雪花算法给机器ID分配了10位空间,早期项目手动给每台机器编个号就可以。但云原生和容器化之后,实例动态扩缩容,手动编号容易撞,也容易漏。
常用的做法有三种:
- 环境变量配置:在
.env里写DATACENTER_ID=1、WORKER_ID=2。部署脚本负责保证每台机器不重复,简单,但要靠运维纪律。 - 启动时向Redis注册:
SET NX一个带过期时间的key,拿到一个没被占用的机器号。实例需要心跳续约,防止机器号被其他实例抢走。这种方式适合动态扩容,但实现复杂度上了一个台阶。 - 数据库注册表:启动时读取数据库里的机器表,找到一个空闲的机器号并占坑。适合有数据库但不想依赖Redis的场景。
我的建议是:实例数量不超过几十台时,用环境变量配置就足够了;实例频繁扩缩容,就要投Redis注册中心方案。
4.5 时钟回拨的三种应对策略
时钟回拨处理不能只有一种策略,要根据回拨幅度做分级。
| 回拨幅度 | 推荐策略 | 说明 |
|---|---|---|
| 小于50毫秒 | 等待追平 | 短暂阻塞,时钟很快回到原位置,不影响整体吞吐 |
| 大于50毫秒 | 快速失败 | 直接抛异常,让上层业务重试或降级,避免长时间阻塞 |
| 不可控环境 | 改用单调时钟 | 用hrtime(true)替代挂钟时间,彻底避开NTP回拨影响 |
这里说的单调时钟,指的是进程启动后持续递增、不受系统挂钟调整影响的时钟。hrtime(true)返回纳秒值,除以1000000就是毫秒。但要注意:单调时钟只保证“本进程内单调”,跨进程、跨机器没有统一的绝对时间语义,所以不能直接替代雪花算法里的标准时间戳,只能作为特定场景下的补充方案。
5. 常见问题与排查技巧实录
5.1 32位环境下为什么总是报错
在32位PHP里,PHP_INT_SIZE = 4,PHP_INT_MAX = 2147483647。雪花ID轻松超过这个值,会直接溢出成负数或浮点数,导致ID重复、解析错误。排查方法非常简单,执行php -r "echo PHP_INT_SIZE;",如果输出不是8,就不能跑标准雪花算法。这个检查必须在构造函数里做,而不是等到生成时再报错。
5.2 microtime(true)的毫秒计算会不会丢精度
有人担心microtime(true)返回浮点数,乘以1000再取整会丢精度。实际上当前时间戳大约是1.7×10^12毫秒,远小于双精度浮点数的2^53精度边界。所以乘以1000后取整,完全不会丢失精度。不过为了严谨,实现里仍然用了floor()先处理浮点误差,再转int。
5.3 解析出来的时间为什么对不上
出现这种情况,绝大多数是EPOCH不一致。生成端和解析端如果用的起始时间不同,同样一段ID位,还原出来的绝对时间就会差出一个固定偏移量。排查时先确认两边EPOCH完全一样,最好把EPOCH放进配置文件统一管理。
5.4 多进程并发重复ID的排查思路
如果撞号,先把重复的ID用parse()拆开看:
- 时间戳相同、机器ID相同、序列号不同,说明是同一进程内序列号冲突,检查位拼接是否有重叠。
- 时间戳相同、机器ID相同、序列号也相同,说明不同进程各自维护了状态,检查是否缺少文件锁或Redis同步。
- 时间戳不同但ID还重复,说明不同节点机器ID撞了,检查机器ID分配。
这样拆开来看,问题定位会非常快。
5.5 NTP校时导致时钟回拨怎么办
Linux服务器默认会用NTP自动校准时间,时间跳跃几毫秒到几百毫秒都可能。NTP配置里要开启fallback和step限制,尽量避免大步长跳变。同时代码里要有回拨检测,不能裸奔。如果真的回拨超过阈值,宁可让接口报错,也不要生成重复ID,因为重复ID造成的脏数据,代价远大于一次请求失败。
5.6 前端拿到ID变科学计数法:JS精度丢失
雪花ID最大接近2^63-1,但JavaScript的Number安全整数范围只有2^53-1。如果直接把64位整数塞进JSON,浏览器端会丢失精度,常见表现是详情页看到最后几位变成0。
解决方式是在输出层把ID转成字符串:
echo json_encode([ 'id' => (string) $id, ]);前后端传参都按字符串处理,才能保住64位精度。这个坑在报表、后台管理系统里很容易踩,因为前端同学拿到手根本不会想到是精度问题。
5.7 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 32位环境ID溢出 | PHP_INT_SIZE为4 | 升级64位,或代码入口强校验 |
| 多进程并发重复 | 进程内序列号状态未共享 | 文件锁或Redis原子自增 |
| 解析时间错乱 | EPOCH不一致 | 统一配置起点时间 |
| 系统时间回拨 | NTP校时或人工调整 | 分级容错:等待或快速失败 |
| 前端ID精度丢失 | JS Number安全范围限制 | JSON输出时转字符串 |
6. 变体设计与进一步思考
6.1 位数分配可以按业务调整:99%的情况不需要照抄
标准雪花算法给机器ID分了10位、序列号12位,但这个分配不是唯一的。如果你的节点数量很少,只有两台机器,而单机峰值并发很高,可以把机器ID位压缩到6位,序列号扩展到16位,单节点单毫秒就能生成65536个ID。反过来,如果机器数量多但并发低,可以压缩序列号位,扩大机器位。
位数调整的核心是搞清楚你的瓶颈在哪。序列号位不够就加序列号位,机器位不够就加机器位,但总位数不能超过63位有效位,否则时间戳就得缩短,可用年限会受影响。
6.2 多业务共用一个发号器:合理分片
如果多个业务线都要用雪花ID,最省事的办法是按数据中心ID或机器ID的某个区间隔离。比如业务A用机器ID 0到31,业务B用32到63,这样生成的ID天然就带业务标识,排查问题时一眼看到ID值就能判断是哪个业务线产生的。
6.3 什么时候可以不用雪花算法
如果业务集中在单机、并发不高,用数据库自增ID完全没问题;如果业务可以接受一个独立的发号服务,用Redis INCR加号段缓存也比雪花算法简单;如果不需要ID有业务语义、也不需要排序,UUID的混乱程度可能反而能帮你在某些场景下“隐藏”数据规模。雪花算法是分布式ID的标准解,但标准解不等于唯一解。
6.4 一点扩展思考:和号段模式互补
雪花算法依赖时钟,号段模式依赖数据库。在极端场景下,可以把两者结合起来:平时用雪花算法保证低延迟和高性能,当检测到时钟回拨超过阈值时,切到号段模式兜底。这个双模式设计实现起来并不复杂,但能显著提升系统的抗异常能力,适合对ID连续性要求极高的对账、批处理系统。
我个人在实际操作中踩过最大的坑,就是PHP-FPM多进程下的重复ID。即使代码是照着经典实现抄的,测试环境下怎么跑都只有一个进程,到了线上就频繁撞号。后来给不同实例分配独立机器ID,并把序列号落到共享存储之后,才彻底消停。最后再分享一个小技巧:日志里一定要带上生成ID用的机器ID或数据中心ID,否则一旦撞号,你根本分不清是哪个实例写的。雪花算法本身不难,难的是它在真实分布式环境里的每一个边界条件。