跳到正文

分布式 ID 生成 ​

一句话:分布式 ID 把「唯一」从数据库自增搬到应用或独立发号器,用位布局换趋势递增和吞吐,代价是时钟依赖、号段跳号和一套新的运维面。

适用:MySQL 8.0+、Redis 7.x;Snowflake 没有统一标准,位宽可自定义;UUID v7 见 RFC 9562。

为什么需要 ​

单库自增在分库分表后失效:每个分片都从 1 开始,跨分片必然重号。UUID v4 完全随机,写入 B+Tree 会频繁页分裂,索引也更大。Redis INCR 能保证唯一,但把发号变成对 Redis 的强依赖。

业务对 ID 的要求通常只有三条:全局唯一、趋势递增(写入和范围查询友好)、长度可控(最好是一个 64 位整数)。连续性、可反解、可读性都是附加项,不能默认全要。

核心机制 ​

Snowflake:用位布局换吞吐 ​

标准布局是 1 位符号 + 41 位毫秒时间戳 + 10 位机器号 + 12 位序列号:

0 | 41-bit timestamp(ms) | 10-bit worker | 12-bit sequence
  • 41 位从自定义 epoch 起算约 69.7 年。
  • 10 位机器号支持 1024 个节点。
  • 12 位序列号支持每节点每毫秒 4096 个 ID,这是位宽上限,不是压测保证的持续 QPS。

ID 随时间趋势递增,但同一毫秒内不保证严格有序。机器号必须全局唯一,否则同一毫秒的两个节点会生成相同 ID。

时钟回拨怎么处置 ​

时钟回拨是 Snowflake 的主要故障面。回拨几毫秒可以等,回拨很大必须拒绝,不能继续用旧时间生成:

text
伪代码:
now = clock_ms()
if now < last_ts:
    drift = last_ts - now
    if drift <= 5: sleep(drift); now = clock_ms()
    else: return ERROR          # 或切备用 workerId,并告警
seq = (now == last_ts) ? (seq + 1) & 0xFFF : 0
if seq == 0 and now == last_ts: wait_next_ms()
last_ts = now
id = ((now - epoch) << 22) | (worker_id << 12) | seq

工程上还要做三件事:NTP/chrony 运行期用 slew 而不是 step;启动时等时间同步完成再注册 workerId;把 last_ts 持久化,重启后不能比它更小。容器环境不要用 IP + 主机名取模分配 workerId,Pod 重建后容易冲突,改用数据库或租约表分配。

号段模式:从数据库批量取号 ​

号段模式一次从数据库取一段 ID,应用在本地递增,用完再取:

sql
START TRANSACTION;
UPDATE id_segment
   SET max_id = LAST_INSERT_ID(max_id + step)
 WHERE biz_type = 'order';
SELECT LAST_INSERT_ID();
COMMIT;

LAST_INSERT_ID(expr) 是连接级变量,UPDATE 把新值写入会话,紧跟的 SELECT 读同一个连接。UPDATE 和普通 SELECT max_id 分开写不是原子的:两个事务可能都读到同一个最大值,必须用 LAST_INSERT_ID 或 PostgreSQL 的 UPDATE ... RETURNING。

双 Buffer 是在当前号段用完前异步加载下一段,避免取号阻塞。代价是重启会浪费未用完的号段,ID 天然不连续。

UUID v7 与 Redis INCR ​

UUID v7 是 48 位毫秒时间戳 + 版本/变体 + 随机位,共 128 位;按时间可排序,不依赖中心节点,但索引占用是 64 位 ID 的两倍。Redis INCR 简单、绝对递增,代价是 Redis 成为发号链路的一部分。

短链接:ID 的另一种用法 ​

短链接不是独立发号问题,而是「唯一 ID + Base62」:用 Snowflake 或号段生成 ID,转成 [0-9a-zA-Z] 的 Base62。7 位可表示 62^7 ≈ 3.52 万亿 个短码。落库保存 短码 -> 原 URL,访问时返回 302。不要用 Base64,+ 和 / 在 URL 里需要额外转义。

最小上手 ​

MySQL 8.0 建表并取一段号:

sql
CREATE TABLE id_segment (
  biz_type VARCHAR(64) PRIMARY KEY,
  max_id   BIGINT NOT NULL,
  step     INT NOT NULL DEFAULT 1000
);
INSERT INTO id_segment(biz_type, max_id, step) VALUES ('order', 0, 1000);

START TRANSACTION;
UPDATE id_segment SET max_id = LAST_INSERT_ID(max_id + step)
 WHERE biz_type = 'order';
SELECT LAST_INSERT_ID();   -- 第一次返回 1000,本次可用 (0, 1000]
COMMIT;

验证:连续执行两次,返回值应相差 step;并发压测时不应出现重复返回值。

生产动作 ​

  • epoch 固定:上线后不能改。改 epoch 会让已发出的 ID 和新 ID 重叠或变成负数。
  • workerId 租约:租约 TTL 要大于进程最长存活时间,进程退出时主动归还;监控租约表和重复 ID 告警。
  • 时钟:chronyc tracking 看 offset;运行期禁止 step,启动阶段才允许 makestep。
  • 号段:step 常见经验值是 100–1000;太小取号频繁,太大重启浪费多。监控剩余号段,低于 20% 触发预加载。
  • 排障:重复 ID 先查 workerId 冲突和时钟回拨;ID 跳变查号段重启;负数查位宽溢出和 epoch。
  • 短链:短码要防遍历,可用随机盐或校验位;原 URL 要做协议和域名白名单,避免开放重定向。

边界 ​

  • 号段模式不保证连续,订单号、流水号如果要求连续,应改用数据库自增并接受单点瓶颈。
  • Snowflake ID 可预测,不能当一次性 token、密码重置码或支付凭证。
  • UUID v7 适合「不想运维发号器」的场景,但 128 位主键的索引和存储成本要提前算。
  • 依赖数据库或 Redis 的方案,发号器本身要按核心链路做高可用;否则 ID 生成会先于业务挂掉。

讲清楚 ​

分布式 ID 的核心是把唯一性从单库自增里拿出来。Snowflake 用时间戳保证趋势递增、机器号保证节点间不撞、序列号保证同毫秒并发,代价是时钟回拨和机器号分配。号段模式从数据库批量取一段,本地发号,绝对递增但会跳号,取号语句必须用 LAST_INSERT_ID 或 RETURNING 保证原子。选型先看业务是否要求连续、是否能接受 128 位主键、是否愿意维护发号器;大多数订单类场景用 Snowflake 或号段就够了。

把一门技术讲透到既懂也会用