Redis实战——分布式业务ID生成器

本文最后更新于:2 个月前

引言

在分布式系统中,为了保证订单、流水等业务单号的唯一性、高可用、高性能和有序递增,我们通常会选择分布式ID生成方案。本文将结合一个“业务单号”案例,演示如何使用RedisAtomicLong来生成符合业务需求的可读单号,并与常见的Snowflake算法做简单对比。

规则

在实际业务场景中,单号除了需要具备唯一性,还希望包含一些能够体现业务维度的信息,方便快速识别或手动对账。例如,一个典型的销售订单号可能会包含:

  • 前缀:业务类型,如销售订单(Sales Order)可简写为SO
  • 业务参数:如店代码DGDA010
  • 日期:单号生成日期,常用yyyyMMdd格式,如20240101
  • 序列号:一天内自增,如00001,长度可配

因此,一个完整的销售订单号示例会是SODGDA0102024010100001

  • SO —— 销售订单前缀
  • DGDA010 —— 店铺代码
  • 20240101 —— 订单日期
  • 00001 —— 序列号(当天从 1 开始累加,左补零)

相比纯数字的全局 ID,这类“业务单号”更方便人工查询和业务审计。

特征

一个完善的分布式全局业务ID生成器,应当满足:

  1. 唯一性:不能产生重复的ID。
  2. 高可用:在分布式和高并发环境中保持稳定运行。
  3. 高性能:ID的生成过程不能成为性能瓶颈。
  4. 递增性:ID有序(如按时间或数值递增),方便做数据分区、排序或日志查询。

实现

在分布式环境中,保证序列号自增的常见做法有:

  1. 数据库自增主键:容易成为性能瓶颈和单点故障。
  2. Snowflake算法:能够快速生成高并发下的全局唯一 ID,但生成的结果通常是无业务含义的 64 位长整型。
  3. Redis自增:基于Redis的INCR命令或RedisAtomicLong,既可以实现分布式场景下的原子性自增,又能灵活拼接业务前缀、参数与日期,生成“可读”的业务单号。

Snowflake

  • 高性能:在内存中本地生成,不依赖外部服务;
  • 分布式:可以将机器ID、时间戳等信息组合在一个64位长整型中,轻松实现跨节点唯一;
  • 不含业务信息:生成的长整型ID难以让人直接看出其含义,通常需要额外查询或计算才能做分类、分表等。

RedisAtomicLong

Spring Data Redis提供的一个便捷类,内置了以下优势:

  • 原子性:底层基于Redis的INCR命令,多线程同时操作也能保持正确的计数。
  • 易用性:提供了incrementAndGetaddAndGet等简洁方法,且支持设置过期时间(expire)。
  • 可维护性:把计数器作为Key存在Redis,易于在运维层面做监控或清理。
  • 可包含业务信息:能够在“纯数字”之外附带前缀、日期、参数等更多逻辑;

如果你仅仅需要一个纯数字型ID(如数据库表主键),不关心其“含义”或“格式”,Snowflake算法更轻便,且不需要额外的外部存储;如果你更关注让ID本身表达业务信息(业务单号),则基于RedisAtomicLong的方式通常更简单直观。

代码示例

下方是一段使用Spring Boot + Spring Data Redis示例代码,用于在分布式场景下生成业务单号。代码具备以下特征:

  • 灵活可定制:可配置前缀、业务参数、日期、序列号长度、重试机制等。
  • 自动过期:每天自动过期,确保不会冗余太多无用数据。
  • 批量生成:可一次性生成多个ID,减少对Redis的交互次数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@Slf4j
