分布式服务接口的幂等性如何设计(比如不能重复扣款)?
一个分布式系统中的某个接口,要保证幂等性,如何保证?这个事,其实是你做分布式系统的时候必须要考虑的一个生产环境的技术问题,为什么呢?
实际案例1:
假如你有个服务提供一个付款业务的接口,而这个服务分别部署在5台服务器上,然后用户在前端操作时,不知道为啥,一个订单不小心发起了两次支付请求,然后这俩请求分散在了这个服务部署的不同的服务器上,这下好了,一个订单扣款扣了两次。
实际案例2:
订单系统调用支付系统进行支付,结果不消息网络,然后订单系统走了前面我们看到的重试retry机制,那就给你重试一次吧,那么支付系统收到了一个支付请求两次,而且因为负载均衡算法落在了不同的机器上。
小结:
所以你必须得知道这事,否则你做出来的分布式系统恐怕很容易埋坑!
用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
举个简单的例子:那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常了,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。在以前的单应用系统中,我们只需要对数据操作加入事务即可,发生错误的时候立即回滚,但是再响应客户端的时候也有可能网络中断或者异常等等情况。
秒杀场景下,一个用户只能购买同一商品一次的解决方法:采用用户ID+商品ID,存储到redis中,使用redis中的setNX操作,等待自然过期。
用户注册时,用户点击注册按钮多次,是不是会注册多个用户?我们可以在用户进入注册页面后由后台生成一个token,传给前端页面,用户在点击提交时,将token带给后台,后台使用该token作为分布式锁,setNX操作,执行成功后不释放锁,等待自然过期。
用户注册时,用户点击注册按钮多次,是不是会注册多个用户? 我们可以使用手机号作为mysql用户表唯一key。也就是一个手机号只能注册一次。
update操作可能存在幂等性的问题:
1.用户更改个人信息,疯狂点击按钮,不会发生幂等性问题,因为数据始终为修改后的数据。
2.用户购买商品,用户在点击后,网络出现问题,可能再次点击,这样就会出现幂等性问题,导致购买了多次,可以使用乐观锁
update order set count=count-1,version=version+1 where id=1 and version=1
根据唯一id删除不会出现幂等性问题,因为第二次删除的时候mysql中已经不存在该数据
查询操作不会改变数据,所以是天然的幂等性操作。
使用Token
机制,或使用Token
+ 分布式锁的方案来解决幂等性问题。
通过Token
机制实现接口的幂等性,这是一种比较通用性的实现方法。
具体流程步骤:
Token
,服务端会生成一个全局唯一的ID
作为Token
保存在Redis
中,同时把这个ID
返回给客户端;Token
;Token
,如果校验成功,则执行业务,并删除Redis
中的 Token
;Redis
中已经没有对应的 Token
,则表示重复操作,直接返回指定的结果给客户端。通过MySQL
唯一索引的特性实现接口的幂等性。
具体流程步骤:
通过Redis
的SETNX
命令实现接口的幂等性。
SETNX key value
:当且仅当key
不存在时将key
的值设为value
;若给定的key
已经存在,则SETNX
不做任何动作。设置成功时返回1
,否则返回0
。
具体流程步骤:
SETNX
的方式存入Redis
中,并根据业务设置相应的超时时间;为需要保证幂等性的每一次请求创建一个唯一的标识token,先获取token,并将此token存入到redis,请求接口时,将此token放在header或者作为请求参数请求接口,后端接口判断redis中是否存在此token;
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<groupId>org.examplegroupId>
<artifactId>springBoot-idempotentartifactId>
<version>1.0-SNAPSHOTversion>
<properties>
<maven.compiler.source>8maven.compiler.source>
<maven.compiler.target>8maven.compiler.target>
properties>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.3.2.RELEASEversion>
parent>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
dependencies>
project>
该注解的目的是为了实现幂等性的校验,即添加了该注解的接口要实现幂等性验证
package com.ldp.idempotent.annotation;
import java.lang.annotation.*;
/**
* 自定义注解
* 说明:添加了该注解的接口要实现幂等性验证
*/
@Target({ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiIdempotentAnn {
boolean value() default true;
}
package com.ldp.idempotent.intceptor;
import com.ldp.idempotent.annotation.ApiIdempotentAnn;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.lang.reflect.Method;
/**
* 幂等性拦截器
*/
@Component
public class ApiIdempotentInceptor extends HandlerInterceptorAdapter {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 前置拦截器
*在方法被调用前执行。在该方法中可以做类似校验的功能。如果返回true,则继续调用下一个拦截器。如果返回false,则中断执行,
* 也就是说我们想调用的方法 不会被执行,但是你可以修改response为你想要的响应。
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//如果hanler不是和HandlerMethod类型,则返回true
if (!(handler instanceof HandlerMethod)) {
return true;
}
//转化类型
final HandlerMethod handlerMethod = (HandlerMethod) handler;
//获取方法类
final Method method = handlerMethod.getMethod();
// 判断当前method中是否有这个注解
boolean methodAnn = method.isAnnotationPresent(ApiIdempotentAnn.class);
//如果有幂等性注解
if (methodAnn && method.getAnnotation(ApiIdempotentAnn.class).value()) {
// 需要实现接口幂等性
//检查token
//1.获取请求的接口方法
//查看当前接口的方法之上是否有自定义的注解@ApiIdempotentAnn
//如果说包含了,则认为该接口是要进行幂等性校验的接口
//检验token
//如果说有,则访问成功,执行逻辑业务,要删除redis中的token
//如果说没有,则表示重复调用
//如果说没有包含了,则直接放行 checkToken(request);
//如果token有值,说明是第一次调用
if (result) {
//则放行
return super.preHandle(request, response, handler);
} else {//如果token没有值,则表示不是第一次调用,是重复调用
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.print("重复调用");
writer.close();
response.flushBuffer();
return false;
}
}
//否则没有该自定义幂等性注解,则放行
return super.preHandle(request, response, handler);
}
//检查token
private boolean checkToken(HttpServletRequest request) {
//从请求头对象中获取token
String token = request.getHeader("token");
//如果不存在,则返回false,说明是重复调用
if(token==null || " ".equals(token)){
return false;
}
//否则就是存在,存在则把redis里删除token
return redisTemplate.delete(token);
}
//后置,暂时没用
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
super.postHandle(request, response, handler, modelAndView);
}
}
package com.ldp.idempotent.config;
import com.ldp.idempotent.intceptor.ApiIdempotentInceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* mvc配置
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private ApiIdempotentInceptor apiIdempotentInceptor;
/*
添加自定义拦截器到Springmvc配置中,拦截所有请求
addInterceptor 需要一个实现HandlerInterceptor接口的拦截器实例
addPathPatterns 用于设置拦截器的过滤路径规则;addPathPatterns("/**")对所有请求都拦截
excludePathPatterns:用于设置不需要拦截的过滤规则
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiIdempotentInceptor).addPathPatterns("/**");
}
}
package com.ldp.idempotent.controller;
import com.ldp.idempotent.annotation.ApiIdempotentAnn;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
public class ApiController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 前端获取token,然后把该token放入请求的header中
*
* @return
*/
@GetMapping("/getToken")
public String getToken() {
String token = UUID.randomUUID().toString().substring(1, 9);
stringRedisTemplate.opsForValue().set(token, "1");
return token;
}
//定义int类型的原子类的类
AtomicInteger num=new AtomicInteger(100);
/**
* 主业务逻辑,num--,并且加了自定义接口
*
* @return
*/
@GetMapping("/submit")
@ApiIdempotentAnn
public String submit() {
// num--
num.decrementAndGet();
return "success";
}
/**
* 查看num的值
*
* @return
*/
@GetMapping("/getNum")
public String getNum() {
return String.valueOf(num.get());
}
}
浏览器访问:http://localhost:9090/getToken,获取token的值
浏览器访问:http://localhost:9090/getNum
使用方法参考Jmeter压力测试工具使用说明v1.0
通过以上代码演示了解到,本案例对submit接口方法使用了基于token的幂等性解决方案,也就是当前submit接口方法只能调用一次,如果由于网络抖动或者网络异常出现多点或者点击多次的情况,就会出现报错提示,不允许调用当前接口,那么也就解决了当前业务接口幂等性的问题。