幂等性:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了了副作用。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣了钱,流水记录也变成了两条,再或者新增用户表单注册时,用户反复提交表单.
简而言之:任意多次执行所产生的影响均与一次执行的影响相同。按照这个含义,最终的含义就是对数据库的影响只能是一次性的,不能重复处理
产生『重复数据或数据不一致』(假定程序业务代码没问题),绝大部分就是发生了重复的请求,重复请求是指『同一个请求因为某些原因被多次提交』。导致这个情况会有几种场景:
接口的幂等性实际上就是『接口可重复调用』,在调用方多次调用的情况下,接口『最终得到的结果是一致的』。
以『增删改查』四大操作来看,『删除』和『查询』操作天然是幂等的,没有(或不在乎)重复提交/重复请求问题。因为不管用户点击多少次删除操作或者是查询操作,也就是重复去调用查询接口或者是删除接口都不会有问题。因此,幂等需求通常是用在『新增』和『修改』类型的业务上。如用户注册表单的重复提交问题
而『修改』类型的业务通过 SQL 改造和 last_upated_at 字段的结合,也可以实现幂等,而无需下述的 token 和去重表方案。
因此,幂等性的处理重点集中在『新增』型业务上。
上述方案适用绝大部分场景。主要思想:
其实,这里的 token 起到的就是全局唯一 ID 的作用。
这里的重点在于:要先删除 token ,再执行业务代码 。
因为『后删除 token』的缺陷太致命:如果进行业务处理成功后,删除 redis 中的 token 失败了,那么 token 仍存在于 Redis 中,这时如果发起了第二次请求,那么因为 token 的存在,会认为该操作未被执行过,这样就导致了有可能会发生重复请求。
当然,『先删除 token』也有缺点,如果先删除 token 成功,而随后执行业务逻辑失败,那么需要再返回信息中告知请求方,在重新获得 token,而不能/无法重复利用之前的 token 。
添加redis、fastjson相关坐标
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.47version>
dependency>
<dependency>
<groupId>commons-beanutilsgroupId>
<artifactId>commons-beanutilsartifactId>
<version>1.9.3version>
dependency>
<dependency>
<groupId>commons-collectionsgroupId>
<artifactId>commons-collectionsartifactId>
<version>3.2.1version>
dependency>
<dependency>
<groupId>commons-langgroupId>
<artifactId>commons-langartifactId>
<version>2.6version>
dependency>
<dependency>
<groupId>commons-logginggroupId>
<artifactId>commons-loggingartifactId>
<version>1.1.1version>
dependency>
<dependency>
<groupId>net.sf.ezmorphgroupId>
<artifactId>ezmorphartifactId>
<version>1.0.6version>
dependency>
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
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;
}
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,发现可以正常访问接口