Springboot + redis + 注解 + 拦截器来实现接口幂等性校验
一:幂等性介绍:通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次
比如:
- 订单接口, 不能多次创建订单
- 支付接口, 重复支付同一笔订单只能扣一次钱
- 支付宝回调接口, 可能会多次回调, 必须处理重复回调
- 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次。。。。
二: 常见解决方案
-
唯一索引 -- 防止新增脏数据
-
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | <?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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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注解, 在需要幂等性校验的方法上声明此注解即可, 不需要校验的无影响
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | 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 就有返回值了
可以 git@github.com:yuanweiGit/SpringbootRedis.git 下载代码
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 25岁的心里话
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现