接口幂等性设计
幂等性: 对于同一个操作发起一次请求或者多次请求,得到的结果都是一样的,不会因为请求多次而出现异常现象。
场景:
- 用户多次请求,比如重复点击页面上的按钮
- 网络异常,右移网络原因导致在一定时间内未返回调用成功的信息,触发了框架层的重试机制
- 页面回退都再次提交的动作
- 程序上的重试机制--对未及时响应的请求发起重试操作
Restful 请求方式的幂等性:
- POST : 相当于新增,不具备幂等性
- GET : 对资源的获取。在浏览器中通过地址进行访问,每次结果都是一样的,天然幂等
- PUT : 将一个资源替换成另一个资源。这是非计算型的更新,无论更新多少次,结果都是一样的,天然幂等
- DELETE : 无论删除多少次,都是一样的,是天然幂等
如何避免重复提交
1.利用全局唯一ID防止重复提交
在向数据库新增一条记录时,有时会出现错误信息“result in duplicate entry for key primary”,原因是插入了相同的ID信息。
利用数据库的主键唯一特性,可以解决重复提交问题
流程:
- 搭建一个生成全局唯一ID的服务,可以参考雪花算法SnowFlow进行搭建
- 在订单确定页面中,调用全局唯一ID服务生成订单号
- 提交订单时带上订单号,请求到达订单订单系统的下单接口
- 订单系统在创建订单信息时,订单号使用前端传过来的订单号,然后直接将订单信息插入数据库
- 如果订单写入成功,则是第一次提交,返回下单成功;如果报ID冲突信息,则是重复提交。
2.利用“Token+Redis”机制防止重复提交
流程:
- 订单系统提供一个发放Token的接口。这个Token是一个防重令牌,即一串唯一字符串(可以使用uuid)
- 在“订单确认页”中调用获取Token的接口,该接口向订单确认页返回Token,同时将Token写入Redis缓存中,并依据实际业务对其设置一定的有效期
- 用户在“订单确认页”中点击“提交订单”按钮时,将第2步Token以参数或者请求头的形式封装进订单信息,然后请求订单系统的下单接口
- 下单接口在收到提交下单的请求后,首先判断在Redis中是否存在当前传入的Token
- 如果存在,则表示这是第1次请求,会删除这个Token,继续创建订单的其他业务
- 如果不存在,则表示这不是第1此请求,而是重复的请求,会终止后面的业务逻辑
代码实现
生成Token
package com.wxclient.controller;
import com.fasterxml.uuid.Generators;
import com.yl.entitys.RespEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/system/idempotence")
public class IdempotenceController {
@Autowired
private RedisTemplate redisTemplate;
public static final String USER_TOKEN_PREFIX = "idempotence:token:";
/**
* 参数token , 放入redis set数据结构,防止重复,返回给前端,做接口幂等性
*/
@GetMapping("/{userId}")
public RespEntity idempotence(@PathVariable Integer userId) {
// 基于时间的UUID(全球唯一)
UUID uuid = Generators.timeBasedGenerator().generate();
// 将token 放入redis set中 ,5分钟的过期时间
redisTemplate.opsForValue().set(USER_TOKEN_PREFIX+userId, uuid,5, TimeUnit.MINUTES);
log.debug("【系统日志】产生的TOKEN->{}", uuid);
return RespEntity.okData(uuid);
}
}
业务检验
/**
* 添加公告信息
*/
@ApiOperation(value = "添加公告信息")
@PostMapping("/")
public RespEntity add(@RequestBody YlNotice notice, HttpServletRequest httpServletRequest) {
log.debug("【系统日志】添加公告信息---》");
// 验证幂等性的标识
String idempotence = httpServletRequest.getHeader("idempotence");
// 用户的JWT信息
String jwt = httpServletRequest.getHeader("jwt");
JWT token = JWTUtil.parseToken(jwt);
Integer userId = (Integer) token.getPayload("id");
// 获取用户ID
log.debug("【系统日志】用户:{}->",userId);
log.debug("【幂等性】idempotence:{}->",idempotence);
//获取redis中的令牌【令牌的对比和删除必须保证原子性】
//LUA脚本 返回0表示校验令牌失败 1表示删除成功,校验令牌成功
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = (Long) redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Arrays.asList(USER_TOKEN_PREFIX + userId),
idempotence);
if (result == 1) {
log.debug("【幂等性】OK:{}->",idempotence);
log.debug("【系统日志】redis验证成功:{}->",idempotence);
//令牌验证成功
//去创建、下订单、验令牌、验价格、锁定库存...
if (notice.getNoticeTitlet().isEmpty()){
return new RespEntity(501, "公告标题不能为空", null);
}
if (notice.getNoticeContent().isEmpty()){
return new RespEntity(501, "公告内容不能为空", null);
}
// 默认启用
notice.setNoticeStates("y");
LocalDateTime dateTime = LocalDateTime.now(); // 获取当前时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
notice.setNoticeTime(dateTime.format(formatter));
noticeService.save(notice);
return RespEntity.SUCCESS;
} else {
log.debug("【幂等性】ERROR:{}->",idempotence);
log.debug("【系统日志】redis验证失败:{}->",idempotence);
//令牌校验失败,返回失败信息
return RespEntity.FAIL;
}
}