【微服务】接口的幂等性怎么设计?

一、什么是幂等?

幂等性:短时间内,对于相同输入的请求,无论进行多少次重复操作,都应该和单次调用的结果一致。

二、幂等问题产生的原因是什么?(或者说为什么需要实现幂等性?)

1、前端重复提交

在用户注册,用户创建商品的时候,用户填写完成注册表单或者创建好了商品点击提交,很多时候会因为网络波动没有及时对用户做出提交成功响应,致使用户认为自己没有成功提交,然后一直点击提交按钮,这时就会发生重复提交表单请求,在数据库中重复创建多条记录。

2、接口超时重试

很多时候HTTP客户端工具都默认开启超时重试的机制,比如Feign。为了防止网络波动超时等造成请求失败,都会添加重试机制,导致一个请求可能提交多次。

3、消息重复消费

当使用MQ消息中间件的时候,如果消费者处理完生产者消息,但是还没有提交offset,然后自己挂掉了。等到自己重启以后就会重复消费生产者消息。

三、幂等问题的解决方案

1、防重token令牌

具体流程步骤:

  1. 客户端会先发送一个请求去获取 token,服务端会生成一个全局唯一的 ID 作为 token 保存在 redis 中,同时把这个 ID 返回给客户端。
  2. 客户端第一次调用业务请求的时候必须携带这个 token。
  3. 服务端会校验这个 token,如果校验成功,则执行业务,并删除 redis 中的 token。
  4. 客户端第二次调用业务请求的时候必须携带这个 token。
  5. 服务端会校验这个 token,如果校验失败,说明 redis 中已经没有对应的 token,则表示重复操作,直接返回指定的结果给客户端。

注意:

  1. 对 redis 中是否存在 token 以及删除 token 的代码逻辑建议用 Lua 脚本实现,保证原子性。Redis 结合 Lua 脚本可以解决多线程并发安全问题。
  2. 全局唯一 ID 可以用UUID (分布式 ID )。

2、基于 mysql 唯一索引实现

具体流程步骤:

  1. 客户端会先发送一个请求去获取到分布式 ID。
  2. 客户端第一次调用业务请求的时候会携带分布式 ID,服务端使用这个分布式ID作为唯一索引来进行插入,一旦出现重复提交的情况,插入自然不会成功。

3、基于 redis 分布式锁实现

具体流程步骤:

  1. 客户端先请求服务端,会拿到一个能代表这次请求业务的唯一字段。
  2. 将该字段以 SETNX 的方式存入 redis 中,并根据业务设置相应的超时时间。
  3. 如果设置成功,表示这是第一次请求,则执行后续的业务逻辑。
  4. 如果设置失败,表示已经执行过当前请求,直接返回。

四、SpingBoot集成Redis实现防重token令牌机制

1.Token生成和验证的工具类TokenUtils

java 复制代码
@Slf4j
@Component
public class TokenUtils {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";

    /**
     * 创建 Token 存入 Redis,并返回该 Token
     */
    public static String generateToken(String value) {
        // 创建Token
        String token = UUID.randomUUID().toString();
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 存储 Token 到 Redis,且设置过期时间为 5分钟
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
        // 返回 Token
        return token;
    }

    /**
     * 验证 Token
     */
    public static 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<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;
    }
}

2.防重接口的自定义注解ApiIdempotent

java 复制代码
package com.changlu.annontions;

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 ApiIdempotent {
}

3.防重注解拦截器ApiIdempotentInterceptor,会拦截所有标注自定义防重注解的controller方法

java 复制代码
@Component
public class ApiIdempotentInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenUtils tokenUtils;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //校验是否有执行方法
        if (!(handler instanceof HandlerMethod)) {
            return true;//若没有对应的方法执行器,就直接放行
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        ApiIdempotent annotation = method.getAnnotation(ApiIdempotent.class);
        //若是没有防重注解直接放行
        if (annotation != null) {
            //解析对应的请求头
            String token = request.getHeader("token");
            if (ObjectUtils.isEmpty(token)) {
                ServletUtils.renderString(response, "请携带token令牌");
                return false;
            }
            //若是校验失败直接进行响应
            if (!tokenUtils.validToken(token, "changlu")) {
                ServletUtils.renderString(response, "重复提交");
                return false;
            }
        }
        return true;
    }
}

4.注册拦截器

java 复制代码
@Configuration
public class MyWebMvcConfigurer implements WebMvcConfigurer {

    @Autowired
    private ApiIdempotentInterceptor apiIdempotentInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(apiIdempotentInterceptor);
    }
}

5.控制器接口

java 复制代码
@RestController
public class OrderController {

    @GetMapping("/order/token")
    public String getToken() {
        String userInfo = "changlu";
        // 生成 Token 字符串,并返回
        return TokenUtils.generateToken(userInfo);
    }

    //防重注解
    @ApiIdempotent
    @PostMapping("/order/create")
    public Object createOrder() {
        return "创建订单成功!";
    }
}

五、最后总结

对于下单 等存在唯一主键的业务,可以使用基于mysql唯一索引的方式实现。订单号是唯一索引。

对于更新订单状态 等相关的更新场景操作,可以使用基于mysql乐观锁的方式实现。version是订单状态

对于一人一单 的业务,可以使用基于redis分布式锁的方式实现。set的key是商品+用户,别忘记超时时间。

对于其他场景 ,可以通过防重token令牌方案的方式实现。Redis+Lua脚本解决多线程并发安全问题。

相关推荐
m0_635502201 小时前
Spring Cloud Gateway组件
网关·微服务·负载均衡·过滤器
bjzhang751 小时前
SpringBoot开发——集成Tess4j实现OCR图像文字识别
spring boot·ocr·tess4j
flying jiang1 小时前
Spring Boot 入门面试五道题
spring boot
小菜yh1 小时前
关于Redis
java·数据库·spring boot·redis·spring·缓存
爱上语文3 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
荆州克莱3 小时前
springcloud整合nacos、sentinal、springcloud-gateway,springboot security、oauth2总结
spring boot·spring·spring cloud·css3·技术
serve the people3 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
罗政8 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
Java小白笔记11 小时前
关于使用Mybatis-Plus 自动填充功能失效问题
spring boot·后端·mybatis
小哇66611 小时前
Spring Boot,在应用程序启动后执行某些 SQL 语句
数据库·spring boot·sql