短链系统发号器设计

2015年,某公司上线了一个短链系统。上线第一周,数据团队发现一个问题:每天生成的短链ID居然是"乱序"的。

第一天生成的短链 abc12345,第二天生成的短链 xyz98765——一个递增的短链系统,短码却是随机分布的。

问题出在发号器上:他们用了UUID作为短链ID,转成Base62后,自然就是乱序的。

后果很严重:所有短链都散落在数据库各处,索引效率极低,查询延迟从5ms飙升到50ms

这是一个被很多人忽视的设计细节。

【架构权衡】

短链发号器的核心不是"生成ID",而是生成什么样的ID。递增的ID有利于索引和分片,无规律的ID有利于安全。短链系统通常选择递增ID,因为存储和查询性能比安全性更重要——短链本身就是公开的,不需要防猜。

一、发号器的核心要求 🔴

1.1 四大要求

1. 唯一性:绝对不能重复
   → 重复会导致两条短链指向同一长链,数据冲突

2. 趋势递增:ID随时间增长而增长
   → 有利于数据库B+树索引,减少页分裂
   → 有利于分库分表的数据分布均匀

3. 高性能:每秒能生成足够多的ID
   → 日均1000万:需要115 QPS
   → 日均10亿:需要11500 QPS
   → 峰值通常是均值的5-10倍

4. 足够短:ID转成62进制后长度可控
   → 雪花ID(41位时间戳):约69年不重复
   → 8位62进制 = 218万亿组合

1.2 为什么不用UUID

UUID的缺点:
1. 无序:UUID.randomUUID() 生成的ID是随机的
   → 写入数据库时随机IO,索引效率低

2. 太长:标准UUID是36个字符,转Base62后更麻烦
   → 不适合作为短链码

3. 可读性差:550e8400-e29b-41d4-a716-446655440000
   → 无法直观判断生成顺序

UUID的优点:
1. 不需要中心节点
2. 绝对不会重复
3. 几乎不可能被预测

适用场景:分布式系统内部ID,而非对外暴露的短链码

1.3 为什么不用数据库自增ID

数据库自增ID的缺点:
1. 性能不够:每秒最多2000-5000次INSERT(单表)
2. 单点故障:依赖单一数据库
3. 跨库麻烦:分库后无法保证全局唯一

适用场景:单库单表、对ID顺序无要求的场景

【面试官心理】

面试官问发号器,通常想看两件事:

  1. 你知不知道雪花算法的原理和组成
  2. 你有没有考虑过时钟回拨的问题

大多数候选人能说出"时间戳+机器ID+序列号"的结构,但被追问"时钟回拨了怎么办"时就开始露馅。这是P6和P7的分水岭。

二、雪花算法原理 🔴

2.1 雪花ID结构

64位雪花ID:

| 符号位(1bit) | 时间戳(41bit) | 机器ID(10bit) | 序列号(12bit) |
|      0       |  0000000000   |   0000000000  |  000000000000 |

各部分详解:
1. 符号位(1bit):固定为0,保留
2. 时间戳(41bit):从某个epoch(2016-01-01)开始的毫秒数
   → 41bit能表示的最大值:2^41 - 1 ≈ 2.2万亿
   → 时间范围:2.2万亿毫秒 ≈ 69年

3. 机器ID(10bit):最多支持1024个节点
   → 二进制范围:0 ~ 1023

4. 序列号(12bit):每毫秒内递增,最多4096个/毫秒
   → 二进制范围:0 ~ 4095

2.2 雪花ID计算

// 雪花ID生成公式
long id = (timestamp - EPOCH) << 22   // 时间戳左移22位
         | (machineId << 12)           // 机器ID左移12位
         | sequence;                   // 序列号占低12位

// 示例:
// 时间戳 = 1734067200000,EPOCH = 1451606400000
// 差值 = 282240000000 毫秒
// 左移22位 = 282240000000 << 22 = ...
// 机器ID = 5
// 序列号 = 100

2.3 雪花算法实现

public class SnowflakeIdGenerator {

    private static final long EPOCH = 1609459200000L; // 2021-01-01
    private static final long MACHINE_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_MACHINE_ID = ~(-1L << MACHINE_BITS); // 1023
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);  // 4095

    private final long machineId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public SnowflakeIdGenerator(long machineId) {
        if (machineId > MAX_MACHINE_ID || machineId < 0) {
            throw new IllegalArgumentException("machineId必须在0-1023之间");
        }
        this.machineId = machineId;
    }

    public synchronized long nextId() {
        long timestamp = timeGen();

        // 时钟回拨:等待时钟追上
        if (timestamp < lastTimestamp) {
            timestamp = lastTimestamp;
        }

        // 新的一毫秒,序列号重置
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                // 序列号用完,等待下一毫秒
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;

        return ((timestamp - EPOCH) << 22)
             | (machineId << 12)
             | sequence;
    }

    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    private long timeGen() {
        return System.currentTimeMillis();
    }
}
⚠️

