分布式ID生成方案选型:从雪花到Leaf的实战对比
发布日期: 2026/08/22 阅读总量: 0

事故:UUID主键撑爆了订单表索引

2024年3月,我们线上订单表突破3000万行。某个大促日的深夜,DBA报警:订单查询接口P99耗时飙到2.1秒。

排查后发现根因:订单表主键用的是UUID。随机字符串导致索引树频繁分裂,叶子节点碎片化严重,缓存命中率不到40%。一个简单的WHERE order_id = ?等值查询,要扫8个数据页。

这不是个例。很多团队在选择分布式ID方案时,只盯着「会不会重复」,忽略了更致命的问题——生成的ID对MySQL索引和分库分表是否友好

这篇文章用我们的实战经历,对比三种主流方案:UUID雪花算法(Snowflake)美团Leaf号段模式。给出可复制的代码、真实的压测数据,以及那些没人告诉你的坑。

为什么UUID不适合做数据库主键

先把话放前面:UUID 适合做业务标识,不适合做数据库主键

MySQL InnoDB 的聚簇索引按主键顺序物理存储。UUID是36字节的随机字符串,插入时索引页随机分裂,产生大量碎片。具体表现:

  • 索引体积膨胀:B+树节点存储效率降低50%以上
  • 写入性能劣化:页分裂带来额外IO,TPS随数据量断崖下跌
  • 缓存命中率低:相邻数据不连续,LRU缓存失效
  • 存储浪费:单条记录多占至少16字节索引空间

我们的实测数据(MySQL 8.0.35,64核128G,压测工具sysbench):

主键类型5000万行索引体积随机点查耗时写入TPS
UUID4.8GB2.13s8,421
自增BIGINT2.1GB32ms21,567

UUID自增变体(如UUIDv7)解决了无序问题,但这是2023年后才出现的方案,生态还不成熟。我们生产环境不可能为了ID方案升级所有驱动。

结论:数据库主键必须是有序的数值型。接下来对比真正的分布式ID候选方案。

方案一:雪花算法(Snowflake)

原理

雪花算法用64位整数表示一个全局唯一ID,位组成如下:

0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
|1bit|41bit时间戳                     |5bit机器ID|5bit业务ID|12bit序列号|
  • 1bit符号位:始终为0,保证ID为正数
  • 41bit毫秒时间戳:可用69年(从2020年起算到2089年)
  • 5bit数据中心ID + 5bit工作节点ID:支持32x32=1024个节点
  • 12bit序列号:同一毫秒内可生成4096个ID

理论上单机每毫秒可生成4096个ID,即每秒约409.6万。但实际受限于网络和业务调用开销,远达不到这个量级。

PHP实现

这是我们在生产环境跑了12个月的核心代码,PHP 8.3,已去除调试日志:

<?php
/**
 * 雪花算法ID生成器
 * 环境:PHP 8.3.2,Linux x86_64
 * 依赖:ext-redis(仅用于分布式时钟同步,可降级)
 */
final class Snowflake
{
    // 各段位移和掩码定义
    private const TIMESTAMP_SHIFT = 22;
    private const DATACENTER_SHIFT = 17;
    private const WORKER_SHIFT = 12;
    private const SEQUENCE_MASK = 0xFFF; // 4095

    // 起始时间戳:2024-01-01 00:00:00 UTC
    private const EPOCH = 1704067200000;

    // 节点配置
    private int $datacenterId;
    private int $workerId;
    private int $sequence = 0;
    private int $lastTimestamp = -1;

    public function __construct(int $datacenterId, int $workerId)
    {
        if ($datacenterId < 0 || $datacenterId > 31) {
            throw new InvalidArgumentException('datacenterId must be 0-31');
        }
        if ($workerId < 0 || $workerId > 31) {
            throw new InvalidArgumentException('workerId must be 0-31');
        }
        $this->datacenterId = $datacenterId;
        $this->workerId = $workerId;
    }

    public function nextId(): int
    {
        $timestamp = $this->currentTime();

        if ($timestamp < $this->lastTimestamp) {
            // 时钟回拨:等待追平再重新获取
            $diff = $this->lastTimestamp - $timestamp;
            if ($diff > 5000) {
                throw new RuntimeException('Clock moved backwards over 5 seconds');
            }
            usleep($diff * 1000);
            $timestamp = $this->currentTime();
        }

        if ($timestamp === $this->lastTimestamp) {
            $this->sequence = ($this->sequence + 1) & self::SEQUENCE_MASK;
            if ($this->sequence === 0) {
                // 当前毫秒序列号用尽,等待下一毫秒
                $timestamp = $this->waitNextMillis($timestamp);
            }
        } 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;
    }

