一:幂等性介绍:通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次
比如:
- 订单接口, 不能多次创建订单
- 支付接口, 重复支付同一笔订单只能扣一次钱
- 支付宝回调接口, 可能会多次回调, 必须处理重复回调
- 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次。。。。
二: 常见解决方案
-
唯一索引 -- 防止新增脏数据
-
token机制 -- 防止页面重复提交
-
悲观锁 -- 获取数据的时候加锁(锁表或锁行)
-
乐观锁 -- 基于版本号version实现, 在更新数据那一刻校验数据
-
分布式锁 -- redis(jedis、redisson)或zookeeper实现
-
状态机 -- 状态变更, 更新数据时判断状态
- redis + token机制实现接口幂等性校验
三:实现思路
为需要保证幂等性的每一次请求创建一个唯一标识token, 先获取token, 并将此token存入redis, 请求接口时, 将此token放到header或者作为请求参数请求接口, 后端接口判断redis中是否存在此token:
-
如果存在, 正常处理业务逻辑, 并从redis中删除此token, 那么, 如果是重复请求, 由于token已被删除, 则不能通过校验, 返回请勿重复操作提示。。。
-
如果不存在, 说明参数不合法或者是重复请求, 返回提示即可。。。
四: 项目框架及
- springboot
- redis
- @ApiIdempotent注解 + 拦截器对请求进行拦截
- @ControllerAdvice全局异常处理
- 压测工具: jmeter
五: 实现
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yw.redis</groupId>
<artifactId>idempotency</artifactId>
<version>1.0.0</version>
<name>idempotency</name>
<description>Springboot + redis + 注解 + 拦截器来实现接口幂等性校验</description>
<properties>
<java.version>1.8</java.version>
<jedis.version>2.9.0</jedis.version>
</properties>
<dependencies>
<!--WEB-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
<!-- Redis-Jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>
<!--mysql connector-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.10</version>
</dependency>
<!-- commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>
<!-- springboot-aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>26.0-jre</version>
</dependency>
<!--joda time-->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10</version>
</dependency>
<!--mq-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!--mail-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.20</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
<!-- 如果不添加此节点mybatis的mapper.xml文件都会被漏掉。 -->
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.*</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>**/*.*</include>
</includes>
</resource>
</resources>
</build>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</pluginRepository>
</pluginRepositories>
</project>
JedisUtil
package com.yw.redis.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
@Component
@Slf4j
public class JedisUtil {
@Autowired
private JedisPool jedisPool;
private Jedis getJedis() {
return jedisPool.getResource();
}
/**
* 设值
*
* @param key
* @param value
* @return
*/
public String set(String key, String value) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.set(key, value);
} catch (Exception e) {
log.error("set key:{} value:{} error", key, value, e);
return null;
} finally {
close(jedis);
}
}
/**
* 设值
*
* @param key
* @param value
* @param expireTime 过期时间, 单位: s
* @return
*/
public String set(String key, String value, int expireTime) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.setex(key, expireTime, value);
} catch (Exception e) {
log.error("set key:{} value:{} expireTime:{} error", key, value, expireTime, e);
return null;
} finally {
close(jedis);
}
}
/**
* 取值
*
* @param key
* @return
*/
public String get(String key) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.get(key);
} catch (Exception e) {
log.error("get key:{} error", key, e);
return null;
} finally {
close(jedis);
}
}
/**
* 删除key
*
* @param key
* @return
*/
public Long del(String key) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.del(key.getBytes());
} catch (Exception e) {
log.error("del key:{} error", key, e);
return null;
} finally {
close(jedis);
}
}
/**
* 判断key是否存在
*
* @param key
* @return
*/
public Boolean exists(String key) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.exists(key.getBytes());
} catch (Exception e) {
log.error("exists key:{} error", key, e);
return null;
} finally {
close(jedis);
}
}
/**
* 设值key过期时间
*
* @param key
* @param expireTime 过期时间, 单位: s
* @return
*/
public Long expire(String key, int expireTime) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.expire(key.getBytes(), expireTime);
} catch (Exception e) {
log.error("expire key:{} error", key, e);
return null;
} finally {
close(jedis);
}
}
/**
* 获取剩余时间
*
* @param key
* @return
*/
public Long ttl(String key) {
Jedis jedis = null;
try {
jedis = getJedis();
return jedis.ttl(key);
} catch (Exception e) {
log.error("ttl key:{} error", key, e);
return null;
} finally {
close(jedis);
}
}
private void close(Jedis jedis) {
if (null != jedis) {
jedis.close();
}
}
}
自定义注解@ApiIdempotent
package com.yw.redis.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 在需要保证 接口幂等性 的Controller的方法上使用此注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}
ApiIdempotentInterceptor拦截器
package com.yw.redis.interceptor;
import com.yw.redis.annotation.ApiIdempotent;
import com.yw.redis.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
/**
* 接口幂等性拦截器
*/
public class ApiIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
ApiIdempotent methodAnnotation = method.getAnnotation(ApiIdempotent.class);
if (methodAnnotation != null) {
check(request);// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
}
return true;
}
private void check(HttpServletRequest request) {
tokenService.checkToken(request);
}
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
TokenServiceImpl
package com.yw.redis.service.impl;
import com.yw.redis.common.Constant;
import com.yw.redis.common.ResponseCode;
import com.yw.redis.common.ServerResponse;
import com.yw.redis.exception.ServiceException;
import com.yw.redis.service.TokenService;
import com.yw.redis.utils.JedisUtil;
import com.yw.redis.utils.RandomUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.text.StrBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
@Service
public class TokenServiceImpl implements TokenService {
private static final String TOKEN_NAME = "token";
@Autowired
private JedisUtil jedisUtil;
@Override
public ServerResponse createToken() {
String str = RandomUtil.UUID32();
StrBuilder token = new StrBuilder();
token.append(Constant.Redis.TOKEN_PREFIX).append(str);
jedisUtil.set(token.toString(), token.toString(), Constant.Redis.EXPIRE_TIME_MINUTE);
return ServerResponse.success(token.toString());
}
@Override
public void checkToken(HttpServletRequest request) {
String token = request.getHeader(TOKEN_NAME);
if (StringUtils.isBlank(token)) {// header中不存在token
token = request.getParameter(TOKEN_NAME);
if (StringUtils.isBlank(token)) {// parameter中也不存在token
throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());
}
}
if (!jedisUtil.exists(token)) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
Long del = jedisUtil.del(token);
if (del <= 0) {
throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
}
}
}
IdempotencyApplication
package com.yw.redis;
import com.yw.redis.interceptor.AccessLimitInterceptor;
import com.yw.redis.interceptor.ApiIdempotentInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@SpringBootApplication
@MapperScan("com.yw.redis.mapper")
@EnableScheduling
public class IdempotencyApplication extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(IdempotencyApplication.class, args);
}
/**
* 跨域
* @return
*/
@Bean
public CorsFilter corsFilter() {
final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
final CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowCredentials(true);
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
return new CorsFilter(urlBasedCorsConfigurationSource);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 接口幂等性拦截器
registry.addInterceptor(apiIdempotentInterceptor());
// 接口防刷限流拦截器
registry.addInterceptor(accessLimitInterceptor());
super.addInterceptors(registry);
}
@Bean
public ApiIdempotentInterceptor apiIdempotentInterceptor() {
return new ApiIdempotentInterceptor();
}
@Bean
public AccessLimitInterceptor accessLimitInterceptor() {
return new AccessLimitInterceptor();
}
}
六: 测试
1、获取token的控制器TokenController
package com.yw.redis.controller;
import com.yw.redis.common.ServerResponse;
import com.yw.redis.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/token")
public class TokenController {
@Autowired
private TokenService tokenService;
@GetMapping
public ServerResponse token() {
return tokenService.createToken();
}
}
2、TestController, 注意@ApiIdempotent注解, 在需要幂等性校验的方法上声明此注解即可, 不需要校验的无影响
package com.yw.redis.controller;
import com.google.common.collect.Lists;
import com.yw.redis.annotation.AccessLimit;
import com.yw.redis.annotation.ApiIdempotent;
import com.yw.redis.common.ServerResponse;
import com.yw.redis.mapper.MsgLogMapper;
import com.yw.redis.mapper.UserMapper;
import com.yw.redis.pojo.Mail;
import com.yw.redis.pojo.User;
import com.yw.redis.service.TestService;
import com.yw.redis.service.batch.mapperproxy.MapperProxy;
import com.yw.redis.utils.RandomUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/test")
@Slf4j
public class TestController {
@Autowired
private TestService testService;
@Autowired
private UserMapper userMapper;
@Autowired
private MsgLogMapper msgLogMapper;
@ApiIdempotent
@PostMapping("testIdempotence")
public ServerResponse testIdempotence() {
return testService.testIdempotence();
}
@AccessLimit(maxCount = 5, seconds = 5)
@PostMapping("accessLimit")
public ServerResponse accessLimit() {
return testService.accessLimit();
}
@PostMapping("send")
public ServerResponse sendMail(@Validated Mail mail, Errors errors) {
if (errors.hasErrors()) {
String msg = errors.getFieldError().getDefaultMessage();
return ServerResponse.error(msg);
}
return testService.send(mail);
}
@PostMapping("single")
public ServerResponse single(int size) {
List<User> list = Lists.newArrayList();
for (int i = 0; i < size; i++) {
String str = RandomUtil.UUID32();
User user = User.builder().username(str).password(str).build();
list.add(user);
}
long startTime = System.nanoTime();
log.info("batch insert costs: {} ms", (System.nanoTime() - startTime) / 1000000);
return ServerResponse.success();
}
@PostMapping("batchInsert")
public ServerResponse batchInsert(int size) {
List<User> list = Lists.newArrayList();
for (int i = 0; i < size; i++) {
String str = RandomUtil.UUID32();
User user = User.builder().username(str).password(str).build();
list.add(user);
}
new MapperProxy<User>(userMapper).batchInsert(list);
return ServerResponse.success();
}
@PostMapping("batchUpdate")
public ServerResponse batchUpdate(String ids) {
List<User> list = Lists.newArrayList();
String[] split = ids.split(",");
for (String id : split) {
User user = User.builder().id(Integer.valueOf(id)).username("batchUpdate_" + RandomUtil.UUID32()).password("123456").build();
list.add(user);
}
new MapperProxy<User>(userMapper).batchUpdate(list);
return ServerResponse.success();
}
@PostMapping("sync")
public ServerResponse sync() {
List<User> list = Lists.newArrayList();
for (int i = 0; i < 300; i++) {
String uuid32 = RandomUtil.UUID32();
User user = User.builder().username(uuid32).password(uuid32).password2(uuid32).password3(uuid32)
.password4(uuid32).password5(uuid32).password6(uuid32).password7(uuid32)
.password8(uuid32).password9(uuid32).password10(uuid32).build();
list.add(user);
}
userMapper.batchInsert(list);
check(list);
return ServerResponse.success();
}
@Async
public void check(List<User> list) {
String username = list.get(list.size() - 1).getUsername();
User user = userMapper.selectByUsername(username);
log.info(user.getUsername());
}
}
ok 则使用localhost:端口/token 就有返回值了