likes
comments
collection
share

基于Redis的分布式ID解决方案及对代码可读性的思考

作者站长头像
站长
· 阅读数 29

在很多业务中,需要生成全局唯一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");

    }
}

执行结果如下:

基于Redis的分布式ID解决方案及对代码可读性的思考

从结果可以明显的看出,这种实现方式是有问题的。

并发安全的实现方式

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");

}

执行结果:

基于Redis的分布式ID解决方案及对代码可读性的思考

可以看出这么写是没有问题的。同事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
评论
请登录