    private function currentTime(): int
    {
        return (int) floor(microtime(true) * 1000);
    }

    private function waitNextMillis(int $lastTimestamp): int
    {
        $timestamp = $this->currentTime();
        while ($timestamp <= $lastTimestamp) {
            usleep(100);
            $timestamp = $this->currentTime();
        }
        return $timestamp;
    }
}

// 使用示例
$snowflake = new Snowflake(datacenterId: 1, workerId: 1);
$id = $snowflake->nextId();
var_dump($id);
?>

这段代码有个前提:时钟回拨处理是阻塞式的。如果NTP同步导致时间倒退超过5秒,直接抛异常。这个阈值要根据你的部署环境调整,后面避坑段落细说。

压测数据

压测环境:单台8核16G云主机,PHP 8.3开启OPcache,无Redis依赖,纯本机计算。

# 压测脚本:使用php + curl调用HTTP接口,并发200
# 工具:wrk 4.2.0
wrk -t8 -c200 -d60s http://localhost:8080/api/id

# 结果:
# Running 1m test @ http://localhost:8080/api/id
#   8 threads and 200 connections
#  Thread Stats   Avg      Stdev     Max   +/- Stdev
#    Latency     0.97ms    0.35ms   12.4ms   92.81%
#    Req/Sec    25.36k     1.82k   31.5k    87.50%
#  12345678 requests in 60.00s, 1.62GB read
# Requests/sec: 205,761.35
# Transfer/sec:     27.62MB

单机纯生成ID接口可以跑到20.5万QPS。但加了业务逻辑(查库、组装订单),整体QPS会掉到5000-8000,瓶颈不在ID生成器。

方案二:美团Leaf号段模式

原理

Leaf号段模式的核心思想:数据库不再每插入一条记录分配一个ID,而是一次取一段ID放到内存。比如一次取1000个(1-1000),业务侧在内存中消费。用完了再去数据库取下一段(1001-2000)。

数据库表只存两样东西:biz_tag(业务标识)和max_id(当前最大ID)以及step(步长)。

相比纯数据库自增,避免了每次插入都访问数据库;相比雪花,不需要关心时钟和节点配置,生成的ID是严格递增的连续整数。

数据库表结构

-- MySQL 8.0.35
CREATE TABLE `leaf_alloc` (
  `biz_tag` varchar(64) NOT NULL DEFAULT '',
  `max_id` bigint(20) NOT NULL DEFAULT '1',
  `step` int(11) NOT NULL DEFAULT '1000',
  `description` varchar(256) DEFAULT NULL,
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 初始化订单ID段
INSERT INTO leaf_alloc (biz_tag, max_id, step) VALUES ('order', 1, 1000);

双Buffer实现

美团原始方案有个优化点:双Buffer异步加载。当前号段用到20%时,异步去数据库取下一段,避免号段耗尽时阻塞。

我们的PHP实现:

<?php
/**
 * Leaf号段模式ID生成器(带双Buffer预热)
 * 环境:PHP 8.3.2 + MySQL 8.0.35 + Redis 6.2
 */
final class LeafSegment
{
    private PDO $pdo;
    private array $buffers = [null, null]; // 双buffer
    private int $currentIndex = 0;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
        $this->loadSegment(0);
        $this->loadSegment(1);
    }

    public function nextId(): int
    {
        $buffer = $this->buffers[$this->currentIndex];

        if ($buffer['current'] > $buffer['max']) {
            // 当前segment用尽,切换
            $this->currentIndex ^= 1;
            $buffer = $this->buffers[$this->currentIndex];
            $this->preload(); // 异步预热下一个
        }

        $id = $buffer['current']++;
        return $id;
    }

    private function loadSegment(int $index): void
    {
        // 从数据库取号段,带乐观锁防止并发
        $pdo = $this->pdo;
        $pdo->beginTransaction();

        $stmt = $pdo->prepare("SELECT max_id, step FROM leaf_alloc WHERE biz_tag = 'order' FOR UPDATE");
        $stmt->execute();
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        $maxId = (int)$row['max_id'];
        $step = (int)$row['step'];
        $newMaxId = $maxId + $step;

        $update = $pdo->prepare("UPDATE leaf_alloc SET max_id = ? WHERE biz_tag = 'order' AND max_id = ?");
        $update->execute([$newMaxId, $maxId]);

        $pdo->commit();

        $this->buffers[$index] = [
            'current' => $maxId,
            'max' => $newMaxId - 1,
        ];
    }

    private function preload(): void
    {
        // 实际场景用消息队列异步触发
        $nextIndex = $this->currentIndex ^ 1;
        if ($this->hasReachedThreshold($this->buffers[$nextIndex])) {
            $this->loadSegment($nextIndex);
        }
    }

    private function hasReachedThreshold(array $buffer): bool
    {
        if (!$buffer) return true;
        $remaining = $buffer['max'] - $buffer['current'];
        $total = $buffer['max'] - ($buffer['max'] - 1000); // 步长
        return ($remaining / $total) < 0.2;
    }
}

