分布式ID生成方案对比:雪花/Leaf/UUID
发布日期: 2026/08/13 阅读总量: 1

一次让我通宵的主键冲突

2023年双11当晚,订单中心做分库分表切换。凌晨1点,告警群里炸了:订单表主键冲突,重复ID!

排查结果:订单表从单库单表拆成32个分表后,还是用MySQL自增ID——好消息是每张表各自自增,坏消息是两张不同的表能自增出同一个ID。当天订单表没有全局唯一主键,某些业务join直接查错数据。

从那天起,我开始系统梳理分布式ID方案。先后试过UUID、数据库自增、雪花算法(Snowflake)、美团Leaf号段、Redis INCR。本文把这五套方案全部跑了一遍压测,附完整代码和你能直接抄的结论。

方案TPSP99耗时(ms)单次ID内存开销(字节)时钟敏感实现成本
UUID(v4)约4.7万0.4536字符极低
数据库自增约1.2万3.808字节
雪花算法约12.8万0.128字节
Leaf号段约2.8万1.208字节
Redis INCR约6.3万0.388字节

压测环境:PHP 8.3,8核16G,MySQL 8.0.35,Redis 7.2.1,单机单实例。

五种方案横测对比

1. UUID —— 最省事,但真的别拿来当主键

UUID v4随机生成128位,用掉字符串36字符。优点:本地生成、无网络IO、无中心节点。缺点:无序、太长,当InnoDB主键时,B+树频繁页分裂,写入性能断崖下跌。

我压了一组数据:插入1000万行记录,UUID主键表碎片率17.8%,雪花ID主键表碎片率2.3%。读性能差异不大,写性能差了约35%。

2. 数据库自增 —— 单库单表还行,分库分表直接废

结构简单,ID全局有序。但分表后,必须靠设置不同初始值和步长来避免重复。比如MySQL 8.0.35,32张表,可以设step=32,第n张表初始为n。这方案维护成本高:扩表要改步长,中间有误操作就撞车。

3. 雪花算法 —— 靠谱,但坑也多

64位长整型:1位符号位 + 41位毫秒时间戳 + 10位机器ID + 12位毫秒内序列。

网上开源的雪花PHP改造版很多,但90%没处理时钟回拨。线上NTP同步把时钟往回拨了50ms,秒级产生重复ID。我在下方代码里给了三种回拨处理策略,你自己选。

4. 美团Leaf号段 —— 性能与成本平衡,生产可用

Leaf segment模式核心思想:预先从DB批量拿号段缓存到内存,用完再取。比如每次取1000个ID,只查一次DB的 UPDATE 语句,更新max_id。

我按美团方案搭了个最小实现,DB只存一行记录,TPS稳定在2.8万左右,压测时DB CPU占用率不到15%。

5. Redis INCR —— 快,但别忽略持久化

Redis单线程执行INCR,天然原子,开箱即用。但Redis没开AOF时,宕机会丢ID;开了AOF,每写一条都刷盘,性能掉一半。折中方案:AOF everysec + 双写补偿。

注意:Redis INCR生成的ID只有唯一性,没有趋势递增特性,业务如果依赖ID范围查询,要小心。我在压测中还发现,Redis批量取号比单次INCR快4倍——用Lua脚本一次取1000个号。

完整代码实现

3.1 雪花算法PHP实现(兼容PHP 8.3)


<?php
/**
 * PHP 8.3 雪花算法实现
 * 支持自定义机器ID、数据中心ID,处理时钟回拨
 * 注意:PHP 的 int 类型在 64 位系统下为 64 位有符号整数
 */
class SnowflakeId
{
    private const EPOCH_OFFSET = 1609459200000; // 2021-01-01 00:00:00 毫秒

    private int $datacenterId;
    private int $machineId;
    private int $sequence;
    private int $lastTimestamp;

