跳到主内容

从时钟回拨到零重复: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 脚本保证「读取旧值、推进步长」原子完成。

应用实例 A内存号段 1~1000应用实例 B内存号段 1001~2000Redis 发号器order:maxid INCRBY原子领取一段段间天然不重叠

注意:即便应用实例的系统时钟发生回拨,已经分配到内存里的整数号段也不会受任何影响,因为 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 80

Redis 的整数键在值较小时采用 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 条硬经验 ​

  1. 切勿用 GET + SET 两步领段。常见错误是先 GET order:maxid 算出新值再 SET,高并发下两个实例读到同一旧值,号段大面积重叠,订单主键冲突。修正方法:一律用 INCRBY 或上面的 Lua 脚本,把读改写收敛为单条原子命令。

  2. 步长 step 不能拍脑袋设太小。step 设成 1 就退化成每次取号都打 Redis,网络抖动直接拖垮下单,并在段边界产生大量超时;设成几百万又会在服务频繁重启时浪费大量号段(形成 ID 空洞)。经验值:普通订单取 1000~5000,配合双 Buffer,既摊薄 Redis 压力,又把重启浪费控制在可接受范围。要接受「ID 允许不连续、只保证唯一递增」这一前提。

  3. 主从切换要防号段回退。故障现象:Redis 主节点异步复制未完成就宕机,哨兵提升了落后的从节点,新主的 maxid 比旧主小,重新领段会分配出已经发过的 ID。修正方法:开启 min-replicas-to-write 强制至少同步到 1 个从节点才可写,或用 WAIT 命令确认复制;关键场景叠加「保留段 + 启动时主动跳号」,实例重启后把本地已知最大值回灌 Redis 做 MAX 对齐。

  4. 不要把号段 ID 再和时间戳拼接。有人为了「看起来有序」在号段前拼毫秒时间,结果又把时钟回拨风险引了回来,还可能因长度溢出破坏趋势递增。号段模式的全部意义就在于去时间化,保持纯整数递增即可;若业务确实需要按时间分表,可在 ID 之外另存 create_time 字段。

  5. 注意计数器上限与持久化。Redis 整数最大约 9.2×10^18,正常业务远用不完,但要避免误把步长传成负数或超大值导致跳号;同时发号器键必须纳入 RDB/AOF 持久化与监控,对 order:maxid 设置过期时间是典型错误——键一旦被淘汰归零,ID 将从头重复。建议用 SET ... KEEPTTL 或干脆不设 TTL,并对 maxid 增长速率配置告警。

号段模式用一次集中仲裁换取全程本地发号,把雪花算法最棘手的时钟回拨问题从架构层面消弭。配合 Lua 原子领段、双 Buffer 预取、复制确认与保留段降级,Redis INCR 完全可以承载金融级订单 ID 的唯一性要求。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。