08SpringCloud 幂等性

RESTful 接口的幂等性

幂等性:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了了副作用。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣了钱,流水记录也变成了两条,再或者新增用户表单注册时,用户反复提交表单.

简而言之:任意多次执行所产生的影响均与一次执行的影响相同。按照这个含义,最终的含义就是对数据库的影响只能是一次性的,不能重复处理

产生『重复数据或数据不一致』(假定程序业务代码没问题),绝大部分就是发生了重复的请求,重复请求是指『同一个请求因为某些原因被多次提交』。导致这个情况会有几种场景:

  1. 微服务场景,在我们传统应用架构中调用接口,要么成功,要么失败。但是在微服务架构下,会有第三个情况『未知』,也就是超时。如果超时了,微服务框架会进行重试;
  2. 用户交互的时候多次点击。如:快速点击按钮多次;
  3. MQ 消息中间件,消息重复消费;
  4. 第三方平台的接口(如:支付成功回调接口),因为异常也会导致多次异步回调;
  5. 其他中间件/应用服务根据自身的特性,也有可能进行重试。

接口的幂等性实际上就是『接口可重复调用』,在调用方多次调用的情况下,接口『最终得到的结果是一致的』。

以『增删改查』四大操作来看,『删除』和『查询』操作天然是幂等的,没有(或不在乎)重复提交/重复请求问题。因为不管用户点击多少次删除操作或者是查询操作,也就是重复去调用查询接口或者是删除接口都不会有问题。因此,幂等需求通常是用在『新增』和『修改』类型的业务上。如用户注册表单的重复提交问题

而『修改』类型的业务通过 SQL 改造和 last_upated_at 字段的结合,也可以实现幂等,而无需下述的 token 和去重表方案。

因此,幂等性的处理重点集中在『新增』型业务上。

上述方案适用绝大部分场景。主要思想:

  1. 服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。(微服务肯定是分布式了,如果单机就适用 jvm 缓存)。
  2. 然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。
  3. 服务器判断 token 是否存在 redis 中,存在表示第一次请求,可以继续执行业务,执行业务完成后,最后需要把 redis 中的 token 删除。
  4. 如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样就保证了业务代码,不被重复执行。

其实,这里的 token 起到的就是全局唯一 ID 的作用。

这里的重点在于:要先删除 token ,再执行业务代码

因为『后删除 token』的缺陷太致命:如果进行业务处理成功后,删除 redis 中的 token 失败了,那么 token 仍存在于 Redis 中,这时如果发起了第二次请求,那么因为 token 的存在,会认为该操作未被执行过,这样就导致了有可能会发生重复请求。

当然,『先删除 token』也有缺点,如果先删除 token 成功,而随后执行业务逻辑失败,那么需要再返回信息中告知请求方,在重新获得 token,而不能/无法重复利用之前的 token 。

业务代码如下:

1、导入相关依赖

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-webartifactId>
dependency>

<dependency>
    <groupId>mysqlgroupId>
    <artifactId>mysql-connector-javaartifactId>
dependency>

<dependency>
    <groupId>org.mybatis.spring.bootgroupId>
    <artifactId>mybatis-spring-boot-starterartifactId>
    <version>2.1.3version>
dependency>

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-jdbcartifactId>
dependency>
<dependency>
    <groupId>org.projectlombokgroupId>
    <artifactId>lombokartifactId>
dependency>

<dependency>
    <groupId>tk.mybatisgroupId>
    <artifactId>mapper-spring-boot-starterartifactId>
    <version>2.1.5version>
dependency>

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-thymeleafartifactId>
dependency>

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
dependency>

2、相关配置 application.yml

server:
  port: 8080
spring:
  application:
    name: distribute-redis

  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://localhost:3306/k15?serverTimezone=UTC

  redis:
    host: 127.0.0.1
    port: 6379
    
  thymeleaf:
    cache: true
    check-template: true
    check-template-location: true
    content-type: text/html
    enabled: true
    encoding: UTF-8
    excluded-view-names: ''
    mode: HTML5
    prefix: classpath:/templates/
    suffix: .html

3、启动类

@SpringBootApplication
@MapperScan("com.woniu.outlet.mybatis")
public class DistributeRedisApplication {

    public static void main(String[] args) {
        SpringApplication.run(DistributeRedisApplication.class, args);
    }
}

4、页面素材

resources/templates下,添加部门页面add-department.html

DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Hellotitle>
head>
<body>
<form th:action="@{/add}">
    <p><input name="name" value="test-name"/>p>
    <p><input name="location" value="test-location">p>
    <p><input type="hidden" name="departmentToken" th:value="${token}">p>
    <p>
        <button type="submit">提交button>
    p>
form>

body>
html>

success.html和失败页面略

5、controller相关代码

@Controller
public class DepartmentController {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 当用户请求进入首页的时候 生成token,返回给页面,页面可以隐藏
     * 同时存到redis 中
     *当用户 提交  执行add方法时,先去redis查是否有,然后做完操作就删除,这样下次再
     * 提交时,redis就没有该数据了  但是这样有什么问题呢 ?
     * @return
     */
    @RequestMapping("/addpage")
    public String addDepartmnetPage(Model model) {
        //生成一个0-1000的随机数
        int random = new Random().nextInt(1000);
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        ops.set("department-token" + random, random + "");
        model.addAttribute("token", random + "");
        return "add-department";
    }

    @RequestMapping("/add")
    public String addDepartment(String name, String departmentToken) {

        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        //1、先查token 是否存在
        String redisToken = ops.get("department-token" + departmentToken);
        if (!StringUtils.isEmpty(redisToken)) {
            try {
                System.out.println("保存到数据库");
                //2、删除token
                redisTemplate.delete("department-token" + departmentToken);
                return "success";
            } catch (Exception exception) {
                return "failure";
            }
        } else {
            return "failure";
        }
    }
}

上面的addDepartment()方法方式实现有没有什么问题呢?

当一个用户快速的点击两下提交,前一次进请求add和后一次进请求add 相差时间很短,那么第一个请求还没来得及删除的时候,第二个请求也能查到,那么又会往数据库保存一次。这还不是没有解决表单的重复提交问题吗?

改进措施:就是进请求的时候 就删除,不执行查询,即使删除失败也是返回false

@RequestMapping("/add")
public String addDepartment(String name, String departmentToken) {
    ValueOperations<String, String> ops = redisTemplate.opsForValue();
    //1、先查token 是否存在
    if(redisTemplate.delete("department-token" + departmentToken)){
        System.out.println("保存到数据库");
        return "success";
    }
    return "failure";
    
}

另外在脚手架前后端分离的项目中,也可以当加载某个vue页面时,可以通过生命周期的钩子函数 如created 给服务器发请求,生成token返回给客户端浏览器

你可能感兴趣的:(SpringCloud,java,开发语言)