3.3 全局唯一 ID:订单号生成

分库之后自增主键彻底失效:16 个分片各自从 1 开始计数,主键立刻冲突。订单号是贯穿全系统的核心标识,必须有全局唯一、趋势递增、可路由的生成方案。这一节讲我们基于雪花算法的订单号设计,包括位分配、机器位分配与时钟回拨的处理。

场景:订单号要扛起三个职责

订单号在分库之前是自增 bigint,分库之后它要同时承担三个职责:全局唯一标识(对用户可见)、分片路由输入(参与哈希取模)、趋势递增(B 加树索引友好,新数据顺序写入)。第三个职责常被忽略——如果订单号完全随机,InnoDB 的聚簇索引插入会变成随机位置插入,页分裂严重。

ID 方案 全局唯一 趋势递增 可读性 依赖
数据库自增 分库后失效 是 好 无
UUID 是 否(乱序) 差 无
号段模式 是 是(号段内) 中 依赖数据库
雪花算法 是 是(毫秒内) 中 依赖机器时钟
Redis INCR 是 是 中 依赖 Redis 可用性

方案:雪花算法加业务位改造

标准雪花算法是 64 位:1 位符号位加 41 位时间戳加 10 位机器位加 12 位序列号。我们做了业务化改造:把 10 位机器位拆成 5 位机房加 5 位机器,并在时间戳与机器位之间插入 4 位业务类型位(区分订单、退单、明细单)。这样同一个机房同一台机器的不同业务类型不会互相挤占序列号。

位分配的权衡值得多说一句:机房和机器共 10 位、最多编址 1024 个节点,对订单系统绰绰有余;但如果换成海量的接入场景(比如每用户发号),机器位会不够用。业界也有把时间戳压到秒级、把机器位扩到 20 位的变体。位分配没有标准答案,按「未来五年最多多少发号节点」反推即可,定下来之后几乎不能改,因为所有历史 ID 的位含义都固化了。

位段 位数 内容 说明
符号位 1 固定 0 保证正数
时间戳 41 毫秒级 约 69 年
业务类型 4 订单、退单等 16 种业务
机房 5 机房编号 32 个机房
机器 5 机器编号 每机房 32 台
序列号 12 毫秒内自增 每毫秒 4096 个

单机每毫秒 4096 个 ID,换算成每秒约 400 万——远超订单系统峰值 1500 QPS 的写入需求。冗余的序列号空间是好事:未来大促流量翻十倍也无需改造。订单号生成器的可用性靠「多机房多实例加双 buffer」保证:任一实例宕机,其他实例无缝接管,因为时间戳与序列号都独立维持。

双 buffer:生成器的高可用保障

订单号生成器一旦成为单点,整个下单链路全部停摆。我们用「双 buffer」策略保证可用性:每个机房部署一组生成器实例,实例启动时预取一段序列号(buffer),buffer 用去一半就异步预取下一段。任意实例宕机时,其他实例的 buffer 还能撑住切换间隙的流量。

可用性措施 实现方式 覆盖的故障
多机房部署 每机房一组生成器 整机房故障
双 buffer 预取两段序列号 实例切换间隙
时钟回拨分级 借时间戳或切换实例 NTP 异常
降级发号 每实例内置应急号段 依赖全部故障

双 buffer 的本质是「用空间换可用性」:预取的号段放在内存里,即便 ZooKeeper 临时挂掉,已分配的号段还能继续发号几分钟。这套设计后来在 ZK 集群一次故障中验证了价值——发号无感知,下单链路没受任何影响。

配置:雪花算法的生成代码

生成代码的核心是位运算,把各段拼进 64 位整数。下面是精简后的核心逻辑:

public synchronized long nextId(int bizType) {
    long now = System.currentTimeMillis();
    if (now < lastTimestamp) {
        throw new ClockBackwardException(now - lastTimestamp);
    }
    if (now == lastTimestamp) {
        sequence = (sequence + 1) & 0xFFF;
        if (sequence == 0) now = tilNextMillis(lastTimestamp);
    } else {
        sequence = 0L;
    }
    lastTimestamp = now;
    return (now - EPOCH) << 22
         | (long) bizType << 18
         | (long) dcId << 13
         | (long) workerId << 8
         | sequence;
}

注意方法用了 synchronized:单实例内保证线程安全。如果追求更高性能可以把对象换成分段锁或者 CAS,但 4000 QPS 级别单机锁完全够用。另外业务里把订单号转成字符串展示,64 位长整数对用户不友好。

踩坑:时钟回拨是最危险的坑

服务器 NTP 同步时可能出现时钟回拨(时间突然往回跳)。生成器发现当前时间小于上次记录的时间戳时,直接抛异常是标准做法,但会导致大面积下单失败。我们的处理分两级:回拨 10 毫秒以内时「借用」上次时间戳继续生成(容忍短暂回拨);超过 10 毫秒直接告警并切换备用实例。某次 NTP 服务异常导致回拨 50 毫秒,系统切换到备用机房生成器,订单创建无感知,靠的是这套分级策略。

第二个坑是机器位分配靠配置文件。早期 worker_id 写在配置里,有台机器换了配置忘记改,与另一台撞了 ID——两天后对账才发现两批订单号完全重合。后来改用 ZooKeeper 临时节点自动分配 worker_id,配置文件不再出现机器位,从根上杜绝。

第三个坑是对外可见的订单号与内部 ID 解耦。用户看到的订单号是「年月日加随机后缀」的展示串(与内部雪花 ID 做映射存储),好处是防爬虫猜测连续订单量、防跨用户越权枚举(雪花 ID 连续,直接遍历就能爬数据)。展示号与内部 ID 的映射表本身也分片存储,按展示号查询时先走映射。

操作清单:ID 方案迁移的三步流程

从自增主键迁移到雪花 ID,我们走了三步,供同样要做全局 ID 改造的团队参考:

步骤 动作 风险点
1 双写灰度 新订单同时写新旧 ID 列 写入耗时略增
2 读切换 查询逐步切到新 ID 回滚预案备好
3 老数据回填 历史订单补雪花 ID 分批低峰执行

唯一庆幸的是这件事发生在分库项目早期、订单表还没有拆分的时候。如果分库之后才发现自增主键冲突,迁移要难上一个量级。ID 方案是分库的前置条件,不是善后事宜。

回填老数据时还有个细节:雪花 ID 的插入顺序与时间相关,老数据回填时时间戳是当前时间而非历史订单的真实创建时间,索引聚簇顺序会有轻微混乱。我们对回填数据的处理是「接受」,因为聚簇键顺序只影响页分裂频率、不影响正确性,微小的空间浪费远小于回填历史时间戳的复杂度。

最后给一组实测数字:发号器在日常 1500 QPS 写入下,生成耗时 p99 在 1 毫秒以内;大促峰值 9000 QPS 时 p99 约 3 毫秒,双 buffer 换号期间没有抖动。这套性能对订单系统足够富余,未来扩容到 10 倍流量也无需改造发号层。

小结

订单号设计三要点:全局唯一靠雪花位分配、趋势递增靠时间戳高位、可用性靠多机房实例与时钟回拨分级处理。机器位分配一定要自动化(ZooKeeper 或数据库号段),手写配置迟早撞车。对外暴露的订单号最好与内部 ID 解耦,兼防爬虫与防越权枚举。ID 看似是最小的设计单元,一旦上线几乎无法变更,值得多花一天设计,它会在系统的余生里每天默默证明这个决定的价值。

延伸阅读