温馨提示:如果有伪代码缺失或者有问题或者错误,欢迎指正!
一、什么是接口幂等性
幂等:(idempotent、idempotence) 是一个数学与计算机学的概念。
通俗语言来说就是:多次重复调用某一方法或者接口,返回的结果都是一致的。
二、幂等性产生场景
1.网络波动引起的重复操作导致重复请求;
2.用户误操作或者多次点击提交按钮导致重复请求;
3.失败或超时重试机制导致重复请求;
4.第三方调用,因为网络或者失败或者异常等原因多次回调;
5.使用回退按钮重复之前的操作;
6.消息重复消费等等;
三、解决方案
- token机制;
- 乐观锁和悲观锁;
- 分布式锁;
- 唯一索引。
(一)token机制
1. 思路:
a. 进入界面时,客户端发出一个请求到服务端,获取一个token,服务端在redis中缓存token,token使用UUID加上时间戳生成,缓存主键使用token;
b. 客户发起提交请求,请求中携带步骤 a 中生成的token;
c. 服务端校验token,校验成功,则执行业务逻辑,同时删除token;校验失败,redis已经没有token,说明是重复提交(也可能是长时间没有提交,redis中的token失效),则返回指定结果;
以下是示例图:
2.实现思路伪代码
以下以springboot项目为例,实现token机制伪代码:
a. 异常抽象枚举类
public interface AbstractExceptionEnum {
/** 获取异常的状态码*/
String getErrorCode();
/**获取给用户提示信息*/
String getUserTip();
}
b. 枚举实现类
import lombok.Getter;
@Getter
public enum IdempotentEnum implements AbstractExceptionEnum {
ILLEGAL_ARGUMENT("A001", "参数不合法"),
REPEATED_SUBMISSION_ERROR("A002", "请勿重复操作");
private final String errorCode;
private final String userTip;
IdempotentEnum(String errorCode, String userTip) {
this.errorCode = errorCode;
this.userTip = userTip;
}
}
c.异常自定义公共类
@Data
@EqualsAndHashCode(callSuper = true)
public class ServiceException extends RuntimeException {
/** 错误码 */
private String errorCode;
/** 返回给用户的提示信息*/
private String userTip;
/**异常的模块名称*/
private String moduleName = "business";
/** 模块名称 */
private final String DEFAULT_MODULE_NAME = "project";
/**根据模块名,错误码,用户提示直接抛出异常*/
public ServiceException(String moduleName, String errorCode, String userTip) {
super(userTip);
this.errorCode = errorCode;
this.moduleName = moduleName;
this.userTip = userTip;
}
/**如果要直接抛出ServiceException,可以用这个构造函数*/
public ServiceException(String moduleName, AbstractExceptionEnum exception) {
super(exception.getUserTip());
this.moduleName = moduleName;
this.errorCode = exception.getErrorCode();
this.userTip = exception.getUserTip();
}
/**不建议直接抛出ServiceException,因为这样无法确认是哪个模块抛出的异常
* 建议使用业务异常时,都抛出自己模块的异常类*/
public ServiceException(AbstractExceptionEnum exception) {
super(exception.getUserTip());
this.moduleName = DEFAULT_MODULE_NAME;
this.errorCode = exception.getErrorCode();
this.userTip = exception.getUserTip();
}
}
d. 常量类
public class IdenponentConstants {
/***
* 系统模块名称
*/
public final static String BUSINESS_MODULE_NAME = "business";
public final static String UNDER_LINE = "_";
}
e. 接口幂等性异常类
public class IdempotentException extends ServiceException {
public IdempotentException(AbstractExceptionEnum exception) {
super(IdenponentConstants.BUSINESS_MODULE_NAME, exception);
}
public IdempotentException(AbstractExceptionEnum exception, Object... params) {
super(IdenponentConstants.BUSINESS_MODULE_NAME, exception.getErrorCode(), StrUtil.format(exception.getUserTip(), params));
}
}
f. 全局异常处理器,拦截控制器层的异常
/**
* 全局异常处理器,拦截控制器层的异常
*
* @author fengshuonan
* @date 2020/12/16 14:20
*/
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ServiceException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ResponseBody
public ErrorResponseData businessError(ServiceException e) {
log.error("业务异常,具体信息为:{}", e.getMessage());
return renderJson(e.getErrorCode(), e.getUserTip(), e);
}
/**渲染异常json*/
private ErrorResponseData renderJson(String code, String message, Throwable throwable){
if (ObjectUtil.isNotNull(throwable)) {
ErrorResponseData errorResponseData = new ErrorResponseData(code, message);
ExceptionUtil.fillErrorResponseData(errorResponseData,
throwable,ROOT_PACKAGE_NAME);
return errorResponseData;
} else {
return new ErrorResponseData(code, message);
}
}
}
g.工具类
public class ProjectUtils {
public static String getIdenponentKey(String moduleName) {
// 生成key
return moduleName + IdenponentConstants.UNDER_LINE + getUserId();
}
public static Long getUserId() {
return LoginContext.me().getLoginUser().getUserId();
}
public static String getIdenponentUUID() {
return UUID.randomUUID().toString().replaceAll("-","") + System.currentTimeMillis();
}
}
h.返回数据载体
import lombok.Data;
@Data
public class IdempotentResponse {
private int status;
private String msg;
private Object data;
public IdempotentResponse(int status, String msg, Object data) {
this.status = status;
this.msg = msg;
this.data = data;
}
}
i. token 服务接口
import javax.servlet.http.HttpServletRequest;
public interface IdempotentService {
/**
* 创建 token
* @return
*/
IdempotentResponse createToken();
/**
* 校验 token
*
* @param request 请求域
* @return
*/
IdempotentResponse checkToken(HttpServletRequest request);
}
j. token 具体实现类
import cn.hutool.core.util.ObjectUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
@Service
public class IdempotentServiceImpl implements IdempotentService{
@Autowired
private RedisTemplate redisTemplate;
@Override
public IdempotentResponse createToken() {
//生成uuid当作token
String token = ProjectUtils.getIdenponentUUID();
//将生成的token存入redis中,5分钟后过期
redisTemplate.opsForValue().set(token, token, 5, TimeUnit.MINUTES);
//返回正确的结果信息
return new IdempotentResponse(0, token, null);
}
@Override
public IdempotentResponse checkToken(HttpServletRequest request) {
//从请求头中获取token
String token=request.getHeader("token");
if (ObjectUtil.isEmpty(token)){
//如果请求头token为空就从参数中获取
token=request.getParameter("token");
//如果都为空抛出参数异常的错误
if (ObjectUtil.isEmpty(token)){
throw new IdempotentException(IdempotentEnum.ILLEGAL_ARGUMENT);
}
}
//如果redis中不包含该token,说明token已经被删除了,抛出请求重复异常
if (!redisTemplate.hasKey(token)){
throw new IdempotentException(IdempotentEnum.REPEATED_SUBMISSION_ERROR);
}
//删除token
Boolean del=redisTemplate.delete(token);
//如果删除不成功(已经被其他请求删除),抛出请求重复异常
if (!del){
throw new IdempotentException(IdempotentEnum.REPEATED_SUBMISSION_ERROR);
}
return new IdempotentResponse(0,"校验成功",null);
}
}
k.自定义需要验证token的注解
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 Idmnponent {
}
l. 验证幂等性注解拦截器
import org.springframework.beans.factory.annotation.Autowired;
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;
public class IdempotentInteceptor implements HandlerInterceptor {
@Autowired
private IdempotentService idempotentService;
@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();
IdempotentAnnotate methodAnnotation = method.getAnnotation(IdempotentAnnotate.class);
if (methodAnnotation != null) {
// 校验通过放行,校验不通过全局异常捕获后输出返回结果
idempotentService.checkToken(request);
}
return true;
}
}
m. token获取和校验控制器(这里的请求方法根据你自己项目使用情况而定,这里是我自己自定义的注解)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
@ApiResource(name = "幂等性token 控制器")
public class IdempotentController {
@Autowired
private IdempotentService idempotentService;
@GetResource(name = "获取token", path = "/idempotent/token", requiredPermission = false)
public ResponseData token() {
return new SuccessResponseData(this.idempotentService.createToken());
}
@GetResource(name = "校验token", path = "/idempotent/checkToken", requiredPermission = false)
public ResponseData checkToken(HttpServletRequest request) {
return new SuccessResponseData(this.idempotentService.checkToken(request));
}
}
token 机制缺点:
1. 如果请求到了服务端,第一次验证成功,执行业务逻辑,然后删除redis中token,但是在还没有删除token的时候,又来一次请求,这时候token还没有删除完毕,此时仍然会执行业务逻辑。
2. token是有失效时间的,如何判断接口中的token是失效还是重复操作导致token验证失败的呢?
为了解决上述问题,这里对上述问题进行了优化(优化方案直通车)
(二)乐观锁和悲观锁
1. 乐观锁
(1)使用数据库版本号 version
思路:通过使用version
字段来记录当前的版本号。当接收到请求时,首先获取当前版本号,并与请求中的版本号进行比较。如果请求中的版本号小于等于当前版本号,则表示该请求已经处理过,直接返回之前的处理结果。如果请求中的版本号大于当前版本号,则表示该请求未处理过,执行请求操作,并更新版本号和请求的处理结果。
以下是示例图:
伪代码实现:
@Override
public Map<String, Object> saveOrder(RepairOrderParam param) {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
try {
String formData = param.getVariables().get("formData").replace(""", "\"");
RepairOrder repairOrder = JSONObject.parseObject(formData, RepairOrder.class);
RepairOrder exists = this.repairOrderMapper.getGdByCode(repairOrder.getGdCode());
if(ObjectUtil.isEmpty(exists)) {
// 新增
this.repairOrderMapper.insert(repairOrder);
}else {
// 修改
// 请求版本号
Integer requestVersion = param.getVersion();
// 当前版本号
Integer currentVersion = exists.getVersion();
if(requestVersion <= currentVersion) {
// 则表示该请求已经处理过
throw new IdempotentException(IdempotentEnum.REPEATED_SUBMISSION_ERROR);
}
exists.setVersion(exists.getVersion() + 1);
this.saveOrUpdate(exists);
}
this.saveRepairOrder("", null, repairOrder, null, true);
} catch (Exception e) {
e.printStackTrace();
result.put("code", 500);
result.put("msg", FlowableExceptionEnum.BUSINESS_DATA_SAVE_FAULT);
}
return result;
}
2. 悲观锁
使用悲观锁需要对数据库进行锁定,可能会对性能产生影响,特别是在高并发的场景下。因此,应该仔细评估并衡量使用悲观锁的成本,还需要确保数据库支持所需的锁机制。这里不做介绍!实际应用中几乎不用!
(三)分布式锁
分布式锁是一种用于协调分布式系统中并发访问的机制,它确保在分布式环境下只有一个节点或线程能够获得对共享资源的访问权。分布式锁的主要目的是避免并发操作导致的数据不一致或竞态条件。
在分布式系统中,实现分布式锁的常见方法包括:
-
基于数据库的分布式锁:可以使用数据库的事务和唯一约束来实现分布式锁。当一个节点或线程要获得锁时,它在数据库中插入一条记录,并设置唯一约束。其他节点或线程在尝试获取锁时,如果插入同样的记录失败,表示锁已经被其他节点或线程获得。
-
基于缓存的分布式锁:使用分布式缓存(如Redis)来实现分布式锁。节点或线程在尝试获取锁时,使用缓存的原子操作(如SETNX)来设置一个标识,只有一个节点或线程能够成功设置标识,表示获得了锁。其他节点或线程可以通过检查标识来确定是否已经获得锁。
-
基于ZooKeeper的分布式锁:ZooKeeper是一个分布式注册中心,可以用来实现分布式锁。每个节点或线程在ZooKeeper上创建一个临时有序节点,节点的顺序表示获取锁的顺序。当一个节点或线程要获取锁时,它检查自己是否是最小的节点,如果是,则表示获得了锁,否则等待前面的节点释放锁。
由于分布式锁存在性能问题,分布式锁的性能影响主要受到网络通信延迟、锁竞争、锁的持有时间和锁的粒度等因素的影响。在设计和使用分布式锁时,需要综合考虑系统的并发量、性能要求和数据一致性需求,权衡性能与数据一致性之间的平衡。
实现分布式锁需要搞清楚一个概念,CAP原理
CAP:C(一致性),A(可用性),P(分区容错)
AP:违背了一致性C的要求,只满足可用性和分区容错,即AP
失去联系的节点依然可以向系统提供服务,不过它的数据就不能保证是同步的了(失去了C属性)。Eureka就是一个AP架构的例子,当Eureka客户端心跳消失的时候,那Eureka服务端就会启动自我保护机制,不会剔除该EurekaClient客户端的服务,依然可以提供需求;
CP:违背了可用性A的要求,只满足一致性和分区容错,即CP
为了保证数据库的一致性,我们必须等待失去联系的节点恢复过来,在这个过程中,那个节点是不允许对外提供服务的,这时候系统处于不可用状态(失去了A属性)。最好的例子就是zookeeper,如果客户端心跳消失的时候,zookeeper会很快剔除该服务,之后就无法提供服务;
搞清楚这几个概念后,再设计系统接口幂等性方案时,则更加清晰和具有针对性。
接下来,将结合我自己项目的设计,来实现分布式锁。即AP架构实现
实现方案:使用 Redissen
Redisson