springboot幂等性_SpringBoot+Redis+拦截器+自定义注解实现接口幂等性

一、概念

任意多次执行所产生的影响均与一次执行的影响相同。按照这个含义,最终的含义就是 对数据库的影响只能是一次性的,不能重复处理。比如:

订单接口,不能多次创建订单。

支付接口,重复支付同一笔订单只能扣一次钱。

支付宝回调接口,可能会多次回调, 必须处理重复回调。

普通表单提交接口,因为网络超时等原因多次点击提交,只能成功一次等等。

二、常见解决方案

唯一索引:防止新增脏数据。

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文件中,引入依赖包和插件。

org.springframework.boot

spring-boot-starter-aop

org.projectlombok

lombok

true

org.springframework.boot

spring-boot-starter-data-redis

io.lettuce

lettuce-core

redis.clients

jedis

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来优雅的实现接口幂等,对于幂等在实际的开发过程中是十分重要的,因为一个接口可能会被无数的客户端调用,如何保证其不影响后台的业务处理,如何保证其只影响数据一次是非常重要的,它可以防止产生脏数据或者乱数据,也可以减少并发量,实乃十分有益的一件事。而传统的做法是每次判断数据,这种做法不够智能化和自动化,比较麻烦。而今天的这种自动化处理也可以提升程序的伸缩性。

更多阅读

你可能感兴趣的:(springboot幂等性)