public class BizIdUtil {

private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

/**
* Redis Key 的前缀,避免与其他业务冲突
*/
private static final String REDIS_BIZ_ID_LOCK = "redis_biz_id_lock:";

public static BizIdBuilder builder(StringRedisTemplate stringRedisTemplate) {
return new BizIdBuilder(stringRedisTemplate);
}

/**
* 获取 BizIdBuilder 实例
*/
public static class BizIdBuilder {

private final StringRedisTemplate stringRedisTemplate;

public BizIdBuilder(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

// 用于缓存 RedisAtomicLong 实例,减少重复创建的开销
private static final ConcurrentHashMap<String, RedisAtomicLong> counterMap = new ConcurrentHashMap<>();

// 前缀
private String prefix;

// 业务参数
private List<String> params;

// 业务参数拼接字符串
private String paramsStr;

// 日期标识(可选,默认为当前日期)
private String dateStr = DATE_FORMATTER.format(LocalDate.now());

// 序列号长度
private Integer length = 5;

// 重试次数
private Integer retryTimes = 1;

// 重试间隔
private Integer retryInterval = 100;

// 重试间隔单位
private TimeUnit retryUnit = TimeUnit.MILLISECONDS;

public BizIdBuilder prefix(String prefix) {
this.prefix = prefix;
return this;
}

public BizIdBuilder params(List<String> params) {
if (CollUtil.isNotEmpty(params)) {
this.paramsStr = String.join("", params);
}
return this;
}

public BizIdBuilder dateStr(String dateStr) {
this.dateStr = dateStr;
return this;
}

public BizIdBuilder length(Integer length) {
this.length = length;
return this;
}

public BizIdBuilder retryTimes(Integer retryTimes) {
this.retryTimes = retryTimes;
return this;
}

public BizIdBuilder retryInterval(Integer retryInterval) {
this.retryInterval = retryInterval;
return this;
}

public BizIdBuilder unit(TimeUnit unit) {
this.retryUnit = unit;
return this;
}

/**
* 生成单个 ID
*/
public String build() {
return buildBatch(1).get(0);
}

/**
* 批量生成 ID
*/
public List<String> buildBatch(int count) {
String counter = REDIS_BIZ_ID_LOCK + prefix + paramsStr + dateStr;
do {
try {
// 从缓存中获取或创建 RedisAtomicLong
RedisAtomicLong entityIdCounter = counterMap.computeIfAbsent(counter, k -> {
// 注意,这里必须确保 stringRedisTemplate 的连接工厂不为 null
RedisAtomicLong ra = new RedisAtomicLong(counter, Objects.requireNonNull(stringRedisTemplate.getConnectionFactory()));
// 设置过期时间为 1 天,防止冗余过多无用数据
ra.expire(1, java.util.concurrent.TimeUnit.DAYS);
return ra;
});

// 批量递增
long serialNum = entityIdCounter.addAndGet(count);

// 检查溢出
StringBuilder maxSerialNum = new StringBuilder();
for (int i = 0; i < length; i++) {
maxSerialNum.append("9");
}
if (serialNum > Long.parseLong(maxSerialNum.toString())) {
throw new RuntimeException("序列号已达最大值");
}

// 最终业务ID
List<String> result = CollUtil.newArrayList();
for (int i = 0; i < count; i++) {
result.add(prefix + paramsStr + dateStr + String.format("%0" + length + "d", serialNum - count + i + 1));
}
return result;
} catch (Exception e) {
log.error("{}生成业务ID失败", counter, e);
}

// 重试间隔
try {
Thread.sleep(retryUnit.toMillis(retryInterval));
} catch (InterruptedException ex) {
log.error("{}线程休眠失败", counter, ex);
throw new RuntimeException(counter + "生成业务ID失败");
}

// 异常重试
} while (retryTimes-- > 0);
throw new RuntimeException(counter + "生成业务ID失败,重试次数已用完");
}
}

}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
@Resource
private StringRedisTemplate stringRedisTemplate;

public String getOrderBizId() {
return BizIdUtil.builder(stringRedisTemplate)
.prefix("SO") // 业务单号前缀
.params(Arrays.asList("DGDA010")) // 参数
.length(5) // 序列号长度
.retryTimes(3) // 失败重试次数
.retryInterval(100) // 失败重试间隔
.unit(TimeUnit.MILLISECONDS) // 重试间隔等待时间单位
.build();
}

每次运行都能得到唯一的自增单号,如

1
2
3
SODGDA0102024010100001
SODGDA0102024010100002
SODGDA0102024010100003

优化方向

内存占用

如果prefix + paramsStr + dateStr可能组合非常多,则会导致Redis中保存大量Key和 RedisAtomicLong 实例。可以考虑:

  • 设置合理的过期策略(每天自动过期)
  • 评估业务实际组合量,或者使用更灵活的生成策略

序列号溢出

如果序列号长度是5位,意味着同一天最多只能生成99999个订单。如果实际业务量更大,需要适当增加序列号长度或在序列号快到上限时发出告警,并做相应扩容策略。

高并发下的原子性

RedisAtomicLong的内部操作本质是Redis的INCR命令,保证了计数的原子性。如果需要更复杂的操作(比如提前判断是否溢出、再进行INCR),可以考虑Lua脚本进行一次性判断与递增操作,以避免两次Redis调用中间产生竞态条件。

重试次数与间隔

在网络抖动或Redis短暂不可用时,此处的重试逻辑可以提供一定的容错能力。也可以结合更专业的断路器(Circuit Breaker)与熔断策略来提高系统稳定性。

分布式锁

如果业务需要在生成 ID 时确保其他关键操作的互斥,可以考虑配合分布式锁(如 Redisson)使用。但本示例只关注生成ID,不涉及到额外的锁。

批量生成的效率

对于需要一次性生成大批量ID(如1万条)的场景,可考虑基于 “段式分配” 的思路,即从Redis一次获取一段计数区间(如1~10000),再在应用内自行分配,以减少与Redis的交互次数。此思路实现较为复杂,此处只作思路提示。

总结

通过以上思路和示例,就能在分布式场景下生成带业务信息的订单号或流水号,既满足了业务侧对“可读性”的需求,也保证了分布式全局唯一与高并发下的有序性。在实际项目中,可按需进一步扩展,如添加分库分表策略、结合监控报警、或引入更多重试和降级机制,打造更完善的分布式业务ID生成方案。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!