Spring Boot 实现高并发分布式 ID 生成:雪花算法原理、时钟回拨处理与性能优化实战

最近做订单系统,ID 生成这事儿看着简单,真要整好,坑不少。尤其是高并发场景,一个破 UUID 都能把数据库索引干趴下。所以打算把雪花算法从原理到落地捋一遍,顺便聊聊 Spring Boot 里怎么封装、怎么处理时钟回拨、怎么压榨性能。这篇文章是我实际写代码的经验总结,不是那种教科书式的贴代码,想到哪儿写到哪儿,但技术点都是真刀真枪的。

1. 业务场景与方案选择

需要全局唯一 ID 的地方太多了,订单号、消息 ID、链路追踪的 TraceId,还有日志 ID、支付流水号。这些场景要求高并发、全局唯一、最好趋势递增,而且生成过程不能成为瓶颈。

常见的生成方案:

  • UUID:最大的问题是无序、太长。字符串乱序,数据库索引性能差,排序也难受。优点是本地生成,没网络开销。
  • 数据库自增 ID:数字有序,但依赖数据库,分布式环境下有单点问题,并发一高就是瓶颈。
  • 号段模式:每次从数据库拿一批号,性能不错,但依赖数据库,重启可能丢号段。
  • 雪花算法:不依赖外部组件,趋势递增,性能极高,缺点也明显------时钟回拨会产生重复 ID,还需要维护 workerId。

综合来看,雪花算法是主流,但得针对时钟回拨做增强。下面从原理开始。

2. 雪花算法结构解析

雪花算法最早是 Twitter 搞出来的,核心思想很简洁:把一个 64 位整数按位拆开,分别表示时间戳、机器 ID 和序列号。

复制代码
 0  1 - 41        42 - 51        52 - 63
 ┌─┬─────────────┬─────────────┬─────────────┐
 │0│  时间戳(ms)  │ 机器ID(10bit)│ 序列号(12bit)│
 └─┴─────────────┴─────────────┴─────────────┘
  • 符号位:永远是 0,保证 ID 是正数。
  • 时间戳:41 位毫秒时间戳。2^41 毫秒差不多 69 年,实际用的时候要设一个起始时间,比如从 2024-01-01 开始,这样可以用到 2093 年。
  • 机器 ID:10 位,可以拆成 5 位机房 + 5 位机器,支持 32 个机房,每个机房 32 台机器,总共 1024 个节点。
  • 序列号:12 位,同一毫秒内最多生成 4096 个 ID。序列号用完了就等下一毫秒。

这个设计的好处很明显:时间戳 + 机器 ID + 序列号组合起来,只要时钟不回拨、机器 ID 不冲突,全局唯一;整体趋势递增,因为毫秒递增,同一毫秒内序列号递增;纯内存运算,没网络开销。

3. Spring Boot 实现一个雪花生成器

先建一个基础版,把位运算核心逻辑写出来。

java 复制代码
public class SnowflakeIdGenerator {

    // 起始时间戳:2024-01-01 00:00:00
    private static final long START_TIMESTAMP = 1704067200000L;

    // 各部分位数
    private static final long SEQUENCE_BITS = 12L;
    private static final long WORKER_ID_BITS = 10L;

    // 最大值
    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
    private static final long WORKER_ID_MASK = ~(-1L << WORKER_ID_BITS);

    // 位移量
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;

