如果服务从单体拆成多个实例,订单号、流水号等就不能继续依赖单库自增。Redis 的命令执行具备单线程串行化特征,INCR 操作在多个客户端并发调用时也会被排队逐条执行,因此可以把 Redis 当成一个轻量级全局计数器。但这只是最基础用法,离生产可用的序列号生成还有不少细节要处理,比如持久化策略、宕机后是否回退、批量获取号段、业务前缀设计等。本文围绕这些问题展开,给出可落地的实现思路。

一、为什么 Redis 适合做全局序列号生成
全局序列号的本质是一个跨进程、跨机器共享的递增计数器。数据库自增主键虽然在单库下稳定,但在分库分表后每张表各自维护自增值,无法直接生成全局唯一编号。UUID 可以解决唯一性,但它是无序字符串,作为主键写入 B+ 树索引时会引起页分裂,影响写入性能。Snowflake 等雪花算法可以在应用层生成趋势递增的 64 位数字,但需要处理机器码分配、时钟回拨等问题,部署复杂度并不低。
Redis 的命令执行由单线程完成,所有命令进入队列后串行处理。INCR 命令会把 key 对应的整数值加一并返回新值,这个读改写过程不会被其他命令打断。无论有多少业务实例同时请求同一个 key,都能得到不重复的连续数字。再加上 Redis 本身具有网络访问能力、主从复制能力和丰富的持久化机制,所以适合作为轻量级全局序列号来源。
不过要强调的是,Redis 的原子性只保证单条命令不会被并发的其他命令插入,但并不保证序列号在持久化或主从切换后一定不重复。如果业务只能接受零重复,那么还需要引入数据库唯一约束等兜底机制。
二、核心生成方案与代码示例
最基础的方案是直接使用 INCR,Java 代码示例如下:
import redis.clients.jedis.Jedis;
public class RedisSeq {
private Jedis jedis;
public RedisSeq(Jedis jedis) {
this.jedis = jedis;
}
public long next(String key) {
return jedis.incr(key);
}
}
Go 版本同样简洁,只需要调用 go-redis 的 Incr 方法:
import (
"context"
"github.com/redis/go-redis/v9"
)
func nextSequence(ctx context.Context, rdb *redis.Client, key string) (int64, error) {
return rdb.Incr(ctx, key).Result()
}
这种方式每次生成序列号都会访问一次 Redis,在网络延迟和连接池稳定的情况下,单节点 Redis 可以轻松支撑数万 QPS。但对于更大流量,每次都做网络往返并不经济,也会把 Redis 变成系统热点。为此可以使用 INCRBY 一次申请一段号段,应用进程在号段内自行分配,当号段耗尽再向 Redis 申请下一段。
long start = jedis.incrBy("order:seq", 100);
long end = start + 99;
// 当前进程可以获得 start 到 end 的连续序号
批量号段的优点是降低 Redis 访问频率,同时减少网络开销,适合订单中心、消息中心这种需要大量连续编号的场景。缺点是应用实例重启后,已经申请但未发完的号段会浪费,序列号会出现空洞。如果业务允许空洞,这个方案非常实用;如果要求严格连续,就应使用第一种方案或考虑其他架构。
很多业务并不需要纯数字序列号,而是希望订单号带有时间可读性。此时可以把时间戳、节点编号、序列号组合成一个 64 位长整型。下面给出一个组合示意:
long ts = System.currentTimeMillis();
long nodeId = 3;
long seq = jedis.incr("order:seq:20250921");
long id = (ts << 22) | (nodeId << 12) | (seq & 0xFFF);
这种设计下,时间戳作为高位保证了 ID 大体按时间递增,节点编号用于区分多个服务实例,序列号用 Redis 统一生成。它的优点是 ID 中带有业务信息,便于排查问题,也不容易与历史数据冲突。需要注意位移位数和掩码要根据业务峰值提前确定,否则序列号溢出后会与节点位重叠。
如果希望订单号包含日期字符串并每天从 1 开始,可以写一段 Lua 脚本,把日期切换和计数重置的逻辑放到 Redis 内部原子完成。脚本示例:
local now = redis.call('TIME')
local today = string.sub(now[1], 1, 10)
local key = KEYS[1] .. ':' .. today
local seq = redis.call('INCR', key)
if seq == 1 then
redis.call('EXPIRE', key, 86400)
end
return seq
这段脚本会根据当前日期动态拼接 key,当某天的第一个序列号出现时设置过期时间,避免历史 key 无限堆积。因为 Lua 脚本在 Redis 中执行时同样不会被其他命令打断,所以能避免应用先读日期再重置计数导致的并发问题。
三、持久化、宕机与主从切换的风险
Redis 的数据保存在内存中,持久化策略直接影响序列号在故障后的可靠性。使用 RDB 快照时,如果距离上次快照有一段时间,那么 Redis 重启后会丢失这段时间内的 INCR 结果,导致序列号回退。AOF 在 everysec 配置下最多可能丢失一秒内的写入,虽然概率较低,但仍然存在重复风险。
主从切换也会造成类似问题。客户端在主节点写入少量序列号后,如果主节点宕机且从节点还未完成同步,这些写入就会丢失。切换后从节点提升为主节点,新的 INCR 会从旧值继续,可能重新发出已经发放过的编号。要缓解这个问题,可以开启 min-replicas-to-write 和 min-replicas-max-lag,让主节点在从节点延迟过大时拒绝写入,但这会牺牲一定可用性。
min-replicas-to-write 1 min-replicas-max-lag 10
因此,在使用 Redis 生成全局唯一序列号时,必须明确业务对重复的容忍程度。例如日志采集、消息去重等场景可以接受极小概率丢失;但资金订单、交易流水等场景仅靠 Redis 是不安全的,还需要数据库唯一索引、业务幂等表或独立的强一致发号服务来兜底。
四、生产环境的设计建议
首先要区分“序列号”和“全局唯一 ID”。序列号通常强调连续递增,而全局唯一 ID 更强调唯一性,可以包含时间、节点、业务类型等多段信息。如果只需要唯一 ID,不必强行使用 INCR,可以考虑 Redis 与雪花算法组合,或使用数据库号段等方式。如果坚持要用 Redis,建议把序列号生成接口封装成独立服务,而不是让每个业务模块直接操作 Redis key。
其次,要控制 key 的粒度。不同业务类型应使用不同的 key,例如订单序列用 order:seq,支付流水用 pay:seq,这样既能避免相互干扰,也方便监控和报警。对于按天重置的序列,要在 key 中加入日期并设置合理的过期时间,防止 Redis 内存被历史 key 占满。
最后,监控 Redis 的写入延迟、连接池排队和键值增长。批量号段方案虽然能降低请求量,但号段过大时会放大空洞,号段过小又无法充分降低热点。一般可以根据单实例 QPS 和序列号使用速度动态调整号段大小,例如从 100 开始,观察本地号段耗尽速度后再决定是否增加到 500 或 1000。
如果团队需要更严格的连续性和可靠性,可以做一个专用发号服务,底层将已发放的最大值持久化到数据库或分布式协调系统,Redis 只作为缓存加速读取。这样在 Redis 重启或主从切换后,服务可以从可靠存储恢复水位,而不是简单依赖 Redis 的 INCR 结果。

