分类:架构与高并发 · 作者:管理员 · 发布于 2026-10-17 · 阅读 0 · 标签:分布式ID / 雪花算法 / 号段模式 / 高并发 / 系统设计

一、痛点:分库分表之后,自增 ID 第一个倒下

单体时代我们用 MySQL 自增主键:简单、有序、性能好。但电商中台一旦分库分表,自增 ID 立刻出现三个问题:

  • 全局唯一性失效:两个库各自从 1 开始自增,订单号必然撞车;
  • 可猜测性:订单号连续递增,攻击者可以遍历接口枚举业务数据,配合越权漏洞就是灾难;
  • 无业务区分度:订单号、支付流水号、券码混在一起,排查问题全靠查表。

于是分布式 ID 成了中台的基础设施。本文以「一个典型的电商中台」为例,讲透两种主流方案:雪花算法与号段模式,以及我在生产落地时踩过的五个坑。

二、方案选型:先看你的 ID 要满足什么

选型之前先列需求,大多数团队的共识是:

  • 全局唯一、趋势递增(对 B+ 树索引友好,避免随机写导致的页分裂);
  • 高可用、低延迟(下单链路要求在毫秒级拿到 ID);
  • 不含敏感业务信息,又不能纯随机(纯随机 UUID 会让索引碎片化严重)。
方案优点缺点
UUID本地生成、无网络开销36 位字符串、无序、索引性能差
DB 自增/号段绝对递增、实现简单依赖数据库、有单点和性能上限
雪花算法本地生成、趋势递增、高性能依赖机器时钟,时钟回拨会重复

我的实践结论:核心交易链路(订单、支付)用雪花算法,流水号、券码等低频业务用号段模式,两者不冲突,反而互补。

三、雪花算法:位运算里挤出的 64 位

雪花 ID 是 64 位 long,按位分配:1 位符号位 + 41 位时间戳 + 10 位机器位 + 12 位序列号。41 位毫秒时间戳约可用 69 年,10 位机器位支持 1024 个节点,12 位序列号单机单毫秒可生成 4096 个 ID。

下面是去掉框架、可直接理解的 Java 实现:

public class SnowflakeIdGenerator {
    // 位分配:1 符号位 + 41 时间戳 + 10 机器位 + 12 序列号
    private static final long START_EPOCH = 1700000000000L; // 自定义起始时间戳
    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) - 1;
    private static final long SEQUENCE_MASK = (1L << SEQUENCE_BITS) - 1;
    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 sequence = 0L;
    private long lastTimestamp = -1L;

    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 <= 5) {
                timestamp = waitUntil(lastTimestamp);   // 轻微回拨,等时钟追上
            } else {
                throw new IllegalStateException("时钟回拨过大: " + offset + "ms");
            }
        }
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                timestamp = waitUntil(lastTimestamp);   // 同毫秒序列号耗尽
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = timestamp;
        return ((timestamp - START_EPOCH) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    private long waitUntil(long lastTimestamp) {
        long ts = System.currentTimeMillis();
        while (ts <= lastTimestamp) {
            ts = System.currentTimeMillis();
        }
        return ts;
    }
}

三个关键点:一是 synchronized 保证线程安全,二是 位运算必须用 long 而不是 int(int 移位会溢出导致负数),三是 起始时间戳要取一个固定的过去时间,不要用 0,否则 ID 会特别长。

四、坑一:时钟回拨——雪花算法最经典的翻车现场

NTP 校时、虚拟机迁移、物理机时钟漂移,都可能让 System.currentTimeMillis() 往回跳。回拨期间生成的 ID 与历史 ID 冲突,而数据库主键冲突的后果是直接写入失败。

我的处理策略分三档:

  • 回拨 ≤ 5ms:自旋等待,等系统时钟追上 lastTimestamp 再生成(见上文代码);
  • 回拨 5ms ~ 1s:拒绝生成,抛异常让上层走降级,同时打告警;
  • 回拨 > 1s:放弃本地生成,临时切换备用 ID 服务(号段模式兜底),并标记该节点需要人工校准。

