外观
04 - 分布式ID生成
知识图谱
RedisIdWorker.nextId("order")
│
┌───────────┴───────────┐
│ │
时间戳部分(32bit) 序列号部分(32bit)
│ │
nowSecond - BEGIN_TIMESTAMP Redis INCR 自增
(1640995200 = 2022-01-01) key = icr:{prefix}:{yyyy:MM:dd}
│ │
└───────────┬───────────┘
│
timestamp << 32 | count
│
64位 Long 型 ID
│
┌─────────────┴──────────────┐
│ 高32位: 时间戳(支持~136年) │
│ 低32位: 序列号(每天43亿) │
└────────────────────────────┘核心代码走读
文件: src/main/java/com/hmdp/utils/RedisIdWorker.java
java
@Component
public class RedisIdWorker {
// 起始时间戳: 2022-01-01 00:00:00 UTC
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 序列号占用位数
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号 — 按天分key
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
// 3.位运算拼接
return timestamp << COUNT_BITS | count;
}
}ID 结构解析
┌─── 符号位(1bit, 始终为0) ─── 时间戳(31bit) ─── 序列号(32bit) ───┐
│ 0 │ 00000000 00000000 00000000 0000000 │ 00000000 00000000 00000000 00000000 │
└───┴──────────────────────────────────┴──────────────────────────────────────┘
注意: Java long 是64位有符号整数
- 第1位: 符号位, 始终为0(正数)
- 第2-32位: 时间戳差值(31bit), 支持 2^31 秒 ≈ 68年 (从2022到2090)
- 第33-64位: 序列号(32bit), 每天最多 2^32 ≈ 43亿个ID使用场景
java
// 秒杀订单ID
long orderId = redisIdWorker.nextId("order");
// 对应 Redis key: icr:order:2024:03:15
// 点赞行为ID
Long behaviorId = redisIdWorker.nextId("like-behavior");
// 对应 Redis key: icr:like-behavior:2024:03:15面试 Q&A
Q1: 为什么不用数据库自增ID?
数据库自增有三个致命问题:
- 单点瓶颈:所有 INSERT 都要走同一个自增序列,高并发下成为性能瓶颈
- 分库分表困难:多个数据库实例的自增ID会冲突
- 信息泄露:连续的ID暴露业务量和增长速度(竞对可以通过下两个订单推算你的单量)
追问:那为什么不用 UUID?
UUID 有两个问题:
- 无序性:UUID 是随机的,插入 B+Tree 索引时会导致频繁页分裂,写入性能差
- 长度大:128位(32个十六进制字符),作为主键浪费存储空间,JOIN 和索引查找都慢
RedisIdWorker 生成的是 64位 Long,单调递增,对 B+Tree 友好。
再追问:单调递增会不会有安全问题?ID 也是连续的啊?
不完全连续。虽然时间戳部分是递增的,但序列号部分按天重置,且不同 keyPrefix 有不同的计数器。攻击者无法通过两个 ID 差值推算精确单量,因为中间可能有其他 prefix 的 ID 生成。
如果安全要求更高,可以在 ID 生成后再做一层混淆(如对低位做异或变换)。
Q2: 为什么按天分 key?
三个好处:
- 避免热 key:如果全局只用一个 key,所有业务的 INCR 都打到同一个 key,成为热点
- 天然统计:可以通过
GET icr:order:2024:03:15直接查当天订单量- 序列号不溢出:每天重置,32位序列号(43亿/天)绰绰有余
追问:按天分 key 会不会导致跨天时刻的ID不递增?
不会。因为高32位是时间戳(秒级),跨天时刻时间戳增大,即使序列号从0重新开始,整体 ID 仍然递增。
例如:
(T_day1 << 32) | 999999<(T_day2 << 32) | 0,因为T_day2 > T_day1。
Q3: 这个方案的并发极限是多少?
理论上限由 Redis INCR 性能决定。单个 Redis 实例的 INCR 命令 QPS 可达 10万+。
按天分 key 后,每个 prefix 每天独立计数,不同业务互不影响。实际瓶颈通常不在 ID 生成,而在下游业务逻辑。
追问:如果 Redis 挂了怎么办?
这是单点问题。解决方案:
- Redis Sentinel / Cluster:高可用部署
- 降级方案:本地 Snowflake(需要预分配 workerId)作为 fallback
- 号段模式:提前从 Redis 批量取一段序列号缓存在本地(类似美团 Leaf)
Q4: 和 Snowflake 对比有什么优劣?
| 维度 | RedisIdWorker | Snowflake | 数据库自增 | UUID |
|---|---|---|---|---|
| 长度 | 64位 | 64位 | 32/64位 | 128位 |
| 有序性 | 单调递增 | 趋势递增 | 严格递增 | 无序 |
| 依赖 | Redis | 机器时钟 | 数据库 | 无 |
| 时钟回拨 | 不受影响 | 致命问题 | 不受影响 | 不受影响 |
| 每秒容量 | ~10万(单Redis) | ~400万(单机) | ~1万 | 无限 |
| 分布式 | 天然支持 | 需分配workerId | 需号段 | 天然支持 |
RedisIdWorker 最大的优势是不受时钟回拨影响(Snowflake 的噩梦),代价是引入 Redis 依赖。
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
ZoneOffset.UTC | 时间戳用 UTC 计算,但 seckill.lua 用的是 ZoneId.systemDefault() | 跨模块时区不一致 |
BEGIN_TIMESTAMP 硬编码 | 1640995200 = 2022-01-01,支持到2090年左右 | 是否考虑长期维护 |
| 无溢出保护 | 理论上 count 超过 2^32 会溢出到时间戳位 | 对位运算的理解 |
| 无降级机制 | Redis 不可用时直接抛异常 | 系统可用性设计 |
加分回答
- 对比美团 Leaf 的号段模式:预取一批 ID 到本地,减少 Redis 调用
- 提到 Snowflake 时钟回拨问题的解决方案(NTP 关闭步进式调整、等待追回)
- 分析
timestamp << 32 | count的位运算:为什么用|不用+(效果相同但位运算更快且语义更清晰) - 提到按天分 key 的副作用:Redis 中会积累大量历史 key,需要定期清理或设置 TTL
关联文档
- 03-秒杀系统 — RedisIdWorker 在秒杀中的使用
- 07-点赞子系统 — 点赞行为ID生成
- 11-已知问题与优化方向 — 降级和溢出问题