什么是幂等性

有时候,不是因为你没有能力,也不是因为你缺少勇气,只是因为你付出的努力还太少,所以,成功便不会走向你。而你所需要做的,就是坚定你的梦想,你的目标,你的未来,然后以不达目的誓不罢休的那股劲,去付出你的努力,成功就会慢慢向你靠近。

导读:本篇文章讲解 什么是幂等性,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

1、幂等性

一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。

在这里插入图片描述
幂等性是系统服务对外一种承诺(而不是实现),承诺只要调用接口成功,外部多次调用对系统的影响是一致的。

2、幂等性要解决的场景

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

除了以上具体的场景,再从SQL层面来看幂等性:

SQL1: select * from table where col=1;

SQL2: update table set col2=666 where col=1;

SQL3: update table set col2=col2+1 where col=1;

SQL1和SQL2不管执行多少次,结果和对库表的影响都是一样的,属于天然的幂等,而SQL3则不幂等

3、幂等性的落地方案

实现幂等,同时也增加了服务端的逻辑和成本,实际开发中,应该结合具体的场景,考虑是否引入幂等性。关于幂等的实现:

前端思路:

  • 前端js提交禁止按钮可以用一些js组件
  • 使用Post/Redirect/Get模式,即在提交后执行页面重定向,这就是Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,执行一个客户端的重定向,转到提交成功信息页面

后端思路:

Q1:数据库唯一主键实现幂等性

使用全局唯一的主键,常用于解决Insert的幂等性。这个主键不是设置自增的主键,而是使用分布式 ID 充当主键,这样才能能保证在分布式环境下 ID 的全局唯一性。

在这里插入图片描述
以上即:

  • 客户端发起请求,调用服务端接口
  • 服务端生成分布式ID,将这个ID做为主键来Insert
  • 重复请求时就会主键重复异常

在这里插入图片描述

Q2:数据库乐观锁实现幂等性

数据库乐观锁即在库表中加一个字段如version,来标识当前数据的版本。这种一般用于update的幂等性实现。

id name price version
1 小米 1000 6
2 苹果 2000 10
3 华为 1600 5
update mytable
set price = price+200 ,version = version+1
where id = 3 AND version = 5

注意这里每次Update都限制了version = 5,而version只要更新成功就会加一而不再等于5。

Q3:防重Token令牌实现幂等性

客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。

在这里插入图片描述

  • 服务端提供获取token的接口,这个token,可以是一个序列号、分布式ID、UUID等
  • 客户端发送请求获取token,这个token被存进Redis
  • 客户端携带token(Header中)调用业务接口
  • 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在
  • 校验成功,则执行业务,并删除 redis 中的 token
  • 校验失败,说明 redis 中已经没有对应的 token,则表示重复操作,返回错误信息
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
public class RedisRepeatUtil {
    /**注入Redis中间件Bean*/
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;

    /**
     * 直接定义个KEY,token做value
     * 也可Token 作为 Key,用户信息作为 Value
     */
    public static String CODE_PUB_ADD_KEY="code:addSome:myToken:";

	/**
     * 使用UUID
     * 设置过期时间30分钟,防止无效数据浪费内存
     */
	public String generToken(String key){
        String uuid= UUID.randomUUID().toString();
        log.info("uuid:"+uuid);
        //为期设置过期时
        redisTemplate.opsForValue().set(key, uuid);
        redisTemplate.expire(key,30, TimeUnit.MINUTES);
        return uuid;
    }

	/**
     * 解决重复提交
     * 前端 token和后端redis token进行比较判断
     */
    public boolean  compareToken(String key,String tokenUUID){
        //删除key
        boolean flag=tokenUUID.equals((String) redisTemplate.opsForValue().get(key));
        redisTemplate.delete(key);

        return flag;
    }
}

Q4:客户端唯一序列号实现幂等性

和Token相对的,使用请求序列号,即每次向服务端请求时候附带一个短时间内唯一不重复的序列号,该序列号可以是一个有序 ID,也可以是一个订单号。

在这里插入图片描述

  • 请求服务端接口时携带该序列号和用于认证的 ID
  • 服务端从请求中拿到序列号和下游认证ID 进行组合,形成Key,存于Redis
  • 到 Redis 中查询是否存在对应的 Key 的键值对
  • 存在即之前来过,响应重复请求的错误信息
  • 不存在即第一次请求,开始执行业务

以上,根据实际场景选择适合的实现方案



4、代码实现

最后,备份一下参考文档中Token机制的实现:

参考文档:https://www.21ic.com/article/883663.html

  • 引入 SpringBoot、Redis、lombok 相关依赖
<dependencies>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
 </dependencies>
  • 在 application.yaml中配置文件中配置连接 Redis 的参数
spring:
  redis:
    ssl: false
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 1000
    password:
    lettuce:
      pool:
        max-active: 100
        max-wait: -1
        min-idle: 0
        max-idle: 20
  • 创建用于生成Token的Sevice类
@Slf4j
@Service
public class TokenUtilService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 存入Redis的Token 键的前缀
     */
    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";

    /**
     * 创建 Token 存入 Redis,并返回该 Token
     * @param value 用于辅助验证的 value 值, 可以传入用户信息做为value
     * @return 生成的 Token 串
     */
    public String generateToken(String value) {
        // 生成UUID
        String token = UUID.randomUUID().toString();
        // 加前缀拼接出 Redis的 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 存储 Token 到 Redis,且设置过期时间为5分钟
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
        // 返回 Token
        return token;
    }

    /**
     * 验证 Token 正确性
     *
     * @param token token 字符串
     * @param value value 存储在Redis中的辅助验证信息
     * @return 验证结果
     */
    public boolean validToken(String token, String value) {
        // 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value
        String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript 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;
    }

}

以上:

  • 使用 UUID 工具创建 Token 串,设置以前缀 “idempotent_token:“+“Token串” 作为 Key,以用户信息当成 Value,将信息存入 Redis 中
  • 接收 Token 串参数,加上 Key 前缀形成 Key,再传入 value 值,执行 Lua 表达式(Lua 表达式能保证命令执行的原子性)进行查找对应 Key 与删除操作

完善Controller层:

@Slf4j
@RestController
public class TokenController {

    @Autowired
    private TokenUtilService tokenService;

    /**
     * 获取 Token 接口
     *
     * @return Token 串
     */
    @GetMapping("/token")
    public String getToken() {
        // 获取用户信息(这里使用模拟数据)
        //将用户信息做为value,也一并做了登录校验
        // 注:这里存储该内容只是举例,其作用为辅助验证,使其验证逻辑更安全,如这里存储用户信息,其目的为:
        // - 1)、使用"token"验证 Redis 中是否存在对应的 Key
        // - 2)、使用"用户信息"验证 Redis 的 Value 是否匹配。
        String userInfo = "mydlq";
        // 获取 Token 字符串,并返回
        return tokenService.generateToken(userInfo);
    }

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

}

注:

在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/146057.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!