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,000 | 1,000 | 1 次/秒 | 1,000 个 ID |
| 10,000 | 1,000 | 10 次/秒 | 1,000 个 ID |
| 100,000 | 1,000 | 100 次/秒 | 1,000 个 ID |
| 100,000 | 10,000 | 10 次/秒 | 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 集群方案。
👉 下一节:集群方案
发现内容有问题?
告诉我们哪里需要改进。
