ElIauk

分布式 ID:雪花算法与号段模式

自增主键在分布式下的三个麻烦 单库单表时,AUTO_INCREMENT 简单可靠。一旦分库分表、或者多个服务都要生成订单号,问题就来了:不同库的自增会重复;ID 不连续会暴露业务量;按 ID 排序不等于按时间排序。更麻烦的是“先拿 ID 再插数据”的场景——很多业务需要在写库前就知道主键,自增做不到

评论
分布式 ID:雪花算法与号段模式

自增主键在分布式下的三个麻烦

单库单表时,AUTO_INCREMENT 简单可靠。一旦分库分表、或者多个服务都要生成订单号,问题就来了:不同库的自增会重复;ID 不连续会暴露业务量;按 ID 排序不等于按时间排序。更麻烦的是“先拿 ID 再插数据”的场景——很多业务需要在写库前就知道主键,自增做不到。

分布式 ID 方案很多,2025 年主流就两类:雪花算法(本地生成、趋势递增)和号段模式(数据库批量发号、绝对递增)。两者的取舍点在于:你能不能接受 ID 里带时间信息,以及愿不愿意为发号引入一次数据库交互。

雪花算法的 64 位结构

标准的雪花 ID 是一个 long,按位切成四段:

位数 含义 说明
1 bit 符号位 恒为 0,保证正数
41 bit 毫秒时间戳 相对自定义纪元的偏移,可用约 69 年
10 bit 工作节点 ID 最多 1024 个节点
12 bit 序列号 同一毫秒内最多 4096 个

ID 整体趋势递增,按 ID 排序接近按时间排序,适合做 MySQL 主键,避免随机 UUID 带来的页分裂。代价是强依赖系统时钟,以及必须保证 workerId 不重复。

public final class SnowflakeIdGenerator {

    private static final long EPOCH = 1704067200000L;
    private static final long WORKER_ID_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;
    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_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 > MAX_WORKER_ID) {
            throw new IllegalArgumentException("workerId 超出范围: " + workerId);
        }
        this.workerId = workerId;
    }

    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        if (timestamp < lastTimestamp) {
            long offset = lastTimestamp - timestamp;
            if (offset > 5L) {
                throw new IllegalStateException("时钟回拨超过 5ms,拒绝发号");
            }
            timestamp = waitUntil(lastTimestamp);
        }
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0L) {
                timestamp = waitUntil(lastTimestamp + 1L);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = timestamp;
        return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    private long waitUntil(long target) {
        long timestamp = System.currentTimeMillis();
        while (timestamp < target) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

时钟回拨的处理要看业务容忍度:小幅回拨(几毫秒)可以等待追平;回拨较大时,直接抛异常让调用方重试,比发出重复 ID 安全。有些实现会切换备用 workerId 继续发号,但切换逻辑本身要保证新 workerId 没被占用。

号段模式:用一次数据库交互换一批 ID

号段模式维护一张发号表,每次从数据库取走一段连续区间,本地内存慢慢发,用完再取:

CREATE TABLE id_segment (
  biz_tag VARCHAR(64) NOT NULL,
  max_id BIGINT NOT NULL,
  step INT NOT NULL,
  update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
      ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (biz_tag)
);

取号用一条原子更新完成,避免并发下拿到重复区间:

UPDATE id_segment SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id, step FROM id_segment WHERE biz_tag = 'order';

这两条要在同一个事务里执行,或者直接用 SELECT ... FOR UPDATE。步长 1000 到 10000 比较常见:太小会频繁访问数据库,太大则重启后浪费一大段 ID(对 long 来说无所谓,但会让 ID 看起来跳变)。

线上通常用双 buffer:当前号段用到 20% 时异步加载下一段,数据库短暂不可用时还能继续发号。号段模式的 ID 绝对递增、不依赖时钟,适合订单号、流水号这类要求可读性和顺序性的场景;代价是强依赖数据库,发号服务本身要考虑高可用。

常见坑

坑一:K8s 里 workerId 全靠随机。 多个 Pod 用 new Random().nextInt(1024),冲突概率随副本数上升,一旦撞上就是重复 ID。可以用 StatefulSet 的序号、启动时向 Nacos/Redis 注册租约,或者把 workerId 写进配置中心按实例下发。

坑二:时钟回拨。 NTP 校时、虚拟机迁移、容器所在宿主机时间跳变都会导致回拨,雪花算法对这类事件非常敏感。生产上应使用 chrony 的平滑校时,并对回拨次数做监控。

坑三:ID 传给前端精度丢失。 JavaScript 的 Number 最大安全整数是 2^53-1,19 位的雪花 ID 直接返回会给前端变成近似值。统一序列化成字符串,Jackson 可以用 ToStringSerializer,或者全局开启 WRITE_NUMBERS_AS_STRINGS。

坑四:把号段 ID 当时间用。 号段只保证递增,不保证连续,也不包含时间信息。别用它反推下单时间,更别用它做分表路由后还指望按时间范围扫描。

排查步骤

  1. 拿到重复 ID 先解码:右移 22 位加纪元得到时间戳,中间 10 位是 workerId,最后 12 位是序列号,能快速判断是同一节点同一毫秒发多了,还是两个节点撞了。
  2. 检查各实例的 workerId 分配记录,确认有没有重复下发。
  3. 在宿主机执行 chronyc tracking 看时钟偏移和回拨事件。
  4. 查数据库唯一索引冲突日志,确认重复发生在哪个业务表、哪个时间段。
  5. 如果是号段模式,检查 id_segment 的 max_id 是否被并发更新覆盖,以及双 buffer 切换逻辑有没有把旧号段重复发放。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者