接口幂等性是什么?
什么是接口幂等性?
接口幂等性是指用户对于同一个操作发起的一次或多次请求的结果是一致的,即发出多个请求对服务器的预期效果应当与发出单个请求对服务器的预期效果相同。
为什么会有这样的需求呢?
场景:一个下单页面用户点击第一次的时候可能因为网络卡顿导致浏览器无法第一时间收到服务器的响应,用户可能会因为着急多次点击(相信这种操作许多人都做过),那么当用户点击多次会造成什么样的后果呢?
没有实现接口幂等性的后果:可能会导致库存多次扣减、可能会产生多笔订单记录、订单支付时,多扣了几次钱等等。可见,如果没有实现接口幂等性,对于这一系列操作带来的后果是很严重的。
当然除了多次点击会带来重复提交外,像用户页面回退再次提交,服务的重试机制等都会带来接口幂等性的问题。
接口幂等性的常见实现方式
根据上面的情况可以看到接口幂等性的实现是尤其重要的,接下来将介绍几个常见的实现方式。
数据库的乐观锁与悲观锁
可通过使用数据库悲观锁方式在获取数据时进行加锁,当同时有多个重复请求时只会有一个请求能操作。当然使用悲观锁一般会伴随着事务一起,如果数据的锁定时间过长,可能会影响性能。
当然也可以使用数据库的乐观锁方式,在数据中增加一个version
字段,当数据需要更新时,先获取数据的version
与更新时数据的version
做对比,因为多次请求时所带的version
版本号是相同的,而如果有一个请求已经完成更新,那么版本号会变为version + 1
,那么此时其余的请求再去对比便会不同,提示更新失败。
分布式锁
这种情况是当分布式系统下,多台服务器同一时刻同时处理相同的数据,此时可以通过分布式锁,锁定此数据,只允许同一时刻只有一个请求可以操作,并且每次请求时都先检查当前锁定的数据是否已被处理过,这样就可以解决并发情况下的接口幂等性问题。
防重表(reids或数据库)
防重表可以在数据库中实现也可以在Redis中实现。
数据库中可以利用需要更改的数据的唯一索引特性创建一张防重表,比如下单操作中可以拿订单号(本身就是唯一的,如果没有唯一值可以将数据的多个字段合并构成唯一值)作为防重表这张表的唯一索引,当多次相同请求访问接口时,最前面的请求操作成功后,会将数据中的唯一索引(订单号)加入这张防重表,当其余请求操作时,因为无法加入相同索引进入防重表而导致请求失败,这样就可以避免幂等性的问题。
Redis实现方式类似,可以将需要更新的数据进行MD5加密等操作,将加密的数据存入Redis的set类型中,每次处理请求时,先通过Redis判断是否加密数据已存在,存在则不进行请求处理。
token机制(常用)
token机制核心实现原理:调用方在发起请求时先向服务器获取一个全局ID(token),并将其存储在Redis中,请求时携带token,先校验token是否存在Redis,存在则执行业务,不存在代表是重复提交,在第一次校验后会删除token。
token机制流程图:
token机制主要步骤:
- 客户端向服务器发送获取token请求,服务器可以通过如UUID的方式生成一个token,并将其存入Redis中,而后响应给客户端。
- 客户端发送业务请求时携带token一并发送,token可以放在请求头或者参数中。
- 服务器校验token是否存在Redis中
- 存在,则先删除Redis中的token,而后执行业务。
- 不存在,则代表这次请求是重复请求,抛出异常,终止操作。
token机制深入考虑:
-
为什么是先删除token,再执行业务呢?
① 先执行业务再删除token,会出现的情况是可能业务处理完成,准备删除token时出现故障或者超时等情况,导致没法删除token,此时会造成重复请求也校验通过而多次执行业务。
② 先删除token再执行业务,会出现情况是删除完token,服务器超时或宕机,此时业务将无法执行,但是相当于多次相同的业务请求都无法执行,除了未执行成功之外并不会带来什么后果。后续可以再次请求获取新token再次执行业务。(虽然这种情况也会带来影响,但是相对于第一种情况的多次执行造成的后果相比要好很多)
-
token在Redis中的获取、比较和删除必须是原子性
如果这三个操作不是原子性,在高并发场景下,可能会导致多个token校验成功,重复执行相同业务。
所以需要使用Redis的Lua脚本执行,LUA脚本代码可参考如下:
// 通过传入的key获取到的value与参数avg进行对比,一致说明数据存在reids中,删除key的数据,不一致说明是重复提交,返回0后续业务处理。 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
token机制的详细实现方案
项目中常用的token机制实现接口幂等性,接下来放入部分核心代码用于参考
- 环境的配置(需要引入Redis依赖)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 可配置一下Redis的序列化方式
/**
* Redis配置类
* @author 单程车票
*/
@Configuration
public class RedisConfig {
/**
* 配置Redis的序列化器
*/
public RedisTemplate<Object,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object,Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 使用jackson序列化方式序列化json
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
- 配置application.yml的Redis基本信息
# 端口号
server:
port: 8888
# 配置Redis
spring:
redis:
host: localhost
port: 6379
database: 0
- 核心代码
业务接口类
/**
* 业务接口类
* @author 单程车票
*/
public interface TokenService {
// 创建token
String getToken();
// 校验token并执行业务代码
boolean checkToken(HttpServletRequest request);
}
接口实现类
/**
* 主要业务
* @author 单程车票
*/
@Service
public class TokenServiceImpl implements TokenService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public String getToken() {
// 通过UUID生成token
String token = UUID.randomUUID().toString().replaceAll("-", "");
// 将生成的token存入redis中,这里为了方便观察,把key写死为testToken,因为只测试一个接口,实际开发中,key是需要动态的(可以找一个唯一值替代,比如订单号等)
stringRedisTemplate.opsForValue().set("testToken", token);
// 返回token
return token;
}
@Override
public boolean checkToken(HttpServletRequest request) {
// 从请求头中获取token
String token = request.getHeader("token");
// 判断是否为空
if (StringUtils.isEmpty(token)) {
// token为空则抛出异常
throw new CustomException("请求头未携带token!");
}
// LUA脚本保证获取token,校验token,删除token是原子性的
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 调用execute执行,第一个参数传入脚本,第二个参数传入key(这里是之前写死的testToken),第三个参数传入AVG(这里是请求头携带的token)
// 该lua脚本会对比 传入的key获取到的value是否与请求头获取的token一致,一致说明存在,删除key的数据,不一致返回0
Long res = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), List.of("testToken"), token);
// 不一致,说明不存在,抛出重复异常
if (res == 0) {
throw new CustomException("请求重复提交!");
}
// 执行业务代码
System.out.println("执行业务代码");
return true;
}
}
测试
/**
* 测试
* @author 单程车票
*/
@RestController
public class OrderController {
@Autowired
private TokenService tokenService;
@GetMapping("/getToken")
public R<String> getToken() {
String token = tokenService.getToken();
return R.success(token);
}
@PostMapping("/order")
public R<String> order(HttpServletRequest request) {
// 校验并执行业务代码
boolean res = tokenService.checkToken(request);
if (res) return R.success("执行成功!");
else return R.fail("执行失败");
}
}
通过Postman验证该token机制是否可行
- 通过接口生成token:b6cbb045f0d84a5eb87cee324de44265
- 查看对应的Redis是否存入token
- 携带上token进行第一次业务请求
- 查看Redis是否被清除
- 第二次重复提交相同业务请求
小结:说明上述代码可以实现token机制。