Redis 原子递增

Redis 是高性能的键值存储,其原子递增命令非常适合用于生成分布式 ID。

基本原理

1. INCR 命令

定义: Redis 的 INCR 命令将键的值加 1,并返回新值。这是一个原子操作。


语法:

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。

示例:

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。

2. 原子性保证

特点:

✅ 原子操作
✅ 无需锁
✅ 线程安全
✅ 分布式安全

基本实现

1. 简单递增

每次调用 Redis 一次,直接拿回自增后的值。INCR 是 Redis 的原子命令,多个客户端同时调用也拿不到同一个值。

Java 实现:

/**
 * 基于 Redis INCR 的分布式 ID 生成器。
 */
public class RedisIdGenerator {

    private static final String KEY = "id:order";

    private final JedisPooled jedis;

    public RedisIdGenerator(String host, int port) {
        this.jedis = new JedisPooled(host, port);
    }

    /**
     * 取一个新 ID。
     *
     * INCR 返回的是自增之后的值,所以第一个 ID 是 1 而不是 0。
     */
    public long nextId() {
        try {
            return jedis.incr(KEY);
        } catch (JedisConnectionException e) {
            throw new IllegalStateException("Redis 不可用,无法生成 ID", e);
        }
    }
}

命令层面就是一条 INCR:

INCR id:order
(integer) 1
INCR id:order
(integer) 2

有三点要注意:

  • 键不存在时 Redis 按 0 处理,所以第一次 INCR 返回 1。业务上需要从别的起点开始时,看下一节的「带初始值」。
  • 计数器在 Redis 重启后可能重置。除非开了 AOF(appendfsync everysec)并有主从复制兜底,否则重启后计数器会从 0 重新开始,ID 会和历史重复。
  • INCR 走的是 64 位有符号整数,上限约 9.22 × 10¹⁸,单个键够用。

2. 带初始值

业务经常需要 ID 从某个起点开始发,比如订单号要从 100000 起步。做法是先把计数器初始化到起点附近,再做自增。

初始化必须用 SETNX 而不是 SET。SET 会把已经发出去的值重置回起点,之前发出去的 ID 会再发一遍。

Java 实现:

/**
 * 从指定起点开始发号的生成器。
 */
public class RedisIdGeneratorWithStart {

    private static final String KEY = "id:order";

    private final JedisPooled jedis;
    private final long start;
    private final long step;

    public RedisIdGeneratorWithStart(String host, int port, long start, long step) {
        this.jedis = new JedisPooled(host, port);
        this.start = start;
        this.step = step;
    }

    /**
     * 取一个新 ID。
     *
     * 计数器预置成 start - step,这样第一次自增之后拿到的正好是 start。
     */
    public long nextId() {
        try {
            // SETNX 只在键不存在时生效,多实例并发初始化时只有一个成功
            jedis.setnx(KEY, String.valueOf(start - step));
            return jedis.incrby(KEY, step);
        } catch (JedisConnectionException e) {
            throw new IllegalStateException("Redis 不可用,无法生成 ID", e);
        }
    }
}

命令层面:

SETNX id:order 99999
(integer) 1
INCRBY id:order 1
(integer) 100000

step 的作用是让 ID 按固定间隔增长:步长为 1 时 ID 连续,步长为 10 时每次跳 10 个,方便把渠道号之类的信息编码进 ID 的分段里。步长一旦定下就不能再改,否则中间会出现跳号。

有个容易踩的坑:SETNX 返回 1 说明键原本不存在,返回 0 说明键已经存在。返回 0 时要接着当前值继续自增,绝不能重置。


高级实现

1. 批量获取

原理: 一次获取多个 ID,减少 Redis 访问。


实现:


性能提升:

原来:每次请求都访问 Redis
现在:1000 次请求才访问 Redis 一次
性能提升:1000x

2. 业务隔离

原理: 为不同业务类型使用不同的键。


实现:


特性分析

优点

1. 高性能 ⭐⭐⭐⭐⭐

QPS:> 100,000
延迟:< 1ms
基于内存,速度极快

2. 原子性 ⭐⭐⭐⭐⭐

✅ 原子操作
✅ 无需锁
✅ 线程安全
✅ 分布式安全

3. 简单易用 ⭐⭐⭐⭐⭐

✅ 实现简单
✅ 代码少
✅ 易于维护
✅ 易于理解

4. 递增性 ⭐⭐⭐⭐

✅ 严格递增
✅ 有序性好
✅ 便于查询
✅ 便于排序

缺点

1. 依赖 Redis ⭐⭐⭐⭐⭐

❌ Redis 故障无法生成
❌ 单点故障
❌ 依赖外部服务

2. 序列恢复 ⭐⭐⭐⭐

❌ Redis 重启后序列可能重置
❌ ID 不连续
❌ 需要持久化

3. 性能瓶颈 ⭐⭐⭐

❌ 超高并发可能成为瓶颈
❌ 网络延迟
❌ 需要优化

性能优化

1. 本地缓存

原理: 在本地缓存一批 ID,减少 Redis 访问。


实现:


2. Pipeline 批量操作

原理: 使用 Redis Pipeline 批量执行命令。


实现:


适用场景

✅ 适合使用

1. 中高并发系统

QPS:10,000 - 100,000
延迟要求:< 1ms
已有 Redis 基础设施

2. 递增性重要

需要严格递增
需要有序查询
需要范围查询

3. 简单场景