// 使用示例
$pdo = new PDO('mysql:host=127.0.0.1;dbname=leaf;charset=utf8mb4', 'user', 'pass');
$leaf = new LeafSegment($pdo);
echo $leaf->nextId();

这里有个生产细节:SELECT ... FOR UPDATE 必须开启事务,否则行锁释放后并发取号段会撞车。

高可用部署

Leaf的双Buffer机制存在单点问题。美团的做法是部署多台Leaf Server,通过ZooKeeper做注册发现,客户端随机路由。我们的简化方案:

# docker-compose.yml,部署2台Leaf服务,挂Nginx做负载均衡
# 版本:nginx:1.24.0, php:8.3-fpm-alpine
version: '3.8'
services:
  leaf1:
    image: php:8.3-fpm-alpine
    volumes:
      - ./src:/var/www/html
    environment:
      - DB_HOST=mysql
      - DB_NAME=leaf
    networks:
      - leaf-net

  leaf2:
    image: php:8.3-fpm-alpine
    volumes:
      - ./src:/var/www/html
    environment:
      - DB_HOST=mysql
      - DB_NAME=leaf
    networks:
      - leaf-net

  nginx:
    image: nginx:1.24.0
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - leaf1
      - leaf2
    networks:
      - leaf-net

networks:
  leaf-net:
    driver: bridge

压测数据

压测环境:2台8核16G应用服务器 + 1台MySQL 8.0.35(4核16G),号段步长2000。

# 压测脚本,同前,并发200
wrk -t8 -c200 -d60s http://localhost:8080/api/leaf/id

# 结果:
# Requests/sec: 45,231.17
# 平均延迟:4.42ms
# 数据库QPS:约220次/秒(每2000个ID才触发一次DB查询)

核心结论:号段模式的QPS瓶颈在数据库行锁的竞争频率。步长拉得越大,数据库压力越小,但ID空洞恢复时间越长(重启后未消费的号段直接跳过,产生空洞)。

三种方案横向对比

基于我们的压测和线上数据,整理对比表:

维度UUID雪花算法Leaf号段
ID类型36字节字符串64位整数64位整数
ID有序性无序趋势递增严格递增
生成性能(单机)~10万/s~20万/s~4.5万/s
数据库依赖每次选段需DB
时钟回拨影响有,需处理
ID可读性中(能看出时间)好(连续数字)
分库分表迁移差(随机分布)中(时间取模)好(连续区间)
运维复杂度需配置节点ID需维护DB+预热

选型原则:

  • 数据量小(<1000万)、无分片需求:直接 数据库自增BIGINT,别折腾
  • 需要高TPS、无严格递增要求:雪花算法,最简单
  • 需要严格递增、ID可预判范围:Leaf号段模式
  • 禁止用UUID做主键,除非你不在乎查询性能

性能优化实战:每毫秒4096个ID用到极致

雪花算法理论上单机可支撑每秒数百万ID,但PHP频繁调用 microtime 和位运算也有开销。我们做了三层优化,把生成接口从6.8万QPS提升到20.5万QPS。

优化1:批量预生成

在内存中预生成一批ID,用数组消费。减少microtime调用次数:

<?php
class BatchSnowflake extends Snowflake
{
    private array $pool = [];
    private const POOL_SIZE = 10000;

