主题
分布式 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 或号段就够了。