不想引入复杂组件
需要快速实现
维护成本要求低

❌ 不适合使用

1. 超高并发系统

QPS > 100,000
极低延迟要求
Redis 压力大

2. 无 Redis 基础设施

没有 Redis 集群
不想引入新组件
运维成本高

最佳实践

1. 使用批量获取

一次 INCRBY 拿回一整段 ID,在本地逐个发放,就把「每个 ID 一次网络往返」压缩成「每段 ID 一次网络往返」。

实现:

/**
 * 批量取号器:一次 INCRBY 预占一段 ID,本地逐个发放。
 */
public class RedisBatchIdGenerator {

    private static final String KEY = "id:order";
    private static final int BATCH_SIZE = 1000;

    private final JedisPooled jedis;

    /** 当前批次的右端点(含)。 */
    private long maxId;
    /** 当前批次的下一个待发 ID。 */
    private long nextId;
    /** 是否已经取过批次。 */
    private boolean loaded;

    public RedisBatchIdGenerator(String host, int port) {
        this.jedis = new JedisPooled(host, port);
    }

    /** 取下一个 ID;本批用完时自动取下一批。 */
    public synchronized long nextId() {
        if (!loaded || nextId > maxId) {
            refill();
        }
        return nextId++;
    }

    /** 取一批 ID:INCRBY 的返回值就是这一段的右端点。 */
    private void refill() {
        long end = jedis.incrby(KEY, BATCH_SIZE);
        this.maxId = end;
        this.nextId = end - BATCH_SIZE + 1;
        this.loaded = true;
    }
}

批大小和流量的关系(每批一次网络往返):

单实例 QPS批大小访问 Redis 频率实例重启浪费
1,0001,0001 次/秒1,000 个 ID
10,0001,00010 次/秒1,000 个 ID
100,0001,000100 次/秒1,000 个 ID
100,00010,00010 次/秒10,000 个 ID

批大小翻十倍,访问 Redis 的频率降十倍,代价是实例异常退出时浪费的 ID 也翻十倍。批大小要按「可接受的空洞量」来定。


2. 使用本地缓存

批量获取解决的是「取号次数」,本地缓存解决的是「取号延迟」:把已经取到的号段缓存在进程内存里,发放过程中完全不碰 Redis,只在号段快用完时才去续。

实现:

/**
 * 带本地缓存的取号器:号段缓存在内存里,接近用完时后台续期。
 */
public class CachedIdGenerator {

    private static final String KEY = "id:order";
    private static final int BATCH_SIZE = 1000;
    /** 剩余量低于这个值就触发后台续期。 */
    private static final int REFILL_THRESHOLD = 100;

    private final JedisPooled jedis;
    private final ScheduledExecutorService scheduler =
            Executors.newSingleThreadScheduledExecutor();

    /** 当前号段的下一个待发 ID。 */
    private long nextId;
    /** 当前号段的右端点(含)。 */
    private long maxId;
    /** 保证同一时刻只有一个续期任务在跑。 */
    private final AtomicBoolean refilling = new AtomicBoolean(false);

    public CachedIdGenerator(String host, int port) {
        this.jedis = new JedisPooled(host, port);
    }

    /**
     * 取下一个 ID:缓存够用时完全不访问 Redis。
     *
     * 这里用 synchronized 保护本地状态;生产上可以换成 AtomicLong 加 CAS,
     * 减少多线程争用。
     */
    public synchronized long nextId() {
        if (nextId > maxId) {
            refill();
        }
        long id = nextId++;
        if (maxId - id < REFILL_THRESHOLD) {
            scheduleRefill();
        }
        return id;
    }

    /** 同步补一批:INCRBY 的返回值就是这一段的右端点。 */
    private void refill() {
        long end = jedis.incrby(KEY, BATCH_SIZE);
        this.maxId = end;
        this.nextId = end - BATCH_SIZE + 1;
    }

    /**
     * 后台续期:新号段只抬高右端点,当前号段继续发到用完为止,
     * 中间既不会重复也不会跳过。
     */
    private void scheduleRefill() {
        if (!refilling.compareAndSet(false, true)) {
            return;
        }
        scheduler.execute(() -> {
            try {
                long end = jedis.incrby(KEY, BATCH_SIZE);
                synchronized (this) {
                    this.maxId = end;
                }
            } finally {
                refilling.set(false);
            }
        });
    }
}

用本地缓存要盯住三个点:

  • 缓存命中率:号段取回来之后应当整段用完再取,命中率低说明批大小配小了。
  • 续期失败:续期跑在后台线程里,失败不会体现在业务错误率上,必须单独告警。
  • 空洞数量:实例异常退出时,内存里还没发出去的号段全部作废,批大小越大空洞越多。

3. 监控 Redis 性能

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。

总结

Redis 方案评价

维度评分说明
唯一性⭐⭐⭐⭐⭐Redis 保证
有序性⭐⭐⭐⭐⭐严格递增
性能⭐⭐⭐⭐高性能
可用性⭐⭐⭐依赖 Redis
扩展性⭐⭐⭐受限

使用建议

推荐使用:

✅ 中高并发系统
✅ 已有 Redis
✅ 需要递增性
✅ 简单场景

不推荐使用:

❌ 超高并发系统
❌ 无 Redis 基础设施
❌ 需要极致性能

下一步

了解了 Redis 方案后,我们学习 Redis 集群方案。

👉 下一节:集群方案

发现内容有问题?

告诉我们哪里需要改进。