一、概念
任意多次执行所产生的影响均与一次执行的影响相同。按照这个含义,最终的含义就是 对数据库的影响只能是一次性的,不能重复处理。比如:
-
订单接口,不能多次创建订单。
-
支付接口,重复支付同一笔订单只能扣一次钱。
-
支付宝回调接口,可能会多次回调, 必须处理重复回调。
-
普通表单提交接口,因为网络超时等原因多次点击提交,只能成功一次等等。
二、常见解决方案
-
唯一索引:防止新增脏数据。
-
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文件中,引入依赖包和插件。
1<dependency> 2 <groupId>org.springframework.boot</groupId> 3 <artifactId>spring-boot-starter-aop</artifactId> 4</dependency> 5 6<!-- lombok --> 7<dependency> 8 <groupId>org.projectlombok</groupId> 9 <artifactId>lombok</artifactId> 10 <optional>true</optional> 11</dependency> 12 13<!-- redis --> 14<!-- 默认情况下,spring-boot-starter-data-redis 使用的 Redis 工具是 Lettuce。 15考虑到有的开发者习惯使用 Jedis,这里从 spring-boot-starter-data-redis 中排除 Lettuce 并引入 Jedis --> 16<dependency> 17 <groupId>org.springframework.boot</groupId> 18 <artifactId>spring-boot-starter-data-redis</artifactId> 19 <exclusions> 20 <exclusion> 21 <groupId>io.lettuce</groupId> 22 <artifactId>lettuce-core</artifactId> 23 </exclusion> 24 </exclusions> 25</dependency> 26<dependency> 27 <groupId>redis.clients</groupId> 28 <artifactId>jedis</artifactId> 29</dependency>
4.2、配置文件application.properties
1#### redis 配置 #### 2# 基本连接信息配置 3spring.redis.database=0 4spring.redis.host=127.0.0.1 5spring.redis.port=6379 6spring.redis.password=123456 7# 连接池信息配置 8spring.redis.jedis.pool.max-active=8 9spring.redis.jedis.pool.max-idle=8 10spring.redis.jedis.pool.max-wait=-1 11spring.redis.jedis.pool.min-idle=0 12spring.redis.timeout=0 13 14import org.springframework.beans.factory.annotation.Value; 15import org.springframework.context.annotation.Bean; 16import org.springframework.context.annotation.Configuration; 17import redis.clients.jedis.JedisPool; 18import redis.clients.jedis.JedisPoolConfig; 19 20/** 21 * Jedis配置类,把Jedis加入到Bean容器里面。 22 * 同时也支持使用RedisTemplate的使用。 23 */ 24@Configuration 25public class JedisConfig { 26 27 @Value("${spring.redis.host}") 28 private String host; 29 30 @Value("${spring.redis.port}") 31 private int port; 32 33 @Value("${spring.redis.password}") 34 private String password; 35 36 @Value("${spring.redis.jedis.pool.max-idle}") 37 private int maxIdle; 38 39 @Value("${spring.redis.jedis.pool.max-wait}") 40 private long maxWait; 41 42 @Value("${spring.redis.jedis.pool.min-idle}") 43 private int minIdle; 44 45 @Value("${spring.redis.timeout}") 46 private int timeout; 47 48 @Bean 49 public JedisPool redisPoolFactory() { 50 JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); 51 jedisPoolConfig.setMaxIdle(maxIdle); 52 jedisPoolConfig.setMaxWaitMillis(maxWait); 53 jedisPoolConfig.setMinIdle(minIdle); 54 55 JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, password); 56 57 return jedisPool; 58 } 59 60}
4.3、编写JedisUtil工具类
1import lombok.extern.slf4j.Slf4j; 2import org.springframework.beans.factory.annotation.Autowired; 3import org.springframework.stereotype.Component; 4import redis.clients.jedis.Jedis; 5import redis.clients.jedis.JedisPool; 6 7/** 8 * Jedis工具类 9 */ 10@Slf4j 11@Component 12public class JedisUtil { 13 14 @Autowired 15 private JedisPool jedisPool; 16 17 private Jedis getJedis() { 18 return jedisPool.getResource(); 19 } 20 21 /** 22 * 设值 23 * @param key 24 * @param value 25 * @return 26 */ 27 public String set(String key, String value) { 28 Jedis jedis = null; 29 try { 30 jedis = getJedis(); 31 return jedis.set(key, value); 32 } catch (Exception e) { 33 log.error("set key:{} value:{} error", key, value, e); 34 return null; 35 } finally { 36 close(jedis); 37 } 38 } 39 40 /** 41 * 设值 42 * @param key 43 * @param value 44 * @param expireTime 过期时间, 单位: s 45 * @return 46 */ 47 public String set(String key, String value, int expireTime) { 48 Jedis jedis = null; 49 try { 50 jedis = getJedis(); 51 return jedis.setex(key, expireTime, value); 52 } catch (Exception e) { 53 log.error("set key:{} value:{} expireTime:{} error", key, value, expireTime, e); 54 return null; 55 } finally { 56 close(jedis); 57 } 58 } 59 60 /** 61 * 取值 62 * @param key 63 * @return 64 */ 65 public String get(String key) { 66 Jedis jedis = null; 67 try { 68 jedis = getJedis(); 69 return jedis.get(key); 70 } catch (Exception e) { 71 log.error("get key:{} error", key, e); 72 return null; 73 } finally { 74 close(jedis); 75 } 76 } 77 78 /** 79 * 删除key 80 * @param key 81 * @return 82 */ 83 public Long del(String key) { 84 Jedis jedis = null; 85 try { 86 jedis = getJedis(); 87 return jedis.del(key.getBytes()); 88 } catch (Exception e) { 89 log.error("del key:{} error", key, e); 90 return null; 91 } finally { 92 close(jedis); 93 } 94 } 95 96 /** 97 * 判断key是否存在 98 * @param key 99 * @return 100 */ 101 public Boolean exists(String key) { 102 Jedis jedis = null; 103 try { 104 jedis = getJedis(); 105 return jedis.exists(key.getBytes()); 106 } catch (Exception e) { 107 log.error("exists key:{} error", key, e); 108 return null; 109 } finally { 110 close(jedis); 111 } 112 } 113 114 /** 115 * 设值key过期时间 116 * @param key 117 * @param expireTime 过期时间, 单位: s 118 * @return 119 */ 120 public Long expire(String key, int expireTime) { 121 Jedis jedis = null; 122 try { 123 jedis = getJedis(); 124 return jedis.expire(key.getBytes(), expireTime); 125 } catch (Exception e) { 126 log.error("expire key:{} error", key, e); 127 return null; 128 } finally { 129 close(jedis); 130 } 131 } 132 133 /** 134 * 获取剩余时间 135 * @param key 136 * @return 137 */ 138 public Long ttl(String key) { 139 Jedis jedis = null; 140 try { 141 jedis = getJedis(); 142 return jedis.ttl(key); 143 } catch (Exception e) { 144 log.error("ttl key:{} error", key, e); 145 return null; 146 } finally { 147 close(jedis); 148 } 149 } 150 151 private void close(Jedis jedis) { 152 if (null != jedis) { 153 jedis.close(); 154 } 155 } 156 157}
我们这里使用的是Jedis,Redis推荐的客户端连接对象。当然小伙伴们也可以使用SpringDataRedis高度封装的RedisTemplate。两者效率Jedis更高一点。
4.4、自定义注解@ApiIdempotent
1import java.lang.annotation.ElementType; 2import java.lang.annotation.Retention; 3import java.lang.annotation.RetentionPolicy; 4import java.lang.annotation.Target; 5 6/** 7 * 在需要保证 接口幂等性 的Controller的方法上使用此注解 8 * 9 * @author piao 10 * @date 2020-05-29 11 */ 12@Target({ElementType.METHOD}) 13@Retention(RetentionPolicy.RUNTIME) 14public @interface ApiIdempotent { 15 16}
4.5、ApiIdempotentInterceptor拦截器
1import com.piao.annotation.ApiIdempotent; 2import com.piao.sys.sysconfig.service.TokenService; 3import org.springframework.beans.factory.annotation.Autowired; 4import org.springframework.stereotype.Component; 5import org.springframework.web.method.HandlerMethod; 6import org.springframework.web.servlet.HandlerInterceptor; 7import org.springframework.web.servlet.ModelAndView; 8 9import javax.servlet.http.HttpServletRequest; 10import javax.servlet.http.HttpServletResponse; 11import java.lang.reflect.Method; 12 13/** 14 * 接口幂等性拦截器 15 */ 16@Component 17public class ApiIdempotentInterceptor implements HandlerInterceptor { 18 19 @Autowired 20 private TokenService tokenService; 21 22 @Override 23 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { 24 if (!(handler instanceof HandlerMethod)) { 25 return true; 26 } 27 28 HandlerMethod handlerMethod = (HandlerMethod) handler; 29 Method method = handlerMethod.getMethod(); 30 31 ApiIdempotent methodAnnotation = method.getAnnotation(ApiIdempotent.class); 32 if (methodAnnotation != null) { 33 // 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示 34 check(request); 35 } 36 37 return true; 38 } 39 40 private void check(HttpServletRequest request) { 41 tokenService.checkToken(request); 42 } 43 44 @Override 45 public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { 46 } 47 48 @Override 49 public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { 50 } 51 52} 53 54import com.piao.interceptor.ApiIdempotentInterceptor; 55import org.springframework.context.annotation.Configuration; 56import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 57import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 58 59/** 60 * webmvc配置 61 */ 62@Configuration 63public class WebMvcConfig implements WebMvcConfigurer { 64 65 @Override 66 public void addInterceptors(InterceptorRegistry registry) { 67 // 配置拦截路径(所有路径都拦截),也可以配置排除的路径.excludePathPatterns() 68 registry.addInterceptor(new ApiIdempotentInterceptor()).addPathPatterns("/**"); 69 } 70 71}
4.6、Token服务和实现
1import com.baomidou.mybatisplus.extension.api.R; 2 3import javax.servlet.http.HttpServletRequest; 4 5/** 6 * token服务 7 * 实现接口幂等性 8 */ 9public interface TokenService { 10 11 R createToken(); 12 13 void checkToken(HttpServletRequest request); 14 15} 16 17import cn.hutool.core.text.StrBuilder; 18import cn.hutool.core.util.IdUtil; 19import com.baomidou.mybatisplus.extension.api.R; 20import com.piao.common.Constant; 21import com.piao.common.ResponseCode; 22import com.piao.exception.ServiceException; 23import com.piao.sys.sysconfig.service.TokenService; 24import com.piao.util.JedisUtil; 25import org.apache.commons.lang3.StringUtils; 26import org.springframework.beans.factory.annotation.Autowired; 27import org.springframework.stereotype.Service; 28 29import javax.servlet.http.HttpServletRequest; 30 31/** 32 * token服务实现 33 * 实现接口幂等性 34 */ 35@Service 36public class TokenServiceImpl implements TokenService { 37 38 @Autowired 39 private JedisUtil jedisUtil; 40 41 @Override 42 public R createToken() { 43 String str = IdUtil.simpleUUID();; 44 StrBuilder token = new StrBuilder(); 45 token.append(Constant.Redis.TOKEN_PREFIX).append(str); 46 47 jedisUtil.set(token.toString(), token.toString(), Constant.Redis.EXPIRE_TIME_MINUTE); 48 49 return R.ok(token.toString()); 50 } 51 52 @Override 53 public void checkToken(HttpServletRequest request) { 54 String token = request.getHeader(Constant.TOKEN_NAME); 55 if (StringUtils.isBlank(token)) {// header中不存在token 56 token = request.getParameter(Constant.TOKEN_NAME); 57 if (StringUtils.isBlank(token)) {// parameter中也不存在token 58 throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg()); 59 } 60 } 61 62 if (!jedisUtil.exists(token)) { 63 throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg()); 64 } 65 66 Long del = jedisUtil.del(token); 67 if (del <= 0) { 68 throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg()); 69 } 70 } 71 72}
非常重要!注意!

上图中,不能单纯的直接删除token而不校验是否删除成功,会出现并发安全性问题。因为有可能多个线程同时走到第46行,此时token还未被删除,所以继续往下执行,如果不校验jedisUtil.del(token)的删除结果而直接放行,那么还是会出现重复提交问题,即使实际上只有一次真正的删除操作。
4.7、使用自定义注解验证效果。
1import com.piao.annotation.SysLog; 2import lombok.extern.slf4j.Slf4j; 3import org.springframework.web.bind.annotation.*; 4 5@RestController 6@RequestMapping(value = "/user") 7public class UserController { 8 9 @ApiIdempotent() 10 @GetMapping(value = "/getDemo") 11 public String getDemo(){ 12 String str = "this is a zhirong user"; 13 return str; 14 } 15 16}
这里我们使用swagger来验证,并不存在并发请求的可能。如果有需要压力测试的请使用jmeter工具测试。

可以看到没有token的会报出异常。

获取幂等性token。

请求带有注解的接口,带上token请求成功。

再次请求该接口返回了重复操作提示。现在已经证明保证接口的幂等性成功。小伙伴可以使用压测工具来测试。
五、总结
本篇文章介绍了使用springboot和拦截器、redis来优雅的实现接口幂等,对于幂等在实际的开发过程中是十分重要的,因为一个接口可能会被无数的客户端调用,如何保证其不影响后台的业务处理,如何保证其只影响数据一次是非常重要的,它可以防止产生脏数据或者乱数据,也可以减少并发量,实乃十分有益的一件事。而传统的做法是每次判断数据,这种做法不够智能化和自动化,比较麻烦。而今天的这种自动化处理也可以提升程序的伸缩性。