Bootstrap

接口的幂等性与分布式锁redisson

接口的幂等性与分布式锁redisson

采用token方式实现接口幂等性的示意图。
在这里插入图片描述

一、幂等性

一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用

二、使用地方

1、 前端重复提交表单:在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
2、 用户恶意进行刷单:例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
3、接口超时重复提交:很多时候 HTTP客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
4、 消息进行重复消费:当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。

三、幂等性解决办法

1、token 机制实现 通过token 机制实现接口的幂等性,这是一种比较通用性的实现方法
2、基于 mysql唯一索引 实现 这种实现方式是利用mysql 唯一索引的特性。
3、基于 redis SETNX 命令实现 这种实现方式是基于 SETNX 命令实现的 SETNX key value:将key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。

四、采用token实现接口幂等性的具体做法(springboot)

例如:
1、点击提交订单按钮:

① 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。
② 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串,返回给前端一份,客户端拿到后应存到表单隐藏域中,后端存redis一份,以该 Token 作为 Redis 的键(注意设置过期时间)。

2、点击结算按钮

③ 客户端在执行提交表单时,把Token 存入到 Headers 中,执行业务请求带上该 Headers。
④ 服务端接收到请求后从 Headers 中拿到Token,然后根据 Token 到 Redis 中查找该 key 是否存
⑤ 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。

**那么该笔交易token只有一个,所以无论你点多少次结算,那我都能以此token去判断是否重复提交**
方法一、采用lua脚本,实现在redis中查找数据和删除数据的原子操作

①导包

<!--springboot集成redis时要导入的包-->
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--分布式锁redisson导入的包-->
 <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.9.0</version>
        </dependency>

配置yml

spring:
  redis:
    host: 127.0.0.1
    port: 6379

②返回一个后端生成的token(比如:提交订单按钮)

  /**
     * 创建 Token 存入 Redis,并返回该 Token
     * @param value 用于辅助验证的 value 值
     * @return 生成的 Token 串
     */
    public String generateToken(String value) {
        String token = UUID.randomUUID().toString();
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        /**
         * 在真实业务中 采用唯一标志 例如 流水号啊
         */
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
        return token;
    }

③执行业务逻辑(比如:点击结算按钮)
控制层:

 /**
     * 接口幂等性测试接口
     *
     * @param token 幂等 Token 串
     * @return 执行结果
     */
    @PostMapping("/test")
    @ApiOperation(value = "测试幂等性")
    public String test(HttpServletRequest request) {
        String token = request.getHeader("token");
        // 获取用户信息(这里使用模拟数据)
        String userInfo = "mydlq";
        // 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
        boolean result = tokenUtilService.validToken(token, userInfo);
        // 根据验证结果响应不同信息
        if (result) {
            /**
             * 执行正常的逻辑
             */
            log.info("执行正常的逻辑………………");
        }
        return result ? "正常调用" : "重复调用";
    }

业务层:

 /**
     * 验证 Token 正确性
     *
     * @param token token 字符串
     * @param value value 存储在Redis中的辅助验证信息
     * @return 验证结果
     */
    public Boolean validToken(String token, String value) {
        // 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value,这段lua脚本的意思是获取redis的KEYS[1]的值,与KEYS[2]的值作比较,如果相等则返回KEYS[1]的值并删除redis中的KEYS[1],否则返回0
        String script = "if redis.call('get',KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        // 根据 Key 前缀拼接 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 执行 Lua 脚本
        Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
        // 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
        if (result != null && result != 0L) {
            log.info("验证 token={},key={},value={} 成功", token, key, value);
            return true;
        }
        log.info("验证 token={},key={},value={} 失败", token, key, value);

        return false;
    }
方法二、分布式锁

分布式锁解决本地锁部属中的,分布式部署多服务会同时访问redis。例如:某服务分布式部署了3份,那么在redis为null,至少有3个请求会进入数据库(已使用本地锁的情况下)。理论应该一个区数据库查出来在放入redis。所以,为了解决这个问题,引入redisson
①导包

<!--springboot集成redis时要导入的包-->
<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--分布式锁redisson导入的包-->
 <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.9.0</version>
        </dependency>

②写配置类(这里也可以把地址配置到yml,方便管理)

@Configuration
public class MyRedissonConfig {
    @Bean(destroyMethod = "shutdown")
   public RedissonClient redisson() throws IOException {
        //创建配置
        Config config = new Config();
        /**
        *如果有密码:
        *config.useSingleServer().setAddress("redis://120.78.179.242:6379").setPassword("123456")
        **/
        config.useSingleServer().setAddress("redis://120.78.179.242:6379");
        //根据Config创建出RedissonClient示例
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
}

③ 业务代码
controller层:

/**
     * 分布式锁实现幂等性
     */
    @PostMapping("/distributeLock")
    @ApiOperation(value = "分布式锁实现幂等性")
    public String distributeLock(HttpServletRequest request) {
        String token = request.getHeader("token");
        // 获取用户信息(这里使用模拟数据)
        String userInfo = "mydlq";
        RLock lock = redissonClient.getLock(token);
        lock.lock(10, TimeUnit.SECONDS);
        try {
           Boolean flag = tokenUtilService.validToken2(token, userInfo);
            // 根据验证结果响应不同信息
            if (flag) {
                /**
                 * 执行正常的逻辑
                 */
                log.info("执行正常的逻辑………………");
            }
            return flag ? "正常调用" : "重复调用";
        } catch (Exception e) {
            e.printStackTrace();
            return  "重复调用";
        } finally {
            lock.unlock();
        }
    }

service层:

public Boolean validToken2(String token, String userInfo) {
        if (StringUtils.isBlank(token)) {
            return false;
        }
        Boolean aBoolean = redisTemplate.hasKey(IDEMPOTENT_TOKEN_PREFIX + token);
        if (aBoolean) {
            //删除token
            Boolean isDeleted = redisTemplate.delete(IDEMPOTENT_TOKEN_PREFIX + token);
            if (!isDeleted) {
                return false;
            }
            return true;
        } else {
            return false;
        }
    }

总结:lua脚本的母的是保证只有一个线程抢到分布式锁,并且获取后就删除,拿redis数据和删除redis数据是原子操作。分布式锁是分布式中只有一个拿到锁,并且删除redis数据,谁先占位谁执行业务,并删除redis,其他再进来redis已无数据,无需执行了,从而实现幂等性。

5. redisson看门狗自动续期源码
private void renewExpiration() {
        RedissonLock.ExpirationEntry ee = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
        if (ee != null) {
            Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
                public void run(Timeout timeout) throws Exception {
                    RedissonLock.ExpirationEntry ent = (RedissonLock.ExpirationEntry)RedissonLock.EXPIRATION_RENEWAL_MAP.get(RedissonLock.this.getEntryName());
                    if (ent != null) {
                        Long threadId = ent.getFirstThreadId();
                        if (threadId != null) {
                            RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
                            future.onComplete((res, e) -> {
                                if (e != null) {
                                    RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", e);
                                } else {
                                    if (res) {
                                        RedissonLock.this.renewExpiration();
                                    }

                                }
                            });
                        }
                    }
                }
            }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
            ee.setTimeout(task);
        }
    }
	
	
	
	
	    protected RFuture<Boolean> renewExpirationAsync(long threadId) {
        return this.commandExecutor.evalWriteAsync(this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(this.getName()), new Object[]{this.internalLockLeaseTime, this.getLockName(threadId)});
    }
;