另外要监控:把每次检测到的回拨偏移量上报到指标系统,回拨是机房硬件故障的前兆信号,不能只靠异常兜底。

五、坑二:workerId 怎么分配?写死在配置里必出事

10 位机器位一共 1024 个,容器化部署后节点是动态伸缩的,写死在 application.yml 里必然出现两个实例 workerId 相同,同毫秒生成重复 ID。我的做法:

  • 启动时向 Redis 申请:SET worker:order lock 1 NX EX 60,按自增序号分配,实例退出后释放;
  • 或用 ZooKeeper 临时节点,节点序号即 workerId,会话断开自动回收;
  • 最次也要用 机器 IP 的最后一段 + 进程号 做哈希,保证同一台机器上多个实例不冲突。

六、坑三:雪花 ID 到了前端就变样——JS 精度丢失

这是最容易忽略的坑。雪花 ID 是 19 位数字,超过 JavaScript 安全整数上限 2^53-1。前端用 number 接收,最后几位会被截断成 0,导致订单详情查不到、跳转错单。

后端必须在序列化时把 Long 转成字符串:

@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;

如果项目里大量实体都有 Long 主键,更省事的做法是全局配置 ObjectMapper,把所有 Long 类型统一序列化为字符串。前端拿到字符串后,不要主动转 number,直接作为字符串传给接口即可。

七、号段模式:一次取一批,数据库不再是瓶颈

号段模式的思路是:数据库只负责发号,每次取 step 个,应用内存里慢慢用。表结构如下:

CREATE TABLE id_segment (
    biz_tag    VARCHAR(32) PRIMARY KEY COMMENT '业务标识,如 order_id',
    max_id     BIGINT NOT NULL COMMENT '当前已分配的最大 ID',
    step       INT    NOT NULL COMMENT '每次取号步长',
    version    INT    NOT NULL COMMENT '乐观锁版本号',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

取号用乐观锁,防止多个实例并发更新:

UPDATE id_segment
SET max_id = max_id + step, version = version + 1
WHERE biz_tag = 'order_id' AND version = #{version};

更新成功即拿到 [旧 max_id + 1, 新 max_id] 这一段号,应用进程内用 AtomicLong 分配。step 取多少要看峰值:峰值 QPS × 峰持续秒数 × 冗余系数,比如峰值 2000/s、持续 30s,step 取 10 万左右,够用又不至于浪费。

进阶做法是双 buffer:内存里始终维护「当前段 + 下一段」两个区间,当前段用到 50% 就异步去数据库预取下一段,彻底消除取号阻塞,把数据库压力降到「几分钟一次 update」。这也是业界开源发号器(如 Leaf)的核心思想。

八、坑四与坑五:号段模式的单点与主从延迟

坑四:单点。号段模式把压力集中到一张表,必须保证这张表高可用。建议独立部署(不跟业务表混库),主从复制 + 双写切换,或者直接上分布式数据库。

坑五:主从延迟导致的重复发号。如果取号走从库,主从延迟期间两个实例可能读到同一个 max_id,各发一段互相重复的号。所以取号必须强制走主库,从库只承担查询。

九、最佳实践清单

  1. ID 不承载业务含义:不要在 ID 里编码店铺、渠道等业务信息,业务属性放独立字段,否则后续扩展会哭;
  2. 核心链路雪花 + 低频业务号段:组合使用,别迷信单一方案;
  3. 对外暴露时注意防枚举:雪花 ID 趋势递增意味着可被遍历,所有按 ID 查询的接口都要做权限校验;
  4. 序列化统一 Long 转 String:前后端一体约定,从根上消灭精度丢失;
  5. 回拨监控 + 降级预案:时钟回拨不是「会不会发生」,而是「什么时候发生」。

分布式 ID 看起来只是「一个发号器」,但它决定了订单链路的可用性上限。把选型、实现、降级想清楚,比上线后救火划算得多。