使用Redis自增特性创建唯一id生成器

需求说明

产品要求实现一个订单编号,此编号规则如下

订单编号规则:

“字母” + “日期” + “自增ID”

订单编号举例

比如业务A,在2020-08-04日有三个订单,那么订单编号如下:

  • A202008040001
  • A202008040002
  • A202008040003

比如业务A,在2020-08-05日有4个订单,那么订单编号如下:

  • A202008050001
  • A202008050002
  • A202008050003
  • A202008050003

通过上面的例子可以看到,后面的“自增ID”每天都会从1开始增加,在一个分布式系统中,要做到每天从1开始不重复并且自增的效果;想到的第一个实现方案就是redis的Incr命令(Redis Incr 命令将 key 中储存的数字值增一)。

需求实现

配置redis

依赖redis相关jar包

因为此模块继承了spring-boot-starter-parent,所以不需要指定版本

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>

编写配置redis的config

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
package com.nicai.config;

import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
* redis集群配置
*
* @author guozhe
* @date 2020/08/04
*/
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class RedisClusterConfig {

private final RedisProperties redisProperties;

public RedisClusterConfig(RedisProperties redisProperties) {
this.redisProperties = redisProperties;
}

/**
* Thread-safe factory of Redis connections配置
*
* @return factory of Redis
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisClusterConfiguration redisClusterConfiguration = new RedisClusterConfiguration(redisProperties.getCluster().getNodes());
redisClusterConfiguration.setPassword(redisProperties.getPassword());
return new JedisConnectionFactory(redisClusterConfiguration);
}

/**
* 创建String类型的redis模板
*
* @param redisConnectionFactory factory of Redis
* @return String-focused extension of RedisTemplate
*/
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate template = new StringRedisTemplate(redisConnectionFactory);
template.afterPropertiesSet();
return template;
}

}

如果是配置范型的RedisTemplate,需要设置值的序列化规则为:StringRedisSerializer,原因可以参考此文章:Spring Boot中使用RedisTemplate优雅的操作Redis,并且解决RedisTemplate泛型注入失败的问题

测试redis的config代码

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
package com.nicai.config;

import com.yuanfeng.accounting.BaseAdminSpringTest;
import com.yuanfeng.accounting.Constants;
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;

import java.util.concurrent.TimeUnit;

/**
* @author guozhe
* @date 2020/08/04
*/
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = AdminApplication.class)
public class RedisClusterConfigTest {

private static final String TEST_KEY = Constants.REDIS_KEY_PREFIX + "test:hello";
private static final String TEST_VALUE = "world";

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Test
public void testStringRedisTemplateGetAndSet() {
stringRedisTemplate.opsForValue().set(TEST_KEY, TEST_VALUE);
String value = stringRedisTemplate.opsForValue().get(TEST_KEY);
Assert.assertEquals(TEST_VALUE, value);
stringRedisTemplate.delete(TEST_KEY);
Assert.assertNull(stringRedisTemplate.opsForValue().get(TEST_KEY));
}

@Test
public void testIncr() {
String key = TEST_KEY;
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set(key, "1", 24, TimeUnit.HOURS);
String initValue = valueOperations.get(key);
log.info("key={}, init value={}", key, initValue);
Assert.assertEquals("1", initValue);
Long increment = valueOperations.increment(key);
log.info("key={}, after increment={}", key, increment);
Assert.assertEquals(Long.valueOf(2), increment);
stringRedisTemplate.delete(key);
Assert.assertNull(valueOperations.get(key));
}

}

基于redis编写唯一ID生成服务

添加抽象的唯一id生成服务

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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
package com.nicai.service;

import cn.hutool.core.util.BooleanUtil;
import com.alibaba.fastjson.JSON;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

