需求说明
产品要求实现一个订单编号,此编号规则如下
订单编号规则:
订单编号举例
比如业务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,所以不需要指定版本
<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
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*/@Beanpublic 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*/@Beanpublic StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {StringRedisTemplate template = new StringRedisTemplate(redisConnectionFactory);template.afterPropertiesSet();return template;}}
如果是配置范型的RedisTemplate,需要设置值的序列化规则为:StringRedisSerializer,原因可以参考此文章:Spring Boot中使用RedisTemplate优雅的操作Redis,并且解决RedisTemplate泛型注入失败的问题
测试redis的config代码
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";@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Testpublic 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));}@Testpublic 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生成服务
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*/@Slf4jpublic 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中获取自增idLong 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服务正常,需要判重;否则直接从数据库中获取下一个idString 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@AllArgsConstructorpublic static class AContext implements Context {/*** 业务类型*/private String businessType;}}
添加一个A服务的唯一id生成服务实现
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@Servicepublic 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);}@Overrideprotected boolean checkIfDuplicated(String nextId) {return false;}@Overrideprotected Long maxIdFromDatabase(String duplicatedId) {return 1L;}@Overrideprotected String assemblyNextId(VoucherNumberContext context, Long redisValue) {return String.join(Constants.BLANK, context.getBusinessType(), getDatePeriod(),StrUtil.fillBefore(String.valueOf(redisValue), FILLED_CHAR, INCREMENT_LENGTH));}@Overrideprotected String getKey() {return String.join(Constants.REDIS_KEY_DELIMITER, Constants.REDIS_KEY_PREFIX, BUSINESS_TYPE, getDatePeriod());}@Overrideprotected long getTimeOutHours() {return EXPIRATION_HOURS;}}
