幂等性解决方案

什么是幂等性

幂等性就是一次请求和多次请求的效果一样,在计算机中就是一次请求和多次请求的参数和响应结果都是一样的。

为什么需要幂等性

  • 调用其他的接口,可能由于网络抖动,会出现超时重试策略,那么会重复发几次请求,譬如dubbo的超时重试机制。
  • 消息队列重复消费问题,譬如rocketmq。
  • 网络不好,客户端多点了几次表单提交。

如何保证幂等性

一锁、二判、三更新

锁:主要是为了防止在分布式环境下,针对服务和接口可能出现并发问题。
判:主要是为了判断是否已经有重复记录了。
更新:就是将数据更新持久化的数据库。

分布式环境未使用锁的问题

分布式环境下,为了保持不同的请求被代理服务器负载均衡到不同的机器上,那么可能出现多个请求打在不同的机器上,出现数据不一致的问题,甚至还会出现ABA的问题。

redisson分布式锁方案

导包

<!--使用redisson作为分布式锁-->
<dependency>
	<groupId>org.redisson</groupId>
	<artifactId>redisson</artifactId>
	<version>3.17.6</version>
</dependency>

yml配置

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 1800000
    lettuce:
      pool:
        max-active: 20
        max-wait: -1
        max-idle: 5
        min-idle: 0

初始化RedisClient的bean

@Configuration
public class InitRedisConfig {

    @Value("${spring.redis.host}")
    private String redisHost;
    @Value("${spring.redis.port}")
    private Integer redisPort;
    @Value("${spring.redis.database}")
    private Integer redisDatabase;

    /**
     * 所有对Redisson的使用都是通过RedissonClient对象
     * @return
     */
    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient(){
        // 创建配置 指定redis地址及节点信息
        Config config = new Config();
        String redisAddr = "redis://" + redisHost + ":" + redisPort;
        config.useSingleServer()
                .setAddress(redisAddr)
                .setPassword(null)
                .setDatabase(redisDatabase);

        // 根据config创建出RedissonClient实例
        return Redisson.create(config);
    }
}

获取锁例子

public void testRedissionCase() {
	// 锁名称
	String redisLock = "SPY-LOCK";
	// 获取锁
	RLock rLock = redissonClient.getLock(redisLock);

	try {
		// 获取非阻塞锁
		boolean flag = rLock.tryLock(10, 30, TimeUnit.SECONDS);

		if (!flag) {
			log.info("++++++++++++++++++++++++++> 没有获取到锁");
			return;
		}
		
		// 业务代码。。。	
	} catch (InterruptedException e) {
		e.printStackTrace();
	} finally {
		// 释放锁
		rLock.unlock();
	}
}

为什么判断是否重复问题

这步主要是为了判断是否已经有重复记录;如果已经有重复记录,那么直接更新;如果没有重复记录,那么直接插入记录。

判断是否重复的方案

方案1. 防重表
由于系统中会调用幂等号,就是根据一笔业务生成的不变的编码,只有不同业务的情况,幂等号才不同。我们需要设计一个幂等表,它脱离于业务表,幂等表中设计一个幂等号字段,设置为唯一索引的特性。我们去根据幂等号查询记录的时候,发现幂等号已经存在,那么就直接返回。

方案2. 状态幂等
需要入侵业务表,根据业务表的状态进行判断是否更新,如果状态为已更新,那么其他的重复请求就不能再更新。

方案3. token + redis
客户端第一次请求,请求里面携带幂等号,服务器程序将幂等号作为key,业务处理的结果作为value,存放到redis中,并且设置了过期时间,同时其他的请求携带相同的幂等号进行业务处理的时候,服务器程序发现redis中幂等号这个key已经存在,那么直接返回对应的value值,如果幂等号这个key不存在,那么继续进行业务处理,将处理结果存放到value,并且返回处理结果。

自定义注解实现

注解:

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NotRepeatSubmit {
    /**
     * token存活时间
     * 默认为30天
     * 单位为秒
     * @return
     */
    int survivalTime() default 24*60*60*30;
}

扫描注解的切面:

@Slf4j
@Component
@Aspect
public class NotRepeatSubmitAspect {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private StringRedisTemplate redisTemplate;


    @Around("@annotation(com.sunpeiyu.demoitem.idempotent.NotRepeatSubmit)")
    public Object doNotRepeatSubmit(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取分布式锁
        RLock rLock = redissonClient.getLock("SPY-LOCK");
        rLock.lock(30, TimeUnit.SECONDS);
        // 获取Http请求中的请求参数token
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String token = request.getParameter("token");

        try {
            // 校验幂等号是否存在,幂等号为必输项
            if (StrUtil.isBlank(token)) {
                throw new RuntimeException("请传入幂等号");
            }

            // 获取redis中获取幂等号键
            // redis中已存在对应的value情况,说明已经处理过一次请求,直接返回
            Object value = redisTemplate.opsForValue().get(token);

            if (!Objects.isNull(value)) {
                return value;
            }

            // redis中键不存在情况
            NotRepeatSubmit notRepeatSubmit = ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(NotRepeatSubmit.class);
            int time = notRepeatSubmit.survivalTime();
            Object result = joinPoint.proceed();
            String jsonStr = JSONUtil.toJsonStr(result);
            redisTemplate.opsForValue().set(token, jsonStr, time, TimeUnit.SECONDS);
            return result;
        } finally {
            // 释放分布式锁
            // 注意,此处必须要加判断
            if (Objects.nonNull(rLock) && rLock.isLocked() && rLock.isHeldByCurrentThread()) {
                rLock.unlock();
            }
        }
    }
}

注意:

if (Objects.nonNull(rLock) && rLock.isLocked() && rLock.isHeldByCurrentThread()) {
	rLock.unlock();
}

在释放锁的时候必须要加这个锁是否存在的校验,否则可能出现异常
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread

tryLock情况时,如果没有判断返回是否为true或false,那么可能会出现执行下面的finally中unlock方法,如果未持有锁却unlock就会报异常。

参考

https://mp.weixin.qq.com/s/EatpiCzNlTw1viO_flQIpg

posted @ 2023-08-22 23:49  sunpeiyu  阅读(60)  评论(0编辑  收藏  举报