这段代码有几个关键点容易出错:

  1. sequence & MAX_SEQUENCE 是为了防止溢出,不是简单地 sequence++
  2. 时钟回拨时选择 timestamp = lastTimestamp 而非抛出异常,是为了让服务继续可用
  3. synchronized 保证单线程内ID递增,如果要高性能,需要用CAS或Disruptor

三、雪花算法的三大问题 🟡

3.1 时钟回拨问题

问题:机器时间回拨,导致生成重复ID

场景1:NTP服务器同步时间,回拨了100ms
场景2:虚拟机挂起,恢复后时间回退
场景3:手动修改系统时间

解决方案:
方案A(等):等待时钟追上,回拨100ms就等100ms
  → 简单但不可接受,回拨大时服务不可用

方案B(记录):记录上一次最大时间戳,回拨时用记录值
  → 折中,性能损失可接受

方案C(换机器):时钟回拨时切换机器ID
  → 需要提前预留备用机器ID

3.2 机器ID分配问题

问题:10bit机器ID,最多1024个节点
      但分布式系统可能有上万个服务实例

解决方案:
1. 配置中心统一分配:Zookeeper / etcd
2. 启动时自注册:写入MySQL/Redis获取唯一ID
3. 内网IP取模:机器IP % 1024(机器IP唯一)

3.3 序列号耗尽问题

问题:12bit序列号,每毫秒最多4096个
      如果单机QPS超过409万怎么办?

解决方案:
1. 多机分担:10台机器,每台扛40万QPS
2. 时间戳用微秒:精度从ms提升到μs,序列号耗尽概率降低1000倍
3. 改用19位序列号:需要修改雪花ID结构

四、号段预分配方案 🟡

4.1 号段模式的原理

发号器模式:每次向中心节点申请一段ID,用完再申请
Leaf模式 / 淘宝TDDL IDGenerator

架构:
应用服务器                    发号器服务
   |                              |
   | → selectMaxId from DB 获取   |
   | ← 返回 [1000001, 2000000]    |
   |                              |
   | → 本地 AtomicLong += 1       |
   | → (用完1百万个后) 再次申请    |

优点:
1. 应用层无锁,本地生成QPS无限制
2. 发号器服务可以集群部署
3. 可以批量申请,减少数据库压力

缺点:
1. ID不严格连续(段内连续,但段间可能有间隙)
2. 服务重启会浪费一段ID

4.2 号段服务实现

// 发号器服务 - 数据库表设计
// CREATE TABLE sequence (
//     biz_tag VARCHAR(64) PRIMARY KEY,  -- 业务标识,如 "short_url"
//     max_id BIGINT NOT NULL,           -- 当前最大ID
//     step INT NOT NULL                 -- 步长,默认10000
// );

public class SegmentIdService {

    private static final int STEP = 10000;
    private final AtomicLong current = new AtomicLong(0);
    private final AtomicLong next = new AtomicLong(0);

    public long getNextId() {
        if (current.get() >= next.get()) {
            refresh();
        }
        return current.incrementAndGet();
    }

    private void refresh() {
        // 从数据库获取下一个号段
        long newMax = dbService.getNextSegment("short_url", STEP);
        next.set(newMax);
        current.set(newMax - STEP);
    }
}

4.3 号段 vs 雪花:选哪个?

特性雪花算法号段模式
ID连续性完全递增段内递增,段间可能间隙
依赖时钟数据库
单机QPS4096/ms无限制(本地自增)
扩展性1024节点无限制
实现复杂度
ID可预测性递增可预测不可预测(更安全)

【架构权衡】

短链系统推荐用号段模式,原因:

  1. ID可预测性:短链本身就是公开链接,递增ID不会泄露更多信息,但可能被爬虫按顺序爬取。号段模式的间隙让爬虫更难批量爬取
  2. 性能更高:本地自增,没有时钟回拨风险
  3. 更适合多机房:各机房独立发号,不需要时钟同步

五、62进制转换优化 🟡

5.1 标准转换

public static String toBase62(long id) {
    String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    StringBuilder sb = new StringBuilder();
    while (id > 0) {
        int remainder = (int) (id % 62);
        sb.append(chars.charAt(remainder));
        id = id / 62;
    }
    return sb.reverse().toString();
}

// 雪花ID转8位短码
public static String snowflakeToShortCode(long snowflakeId) {
    // 雪花ID去掉高位时间戳,取低41位
    long id = snowflakeId & ((1L << 41) - 1);
    return leftPad(toBase62(id), 8, '0');
}

5.2 优化:查表法

// 预计算62^2的所有组合,减少除法运算
private static final String[] BASE62_TABLE = new String[62 * 62];

static {
    String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    for (int i = 0; i < 62; i++) {
        for (int j = 0; j < 62; j++) {
            BASE62_TABLE[i * 62 + j] = String.valueOf(chars.charAt(i)) + chars.charAt(j);
        }
    }
}