    private final long workerId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > WORKER_ID_MASK) {
            throw new IllegalArgumentException("workerId must be between 0 and " + WORKER_ID_MASK);
        }
        this.workerId = workerId;
    }

    public synchronized long nextId() {
        long currentTimestamp = System.currentTimeMillis();

        if (currentTimestamp < lastTimestamp) {
            // 先留空,后面处理时钟回拨
            throw new IllegalStateException("Clock moved backwards");
        }

        if (currentTimestamp == lastTimestamp) {
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                currentTimestamp = waitNextMillis(currentTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = currentTimestamp;

        return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    private long waitNextMillis(long currentTimestamp) {
        while (currentTimestamp <= lastTimestamp) {
            currentTimestamp = System.currentTimeMillis();
        }
        return currentTimestamp;
    }
}

配置里加数据中心和机器 ID。我习惯把 10 位拆成 5 位机房 + 5 位机器,最终 workerId = (dataCenterId << 5) | machineId。

java 复制代码
@Component
@ConfigurationProperties(prefix = "snowflake")
public class SnowflakeProperties {
    private long dataCenterId = 1;
    private long machineId = 1;

    // getter/setter 省略

    public long getWorkerId() {
        return (dataCenterId << 5) | machineId;
    }
}

配置类里生成 Bean:

java 复制代码
@Configuration
public class IdGeneratorConfig {

    @Bean
    public SnowflakeIdGenerator snowflakeIdGenerator(SnowflakeProperties properties) {
        return new SnowflakeIdGenerator(properties.getWorkerId());
    }
}

这样就能直接 @Autowired 使用了。但还没处理时钟回拨,接下来是关键。

4. 时钟回拨问题处理

雪花算法最怕系统时间往回跳,一回到过去,同一毫秒内就可能生成重复 ID。业界常见的招数有三招:等待、抛错、历史时间补偿。

4.1 等待

如果回拨时间不长(比如 5 秒内),就让线程睡一会儿,等系统时间追上来。

java 复制代码
if (currentTimestamp < lastTimestamp) {
    long offset = lastTimestamp - currentTimestamp;
    if (offset <= 5000) {
        try {
            Thread.sleep(offset * 2);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        currentTimestamp = System.currentTimeMillis();
        while (currentTimestamp < lastTimestamp) {
            currentTimestamp = System.currentTimeMillis();
        }
    } else {
        throw new IllegalStateException("Clock moved backwards too much");
    }
}

思路很直白,但回拨频繁的话,线程会被反复阻塞,请求堆积。

4.2 直接抛错

回拨超过阈值,直接抛异常,让上层决定是降级还是拒绝。

java 复制代码
if (currentTimestamp < lastTimestamp) {
    long offset = lastTimestamp - currentTimestamp;
    if (offset > 100) {
        throw new IllegalStateException("Clock moved backwards. Refusing to generate ID for " + offset + " ms");
    }
    // 小回拨,继续处理
}

这种策略容易导致服务不可用,但能保证不生成重复 ID。

4.3 历史时间补偿

这个思路比较巧妙。既然当前时钟回拨了,那就假装时间还在上次生成 ID 的时候,序列号继续往上加,直到加满 4096 个为止。这样避免等待,也不抛错。

java 复制代码
public synchronized long nextId() {
    long currentTimestamp = System.currentTimeMillis();

    if (currentTimestamp < lastTimestamp) {
        sequence = (sequence + 1) & SEQUENCE_MASK;
        if (sequence == 0) {
            throw new IllegalStateException("Sequence exhausted while clock moved backwards");
        }
        // 继续用 lastTimestamp,序列号递增
        return ((lastTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    // 正常流程...
}

但注意,这个方案只能补偿短时间回拨。如果回拨太久,序列号用尽就只能抛错了。所以生产上我更喜欢"等待 + 补偿"的组合:

java 复制代码
public synchronized long nextId() {
    long currentTimestamp = System.currentTimeMillis();
    long offset = lastTimestamp - currentTimestamp;

    if (offset > 0) {
        if (offset > 5000) {
            throw new IllegalStateException("Clock moved backwards, offset=" + offset + "ms");
        }
        try {
            Thread.sleep(offset + 1);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        currentTimestamp = System.currentTimeMillis();
    }

    if (currentTimestamp == lastTimestamp) {
        sequence = (sequence + 1) & SEQUENCE_MASK;
        if (sequence == 0) {
            currentTimestamp = waitNextMillis(currentTimestamp);
        }
    } else {
        sequence = 0L;
    }

    lastTimestamp = currentTimestamp;
    return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
            | (workerId << WORKER_ID_SHIFT)
            | sequence;
}

另外,如果你们有 Redis 或者 ZooKeeper,可以把最近生成 ID 的时间戳存一份,本机时间小于全局最大时间戳时,直接拿全局时间戳用。不过这会引入网络开销,我一般不用。

5. 预生成 ID 池与双缓冲

雪花算法本身延迟已经很低了,但 System.currentTimeMillis() 是有系统调用开销的,再加上 synchronized 锁,高并发下还是会被拖死。有一个土办法:先把 ID 批量生成好放在本地队列里,用的时候直接从队列取,一步到位。

java 复制代码
public class IdPool {
    private final SnowflakeIdGenerator generator;
    private final BlockingQueue<Long> queue = new LinkedBlockingQueue<>(10000);
    private final ExecutorService executor = Executors.newSingleThreadExecutor();

    public IdPool(SnowflakeIdGenerator generator) {
        this.generator = generator;
        fill();
    }

    private void fill() {
        for (int i = 0; i < 10000; i++) {
            queue.offer(generator.nextId());
        }
    }

    public Long nextId() throws InterruptedException {
        if (queue.size() < 5000) {
            executor.submit(this::fill);  // 异步补充,注意防重
        }
        return queue.take();
    }
}

生产环境别这么裸,至少加个 AtomicBoolean 防止重复提交填充任务。也可以用双缓冲:两个队列,一个消费一个后台填充,消费到一半就交换。

java 复制代码
public class DoubleBufferIdPool {
    private final SnowflakeIdGenerator generator;
    private final int bufferSize;
    private volatile Queue<Long> currentBuffer;
    private Queue<Long> nextBuffer;
    private final ExecutorService executor = Executors.newSingleThreadExecutor();

    public DoubleBufferIdPool(SnowflakeIdGenerator generator, int bufferSize) {
        this.generator = generator;
        this.bufferSize = bufferSize;
        this.currentBuffer = new ArrayDeque<>();
        this.nextBuffer = new ArrayDeque<>();
        fillBuffer(currentBuffer);
    }

    private void fillBuffer(Queue<Long> buffer) {
        for (int i = 0; i < bufferSize; i++) {
            buffer.offer(generator.nextId());
        }
    }

    public Long nextId() {
        Long id = currentBuffer.poll();
        if (id == null) {
            synchronized (this) {
                if (currentBuffer.isEmpty()) {
                    Queue<Long> tmp = currentBuffer;
                    currentBuffer = nextBuffer;
                    nextBuffer = tmp;
                    fillBuffer(nextBuffer);
                }
                id = currentBuffer.poll();
            }
        } else {
            if (currentBuffer.size() <= bufferSize / 2 && nextBuffer.size() < bufferSize) {
                executor.submit(() -> fillBuffer(nextBuffer));
            }
        }
        return id;
    }
}

预生成会浪费一些 ID,但相比性能提升,这点浪费值得。另外,批量接口可以一次返回多个 ID,进一步减少调用次数。

6. 集成 MyBatis-Plus

MyBatis-Plus 默认的 ASSIGN_ID 就是雪花算法,但我们可以换成自己的生成器,做到统一管理。只需要实现 IdentifierGenerator 接口。

java 复制代码
@Component
public class MyBatisPlusIdGenerator implements IdentifierGenerator {

    @Autowired
    private IdPool idPool; // 或者直接用 SnowflakeIdGenerator

    @Override
    public Number nextId(Object entity) {
        return idPool.nextId();
    }
}

实体类主键用 @TableId(type = IdType.ASSIGN_ID) 就行。如果想生成字符串订单号,可以单独写个服务:

java 复制代码
@Service
public class OrderIdService {
    @Autowired
    private SnowflakeIdGenerator generator;

    public String generateOrderId() {
        return "ORDER" + new SimpleDateFormat("yyyyMMdd").format(new Date())
                + generator.nextId();
    }
}

7. 美团 Leaf 的设计思路

美团 Leaf 是业界很经典的方案,两种模式都值得学习。

号段模式:从数据库拿一个号段(比如 1~1000),进程内按顺序分配。数据库表里有 biz_tag、max_id、step 这些字段。当号段快用完时,后台异步去数据库加载下一个号段。优点是对数据库压力小,ID 趋势递增;缺点是要维护号段表,数据库挂了也玩不转。

雪花模式:Leaf 做雪花模式的时候解决了两个痛点。一个是 workerId 动态分配,通过 ZooKeeper 注册节点,启动时获取递增序号作为 workerId,关闭时释放。另一个是时钟回拨,Leaf 有 checkTimestamp 机制,检测到回拨就抛异常,然后告警人工处理。

参考 Leaf,我们在生产环境可以考虑用 Redis 动态分配 workerId,但大多数团队规模用配置文件就行,只要不搞混。

8. 压测与监控

我拿 8C16G 的机器压了一次(JMeter 100 线程,60 秒),基础雪花算法 QPS 大概 3.5 万,平均响应 2.8ms,P99 8.1ms。加了 ID 池双缓冲之后,QPS 能到 12 万,平均响应降到 0.8ms,P99 也只有 2.3ms。差距很明显。

生产环境建议重点监控这几个指标:

  • 生成耗时:平均耗时、TP99。
  • 池水位:剩余 ID 数量,低于阈值就告警。
  • 时钟回拨次数:记下每次回拨的时间差和频率。
  • QPS:评估容量和扩缩容。

用 Micrometer + Prometheus + Grafana 就能搞定,代码里埋点记录一下 Timer 和 Counter 就行。

9. 总结

雪花算法不是什么银弹,但掌握了原理和变体,大部分场景都能应对。我的建议是:

  • 起始时间戳设得远一点,保证 ID 用几十年。
  • workerId 别重复,运维要统一登记。
  • 开启 NTP 同步,加 -x 参数避免时间跳变。
  • 日志里记下时钟回拨详情,出了问题好排查。
  • ID 生成服务要做限流降级,别让上游突起压垮。

差不多就是这些,有问题评论区聊。

官网:www.farerboy.com
相关推荐
小蒜学长21 小时前
基于SpringBoot和Vue的低卡食品销售系统的设计与实现(代码+数据库+LW)
java·后端·springboot·健康管理·低卡食品销售系统
SL-staff2 天前
JVS私有化交付技术解析:如何通过全栈开源与引擎解耦实现真正可控的源码级交付
开源·私有化部署·springboot·信创适配·jvs·spi架构
正在走向自律3 天前
从只写接口到独立交付前后端,飞算JavaAI能给Java后端工程师多少溢价?
java·springboot·ai编程·全栈开发·java求职·飞算javaai·金九银十
程序猿_极客9 天前
【免费】分享一套优质的基于SpringBoot的扶贫助农管理系统(详解)
java·springboot·课程设计·计算机项目
苏渡苇11 天前
Spring Insight 里如何把 Span 画成瀑布时间线
后端·spring·spring cloud·springboot·监控·apm
好菇娘の当自强11 天前
【Spring Boot 2.x 与 3.x YAML 配置区别详解】
springboot
cnkeysky11 天前
SpringBoot 整合 nacos 配置中心
nacos·springboot
她说..11 天前
常见设计模式-模板方法模式
java·spring·设计模式·springboot
小蒜学长12 天前
大学生健康饮食的智慧管理系统(代码+数据库+LW)
java·后端·springboot·大学生·健康饮食