Administrator
发布于 2020-12-11 / 5714 阅读
40

分布式 ID 生成:Snowflake 的坑与改造

上线第二天:生成了两个一样的订单号

十二月十号,我们把自增主键换成了 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,我现在的建议是:

  1. workerId 必须动态分配,写死或者用 IP 取模迟早会撞。Redis 的 putIfAbsent + 心跳续约是个够用的方案。
  2. 必须处理时钟回拨。分三档:小于 5ms 等待,5ms~1s 借位,大于 1s 抛异常让上层熔断。同时从运维层面把 NTP 的 step 校时改成 slew。
  3. EPOCH 不要用 1970,41 位时间戳到 2039 年就溢出了。用项目启动的年份。
  4. ID 返回给前端必须转字符串。63 位的 long 超过 JS 的 53 位安全整数,精度会丢。
  5. 如果对"严格递增"或者"不泄露业务量"有要求,用号段模式。它没有时钟依赖,实现还更简单。业务量大了再考虑 Leaf。

最后说下那个重复订单的处理:那笔订单因为主键冲突插入失败,事务回滚了,用户看到的是"下单失败,请重试",没有产生脏数据。我们扫了一遍全表确认没有漏网的重复 ID,然后把 uk_order_no 唯一索引加上了——就算 ID 生成器出问题,数据库层也要能兜住

参考