号段缓存
我以为 Redis INCR 已经足够快了,直到那次压力测试。
那是周二的下午,我正在做 10 万 QPS 的压测。短链接服务的各项指标都很好——响应时间在 5ms 以内,CPU 和内存也正常。但 Redis 的监控曲线让我皱起了眉:
Redis QPS: 98,000/s
Redis 延迟: P99 从 1ms 飙升到 15ms
问题很明显:每次生成都要走一次 Redis。在高并发时,网络往返成了最大的开销。即使 Redis 本身够快,但每次调用都要:
- 序列化请求
- 发送到 Redis
- Redis 执行 INCR
- 序列化响应
- 返回给应用
这 5 步加起来,单次可能只要 0.5ms,但乘上每秒 10 万次,就成了巨大的负担。
更重要的是,这让我的系统高度依赖 Redis。如果 Redis 抖一下,整个 ID 生成都受影响。
我需要减少对 Redis 的依赖。
我开始研究业界的方案,看到了美团技术团队的 Leaf 项目。他们的思路让我眼前一亮:
不是每次取一个 ID,而是一次取一批。
就像我去超市买东西——不是每需要一件东西就跑一趟超市,而是一次买一周的量放在家里慢慢用。
传统模式(每次 1 个 ID):
我 → Redis: "给我 1 个 ID" → 1
我 → Redis: "给我 1 个 ID" → 2
我 → Redis: "给我 1 个 ID" → 3
...(每次都要网络往返)
号段模式(每次取一批):
我 → Redis: "给我 1000 个 ID" → 1~1000
我在本地分配: 1, 2, 3, ..., 1000
用完后 → Redis: "再给我 1000 个" → 1001~2000
这样的好处显而易见:
| 指标 | 传统模式 | 号段模式 |
|---|---|---|
| Redis 请求次数 | 每生成 1 次 | 每 1000 个 1 次 |
| Redis 压力 | 高 | 减少 1000 倍 |
| ID 生成速度 | 受限于网络 | 纯内存操作 |
| 外部依赖 | 每次都依赖 | 可短暂独立运行 |
99% 的 ID 生成都变成了纯内存操作,性能提升会是数量级的。
我决定实现一个号段分配器。先用最简单的方案——单号段。
数据库设计
我把 Redis 换成了数据库,因为数据库更可靠,而且号段模式下请求次数极少:
数据设计要点
- 核心是在
id_segment里保存业务事实,而不是把规则散落在应用逻辑里。- 每个业务线一行:
biz_tag区分业务,max_id记录已经分配出去的最大 ID,step是每次申请的号段长度。- 申请号段是一次「读 → 改 → 写」的更新,读到的
max_id和写回时的max_id必须对得上,否则会重复发号。- 关键字段包括
biz_tag、max_id、step、version、updated_at、INSERT,它们决定了后续查询和管理能力。
这里的关键是 max_id 字段。它表示”已经分配出去的最大 ID”,所以下一个号段从 max_id + 1 开始。
第一版:单号段实现
落地思路
- 号段的获取是一次数据库操作:读出
max_id,算出下一段[max_id+1, max_id+step],再把max_id写回去。- 写回时带上
version做乐观锁:UPDATE ... SET max_id=?, version=version+1 WHERE biz_tag=? AND version=?。- 影响行数为 0 表示这一段被别人抢走了,重新读一次再重试,不能直接用本地算出来的值。
- 本地再维护一个游标,从
max_id+1开始往上发号,发到max_id+step就说明这段用完了。
这个实现的核心是 乐观锁。通过 version 字段保证多个实例同时请求时,只有一个能成功抢到号段:
数据设计要点
- 这条更新必须带条件:
WHERE biz_tag = ? AND version = ?,更新行数为 0 就说明号段被别人抢走了,要重新读取后重试。- 更新行数为 0 不是错误,是「重试」信号;只有反复重试都拿不到号段,才说明数据库出了真问题。
如果 affected 是 0,说明被别人抢先了,重新读取并重试。
性能测试
我跑了压测,结果让我兴奋:
单号段模式性能:
- QPS: 95,000/s(纯内存操作)
- 数据库请求:约 95 次/秒(每 1000 个 ID 一次)
- 响应时间 P99: 2ms
对比之前的 Redis 方案:
- QPS: 70,000/s
- Redis 请求:70,000 次/秒
- 响应时间 P99: 15ms
性能提升了约 35%,数据库压力减少了 99%!
更重要的是,系统更加稳定了。因为 99% 的 ID 生成是纯内存操作,不再依赖外部系统。
但第一版上线后,我很快发现了问题。
有次查看监控,我发现每隔几分钟就会有 短暂的延迟尖刺:
正常请求:2ms
尖刺时刻:120ms, 85ms, 150ms...
我查看日志,发现这些尖刺都出现在 号段用完、重新加载数据库 的时候:
落地思路
- 号段用完的判断是本地游标超过当前段的末尾,此时必须去数据库申请下一段。
- 申请要做成阻塞式的:先拿到新号段再继续发号,绝不能边等边发,否则会发出重复的 ID。
- 尖刺的根源是所有线程同时发现号段用完、同时去抢数据库,这批请求一起等在同一个往返上。
- 兜底是申请失败要有退避重试,重试期间请求可以先返回错误,不能默默发重复 ID。
在高并发时,号段用完的瞬间,所有线程都会被阻塞,等待数据库返回。虽然只有几十毫秒,但在高并发系统中,这就是一个明显的延迟尖刺。
双 Buffer 方案
我想到一个解决方案:双缓冲。
就像视频播放器的预加载——不是等到当前视频播完了再加载下一个,而是在播放到 80% 时就开始预加载下一个。
落地思路
- 维护两个号段:当前段负责发号,下一段在后台异步预取。
- 当前段用到 80% 就触发预加载,预加载走独立线程,不占用发号线程。
- 当前段耗尽时直接切换到已经就绪的下一段,切换本身只是一次指针替换,不涉及等待。
- 兜底是预加载失败:当前段还剩两成时还有时间重试,实在取不到才退化成同步申请,尖刺不可避免但服务不至于中断。
双 Buffer 的工作流程
时间轴:
┌─── 号段1(1~1000)────┐ ┌─── 号段2(1001~2000)───┐
│ │ │ │
│ 用到 80% 时触发预加载 │→ │ 异步从数据库获取 │
│ │ │ │
│ 继续使用到 100% │→ │ 切换!无缝衔接 ✅ │
└────────────────────────┘ └──────────────────────────┘
↓
用到 80% 时预加载号段3...
再跑压测,延迟尖刺消失了:
双缓冲模式性能:
- QPS: 98,000/s
- 响应时间 P99: 2ms(无尖刺)
- 数据库请求:约 98 次/秒
完美!延迟稳定,性能进一步提升。
号段模式天然支持多实例部署。每台服务器会取到不同的号段,不会冲突:
服务器 A:取到 1~1000
服务器 B:取到 1001~2000
服务器 C:取到 2001~3000
各用各的号段,完全不冲突 ✅
生成的 ID 序列:
实际生成的 ID 顺序:
A: 1, 2, 3, ... ← A 的号段
B: 1001, 1002, 1003, ... ← B 的号段
C: 2001, 2002, ... ← C 的号段
A: 4, 5, ... ← A 继续
总体:1, 2, 3, 1001, 1002, 4, 5, 2001, ...
→ 不是严格递增,但趋势递增 ✅
Step 大小选择
号段的 step 大小需要权衡:
- 太小:频繁请求数据库,失去缓存的意义
- 太大:浪费 ID,重启后丢失大量 ID
我根据业务量计算最优值:
落地思路
- step 的取值要在「数据库往返次数」和「浪费的 ID 数」之间取平衡。
- 估算方式:step ≈ 单实例每秒的 ID 需求 × 一个号段能支撑的时长,再乘上实例数。
- 按本章的量级看,数据库申请号段的频率在每 5 分钟一次这个量级,说明 step 与实例数、单实例需求是匹配的。
- 兜底是 step 选大时浪费的 ID 要有上限意识:实例重启会丢掉手里没用完的整段,step 越大丢得越多。
经过这些优化,我的分布式 ID 方案已经相当成熟了。但我想再进一步——如果数据库挂了呢?
虽然号段模式下数据库请求极少,但如果真的挂了,系统还是不能生成 ID。所以我加了一层保险:
号段缓存 + Snowflake fallback:
1. 优先使用号段缓存(短 ID,性能高)
2. 如果号段用完且数据库不可用,降级到 Snowflake(长 ID,但可用)
3. 监控告警,尽快恢复数据库
这样,即使数据库挂了,系统依然可以生成 ID,只是 ID 会变长,不再是连续的。
我加上了监控指标:
落地思路
- 盯住四个指标:当前号段的已用比例、剩余数量、预加载状态、数据库申请号段的频率。
- 已用比例是提前预警的信号:接近 100% 而预加载还没成功,说明下一段可能接不上。
- 预加载状态要单独监控,它一旦变成「未就绪」,延迟尖刺就会重新出现。
- 兜底是数据库申请失败率超阈值就告警:号段模式靠数据库兜底,数据库不可用时这层保险就失效了。
监控仪表盘:
┌─────────────────────────────────────────┐
│ 号段模式监控 │
├─────────────────────────────────────────┤
│ 当前号段:12,001 ~ 13,000 │
│ 已使用:78% ████████████░░░ │
│ 剩余:274 个 │
│ 预加载状态:✅ 下一号段已就绪 │
│ 数据库请求频率:约 1 次/5 分钟 │
│ ID 生成速度:约 200 个/秒 │
└─────────────────────────────────────────┘
- 号段模式下,如果一台服务器拿到号段后挂了,那些 ID 会怎样?(答案:丢失了,但不影响唯一性)
- 为什么说号段模式生成的 ID 是”趋势递增”而不是”严格递增”?
- 如果号段的 step 设太小(如 10),会有什么问题?设太大(如 1000 万)呢?
| 特性 | 数据库自增 | Redis INCR | 号段模式 | 雪花算法 |
|---|---|---|---|---|
| 唯一性 | ✅ | ✅ | ✅ | ✅ |
| 递增 | 严格 | 严格 | 趋势 | 趋势 |
| 数据库压力 | 高 | 低 | 极低 | 无 |
| 外部依赖 | 数据库 | Redis | 数据库 | 无 |
| 性能 | 低 | 高 | 极高 | 极高 |
| 实现复杂度 | 低 | 低 | 中 | 高 |
| ID 连续性 | 连续 | 连续 | 段内连续 | 不连续 |
发现内容有问题?
告诉我们哪里需要改进。