/**
* 分布式ID生成服务
*
* @author guozhe
* @date 2020/08/04
*/
@Slf4j
public abstract class AbstractRedisDistributedIDGenerateService<T extends AbstractRedisDistributedIDGenerateService.Context> {

/**
* 初始化key时的默认值
*/
private static final long DEFAULT_VALUE = 0;

protected final StringRedisTemplate redisTemplate;

public AbstractRedisDistributedIDGenerateService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

/**
* 获取下一个ID,直接从redis中获取自增后的值;
*
* @return 下一个ID, 如果redis出现异常则返回null,请使用者自行处理
*/
public final Optional<Long> nextId() {
// 从redis中获取自增id
Long id = incr(getKey());
return Objects.isNull(id) ? Optional.empty() : Optional.of(id);
}

/**
* 获取下一个ID,根据传入的上下文和redis中自增后的值最终组装成下一个ID;
* 获取之后会交给子类检查此ID是否重复,如果重复会从子类中获取最新的ID,然后更新redis中的值
*
* @param context 拼装id时需要的上下文
* @return 下一个ID
*/
public final String nextId(T context) {
Optional<Long> id = nextId();
// 如果可以从redis中获取值,则说明redis服务正常,需要判重;否则直接从数据库中获取下一个id
String nextId = id.isPresent() ? ifDuplicatedThenUpdate(context, assemblyNextId(context, id.get())) :
getNewIdFromDbAndUpdateRedis(context, null);
if (log.isDebugEnabled()) {
log.debug("context={},redisIncrId={} nextId={}", JSON.toJSONString(context), id, nextId);
}
return nextId;
}

/**
* 检查获取到的ID是否重复
* 如果重复则说明由于redis的一些原因导致的重复,返回最新的redis中应该存在的值
*
* @param nextId 下一个ID
* @return 如果当前ID没有重复,则返回null,否则如果重复了则返回redis中应该有的值
*/
protected abstract boolean checkIfDuplicated(String nextId);

/**
* 从数据库获取下一个id
*
* @param duplicatedId 重复的id,此入参可能为null,子类需要自己处理
* @return 数据库获取下一个id
*/
protected abstract Long maxIdFromDatabase(String duplicatedId);

/**
* 子类根据redis当前的值自行组装最终的ID
*
* @param context 上下文
* @param redisValue redis当前的值
* @return 最终的ID
*/
protected abstract String assemblyNextId(T context, Long redisValue);

/**
* 获取redis自增的key
*
* @return redis自增的key
*/
protected abstract String getKey();

/**
* 调用redis的自增方法
* 如果key不存在则先设置key,再调用自增方法
*
* @param key 需要自增的key
* @return 自增之后的值,如果redis出现异常则返回null
*/
Long incr(String key) {
Long increment = null;
try {
// 先检查redis中是否有key,如果没有,先设置key并且设置过期时间
if (BooleanUtil.isFalse(redisTemplate.hasKey(key))) {
initOrUpdateValue(key, getKeyInitValue());
}
increment = redisTemplate.opsForValue().increment(key);
} catch (Exception e) {
log.error("调用redis的自增方法异常,error_message={}", e.getMessage(), e);
}
log.debug("key = {}, increment={}", key, increment);
return increment;
}

/**
* 获取初始化key时的value值,默认是0,自增之后id从1开始;
* 如果子类想从其他数字开始则自己覆盖此方法即可
*
* @return 初始化key时的value值
*/
protected long getKeyInitValue() {
return DEFAULT_VALUE;
}

/**
* 获取key的超时时间,单位是小时,由子类设置
*
* @return 超时时间,单位小时
*/
protected abstract long getTimeOutHours();

/**
* 判断是否重复,如果重复则从别的渠道(由子类自己决定从哪个渠道)更新
*
* @param context 拼装id时需要的上下文
* @param nextId 下一个id
* @return 如果重复则返回新的nextId,否则返回入参传入的nextId
*/
private String ifDuplicatedThenUpdate(T context, String nextId) {
// 判断是否重复,如果重复则从数据库中获取,否则直接返回当前值
return checkIfDuplicated(nextId) ? getNewIdFromDbAndUpdateRedis(context, nextId) : nextId;
}

/**
* 从数据库获取新id并更新redis中的值
*
* @param context 拼装id时需要的上下文
* @param nextId 下一个id
* @return 根据数据库的id获得的新id
*/
private String getNewIdFromDbAndUpdateRedis(T context, String nextId) {
Long maxIdFromDatabase = maxIdFromDatabase(nextId);
String newId = assemblyNextId(context, maxIdFromDatabase);
log.warn("nextId={} 在数据库中已经存在,maxIdFromDatabase={} 重新获取新的newId={}", nextId, maxIdFromDatabase, newId);
initOrUpdateValue(getKey(), maxIdFromDatabase);
return newId;
}

/**
* 初始化或者更新redis中的自增的值
*
* @param key redis中的key
* @param value 需要设置的值
*/
private void initOrUpdateValue(String key, Long value) {
try {
redisTemplate.opsForValue().set(key, String.valueOf(value), getTimeOutHours(), TimeUnit.HOURS);
} catch (Exception e) {
log.error("设置redis值异常,value={} error_message={}", value, e.getMessage(), e);
}
}

/**
* 上下文;子类自己定义上下文,然后根据上下文的数据来最终组装ID
*/
public interface Context {

}

/**
* 凭证编号上下文
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class AContext implements Context {
/**
* 业务类型
*/
private String businessType;
}
}

添加一个A服务的唯一id生成服务实现

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
package com.nicai.service.impl;

import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.yuanfeng.accounting.Constants;
import com.yuanfeng.accounting.dao.ManualVoucherDAO;
import com.yuanfeng.accounting.entity.ManualVoucherEntity;
import com.yuanfeng.accounting.exception.AccountingException;
import com.yuanfeng.accounting.service.AbstractRedisDistributedIDGenerateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;
import java.util.Optional;

/**
* 分布式唯一ID生成-A实现类
* 编号规则:用途+日期+自增ID,如:A202007310001;A202007310002;A202008070001;
*
* @author guozhe
* @date 2020/08/04
*/
@Slf4j
@Service
public class DistributedIDGenerateServiceAImpl extends AbstractRedisDistributedIDGenerateService<AbstractRedisDistributedIDGenerateService.AContext> {

/**
* 业务类型
*/
private static final String BUSINESS_TYPE = "A:";

/**
* ID长度不足4位时在前面填充的字符
*/
private static final char FILLED_CHAR = '0';

/**
* 最后的自增ID的长度
*/
private static final int INCREMENT_LENGTH = 4;

/**
* 过期小时数,即在24小时候过期
*/
private static final int EXPIRATION_HOURS = 24;

public DistributedIDGenerateServiceAImpl(StringRedisTemplate redisTemplate) {
super(redisTemplate);
}

@Override
protected boolean checkIfDuplicated(String nextId) {
return false;
}

@Override
protected Long maxIdFromDatabase(String duplicatedId) {
return 1L;
}

@Override
protected String assemblyNextId(VoucherNumberContext context, Long redisValue) {
return String.join(Constants.BLANK, context.getBusinessType(), getDatePeriod(),
StrUtil.fillBefore(String.valueOf(redisValue), FILLED_CHAR, INCREMENT_LENGTH));
}

@Override
protected String getKey() {
return String.join(Constants.REDIS_KEY_DELIMITER, Constants.REDIS_KEY_PREFIX, BUSINESS_TYPE, getDatePeriod());
}

@Override
protected long getTimeOutHours() {
return EXPIRATION_HOURS;
}
}