基于Redis的分布式ID解决方案及对代码可读性的思考
在很多业务中,需要生成全局唯一ID,一般还需要有不同的前缀以区分业务场景。基于Redis的自增原子命令是一个很好的解决方案,网上也有很多基于incr和increby的生成全局唯一ID的实现方案及代码。本文主要记录自己遇到过的有些瑕疵的实现方案,说明其问题所在、分析下生产中却没有爆出问题以及正确的实现方案。因为自己当初是第一次在生产中使用Redis,完成后请其他同事帮忙review一下,然后确认没问题而且给了一个更简洁写法的建议,但是最终并没有按照建议修改,本文会说说没有修改的原因以及自己编码的思考。
初始化项目
首先新建一个Spring Boot项目,引入Redis,注入RedisTemplate,将需要用到的Redis方法封装成一个工具类。代码如下:
pom.xml文件引入的依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.4.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.15.0</version>
</dependency>
</dependencies>
application.properties:
spring.application.name=redis-app
#redis 配置
spring.data.redis.host=xxx.xxx.xxx.xxx
启动类,设置redis序列化器:
@SpringBootApplication
public class RedisAppApplication {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 序列化器
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL,JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance ,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(om, Object.class);
// key
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
//value
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
public static void main(String[] args) {
SpringApplication.run(RedisAppApplication.class, args);
}
}
Redis 工具类
/**
* redis 工具类,封装了RedisTemplate<String, Object>常用方法
*/
@Component
public class RedisUtil {
@Autowired
RedisTemplate<String, Object> redisTemplate;
public boolean setNx(String key, Object value, Duration duration) {
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, value, duration));
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public Long incr(String key) {
return redisTemplate.opsForValue().increment(key);
}
}
有并发问题的实现方式
@Component
public class IDGeneratorUtil {
@Autowired
RedisUtil redisUtil;
String FORMAT_YYMMDD = "yyyyMMdd";
Long SERIAL_NO_INIT_VALUE = 1L;
public String genOrderNo(String keyPrefix, String bizCode) {
String dateStr = dateFormat(LocalDateTime.now(), FORMAT_YYMMDD);
String key = keyPrefix + "_" + dateStr;
Object serialNo = redisUtil.get(key);
if (serialNo == null) {
redisUtil.setNx(key, SERIAL_NO_INIT_VALUE, Duration.ofHours(24));
// 这里直接返回是有问题的
return concatSerialNo(bizCode, dateStr, SERIAL_NO_INIT_VALUE, 5);
}
Long incrNo = redisUtil.incr(key);
return concatSerialNo(bizCode, dateStr, incrNo, 5);
}
private String dateFormat(LocalDateTime dateTime, String pattern) {
return DateTimeFormatter.ofPattern(pattern).format(dateTime);
}
private String concatSerialNo(String bizCode, String dateStr, Long serialNo, int size) {
// 使用org.apache.commons.lang3 包中的字符串工具类对其序列号位数
return bizCode + dateStr + StringUtils.leftPad(String.valueOf(serialNo), size, "0");
}
}
调用 setNx 后,直接返回是有问题的,尽管只有当 key 不存在的时候,才会设置指定的值,但是在多线程场景,其他线程设置失败,但是不会报错,也会正常执行完,此时如果直接返回,那么会生成多个序列号为1的id。为了能够更容易的暴露问题,我们在setNx前加上一段睡眠时间。
if (serialNo == null) {
// 加上睡眠时间,让问题更容易暴露
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
redisUtil.setNx(key, SERIAL_NO_INIT_VALUE, Duration.ofHours(24));
// 这里直接返回是有问题的
return concatSerialNo(bizCode, dateStr, SERIAL_NO_INIT_VALUE, 5);
}
测试代码如下:
@SpringBootTest
@Slf4j
public class IDGeneratorUtilTest {
@Autowired
IDGeneratorUtil idGeneratorUtil;
@Test
void testGenOrderNo() {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
String orderNo = idGeneratorUtil.genOrderNo("ORDER", "ORDER");
log.info("thread name = {}, orderNo = {}", Thread.currentThread().getName(), orderNo);
}, "thread-" + i).start();
}
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end");
}
}
执行结果如下:
从结果可以明显的看出,这种实现方式是有问题的。
并发安全的实现方式
public String genOrderNoSafe(String keyPrefix, String bizCode) {
String dateStr = dateFormat(LocalDateTime.now(), FORMAT_YYMMDD);
String key = keyPrefix + "_" + dateStr;
Object serialNo = redisUtil.get(key);
if (serialNo == null) {
// 加上睡眠时间,让问题更容易暴露
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
boolean success = redisUtil.setNx(key, SERIAL_NO_INIT_VALUE, Duration.ofHours(24));
if (success) {
// 返回tue,表示设置key成功,是第一次设置
return concatSerialNo(bizCode, dateStr, SERIAL_NO_INIT_VALUE, 5);
}
}
Long incrNo = redisUtil.incr(key);
return concatSerialNo(bizCode, dateStr, incrNo, 5);
}
测试代码:
@Test
void testGenOrderNoSafe() {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
String orderNo = idGeneratorUtil.genOrderNoSafe("ORDER", "ORDER");
log.info("thread name = {}, orderNo = {}", Thread.currentThread().getName(), orderNo);
}, "thread-" + i).start();
}
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end");
}
执行结果:
可以看出这么写是没有问题的。同事review代码之后,觉得没问题,但是给出了写法上更为简洁的建议。
并发安全的简洁实现方式以及对编码的思考
public String genOrderNoOpt(String keyPrefix, String bizCode) {
String dateStr = dateFormat(LocalDateTime.now(), FORMAT_YYMMDD);
String key = keyPrefix + "_" + dateStr;
boolean success = redisUtil.setNx(key, SERIAL_NO_INIT_VALUE, Duration.ofHours(24));
if (success) {
// 返回tue,表示设置key成功,是第一次设置
return concatSerialNo(bizCode, dateStr, SERIAL_NO_INIT_VALUE, 5);
}
Long incrNo = redisUtil.incr(key);
return concatSerialNo(bizCode, dateStr, incrNo, 5);
}
这么写,和上一个版本相比,要更为简洁,但是我最终没有修改,我当时考虑的并不是 setNx 和 get 2个方法的性能差异(这个我也并不是很清楚,按理说get应该性能更快的,但是与一次通信相比,这个性能提高是可以忽略不计的),我当时主要想到2点:
第一,这2段代码在正确性、性能等方面可以说是毫无差异的,他们只是写法上的差异,在这种情况下,如果代码的修改只是变得稍微简洁,那么这种并不能说是优化,可能更多的是一种折腾,尤其对于经验不太丰富的开发来说,git用的并不熟练,他可能不会去reset后再修改重新commit,而是修改后直接commit,这样还多了一条commit记录。开发之间review代码是经常的事情,我们更多的应该是关注正确性、性能、安全性、拓展性等方面,而不是讨论另一种写法,就像讨论西瓜横着切和竖着切一样(搜了下,好像顺着纹路切不会爆裂,但是从最终迟到的口味口感来说没啥区别的),没有太大的意义,尤其是团队会议集体review代码,这样的建议就成了浪费团队的时间了。
第二,我当时更多的是考虑到这一点,当我写的是业务代码,比起代码写的更简洁、代码量更少,让其他人一眼就能看懂是更为重要的,因为就算是一个人owner一整条业务线,但是依然可能与其他人owner的业务是相关的,别人很有可能需要查看你所负责的代码,让人易于理解的代码对于彼此都是有好处的,同样不是框架中的工具类代码,很大概率不仅要被其他人使用,更会去看实现方式,这样用起来更安心,因此代码的可读性在业务开发中是需要考虑的重要因素,而简洁不代表可读性更强,符合人的直觉的代码具有更高的可读性,P.S.这是我的主观感受。
总结
本文主要介绍了基于Redis的 setNx 和 incr 实现全局唯一ID的实现方式,需要注意的是setNx,尽管是线程安全的,但是在并发的时候,我们需要根据其返回值判断当前线程是否设置成功。接着比较了2种并发安全的写法以及自己对编码的思考。
最后有个疑问,网上基于Redis的分布式ID解决方案都是直接使用incr,没看到使用setNx,setNx更多的用于分布式锁。
转载自:https://juejin.cn/post/7398789000222900263