赞
踩
幂等性: 用户同一操作发起的一次
或多次
请求的结果是一致的
在增删改查4个操作中, 查询不会修改数据, 删除进行一次或者多次的产生的结果一致, 所以只需要关注修改
和新增
操作, 修改和新增在重复提交的场景下会产生接口幂等性问题
解决思路分为两个方向:
根据业务需求, 对数据表中字段设置唯一索引, 可以是单一索引, 也可以是联合索引, 防止新增时出现脏数据
例如: 新增用户数据, 具体流程:
设置唯一索引
唯一索引冲突异常(DuplicateKeyException)
, 插入失败优缺点: 操作简单, 只要对字段建立唯一索引即可, 但是只适用于新增操作
, 而且效率不高, 基于数据库机制去防止重复新增, 相当于把压力都给到了数据库, 在高并发情况下会出现性能问题
根据业务需求, 给数据表添加一个版本字段(version), 执行更新操作时, 比较版本号. 如果版本号相同, 则可以更新成功, 并在更新时增加版本号, 如果版本号不同, 则更新失败
例如: 更新账户余额, 具体流程:
start transaction;
update account set money = money + 10, version = version + 1 where id = 1 and version = 1;
start transaction;
update account set money = money - 20, version = version + 1 where id = 1 and version = 1;
version = 1
这个条件已经不符合了缺点:
这里演示使用Redis + 自定义注解 + AOP解决
代码实现:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 过期时长(毫秒)
*/
long expire();
}
@Aspect @Component @Slf4j public class IdempotentAspect{ @Resource private RedisTemplate<String,String> redisTemplate; @Pointcut("@annotation(com.itheima.annotation.Idempotent)") public void execute(){} @Around("execute()") public Object around(ProceedingJoinPoint joinPoint) { HttpServletRequest request =((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest(); // 获取本次请求唯一标识 String token = request.getHeader("token"); // 获取注解对象 Idempotent annotation = method.getAnnotation(Idempotent.class); // 缓存设置(setNx方法), key为唯一标识, value为随机值, 过期时间为注解的设置, 单位是毫秒 Boolean b = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", nnotation.expireMillis(), TimeUnit.MILLISECONDS); if (b != null && b) { // 放行, 执行业务方法 Object obj = joinPoint.proceed(); // 删除缓存 redisTemplate.opsForValue().delete(redisKey); return obj; }else { // 友好提示 throw new RuntimeException("您操作的太快,请稍后再试");; } } }
缺点:
客户端连续发起多次请求,这多次请求同时到达服务端,此时开始争抢锁,谁抢到锁谁就执行,其他没有抢到锁的请求都统统不执行。这种情况能保证幂等性。
解决幂等性的思路: 为每一次操作(即使发生多次请求)提供一个唯一Token, 我们确保Token的一次性
和唯一性
, 唯一性很好理解, 每次产生的Token都是UUID(类似技术), 一次性可以想象为一个待消费的二维码, 扫描一次后即失效(一次性凭证)
服务端要记录这个一次性凭证, 所以Token需要在服务端生成, 在服务端提供一个返回Token的接口, 每次请求都会将Token写入Redis缓存(记录凭证, 后期验证), 并响应给浏览器(凭证发放), 这个Token相当于一次性凭证, 例如消费券的二维码
后端提供一个返回Token的接口, 后端会将Token写入缓存, 并响应给前端(这个token等于是一个一次性的钥匙, 例如二维码)
浏览器携带Token发起请求
服务端在拦截器(或者AOP)中校验Token的有效性, 实质就是判断Redis中是否存在这个Token
有这个token, 就开门放行, 并删除Redis中token(一次性凭证已使用), 然后执行相应的业务逻辑
如果没有这个token, 可能是因为token已经过期了(有过期时间)、伪造的token、token已经销毁了(delete)这些情况都属于访问失败, 服务器会拒绝请求
PS: 一般来说,服务端会在验证通过后立即删除Token,以确保后续的请求都被认为是无效的(更接近原子性)
简单来说: 为每一次操作生成一个待使用的一次性凭证, 第一次请求使用凭证, 开门放行, 后续请求再携带凭证, 但凭证已经失效了, 无法放行
@RestController
@RequestMapping("token")
public class TokenController {
@Autowired
private TokenService TokenService;
@GetMapping("get")
public String getToken() {
return TokenService.getToken();
}
}
// 业务接口
public interface TokenService {
String getToken();
void check(HttpServletRequest request);
}
// 业务实现 @Service public class TokenServiceImpl implements TokenService { @Autowired private StringRedisTemplate redisTemplate; @Override public String getToken() { // 使用UUID作为Token String token = UUID.randomUUID().toString().replace("-", ""); // 给Token加个前缀, 意思是进行幂等性校验的Token token = "API_IDEMPOTENT_TOKEN:" + token; // 将Token缓存到redis中, key是token, value是随机值, 过期时间为300(5分钟), 过期单位为秒 // 如果5分钟之内, 客户端不携带token进行一次请求, 则token过期, 访问目标接口需要重新获取Token redisTemplate.opsForValue().set(token, "0", 5 * 60, TimeUnit.SECONDS); return token; } @Override public void check(HttpServletRequest request) { // 从请求头里拿到Toekn String token = request.getHeader("idempotentToken"); if (StringUtil.isBlank(token)) { // 请求头中不存在, 就从请求参数中拿 token = request.getParameter("idempotentToken"); if (StringUtil.isBlank(token)) { throw new RuntimeException("参数不合法"); } } // 判断redis中是否存在token if (!redisTemplate.hasKey(token)) { // 不存在, 其实分为好几种情况. 1-过期了、2-伪造、3-已经被消费了, 我们同一回复 throw new RuntimeException("请勿重复操作"); } // 校验通过, redis中存在token, 一次性消费成功, 任务放行. 凭证过期 redisTemplate.delete(token); } }
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}
@RestController
@RequestMapping("api")
public class ApiController {
@ApiIdempotent
@GetMapping("test")
public void test() {
System.out.println("执行业务, 模拟一个比较耗时的操作");
}
}
拦截器中怎么使用@Autowired!!!
SpringBoot常使用的拦截器案例, 是无法在拦截器中直接使用@Autowired的
原因: 拦截器并非是Spring容器中的Bean, Spring无法对其进行自动装配,
问题扩展: 如果给拦截器上添加@Component注解, 依旧无法使用@Autowired, 因为被@Component注解的类确实被Spring容器管理了, 但你注册到SpringMVC容器中的是new ApiIdempotentInterceptor(), 它们都不是同一个
解决方案:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private ApiIdempotentInterceptor apiIdempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiIdempotentInterceptor)
.addPathPatterns("/**"); // 可以指定拦截的路径,/** 表示拦截所有路径
}
}
之后就可以在拦截器中, 直接使用@Autowired
@Component public class ApiIdempotentInterceptor implements HandlerInterceptor { // 注入Spring容器 @Autowired private TokenService tokenService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { try { // 转换为可处理的method对象 HandlerMethod handlerMethod = (HandlerMethod) handler; // 校验方法上是否添加了幂等性校验注解 Method method = handlerMethod.getMethod(); ApiIdempotent annotation = method.getAnnotation(ApiIdempotent.class); if (annotation != null) { // 方法上添加了自定义注解 tokenService.check(request); } } catch (Exception e) { // 为统一异常处理对象设置code和msg, 并将其转换为JSON字符串 ErrorResponse errorResponse = new ErrorResponse(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage()); String json = JSON.toJSONString(errorResponse); // response返回统一异常处理对象 response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); // 设置响应类型为JSON response.setContentType("application/json"); // 设置响应编码为UTF-8 response.setCharacterEncoding("UTF-8"); // 将JSON字符串写入响应输出流 response.getWriter().write(json); // 不放行 return false; } // 在请求处理前执行的逻辑 return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // 在请求处理后,视图渲染前执行的逻辑 } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 在请求处理完成后执行的逻辑,可以用于资源清理等操作 } }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。