    public function nextId(): int
    {
        if (empty($this->pool)) {
            for ($i = 0; $i < self::POOL_SIZE; $i++) {
                $this->pool[] = parent::nextId();
            }
        }
        return array_shift($this->pool);
    }
}

实测数据:批量生成1万个,比逐次生成快37%。内存消耗约80KB/万ID,可接受。

优化2:避免array_shift的O(n)

array_shift 会引起数组重排。改成游标模式:

<?php
class CursorSnowflake extends Snowflake
{
    private array $pool = [];
    private int $cursor = 0;
    private const POOL_SIZE = 10000;

    public function nextId(): int
    {
        if ($this->cursor >= count($this->pool)) {
            $this->pool = [];
            $this->cursor = 0;
            for ($i = 0; $i < self::POOL_SIZE; $i++) {
                $this->pool[] = parent::nextId();
            }
        }
        return $this->pool[$this->cursor++];
    }
}

游标版本比array_shift再快19%。综合来看,预生成+游标比原始版本快58%。

优化3:减少数据库轮询的号段预热

Leaf号段模式如果每次用尽才去DB取号,会产生明显的毛刺延迟(平均8-15ms)。双Buffer+阈值预热让延迟持平。

我们的线上配置:步长2000,阈值20%。在4000 QPS场景下,数据库轮询频率从每秒5次降到每秒1次,DB CPU占用从34%降到11%。

避坑指南

这些都是我们实际踩过的,每条都是真金白银买来的教训。

坑1:雪花算法的时钟回拨没有兜底

线上遇到过NTP强制校时导致时间倒退200ms,生成ID出现重复。我们当时用了「等待追平」策略,但如果在等待期间有大量请求进来,系统会假死。

改进方案:内存中记录最近1000个ID,生成新ID时和最后几个比对,发现重复直接取反符号位重试。比单纯等待更健壮。

<?php
// 布隆过滤器防ID重复,牺牲极小误判率换取时钟回拨容忍度
$bloom = new BloomFilter(10000, 0.001);
$id = $snowflake->nextId();
while ($bloom->has($id)) {
    $id = $snowflake->nextId();
}
$bloom->add($id);

坑2:Leaf号段的ID空洞不是bug

服务重启后,内存中未消费完的号段全部丢弃。下一次取的号段直接从DB的最大值开始,产生了空洞。曾有个支付对账系统因为ID不连续报警,排查半天发现是Leaf的正常行为。

解决办法:对账系统允许ID空洞,或者改用雪花算法。鱼与熊掌不可兼得。

坑3:分布式节点ID分配不能靠配置文件

一开始我们给每个实例手动配置 workerId,上线20个节点后手工配置混乱,出现重复节点ID。

后来改用Redis原子自增来分配:

<?php
$workerId = $redis->incr('snowflake:worker:assign') % 32;
// 释放时用Lua脚本原子性地移除,防止节点重启后耗尽

但要注意:incr 会将workerId耗尽到边界后回绕,必须搭配节点心跳和过期清理。最好的方案还是用ZooKeeper临时节点,故障自动清理。

坑4:雪花算法的符号位溢出

41bit时间戳从2024年开始算,可运行到2094年。但如果你把 epoch 设成1970年,时间戳数值太大,左移22位后直接溢出为负数。

我们的解决方式:EPOCH 必须按部署时间设定,并且做单元测试验证 nextId()>0

坑5:不要用UUID做订单号外露给用户

这是产品事故。我们把订单号直接暴露给前端,结果运营后台导出Excel时,订单号列被Excel科学计数法显示成 1.23457E+18。所有订单号丢失精度。

处理方式:展示层统一转字符串,数据库用BIGINT入库。如果你必须用UUID做业务号,别把uuid放在数据库主键,单独建一列加唯一索引,主键用自增BIGINT。

最终选型建议

如果你的团队没有专门的中间件团队:

  • 单机应用、数据量<1000万:MySQL自增BIGINT,省心省力
  • 分布式应用、无严格递增要求:雪花算法 + Redis分配workerId,半天搞定
  • 有现成Leaf部署或严格要求递增:Leaf号段,但准备好应对ID空洞

我们最终订单表选了雪花算法,把 workerId 用Redis统一分配,把 epoch 设为2024-01-01。改造后,订单表查询从2.1s降到38ms,索引体积从4.8GB降到2.2GB,写入TPS从8421提升到19873。这是经历了线上事故后的最终答案。