    public function __construct(int $datacenterId, int $machineId)
    {
        if ($datacenterId < 0 || $datacenterId > 31 ||
            $machineId < 0 || $machineId > 31) {
            throw new InvalidArgumentException('datacenter/machine 必须在 0~31 之间');
        }
        $this->datacenterId = $datacenterId;
        $this->machineId = $machineId;
        $this->sequence = 0;
        $this->lastTimestamp = 0;
    }

    public function nextId(): int
    {
        $ts = $this->timestampMs();
        // 时钟回拨处理策略:回拨小于5ms则短暂等待,大于5ms抛异常
        if ($ts < $this->lastTimestamp) {
            $offset = $this->lastTimestamp - $ts;
            if ($offset <= 5) {
                usleep(($offset + 1) * 1000);
                $ts = $this->timestampMs();
            } else {
                throw new RuntimeException('时钟回拨超过5ms,拒绝生成ID');
            }
        }

        if ($ts === $this->lastTimestamp) {
            $this->sequence = ($this->sequence + 1) & 4095; // 12位序列
            if ($this->sequence === 0) {
                // 同一毫秒内序列溢出,等待下一毫秒
                $ts = $this->waitNextMs($ts);
            }
        } else {
            $this->sequence = 0;
        }

        $this->lastTimestamp = $ts;

        return (($ts - self::EPOCH_OFFSET) << 22)
            | ($this->datacenterId << 17)
            | ($this->machineId << 12)
            | $this->sequence;
    }

    private function timestampMs(): int
    {
        return (int) round(microtime(true) * 1000);
    }

    private function waitNextMs(int $lastTs): int
    {
        $ts = $this->timestampMs();
        while ($ts <= $lastTs) {
            usleep(100); // 100微秒检测一次
            $ts = $this->timestampMs();
        }
        return $ts;
    }
}

// 使用示例:datacenterId=1, machineId=2
$snowflake = new SnowflakeId(1, 2);
echo $snowflake->nextId() . PHP_EOL;

3.2 Leaf号段模式实现

step1:建表SQL


-- MySQL 8.0.35,叶号段模式
CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(64) NOT NULL COMMENT '业务标识,比如order_id',
  `max_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '已分配的最大ID',
  `step` int(11) NOT NULL DEFAULT 1000 COMMENT '每次拉取号段步长',
  `description` varchar(256) DEFAULT NULL,
  `update_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 初始化一条业务记录
INSERT INTO leaf_alloc (biz_tag, max_id, step)
VALUES ('order_id', 0, 1000);

step2:号段拉取PHP代码


<?php
/**
 * Leaf号段模式:从DB批量取号段
 * PHP 8.3 + PDO MySQL
 */
class LeafSegment
{
    private PDO $pdo;
    private int $step;
    private array $segments = []; // 本地缓存的号段

    public function __construct(PDO $pdo, int $step = 1000)
    {
        $this->pdo = $pdo;
        $this->step = $step;
    }

    public function nextId(string $bizTag): int
    {
        // 如果本地号段还有剩余,直接取
        if (!empty($this->segments[$bizTag])) {
            return $this->getFromLocal($bizTag);
        }

        // 否则从DB拉取新号段
        $this->loadSegment($bizTag);
        return $this->getFromLocal($bizTag);
    }

    private function loadSegment(string $bizTag): void
    {
        $sql = <<<SQL
            UPDATE leaf_alloc
            SET max_id = max_id + step
            WHERE biz_tag = ?
            RETURNING max_id - step AS old_max, max_id AS new_max
        SQL;

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$bizTag]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$row) {
            throw new RuntimeException("biz_tag: {$bizTag} 不存在");
        }

        // 区间是 (old_max, new_max],左开右闭
        $this->segments[$bizTag] = [
            'start' => (int)$row['old_max'] + 1,
            'end' => (int)$row['new_max'],
            'cursor' => (int)$row['old_max'] + 1,
        ];
    }

    private function getFromLocal(string $bizTag): int
    {
        $seg = &$this->segments[$bizTag];
        if ($seg['cursor'] > $seg['end']) {
            throw new RuntimeException('号段已耗尽,理论不会发生');
        }
        $id = $seg['cursor'];
        $seg['cursor']++;
        if ($seg['cursor'] > $seg['end']) {
            unset($this->segments[$bizTag]);
        }
        return $id;
    }
}

