Redis 分布式全局唯一 ID 生成方案:时间戳 + Redis 自增
Redis 分布式全局唯一 ID 生成方案:时间戳 + Redis 自增
这篇文章介绍一种在分布式系统中常见的全局唯一 ID 生成方式:使用时间戳与 Redis 原子自增序列进行组合,通过位运算拼接成一个
long类型 ID。该方案实现简单、性能较高、趋势递增,尤其适合订单、优惠券、支付流水等业务场景。
一、为什么我们需要自己生成 ID?
在业务系统里,很多对象都需要唯一标识,例如:
- 订单 ID
- 支付单号
- 优惠券领取记录 ID
- 秒杀订单 ID
- 用户业务编号
最容易想到的做法是数据库自增主键,但在分布式场景下,它往往不够理想。
1. 数据库自增 ID 的问题
数据库自增虽然简单,但有几个明显缺点:
- 依赖单库,扩展性一般
- 高并发下容易成为瓶颈
- 不适合多服务、多节点同时生成
- 业务上有时不希望直接暴露连续主键
2. UUID 的问题
UUID 也能保证唯一,但它也有不足:
- 字符串过长,不利于存储和索引
- 无序,数据库索引性能不够友好
- 可读性较差
因此,很多系统会选择一种折中方案:
生成一个 long 型、全局唯一、趋势递增、适合分布式场景的业务 ID。
二、这套方案的核心思路
这套方案的核心思想很简单:
ID = 时间戳部分 + 序列号部分
其中:
- 时间戳部分:用于体现大致时间顺序
- 序列号部分:用于保证同一时刻生成多个 ID 时仍不重复
而序列号不是在本地内存里递增,而是交给 Redis 来完成,因为 Redis 的 INCR 操作具有原子性,非常适合在分布式环境下生成全局递增序号。
三、示例代码
下面是一段典型实现:
public long nextId(String keyPrefix) {
// 1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2. 生成序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
// 3. 拼接并返回
return timestamp << COUNT_BITS | count;
}
从方法名可以看出,这段代码的作用就是:生成下一个唯一 ID。
四、逐步拆解这段代码
1. 获取当前时间
LocalDateTime now = LocalDateTime.now();
这里获取当前时间。
例如当前可能是:
2025-03-08 15:30:20
2. 转换为秒级时间戳
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
这一步会将当前时间转换为 Unix 时间戳(单位:秒)。
也就是从 1970-01-01 00:00:00 到现在经过的总秒数。
3. 减去自定义起始时间
long timestamp = nowSecond - BEGIN_TIMESTAMP;
这里并没有直接使用完整的 Unix 时间戳,而是减去了一个固定起点 BEGIN_TIMESTAMP。
例如:
private static final long BEGIN_TIMESTAMP = 1640995200L;
这样做的好处有两个:
- 减少时间戳数值,节省位数
- 更方便通过位运算拼接到高位中
换句话说,这里保存的是相对时间戳,而不是完整时间戳。
4. 生成日期字符串
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
得到类似这样的结果:
2025:03:08
为什么要保留“天”这个维度?
因为后面 Redis 的自增 key 会按天区分,也就是每天使用一个新的计数器。
5. 使用 Redis 生成当天自增序列
long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
这句是整个方案里最关键的一步。
它本质上相当于执行了 Redis 命令:
INCR icr:order:2025:03:08
如果 keyPrefix = "order",那么:
- 第一次调用返回
1 - 第二次调用返回
2 - 第三次调用返回
3
……
这样就能在分布式环境下拿到一个全局唯一的递增序列号。
五、为什么要加 keyPrefix?
keyPrefix 用来区分业务类型。
例如:
- 订单:
order - 用户:
user - 优惠券:
voucher
最终 Redis key 可能长这样:
icr:order:2025:03:08
icr:user:2025:03:08
icr:voucher:2025:03:08
这样不同业务之间的计数器互不影响。
六、为什么要按天分隔计数器?
如果 Redis key 不带日期,那么计数器会一直增长,长期运行后数值会越来越大。
按天区分的好处是:
- 每天从一个新的计数器开始
- 避免序列号无限膨胀
- 更容易管理和排查问题
即使第二天计数器又从 1 开始,也不会发生重复,因为高位的时间戳已经变化了。
七、最终 ID 是如何拼接的?
return timestamp << COUNT_BITS | count;
这句代码的含义是:
- 把时间戳左移若干位
- 给低位留出空间存放序列号
- 使用按位或
|将序列号拼进去
假设:
COUNT_BITS = 32
那么就意味着:
- 高位存储时间戳
- 低 32 位存储 Redis 自增序列号
可以抽象理解为:
[时间戳][序列号]
八、位结构示意
如果按照常见设计,一个 long 的结构可以表示为:
0 | 时间戳 | 序列号
更具体一点:
- 1 bit:符号位,固定为 0
- 31 bit:时间戳
- 32 bit:序列号
这样最终得到的就是一个正数类型的 long。
九、举一个简单例子
假设:
timestamp = 1000count = 25COUNT_BITS = 32
那么:
long id = (1000L << 32) | 25;
这里的含义就是:
- 把
1000放在高位 - 把
25放在低位 - 组合成一个完整的 long 值
由于时间戳和序列号的组合是唯一的,所以最终 ID 也唯一。
十、为什么这种方式能保证唯一?
唯一性来自两部分共同约束:
1. 不同时刻生成
只要时间戳不同,最终 ID 一定不同。
2. 同一时刻生成多个 ID
哪怕时间戳相同,只要 Redis 自增得到的 count 不同,最终 ID 也不同。
因此:
时间戳负责区分时间范围,Redis 自增负责区分同一时间内的并发请求。
两者配合起来,就可以保证全局唯一。
十一、为什么它适合分布式系统?
因为 Redis 的 INCR 操作是原子的。
假设有两个服务实例同时生成订单 ID:
- 实例 A 执行一次
INCR,返回1001 - 实例 B 执行一次
INCR,返回1002
即使它们部署在不同机器上,也不会拿到重复值。
这就是 Redis 在这个方案中的价值:
将“全局递增序列”的生成交给一个天然支持原子操作的中间件。
十二、这种方案的优点
1. 全局唯一
借助 Redis 自增,可以在分布式环境下保证唯一性。
2. 趋势递增
由于高位是时间戳,所以整体趋势上是递增的。
3. long 型更适合数据库索引
相比 UUID 字符串,long 更节省存储空间,也更利于索引。
4. 实现简单
相较于更复杂的分布式 ID 算法,这种方式逻辑清晰,容易落地。
5. 业务隔离方便
通过 keyPrefix 可以轻松区分不同业务类型。
十三、这种方案的局限性
任何方案都不是完美的,这种实现也有自己的边界。
1. 依赖 Redis
如果 Redis 不可用,那么 ID 生成就会受到影响。
2. 每次生成都需要一次远程调用
相比完全本地生成 ID 的算法,这种方式会多一次网络开销。
3. 位数设计需要提前规划
例如:
- 时间戳占多少位
- 序列号占多少位
- 能支撑多少年
- 每天最多支持多大量级的自增序列
这些都需要结合业务体量提前设计。
十四、它和雪花算法有什么区别?
很多人看到这种写法,会联想到 Snowflake(雪花算法)。
它们确实很像,因为都采用了:
- 时间戳
- 序列号
- 位运算拼接
但两者并不完全一样。
雪花算法通常包含
- 时间戳
- 机房 ID
- 机器 ID
- 毫秒内序列号
本文这种方案包含
- 时间戳
- Redis 全局自增序列号
也就是说:
雪花算法依赖机器号区分节点,而本文方案依赖 Redis 统一发号。
对比来看:
本文方案的优势
- 逻辑更直观
- 不需要维护机器号和机房号
- 分布式唯一性更容易理解
本文方案的不足
- 强依赖 Redis
- 远程调用成本高于本地雪花算法
十五、适合哪些业务场景?
这种方式非常适合下面这些场景:
- 订单 ID
- 秒杀订单 ID
- 支付流水号
- 优惠券领取记录 ID
- 业务侧唯一编号
这些场景通常都有共同特点:
- 并发量高
- 需要全局唯一
- 最好趋势递增
- 不适合直接使用数据库自增主键
十六、实现时的注意事项
1. 注意时区问题
代码里如果这样写:
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
需要确保你的系统时区和转换逻辑是一致的。
更稳妥的方式通常是直接使用:
long nowSecond = Instant.now().getEpochSecond();
这样语义会更清晰一些。
2. 序列号位数要足够
如果低位留给序列号的 bit 太少,在高并发场景下可能不够用。
例如 32 bit 的序列号理论上已经非常大,但实际设计时仍然应结合业务规模评估。
3. Redis key 可以考虑设置过期时间
由于 key 按天生成,可以根据业务需要给这类自增 key 设置较长的过期时间,避免无限积累。
十七、一个更完整的示例
下面给出一个更完整的 Java 示例,便于理解:
@Component
public class RedisIdWorker {
private static final long BEGIN_TIMESTAMP = 1640995200L;
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
public long nextId(String keyPrefix) {
// 1. 当前时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2. 当天序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
// 3. 拼接返回
return (timestamp << COUNT_BITS) | count;
}
}
这个工具类可以作为通用组件注入到订单、优惠券、支付等业务模块中统一使用。
十八、总结
本文介绍了一种非常实用的分布式 ID 生成方式:
利用相对时间戳作为高位,利用 Redis 原子自增序列作为低位,再通过位运算拼接成一个 long 型全局唯一 ID。
它的特点是:
- 全局唯一
- 趋势递增
- 存储友好
- 实现简单
- 分布式可用
如果你的项目已经引入 Redis,并且希望快速实现一套可靠的业务 ID 生成机制,那么这种方案会是一个非常合适的选择。
十九、参考一句话总结
可以把它理解成:
先用时间区分“大范围”,再用 Redis 自增区分“同一时间内的顺序”,最后把两部分合成一个 long 值。
这就是这套 ID 生成方案的本质。
更多推荐
所有评论(0)