一、概念
任意多次执行所产生的影响均与一次执行的影响相同。按照这个含义,最终的含义就是 对数据库的影响只能是一次性的,不能重复处理。比如:
订单接口,不能多次创建订单。
支付接口,重复支付同一笔订单只能扣一次钱。
支付宝回调接口,可能会多次回调, 必须处理重复回调。
普通表单提交接口,因为网络超时等原因多次点击提交,只能成功一次等等。
二、常见解决方案
唯一索引:防止新增脏数据。
token机制 :防止页面重复提交。
悲观锁 :悲观锁可以保证每次for update的时候其他sql无法update数据(在数据库引擎是innodb的时候,select的条件必须是唯一索引,防止锁全表)。
乐观锁:基于版本号version实现, 在更新数据那一刻校验数据。
分布式锁:redis(jedis、redisson)或zookeeper实现。
状态机:状态变更, 更新数据时判断状态。
三、实现思路
本文采用第2种方式实现, 即通过redis + token机制实现接口幂等性校验。为需要保证幂等性的每一次请求创建一个唯一标识token, 先获取token, 并将此token存入redis, 请求接口时, 将此token放到header或者作为请求参数请求接口, 后端接口判断redis中是否存在此token:
如果存在正常处理业务逻辑,并从redis中删除此token。如果是重复请求,由于token已被删除,则不能通过校验,返回请勿重复操作提示。
如果不存在,说明参数不合法或者是重复请求,返回提示即可。
四、代码实现
4.1、在pom文件中,引入依赖包和插件。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- redis -->
<!-- 默认情况下,spring-boot-starter-data-redis 使用的 Redis 工具是 Lettuce。
考虑到有的开发者习惯使用 Jedis,这里从 spring-boot-starter-data-redis 中排除 Lettuce 并引入 Jedis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
4.2、配置文件application.properties
#### redis 配置 ####
# 基本连接信息配置
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=123456
# 连接池信息配置
spring.redis.jedis.pool.max-active=8
spring.redis.jedis.pool.max-idle=8
spring.redis.jedis.pool.max-wait=-1
spring.redis.jedis.pool.min-idle=0
spring.redis.timeout=0
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/**
* Jedis配置类,把Jedis加入到Bean容器里面。
* 同时也支持使用RedisTemplate的使用。
*/
@Configuration
public class JedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.jedis.pool.max-idle}")
private int maxIdle;
@Value("${spring.redis.jedis.pool.max-wait}")
private long maxWait;
@Value("${spring.redis.jedis.pool.min-idle}")
private int minIdle;
@Value("${spring.redis.timeout}")
private int timeout;
@Bean
public JedisPool redisPoolFactory() {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(maxIdle);
jedisPoolConfig.setMaxWaitMillis(maxWait);
jedisPoolConfig.setMinIdle(minIdle);
JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, password);
return jedisPool;
}
}
4.3、编写JedisUtil工具类
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;
/**
* Jedis工具类
*/
@Slf4j
@Component
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();
}
}
}
我们这里使用的是Jedis,Redis推荐的客户端连接对象。当然小伙伴们也可以使用SpringDataRedis高度封装的RedisTemplate。两者效率Jedis更高一点。
4.4、自定义注解@ApiIdempotent
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 在需要保证 接口幂等性 的Controller的方法上使用此注解
*
* @author piao
* @date 2020-05-29
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}
4.5、ApiIdempotentInterceptor拦截器
import com.piao.annotation.ApiIdempotent;
import com.piao.sys.sysconfig.service.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 org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
/**
* 接口幂等性拦截器
*/
@Component
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 {
}
}
import com.piao.interceptor.ApiIdempotentInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* webmvc配置
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 配置拦截路径(所有路径都拦截),也可以配置排除的路径.excludePathPatterns()
registry.addInterceptor(new ApiIdempotentInterceptor()).addPathPatterns("/**");
}
}
4.6、Token服务和实现
import com.baomidou.mybatisplus.extension.api.R;
import javax.servlet.http.HttpServletRequest;
/**
* token服务
* 实现接口幂等性
*/
public interface TokenService {
R createToken();
void checkToken(HttpServletRequest request);
}
import cn.hutool.core.text.StrBuilder;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.extension.api.R;
import com.piao.common.Constant;
import com.piao.common.ResponseCode;
import com.piao.exception.ServiceException;
import com.piao.sys.sysconfig.service.TokenService;
import com.piao.util.JedisUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
/**
* token服务实现
* 实现接口幂等性
*/
@Service
public class TokenServiceImpl implements TokenService {
@Autowired
private JedisUtil jedisUtil;
@Override
public R createToken() {
String str = IdUtil.simpleUUID();;
StrBuilder token = new StrBuilder();
token.append(Constant.Redis.TOKEN_PREFIX).append(str);
jedisUtil.set(token.toString(), token.toString(), Constant.Redis.EXPIRE_TIME_MINUTE);
return R.ok(token.toString());
}
@Override
public void checkToken(HttpServletRequest request) {
String token = request.getHeader(Constant.TOKEN_NAME);
if (StringUtils.isBlank(token)) {// header中不存在token
token = request.getParameter(Constant.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());
}
}
}
非常重要!注意!
上图中,不能单纯的直接删除token而不校验是否删除成功,会出现并发安全性问题。因为有可能多个线程同时走到第46行,此时token还未被删除,所以继续往下执行,如果不校验jedisUtil.del(token)的删除结果而直接放行,那么还是会出现重复提交问题,即使实际上只有一次真正的删除操作。
4.7、使用自定义注解验证效果。
import com.piao.annotation.SysLog;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/user")
public class UserController {
@ApiIdempotent()
@GetMapping(value = "/getDemo")
public String getDemo(){
String str = "this is a zhirong user";
return str;
}
}
这里我们使用swagger来验证,并不存在并发请求的可能。如果有需要压力测试的请使用jmeter工具测试。
可以看到没有token的会报出异常。
获取幂等性token。
请求带有注解的接口,带上token请求成功。
再次请求该接口返回了重复操作提示。现在已经证明保证接口的幂等性成功。小伙伴可以使用压测工具来测试。
五、总结
本篇文章介绍了使用springboot和拦截器、redis来优雅的实现接口幂等,对于幂等在实际的开发过程中是十分重要的,因为一个接口可能会被无数的客户端调用,如何保证其不影响后台的业务处理,如何保证其只影响数据一次是非常重要的,它可以防止产生脏数据或者乱数据,也可以减少并发量,实乃十分有益的一件事。而传统的做法是每次判断数据,这种做法不够智能化和自动化,比较麻烦。而今天的这种自动化处理也可以提升程序的伸缩性。