
最近做订单系统,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