// 使用
$pdo = new PDO('mysql:host=127.0.0.1;port=3306;dbname=test;charset=utf8mb4',
    'root', '123456', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$leaf = new LeafSegment($pdo, 2000);
echo $leaf->nextId('order_id') . PHP_EOL;

上面SQL用了MySQL 8.0.13+的RETURNING语法。如果是MySQL 8.0.12以下,改成:第一步查询、第二步更新,需要包一个事务。因为返回旧ID这一步,本质上就是 SELECT max_id FOR UPDATE 旧值,再UPDATE新值。有锁竞争,但这是号段模式的核心:保证号段不重复。

3.3 Redis INCR带Lua批量取号


<?php
/**
 * Redis INCR 批量取号 Lua 脚本
 * 一次取 batchSize 个ID,减少网络开销
 * 注意:Redis 7.2.1,AOF everysec
 */
class RedisIdGenerator
{
    private Redis $redis;
    private const BATCH_SIZE = 1000;
    private int $localCursor = 0;
    private int $localEnd = 0;
    private string $key;

    public function __construct(Redis $redis, string $key = 'global_id')
    {
        $this->redis = $redis;
        $this->key = $key;
    }

    public function nextId(): int
    {
        // 本地还有缓存就直接用
        if ($this->localCursor < $this->localEnd) {
            $id = $this->localCursor;
            $this->localCursor++;
            return $id;
        }

        // 用Lua脚本一次取BATCH_SIZE个,原子操作,避免竞态
        $lua = <<<LUA
            local current = redis.call('GET', KEYS[1]) or 0
            local newVal = current + ARGV[1]
            redis.call('SET', KEYS[1], newVal)
            return {current, newVal}
        LUA;

        $result = $this->redis->eval($lua, [$this->key, self::BATCH_SIZE], 1);
        $this->localCursor = (int)$result[0] + 1;
        $this->localEnd = (int)$result[1];

        $id = $this->localCursor;
        $this->localCursor++;
        return $id;
    }
}

// 使用
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$gen = new RedisIdGenerator($redis, 'order_global_id');
echo $gen->nextId() . PHP_EOL;

3.4 压测脚本(JMeter 5.6.3命令行)


# 使用JMeter 5.6.3命令行模式跑压测
# 线程数=200,循环=50000,总请求约1000万次

jmeter -n \
  -t /tmp/id_generator_test.jmx \
  -JTHREADS=200 \
  -JLOOPS=50000 \
  -l /tmp/result.jtl \
  -e -o /tmp/report_html

# 生成汇总报告
# 注:JMeter的javax脚本+各方案接口用了独立进程,避免内存互相干扰

3.5 各方案TPS与内存对比(1000万次调用)


{
  "env": {
    "php": "8.3.7",
    "mysql": "8.0.35",
    "redis": "7.2.1",
    "cpu": "8核16G",
    "jmeter": "5.6.3"
  },
  "results": [
    {
      "scheme": "uuid_v4",
      "tps": 47123,
      "p99_ms": 0.45,
      "peak_memory_mb": 128,
      "id_type": "string36"
    },
    {
      "scheme": "mysql_auto_incr",
      "tps": 12150,
      "p99_ms": 3.8,
      "peak_memory_mb": 96,
      "id_type": "bigint"
    },
    {
      "scheme": "snowflake",
      "tps": 128400,
      "p99_ms": 0.12,
      "peak_memory_mb": 72,
      "id_type": "bigint"
    },
    {
      "scheme": "leaf_segment",
      "tps": 28100,
      "p99_ms": 1.2,
      "peak_memory_mb": 88,
      "id_type": "bigint"
    },
    {
      "scheme": "redis_incr_batch",
      "tps": 63000,
      "p99_ms": 0.38,
      "peak_memory_mb": 102,
      "id_type": "bigint"
    }
  ]
}

上表看,雪花算法TPS最高,因为纯本地位运算,没有网络IO。Leaf号段虽然TPS最低,但胜在省资源,压测过程中DB和Redis的CPU占用率都很低。Redis批量INCR做了1000个一批的优化,比单条INCR快了4倍,但多一次Lua脚本执行和内存缓存维护。

避坑指南

坑1:雪花算法的时钟回拨比想象中频繁

线上NTP默认每1024秒同步一次,一旦宿主机时钟偏差超过阈值,回拨几毫秒是常事。我生产的处理方式是:回拨小于5ms等待,回拨大于5ms就拒绝服务并告警。千万别不做处理,否则会有极小概率生成重复ID。

坑2:Leaf号段DB UPDATE用RETURNING有版本陷阱

MySQL 8.0.13才支持RETURNING,很多人用8.0.12或5.7跑那段SQL直接语法报错。生产环境如果MySQL是5.7,老老实实SELECT ... FOR UPDATE + UPDATE,两条SQL包一个事务。

坑3:Redis INCR的持久化丢号

Redis默认RDB快照的策略是900秒一次,改appendonly yes + appendfsync everysec只能保证最多丢1秒的ID。如果业务要求ID完全不重复,Redis方案必须做双缓存:本地号段+Redis兜底,本地号段耗尽才取Redis,这样宕机恢复后本地号段可能重复——所以Redis方案天然不适合强一致场景。

坑4:UUID做分表键,别用雪花算法替代

如果分表路由键就是ID本身,那UUID的无序性在分表场景下会导致特定分表的热点问题。雪花ID的毫秒内序列是连续的,在分布式场景里天然均衡,但跨天跨毫秒又避免哈希倾斜。用UUID做分表键的兄弟,建议把ID和业务键分离,业务键用哈希。如果只能用UUID,至少把它转成binary(16),不要存36字符字符串。

坑5:序列号回绕

雪花算法中12位序列是4096个,1000万次压测没问题,但如果单机单毫秒并发超过4096,序列号会溢出。溢出处理不是重置回0,而是要自旋等待下一毫秒。很多网上的demo写成了重置sequence=0,这样会导致同一毫秒内重复ID,极其隐蔽。

总结

没有万能的分布式ID方案,选型看业务接口。

  • 如果只追求绝对简单、不要求趋势递增、并发生少量:UUID + 转binary存储
  • 如果在意性能、能接受时钟回拨的小概率风险:雪花算法 + 时钟回拨处理
  • 如果对ID连续性有要求、DB资源充足:Leaf号段
  • 如果已经在用Redis、能容忍最多1秒的ID空洞:Redis INCR批量模式

最推荐的组合:主键用雪花算法,业务ID用Leaf号段。我们订单系统目前就是这个方案,线上跑了14个月,累计生成12亿个ID,0冲突。雪花ID做InnoDB主键,页分裂率低于2%。

Snowflake代码里有三个隐蔽bug,修完以后才拿到上面的压测数据:

  • 位或优先级问题:PHP里|<<优先级低,返回值要加括号,不加就是大坑
  • 数据溢出:32位系统下必须强制转int64,否则生成到最大值直接变浮点数,精度丢失
  • 微小的时间戳回拨:如果你只判断ts < lastTimestamp,回拨1ms也会被忽略,正确做法是回拨小于5ms时直接wait

总结与选型建议

没有银弹,看业务场景:

  • 日志采集、消息队列Kafka/Kinesis生产:雪花算法,最高性能
  • 订单、支付等强一致场景:Leaf号段,避免时钟依赖
  • 纯本地单机应用:UUID合理利用,别过度设计
  • 已有Redis且能接受ID有空洞:Redis INCR批量模式

最后说一句:ID生成本质是「以空间换时间」或「以时间换空间」的博弈。不用迷信某一种方案,把回退方案、监控指标(生成耗时、时钟偏移、号段水位)一起设计进去,才是生产级做法。