一、幂等性的概念
1.1、认识幂等
百度百科这么解释:在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。
简而言之就是:在某个场景下,同一个用户去请求某个接口多次也只有一次效果。
实际生产中的例子:
1、对于订单接口,在你提交的时候不可能创建多个吧。
2、支付接口,转账操作肯定也是只能操作一次。
3、支付宝的回调接口,有些时候会产生多次回调,这种情况也要避免。
4、一些普通的表单提交接口。
1.2、Restful API中的幂等性(通俗约定)
一般分为Get、POST、DELETE、PUT,一半各自定义为查、增、删、修改。
GET:一般获取信息是本身天然就满足幂等的。
POST、DELETE、PUT:其中POST一般都是新增,肯定是不满足幂等的,另外两个根据实际业务来决定。
二、幂等性的解决方案
学习:黑马程序员-玩转微服务接口幂等性与安全设计
下面介绍几种实现方案:
方案1、数据库唯一主键实现幂等性
限制:分布式ID。
流程:客户端从服务端获取到分布式ID,接着去调用请求时携带分布式ID,此时订单使用这个分布式ID作为唯一主键来进行insert,一旦出现重复提交情况,自然不会成功。
方案2、数据库悲观锁实现
场景:例如我们要查询某个订单的状态然后进行更新,此时就可以使用数据库的悲观锁实现。
对于如下正常逻辑在高并发场景下会出现问题:
select * from where id = 'xxx' if (查询状态 == xxx) { //业务逻辑 } update x
使用悲观锁,需要开启事务:
begin # 事务开始 # 添加for update关键字,即可对这行记录上锁(上行锁还是表锁取决于where的这个字段是否为索引) select * from where id = 'xxx' for update update xx commit # 事务结束
若是线程A执行了select … for update,此时就会对这行记录上锁,直到整个线程A提交事务之前,其他线程来走这个查询操作都会进入到阻塞状态。
示例视频演示:基于for update实现悲观锁
方案3、数据库乐观锁
限制:表中需要多增加一个字段version来进行乐观锁判断
# 在更新的时候会拿上版本号来进行字段校验以及进行update更新 # 一条sql语句是原子性的,就不会产生线程安全问题 UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5
方案4:防重token令牌
限制:①唯一token。②集成redis校验
流程:①在执行某个重要业务前客户端请求服务器获取到防重token令牌(服务器端生成时会在redis中插入与该令牌相关的一条记录)。
②拿到token后来进行消费,也就是向服务器端进行重要业务请求。(该令牌token只能够被消耗一次,进入到服务器端时会将是否删除redis那条记录结果来作为约束条件)
方案5:状态机实现
限制:根据数据库表记录中的状态来作为条件(跟之前乐观锁使用版本号类似)。
# 在日常业务中对于类似订单都有不同的状态,我们可以来定义status状态字段来进行表示,在进行更新操作时就可以使用对应的状态字段来实现幂等性 update transfr_flow set status=2 where biz_seq=‘666’ and status=1;
方案6:分布式锁
限制:redis或者zookeeper。
流程:业务来临的时候在redis中插入一条记录,若是成功说明可以进行,失败就表示业务执行处理结束。(具体实际凭证条件参数根据业务来决定)
三、幂等性实战
3.1、SpringBoot集成Redis实现—(防重token令牌)
实现思路
流程:在进行请求重要业务接口前,首先获取到防重令牌,之后再去发送请求。
原理:在生成令牌的同时,会向redis中去插入一条记录,之后在进行消费时使用一条lua脚本(查询校验+删除操作),lua脚本可以保证在redis里进行原子性操作。
实操代码
依赖配置xml: <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!--springboot data redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> </dependencies>
yaml配置文件:
spring: redis: ssl: false host: 127.0.0.1 port: 6379 database: 0 timeout: 1000 password: 123456 lettuce: pool: max-active: 100 max-wait: -1 min-idle: 0 max-idle: 20 server: port: 8081
①TokenUtilService:token生成以及token校验(集成redis)
package com.changlu.utils; import java.util.Arrays; import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; @Slf4j @Service public class TokenUtilService { @Autowired private StringRedisTemplate redisTemplate; /** * 存入 Redis 的 Token 键的前缀 */ private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:"; /** * 创建 Token 存入 Redis,并返回该 Token * * @param value 用于辅助验证的 value 值 * @return 生成的 Token 串 */ public String generateToken(String value) { // 实例化生成 ID 工具对象 String token = UUID.randomUUID().toString(); // 设置存入 Redis 的 Key String key = IDEMPOTENT_TOKEN_PREFIX + token; // 存储 Token 到 Redis,且设置过期时间为5分钟 redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES); // 返回 Token return token; } /** * 验证 Token 正确性 * * @param token token 字符串 * @param value value 存储在Redis中的辅助验证信息 * @return 验证结果 */ public boolean validToken(String token, String value) { // 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end"; RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class); // 根据 Key 前缀拼接 Key String key = IDEMPOTENT_TOKEN_PREFIX + token; // 执行 Lua 脚本 Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value)); // 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过 if (result != null && result != 0L) { log.info("验证 token={},key={},value={} 成功", token, key, value); return true; } log.info("验证 token={},key={},value={} 失败", token, key, value); return false; } }
②防重注解:ApiIdempotent.java
package com.changlu.annontions; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) //使用于方法 @Retention(RetentionPolicy.RUNTIME) //运行时 public @interface ApiIdempotent { }
③防重注解拦截器,会拦截所有标注自定义防重注解的controller方法:
package com.changlu.interceptors; import com.changlu.annontions.ApiIdempotent; import com.changlu.utils.ServletUtils; import com.changlu.utils.TokenUtilService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method; /** * @Description: aoi幂等性接口拦截器 * @Author: changlu * @Date: 10:07 AM */ @Component public class ApiIdempotentInterceptor implements HandlerInterceptor { @Autowired private TokenUtilService tokenUtilService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //校验是否有执行方法 if (!(handler instanceof HandlerMethod)) { return true;//若没有对应的方法执行器,就直接放行 } HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); ApiIdempotent annotation = method.getAnnotation(ApiIdempotent.class); //若是没有幂等性注解直接放行 if (annotation != null) { //解析对应的请求头 String token = request.getHeader("token"); if (ObjectUtils.isEmpty(token)) { ServletUtils.renderString(response, "请携带token令牌"); return false; } //若是校验失败直接进行响应 if (!tokenUtilService.validToken(token, "changlu")) { ServletUtils.renderString(response, "重复提交失败!"); return false; } } return true; } }
④webmvc配置,来注册拦截器:WebConfiguration.java
package com.changlu.config; import com.changlu.interceptors.ApiIdempotentInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; import java.nio.charset.Charset; import java.util.List; /** * @Description: * @Author: changlu * @Date: 10:05 AM */ @Configuration public class WebConfiguration extends WebMvcConfigurationSupport { @Autowired private ApiIdempotentInterceptor apiIdempotentInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(apiIdempotentInterceptor); super.addInterceptors(registry); } // http请求时编码 @Bean public HttpMessageConverter<String> responseBodyConverter() { StringHttpMessageConverter converter = new StringHttpMessageConverter( Charset.forName("UTF-8")); return converter; } /** * 系统配置参数编码 * @param converters */ @Override protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) { super.configureMessageConverters(converters); converters.add(responseBodyConverter()); } }
⑤工具类向response进行写数据:ServletUtils.java
package com.changlu.utils; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class ServletUtils { /** * 将字符串渲染到客户端 * * @param response 渲染对象 * @param string 待渲染的字符串 * @return null */ public static String renderString(HttpServletResponse response, String string) { try { response.setStatus(200); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(string); } catch (IOException e) { e.printStackTrace(); } return null; } }
⑥两个控制器接口:
TokenController.java: package com.changlu.controller; import com.changlu.utils.TokenUtilService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @Slf4j @RestController public class TokenController { @Autowired private TokenUtilService tokenService; /** * 获取 Token 接口 * * @return Token 串 */ @GetMapping("/token") public String getToken() { // 获取用户信息(这里使用模拟数据) // 注:这里存储该内容只是举例,其作用为辅助验证,使其验证逻辑更安全,如这里存储用户信息,其目的为: // - 1)、使用"token"验证 Redis 中是否存在对应的 Key // - 2)、使用"用户信息"验证 Redis 的 Value 是否匹配。 String userInfo = "changlu"; // 获取 Token 字符串,并返回 return tokenService.generateToken(userInfo); } /** * 接口幂等性测试接口 * * @param token 幂等 Token 串 * @return 执行结果 */ @PostMapping("/test") public Object test(@RequestHeader(value = "token") String token) { // 获取用户信息(这里使用模拟数据) String userInfo = "changlu"; // 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息 boolean result = tokenService.validToken(token, userInfo); // 根据验证结果响应不同信息 return result ? "正常调用" : "重复调用"; } }
OrderController.java:
package com.changlu.controller; import com.changlu.annontions.ApiIdempotent; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * @Description: 订单控制器 * @Author: changlu * @Date: 10:03 AM */ @RestController @RequestMapping("/order") public class OrderController { //幂等性注解 @ApiIdempotent @PostMapping public Object createOrder() { return "创建订单成功!"; } }
测试接口(jemter测试)
根据控制器类提供的接口进行测试:
1、TokenController主要来测试生成token以及校验token接口
生成接口测试如下:
接着我们将这个token添加到post的测试请求header中:
测试一下:
调用成功!
此时再调用一次,就调用失败了,因为在redis中的那条记录被删除了:
2、OrderController来测试(jmeter高并发测试)
在对应的订单接口上我们添加了防重接口,之后发起请求时就会走我们的拦截器,在拦截器中会取出token并进行校验,若是通过则会执行后面的业务方法:
HTTP请求配置信息如下:
还是老样子先获取到token,然后我们将token存入到header头中,这里我们在jmeter中配置一个http管理器:
我们也设置了同步定时器,能够在同一时间有100个请求:
线程属性如下:
接着我们启动jmeter来进行测试:防重接口成功!
注:为了能够在jmeter中显示x错误的效果,我在拦截器中进行错误响应内容渲染改成了抛出异常,仅仅只是为了显示明显,如下:
对应jemter测试文件,我放在项目目录下: