外观
从时钟回拨到零重复:Redis INCR 号段订单 ID 容错实录
问题背景:订单 ID 为什么不能只靠时间戳
在分库分表的交易系统里,订单 ID 必须满足全局唯一、趋势递增、高可用三个硬指标。最常见的做法是雪花算法(Snowflake),它由时间戳、机器位、序列号拼接而成,生成速度极快,却埋下一个致命隐患——时钟回拨。当 NTP 校时把系统时钟往回拨,或容器实例漂移到时间更慢的节点,雪花算法可能生成与历史窗口重复的 ID。订单主键一旦冲突,轻则写入失败、资金状态错乱,重则需要人工对账。
时钟回拨在云上并不罕见:虚拟机热迁移、宿主机时钟异常、K8s 节点重启都可能触发几百毫秒到数秒的偏移。雪花算法通常只能靠「等待时钟追上」或「拒绝服务」应对,这对要求 7×24 不中断的下单链路并不友好。
号段模式换了一个思路:不在本地拼时间,而是让发号器一次性从中央存储领取一段连续整数(如 1000 个),应用在内存里顺序消耗,用完再领下一段。这样生成的 ID 与本机时钟完全解耦,从根本上免疫时钟回拨。Redis 的 INCR 原子自增正是实现号段预分配的轻量利器。
方案对比:四类全局 ID 生成策略选型
| 方案 | 全局唯一 | 趋势递增 | 时钟回拨风险 | 性能 | 依赖复杂度 |
|---|---|---|---|---|---|
| UUID / UUIDv7 | 是 | v4 无序、v7 递增 | 无 | 极高 | 无 |
| 数据库自增 | 是 | 是 | 无 | 低,主库瓶颈 | 需主从,扩容难 |
| 雪花算法 | 是 | 是 | 有,可能重复 | 极高 | 需分配 workerId |
| Redis INCR 号段 | 是 | 是 | 无 | 高,内存发号 | 需 Redis 高可用 |
UUID 过长且 v4 完全无序,作为 InnoDB 聚簇索引主键会造成页分裂;数据库自增受单主写入限制;雪花虽快但把正确性押在时钟单调上。号段模式把「唯一性仲裁」集中到 Redis 一次,领取后在本地内存发号,兼顾了性能与安全,是订单、支付、消息 ID 的主流折中。
号段模式核心原理与数据结构
号段模式的关键是「预分配 + 内存消费」。服务启动或当前号段将耗尽时,向 Redis 申请一段 [max_id+1, max_id+step],把 max_id 推进一个步长。Redis 只需保存一个当前最大值键,配合 Lua 脚本保证「读取旧值、推进步长」原子完成。
注意:即便应用实例的系统时钟发生回拨,已经分配到内存里的整数号段也不会受任何影响,因为 ID 数值来自 Redis 维护的单调计数器,而非本地时间。
实操步骤:Lua 原子预分配可运行示例
第一步:用 INCRBY 原子领取号段
直接用两条命令(先 GET 再 SET)在并发下会丢号段,必须借助 Lua 脚本让「读旧值、加步长、返回新区间」在 Redis 单线程内一次执行完成。
bash
# 定义号段分配脚本:返回本次号段的起始值(含),结束值由客户端自行 +step
# KEYS[1]=计数器键 ARGV[1]=步长 step
redis-cli --eval seg_alloc.lua order:maxid , 1000其中 seg_alloc.lua 内容如下:
lua
-- seg_alloc.lua:原子领取一个号段
local key = KEYS[1]
local step = tonumber(ARGV[1])
-- 键不存在时初始化为 0,再原子增加 step
local end_id = redis.call('INCRBY', key, step)
local start_id = end_id - step + 1
-- 同时返回起始与结束,便于客户端直接使用
return {start_id, end_id}也可以不落地文件,直接用 EVAL 验证:
bash
# 第一次领取,返回 1) "1" 2) "1000"
redis-cli EVAL "local e=redis.call('INCRBY',KEYS[1],ARGV[1]) return {e-ARGV[1]+1,e}" 1 order:maxid 1000
# 第二次领取,返回 1) "1001" 2) "2000",两段绝不重叠
redis-cli EVAL "local e=redis.call('INCRBY',KEYS[1],ARGV[1]) return {e-ARGV[1]+1,e}" 1 order:maxid 1000
# 查看当前最大值
redis-cli GET order:maxid第二步:关键数据结构演示
bash
# 计数器:字符串存储的 64 位整数(Redis 内部为 long long)
SET order:maxid 2000
OBJECT ENCODING order:maxid # 返回 int,紧凑编码
# 为不同业务线隔离号段,用键前缀区分,互不影响
SET order:maxid:pay 500
SET order:maxid:refund 80Redis 的整数键在值较小时采用 int 编码,INCRBY 时间复杂度 O(1),单实例可支撑十万级 QPS 的领段请求;又因为每次领取的是一整段,实际对 Redis 的访问频率 = 发号 QPS / step,压力被进一步摊薄。
第三步:客户端内存发号(Java 示意)
java
// 双 Buffer 号段容器:当前段消耗到阈值时异步加载下一段
public class SegmentBuffer {
private volatile long start, end, cursor; // 当前号段区间与游标
private final long step = 1000;
private final AtomicBoolean loading = new AtomicBoolean(false);
// 取号:纯内存自增,无锁竞争,与本机时钟无关
public synchronized long nextId(Jedis jedis) {
long id = ++cursor;
// 剩余不足 20% 时异步预取下一段,避免用完瞬间阻塞
if (id >= end - step * 0.2 && loading.compareAndSet(false, true)) {
CompletableFuture.runAsync(() -> preload(jedis));
}
if (id > end) { // 极端情况下当前段已耗尽,同步等待
waitNext();
return nextId(jedis);
}
return id;
}
// preload:调用 Lua 领取 [start,end],加载完成后切换 buffer
}双 Buffer 的价值在于:业务线程绝大多数时候只在内存里 cursor++,领段发生在后台,既无网络抖动,也不会在段边界出现请求毛刺。
第四步:高可用与故障降级
bash
# 生产环境用哨兵或 Cluster 保证发号器不单点
# 哨兵监控主节点,故障自动切换
redis-sentinel /etc/redis-sentinel.conf
redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster当 Redis 短暂不可用时,客户端可启用「本地保留段」兜底:每个应用实例在启动时额外预留一小段(如 100 个)作为应急,平时不动,Redis 故障期间消耗保留段维持发号,恢复后再通过新的 INCRBY 对齐。由于各实例保留段也是提前从 Redis 领取的不重叠区间,降级期间依然不会重复。
避坑总结:来自生产实践的 5 条硬经验
切勿用 GET + SET 两步领段。常见错误是先
GET order:maxid算出新值再SET,高并发下两个实例读到同一旧值,号段大面积重叠,订单主键冲突。修正方法:一律用INCRBY或上面的 Lua 脚本,把读改写收敛为单条原子命令。步长 step 不能拍脑袋设太小。step 设成 1 就退化成每次取号都打 Redis,网络抖动直接拖垮下单,并在段边界产生大量超时;设成几百万又会在服务频繁重启时浪费大量号段(形成 ID 空洞)。经验值:普通订单取 1000~5000,配合双 Buffer,既摊薄 Redis 压力,又把重启浪费控制在可接受范围。要接受「ID 允许不连续、只保证唯一递增」这一前提。
主从切换要防号段回退。故障现象:Redis 主节点异步复制未完成就宕机,哨兵提升了落后的从节点,新主的
maxid比旧主小,重新领段会分配出已经发过的 ID。修正方法:开启min-replicas-to-write强制至少同步到 1 个从节点才可写,或用 WAIT 命令确认复制;关键场景叠加「保留段 + 启动时主动跳号」,实例重启后把本地已知最大值回灌 Redis 做 MAX 对齐。不要把号段 ID 再和时间戳拼接。有人为了「看起来有序」在号段前拼毫秒时间,结果又把时钟回拨风险引了回来,还可能因长度溢出破坏趋势递增。号段模式的全部意义就在于去时间化,保持纯整数递增即可;若业务确实需要按时间分表,可在 ID 之外另存
create_time字段。注意计数器上限与持久化。Redis 整数最大约 9.2×10^18,正常业务远用不完,但要避免误把步长传成负数或超大值导致跳号;同时发号器键必须纳入 RDB/AOF 持久化与监控,对
order:maxid设置过期时间是典型错误——键一旦被淘汰归零,ID 将从头重复。建议用SET ... KEEPTTL或干脆不设 TTL,并对 maxid 增长速率配置告警。
号段模式用一次集中仲裁换取全程本地发号,把雪花算法最棘手的时钟回拨问题从架构层面消弭。配合 Lua 原子领段、双 Buffer 预取、复制确认与保留段降级,Redis INCR 完全可以承载金融级订单 ID 的唯一性要求。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。