上线第二天:生成了两个一样的订单号
十二月十号,我们把自增主键换成了 Snowflake 生成分布式 ID。上线第二天,测试同学在日志里发现了重复:
2020-12-11 10:22:31.114 WARN IdGenerator - 检测到时钟回拨, workerId=3, lastTimestamp=1607658151114, current=1607658151021
2020-12-11 10:22:31.114 INFO IdGenerator - 时钟回拨 93ms, 等待中...
2020-12-11 10:22:33.882 ERROR OrderService - 订单号重复: 1428831204789207041
org.springframework.dao.DuplicateKeyException:
### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException:
Duplicate entry '1428831204789207041' for key 't_order.PRIMARY'
有告警日志,说明我当时已经考虑到时钟回拨了。但还是出问题了。
先看看原始实现
我一开始是照着 Twitter 的经典实现写的(我们用的是 Hutool 5.4 的 Snowflake,但为了理解我手写了一遍):
public class SnowflakeIdGenerator {
/** 起始时间戳:2020-01-01 00:00:00 */
private static final long EPOCH = 1577808000000L;
private static final long WORKER_ID_BITS = 5L;
private static final long DATA_CENTER_ID_BITS = 5L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 31
private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS); // 31
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; // 12
private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 17
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS; // 22
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS); // 4095
private final long workerId;
private final long dataCenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException(
String.format("时钟回拨, 拒绝生成 ID, 回拨 %d ms", lastTimestamp - timestamp));
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
// 同一毫秒内序列用尽,等到下一毫秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (dataCenterId << DATA_CENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
坑一:workerId 没分配,两个实例拿到了同一个
这是重复 ID 的直接原因。我们的服务用 Docker Compose 部署了 4 个实例:
version: '3'
services:
order-service:
image: shop/order-service:1.2.0
deploy:
replicas: 4
environment:
- SNOWFLAKE_WORKER_ID=3 # 四个实例写死了同一个!
四个容器用同一份配置,workerId 全是 3。同一毫秒内,四个实例各自从 sequence=0 开始计数,生成完全相同的 ID 序列。
我一开始用的是"读 IP 后两段取模"的方案:
// 这个方案有问题
private long calcWorkerId() {
String ip = InetAddress.getLocalHost().getHostAddress(); // 10.20.1.31
String[] parts = ip.split("\\.");
return (Long.parseLong(parts[2]) * 256 + Long.parseLong(parts[3])) % 32;
}
问题有两个:容器重启 IP 会变,导致 workerId 变化(虽然不一定重复);不同 IP 取模可能撞车(10.20.1.31 和 10.20.2.31 都是 (1*256+31) % 32 = 31)。
方案 A:用 Redis 分配(我们最终选的)
启动时用 INCR 拿一个自增编号,配合过期时间做心跳续约:
@Component
public class WorkerIdAllocator {
@Autowired private StringRedisTemplate redis;
private static final String WORKER_ID_SEQ = "snowflake:worker:seq";
private static final String WORKER_ID_HOLDER = "snowflake:worker:holder"; // Hash: workerId -> 心跳时间
private static final long MAX_WORKER_ID = 31;
private static final long HEARTBEAT_INTERVAL = 30_000;
private static final long EXPIRE_TIME = 90_000;
private long workerId;
private String instanceId;
@PostConstruct
public void init() {
instanceId = InetAddress.getLocalHost().getHostAddress() + ":" + ProcessHandle.current().pid();
// 1. 先看自己之前是不是已经占了一个(进程重启的场景)
String existed = (String) redis.opsForHash().get(WORKER_ID_HOLDER, instanceId);
if (existed != null) {
// 心跳时间续上,复用之前的 workerId
workerId = Long.parseLong(existed.split("\\|")[0]);
} else {
// 2. 从 0~31 里找一个没被占用的
for (int i = 0; i <= MAX_WORKER_ID; i++) {
Boolean ok = redis.opsForHash().putIfAbsent(
WORKER_ID_HOLDER, String.valueOf(i), instanceId + "|" + System.currentTimeMillis());
if (Boolean.TRUE.equals(ok)) { workerId = i; break; }
// 已被占用,检查心跳是否过期,过期则抢占
String v = (String) redis.opsForHash().get(WORKER_ID_HOLDER, String.valueOf(i));
if (v != null && System.currentTimeMillis() - Long.parseLong(v.split("\\|")[1]) > EXPIRE_TIME) {
redis.opsForHash().put(WORKER_ID_HOLDER, String.valueOf(i),
instanceId + "|" + System.currentTimeMillis());
workerId = i;
break;
}
}
}
log.info("分配 workerId={}, instance={}", workerId, instanceId);
startHeartbeat();
}
/** 每 30 秒续约一次 */
@Scheduled(fixedRate = HEARTBEAT_INTERVAL)
public void heartbeat() {
redis.opsForHash().put(WORKER_ID_HOLDER, String.valueOf(workerId),
instanceId + "|" + System.currentTimeMillis());
}
}
这个方案解决了重启复用和过期回收的问题。缺点是引入了 Redis 依赖——Redis 挂了,新启动的实例拿不到 workerId。我们的处理是:Redis 不可用时降级为告警 + 拒绝启动,宁可起不来也不能生成重复 ID。
方案 B:用 Zookeeper 持久顺序节点
如果项目里已经有 ZK(我们用 Nacos 就没装),这是更标准的做法:
// 创建持久顺序节点,序号就是 workerId
String path = curatorFramework.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.PERSISTENT_SEQUENTIAL)
.forPath("/snowflake/worker-");
// path 形如 /snowflake/worker-0000000003
long workerId = Long.parseLong(path.substring(path.length() - 10));
持久节点的特性是客户端断开后不删除,所以序号单调递增不会重复。缺点是节点会一直累积,需要定期清理。Curator 4.3 有封装好的 DistributedAtomicLong 或者用 PersistentEphemeralNode。
方案 C:用数据库(最土但最稳)
我们内部的另一个项目用了这个:
CREATE TABLE t_worker_id (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(32) NOT NULL,
port INT NOT NULL,
heartbeat DATETIME NOT NULL,
UNIQUE KEY uk_ip_port (ip, port)
) ENGINE = InnoDB;
启动时 insert 一条,拿自增 id 当 workerId;定期更新 heartbeat;超过 90 秒没心跳的记录由定时任务清理。简单、依赖少,缺点是启动多一次 DB 交互。
坑二:时钟回拨
workerId 解决了,但日志里那条"检测到时钟回拨"还在。这是 Snowflake 最根本的软肋——它的 ID 里嵌了时间戳,如果系统时钟往回走,就可能生成跟之前一样的 ID。
时钟回拨怎么发生的?我们的情况是:容器的宿主做了 NTP 校时,把慢了 3 秒的时钟往前拨;另外虚拟机迁移、人工改时间也会触发。查了一下系统日志:
$ grep -i 'ntpd\|chronyd' /var/log/syslog | tail -10
Dec 11 10:22:28 node-3 chronyd[842]: Selected source 10.20.0.1
Dec 11 10:22:31 node-3 chronyd[842]: System clock wrong by -0.093041 seconds (step)
Dec 11 10:22:31 node-3 chronyd[842]: System clock was stepped by -0.093041 seconds
确实是 chronyd 做了 step 校时(不是 slewing 平滑调整,是直接跳变),回拨了 93 毫秒。
三档处理策略
按回拨幅度分档,这个是我们最后定的规则:
private long handleClockBackward(long lastTimestamp) {
long offset = lastTimestamp - System.currentTimeMillis();
if (offset <= 5) {
// 小幅度回拨(<=5ms):直接等待追上,业务几乎无感
try {
Thread.sleep(offset);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return System.currentTimeMillis();
} else if (offset <= 1000) {
// 中等回拨(5ms~1s):借用workId位作为扩展序列号,避开这一毫秒
// 用 sequence 的高位记录一个"回拨次数",保证不重复
backwardCount = (backwardCount + 1) & 0x1F;
return lastTimestamp;
} else {
// 大幅度回拔(>1s):拒绝服务,让上层熔断
log.error("时钟回拨超过阈值, offset={}ms, 拒绝生成 ID", offset);
throw new ClockBackwardException("时钟回拨 " + offset + "ms,超过阈值");
}
}
中等回拨借位的做法是美团 Leaf 的思路之一:sequence 是 12 位(0~4095),正常每毫秒最多用掉几百个,高位基本空着。把 sequence 拆成"3 位回拨计数 + 9 位序列号",每次检测到回拨就把计数加一,即使时间戳相同,ID 也不会撞。代价是每毫秒的 ID 容量从 4096 降到 512,对我们(峰值 300 QPS)完全够用。
完整的改造:
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset > MAX_BACKWARD_MS) {
throw new ClockBackwardException("时钟回拨 " + offset + "ms");
}
// 小回拨等待,中等回拨借位
if (offset <= 5) {
timestamp = waitUntil(lastTimestamp);
} else {
backwardCount = (backwardCount + 1) & BACKWARD_MASK; // 0~31
timestamp = lastTimestamp;
}
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK; // 9 位,0~511
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
if (backwardCount > 0) backwardCount = 0; // 时间追上了就清零
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (dataCenterId << DATA_CENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| (backwardCount << SEQUENCE_BITS) // 3 位回拨计数
| sequence;
}
运维侧的配套:禁用 step 校时
代码层面再怎么防,不如从源头避免。我们让运维把 chrony 的 step 校时改成 slew(平滑调整):
# /etc/chrony.conf
# 原来:makestep 1.0 3 表示前 3 次校时允许直接跳变(即使超过 1 秒)
# 改成:
makestep 0.1 -1 # 偏差超过 0.1 秒时才 step,且不限次数
maxslewrate 500 # 平滑调整的最大速率,500 ppm(百万分之五百)
maxslewrate 500 意味着每秒最多调整 0.5 毫秒的速率差。这样校时是"慢慢追"而不是"瞬间跳",Snowflake 完全感知不到。
另外在监控里加了时钟偏移告警,用 chronyc tracking:
$ chronyc tracking
Reference ID : 0A140001 (10.20.0.1)
Stratum : 3
Ref time (UTC) : Fri Dec 11 02:22:31 2020
System time : 0.000093041 seconds slow of NTP time
Last offset : -0.000088412 seconds
RMS offset : 0.000112331 seconds
Frequency : 12.441 ppm slow
Residual freq : -0.001 ppm
Skew : 0.031 ppm
Root delay : 0.001231 seconds
Last offset 超过 50ms 就告警。
坑三:ID 里的时间戳会溢出
这个坑比较隐蔽。Snowflake 的结构是:
0 | 0000000000...0000000 | 00000 | 00000 | 000000000000
| 41 bit 时间戳 | 5bit | 5bit | 12bit 序列
| | 数据中心| 机器 |
41 位时间戳,毫秒精度,能表示 2^41 = 2199023255552 毫秒,约 69 年。如果起始时间戳定在 2020-01-01,那么到 2089 年就溢出了。
我见过有人把 EPOCH 设成 1970-01-01(跟 Unix 时间戳对齐),这样 41 位在 2039 年就溢出了——现在已经是 2020 年,只剩 19 年。我们设的 2020-01-01,能用到 2089 年,足够。
另一个更实际的问题:JavaScript 的 Number 精度。JS 的 Number 是 IEEE 754 双精度,能安全表示的整数是 53 位(Number.MAX_SAFE_INTEGER = 9007199254740991)。Snowflake 的 ID 是 63 位,直接传给前端会精度丢失:
> var id = 1428831204789207041;
> console.log(id);
1428831204789207000 // 后三位丢了!
解决办法是序列化时转成字符串:
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
// 全局:Long 类型序列化为字符串
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
mapper.registerModule(module);
return mapper;
}
}
或者只在 ID 字段上加注解,影响面更小:
@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;
我们用了全局方案,因为项目里所有 Long 主键都是这个情况。改完要跟前端对齐,让他们按字符串处理。
备选方案:号段模式和 Leaf
做完这一圈之后,我评估了一下更简单的替代方案。
号段模式(Segment)
思路完全不一样:每次从数据库批量取一段 ID 区间,在内存里发完再去取下一段。
CREATE TABLE t_id_segment (
biz_tag VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '业务标识',
max_id BIGINT NOT NULL DEFAULT 1 COMMENT '当前已分配的最大ID',
step INT NOT NULL COMMENT '号段长度',
update_time DATETIME NOT NULL,
version INT NOT NULL DEFAULT 0
) ENGINE = InnoDB;
INSERT INTO t_id_segment VALUES ('order', 1, 1000, NOW(), 0);
取号段用乐观锁,避免多个实例取到同一段:
<!-- 原子地取走一个号段 -->
<update id="nextSegment">
UPDATE t_id_segment
SET max_id = max_id + #{step}, version = version + 1, update_time = NOW()
WHERE biz_tag = #{bizTag}
</update>
<select id="get" resultType="IdSegment">
SELECT max_id, step FROM t_id_segment WHERE biz_tag = #{bizTag}
</select>
public synchronized long nextId(String bizTag) {
if (current >= max) {
// 当前号段用完,取新的一段
IdSegment seg = segmentMapper.nextSegment(bizTag, STEP);
current = seg.getMaxId() - seg.getStep();
max = seg.getMaxId();
}
return current++;
}
优点非常明显:完全不依赖时钟,没有时钟回拨问题;ID 是纯数字单调递增;实现简单。缺点也有:
- 服务重启会浪费掉未用完的号段(ID 不连续,有空洞)。这不算问题,ID 只要唯一就行。
- 号段用完的那一刻取号会有一次数据库交互,有 RT 抖动。美团 Leaf 的双 buffer 优化能解决——当前号段用到 10% 时就异步预取下一段。
- ID 是连续的,容易被人猜到业务量(比如订单号能看出一天多少单)。可以加个扰码。
Leaf:两种模式都支持
美团开源的 Leaf(1.0.1)同时支持号段和 Snowflake 两种模式,它解决了几个我们没做的事:
- 号段模式的双 buffer:异步预取,消除取号段的 RT 尖刺。
- Snowflake 模式的 Zookeeper 集成:workerId 自动分配,不用自己写。
- 时钟回拨的处理:它在启动时会上报自己的时钟,跟集群平均时钟比对,偏差太大直接拒绝启动。
我们评估之后最终没上 Leaf,原因很实际:引入一个新中间件要运维支持,而我们当时只有订单这一个业务需要分布式 ID,量也不大(峰值 300 QPS)。自己那 200 行代码加上 Redis 分配 workerId 已经够用了。如果后续有五六个业务都要用,那肯定是上 Leaf 划算。
最终方案和效果
我们最后是这么组合的:
| 业务 | 方案 | 理由 |
|---|---|---|
| 订单号 | Snowflake(改造版) | 要带时间信息,方便按 ID 排序和分片 |
| 支付流水号 | Snowflake | 同上 |
| 商品 SKU 编码 | 号段模式 | 不希望被猜出商品数量,且要求严格递增 |
| 优惠券码 | 随机数 + 唯一索引 | 用户要手输,不能太长 |
改造后跑了三周的数据:
- ID 重复:0(之前平均每天 3~5 次)
- 时钟回拨触发:11 次,全部是小幅回拨(<5ms),等待后正常生成,无感知
- ID 生成 QPS:单实例 42 万(synchronized 同步块的开销可以接受)
- 生成耗时 P99:2.4 微秒
几条硬规矩
如果有人要用 Snowflake,我现在的建议是:
- workerId 必须动态分配,写死或者用 IP 取模迟早会撞。Redis 的
putIfAbsent+ 心跳续约是个够用的方案。 - 必须处理时钟回拨。分三档:小于 5ms 等待,5ms~1s 借位,大于 1s 抛异常让上层熔断。同时从运维层面把 NTP 的 step 校时改成 slew。
- EPOCH 不要用 1970,41 位时间戳到 2039 年就溢出了。用项目启动的年份。
- ID 返回给前端必须转字符串。63 位的 long 超过 JS 的 53 位安全整数,精度会丢。
- 如果对"严格递增"或者"不泄露业务量"有要求,用号段模式。它没有时钟依赖,实现还更简单。业务量大了再考虑 Leaf。
最后说下那个重复订单的处理:那笔订单因为主键冲突插入失败,事务回滚了,用户看到的是"下单失败,请重试",没有产生脏数据。我们扫了一遍全表确认没有漏网的重复 ID,然后把 uk_order_no 唯一索引加上了——就算 ID 生成器出问题,数据库层也要能兜住。