第10讲:Redis幂等性

问:什么是幂等性?

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

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

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

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

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

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

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

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

问:如何使用Redis进行产品设计实现幂等性?

在这里插入图片描述

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

  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 。

实现幂等性的业务代码设计

pom.xml

添加redis、fastjson相关坐标

<!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--fastjson-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
        </dependency>
        <!-- JSONObject对象依赖的jar包 -->
        <dependency>
            <groupId>commons-beanutils</groupId>
            <artifactId>commons-beanutils</artifactId>
            <version>1.9.3</version>
        </dependency>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>3.2.1</version>
        </dependency>
        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>
        <dependency>
            <groupId>commons-logging</groupId>
            <artifactId>commons-logging</artifactId>
            <version>1.1.1</version>
        </dependency>
        <dependency>
            <groupId>net.sf.ezmorph</groupId>
            <artifactId>ezmorph</artifactId>
            <version>1.0.6</version>
        </dependency>

application.yml配置文件

spring:
  redis:
    port: 6379 # Redis服务器连接端口
    host: 127.0.0.1 # Redis服务器地址
    database: 0 # Redis数据库索引(默认为0)
    password: # Redis服务器连接密码(默认为空)
    timeout: 5000ms # 连接超时时间(毫秒)
    jedis:
      pool:
        max-active: 8 # 连接池最大连接数(使用负值表示没有限制)
        max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-idle: 8 # 连接池中的最大空闲连接
        min-idle: 0 # 连接池中的最小空闲连接
server:
  port: 8080

用于处理Http响应结果的实体类

package demo.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class HttpResult {

    public static final Integer CODE_SUCCESS = 200; //正确
    public static final Integer IDEMPOTENCY_ERROR = 808; //幂等性校验错误

    private Integer code;
    private String msg;
    private Object data;
}

用于增强的注解

将该注解@Idempotency 添加到要实现幂等性的controller方法上即可完成幂等性操作

package demo.idempotency;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotency {
    boolean required() default true;
}

token生成及校验服务

package demo.idempotency;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface TokenService {
    /**
     * 创建token
     */
    String createToken();

    /**
     * 检验token
     */
    boolean checkToken(HttpServletRequest request, HttpServletResponse response) throws Exception;

}
package demo.idempotency;

import com.alibaba.fastjson.JSON;
import demo.entity.HttpResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class TokenServiceImpl implements TokenService {

    @Autowired(required = false)
    private RedisTemplate<String, Object> redisTemplate;
//    StringRedisTemplate stringRedisTemplate;

    private final String TOKEN_PREFIX = "idempotency";
    private final String TOKEN_NAME = "ACCESS-Token";

    @Override
    public String createToken() {
        String str = UUID.randomUUID().toString();
        StringBuilder token = new StringBuilder();
        try {
            token.append(TOKEN_PREFIX).append(str);
            redisTemplate.boundValueOps(token.toString()).set(token.toString(), 10000L, TimeUnit.SECONDS);
            if (!StringUtils.isEmpty(token.toString())) {
                return token.toString();
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return null;
    }

    @Override
    public boolean checkToken(HttpServletRequest request, HttpServletResponse response) throws Exception {

        boolean isOk = true;

        String token = request.getHeader(TOKEN_NAME);
        if (!StringUtils.hasText(token)) {
            token = request.getParameter(TOKEN_NAME);
            if (!StringUtils.hasText(token)) {
                String jsonString = JSON.toJSONString(new HttpResult(HttpResult.IDEMPOTENCY_ERROR, "缺少参数ACCESS-Token", null));
                writeReturnJson(response, jsonString);
                isOk = false;
            }
        } else {
            boolean isExists = redisTemplate.hasKey(token);
            if (!isExists) {
                String jsonString = JSON.toJSONString(new HttpResult(HttpResult.IDEMPOTENCY_ERROR, "不能重复提交", null));
                writeReturnJson(response, jsonString);
                isOk = false;
            }

            if(isExists){
                boolean remove = redisTemplate.delete(token);
                if (!remove) {
                    log.error("Token刷新失败");
                    String jsonString = JSON.toJSONString(new HttpResult(HttpResult.IDEMPOTENCY_ERROR, "Token刷新失败", null));
                    writeReturnJson(response, jsonString);
                    isOk = false;
                }
            }
        }

        return isOk;
    }

    private void writeReturnJson(HttpServletResponse response, String json) throws Exception {
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(json);

        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
    }
}

拦截器

使用拦截器拦截请求,如果发现请求的Controller方法是使用@Idempotency注解标注的方法,则进行幂等性验证

package demo.interceptor;

import demo.idempotency.Idempotency;
import demo.idempotency.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;

/**
 * 拦截器
 */
@Component
public class IdempotencyInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    /**
     * 预处理
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if (!(handler instanceof HandlerMethod)) {//如果没有注解,直接返回true
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        //被Idempotment标记的扫描
        if (method.isAnnotationPresent(Idempotency.class)) {
            Idempotency idempotencyAnnotation = method.getAnnotation(Idempotency.class);//通过反射获取注解
            if (idempotencyAnnotation.required()) {
                return tokenService.checkToken(request, response);// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
            }
        }
        return true;
    }
}
package demo.interceptor;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

import javax.annotation.Resource;

@Configuration
public class WebConfiguration extends WebMvcConfigurerAdapter {

    @Resource
    private IdempotencyInterceptor autoIdempotentInterceptor;

    /** * 添加拦截器 * @param registry */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(autoIdempotentInterceptor);
        super.addInterceptors(registry);
    }
}

测试

在Controller测试,首先先通过接口获取token,然后携带token发送请求

package demo;

import demo.idempotency.Idempotency;
import demo.idempotency.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class Demo {

    @Autowired
    private TokenService tokenService;

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

    @RequestMapping("/get_token")
    public String getToken(){
        return tokenService.createToken();
    }

    @RequestMapping("/t1")
    public String t1(){
        return "hello t1";
    }

    @Idempotency
    @RequestMapping("/t2")
    public String t2(){
        return "hello t2";
    }
}

使用postman发送请求获取token

在这里插入图片描述

使用postman发送请求,不携带token

在这里插入图片描述
使用postman发送请求,携带token,发现可以正常访问接口

在这里插入图片描述
再次使用postman发送请求,携带相同token,发现已经是重复提交

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值