public static String toBase62Fast(long id) {
    StringBuilder sb = new StringBuilder();
    while (id > 0) {
        int remainder = (int) (id % 62);
        sb.append(CHARS.charAt(remainder));
        id = id / 62;
    }
    return sb.reverse().toString();
}

5.3 短码长度控制

雪花ID转Base62后的长度分布:

假设机器ID=5,序列号=100
雪花ID = (当前时间差 << 22) | (5 << 12) | 100

当前时间差约:280000000000 ms(从2016-01-01到2024-01-01)

转62进制:
62^6 = 56.8万亿
62^7 = 3.52千万亿
62^8 = 218万亿

雪花ID ≈ 10^12 量级 → 需要10位62进制
雪花ID低位部分 ≈ 10^8 量级 → 需要6位62进制

结论:用雪花ID的低48位(约2.8万亿)转62进制 = 8位

六、生产避坑 🟡

6.1 发号器的四大坑

坑1:时钟回拨导致ID重复

问题:NTP同步时间后,发号器开始生成重复ID
场景:系统运行1年后,NTP服务器把时间同步到出厂设置
影响:短链重复,数据覆盖
解决方案:
- 方案A:机器ID预留高位,遇到时钟回拨切换机器ID
- 方案B:时钟回拨时抛出异常,服务降级
- 方案C:用号段模式,彻底避免时钟依赖

坑2:号段耗尽时阻塞

问题:本地号段用完后,等待数据库返回新号段时服务暂停
场景:数据库主从切换,响应时间从1ms变成100ms
影响:批量请求等待,P99延迟飙升
解决方案:
- 提前刷新:当剩余号段 < 10%时,异步刷新
- 多级号段:本地+远程,本地用完前刷新远程
- 熔断降级:如果刷新失败,用雪花模式兜底

坑3:机器ID冲突

问题:多台机器被分配了相同的机器ID
场景:配置中心故障,机器重启后获取到重复ID
影响:两台机器生成相同的短链
解决方案:
- 机器ID = 内网IP的CRC32 % 1024(IP唯一性保证ID唯一)
- 或:机器ID写入本地文件,启动时检查

坑4:短码长度不可控

问题:雪花ID转Base62后,长度在6-10位之间波动
场景:用户看到有的短链6位,有的10位,体验不一致
影响:短链长度不一致,无法统一处理
解决方案:
- 统一补零到固定长度(如8位)
- 或:用号段模式,号段内ID递增,转Base62后长度固定

6.2 监控指标

指标目标值告警阈值
ID生成QPS>10<5
ID生成延迟P99<1ms>10ms
时钟回拨次数0>1次/天
号段剩余>20%<10%

七、真实面试回放 🟡

面试官:你的短链系统用雪花算法生成ID,但雪花算法有时钟回拨问题,怎么处理?

候选人(大张):时钟回拨有两种场景:

一是NTP小幅度回拨(几毫秒到几秒),我选择等待。代码里 timestamp = lastTimestamp,等待时钟追上来再生成。最多等待几秒,不影响服务可用性。

二是NTP大幅度回拨(几分钟到几年),这是异常情况。我的处理是:抛异常 + 告警 + 服务降级为读数据库。同时准备备用机器ID,回拨时切换到备用ID。

面试官:如果等待时间太长怎么办?

大张:可以优化为"快等待+慢降级":

  • 回拨 < 100ms:同步等待
  • 回拨 100ms-1s:异步等待 + 返回错误(降级)
  • 回拨 > 1s:直接抛异常,切备用机器ID

面试官:为什么不用号段模式彻底避免时钟问题?

大张:号段模式确实更优雅,但我们评估后选择雪花是因为:

  1. 短链不需要防预测(链接本身就是公开的)
  2. 雪花ID可以反解出生成时间(方便排查问题)
  3. 不需要额外的数据库表和运维

号段模式更适合订单号这类需要防预测的场景。

面试官:雪花ID转Base62,怎么保证长度一致?

大张:我的做法是取雪花ID的低48位,这部分最大值约280万亿,转Base62后是8位。我统一补零到8位,不够的在前面加0。

如果8位不够(如业务增长超过预期),可以扩展到12位或16位,但这需要修改前端URL解析规则。

【面试官手记】

大张的回答展示了几个亮点:

  1. 时钟回拨分层处理:不是简单的"等待"或"抛异常",而是分级别处理
  2. 知道什么时候用雪花、什么时候用号段:不是无脑推荐某种方案,而是分析trade-off
  3. 反问面试官"为什么不用号段":说明他对两种方案都有深入理解

这场面试属于P7级别,亮点是"方案对比"和"trade-off分析"。雪花 vs 号段的讨论,是系统设计中经典的"正确性 vs 性能"矛盾的缩影。

发号器设计是短链系统中最体现功力的环节。记住三个关键点:

  1. 雪花 vs 号段:短链用雪花(简单),订单用号段(安全)
  2. 时钟回拨:分层处理,不要一刀切
  3. 长度控制:统一补零,保证用户体验

当你能在面试中讲清楚"为什么选这个方案而不是那个方案",P7的offer就近在眼前了。