接口的幂等性与分布式锁redisson
采用token方式实现接口幂等性的示意图。
一、幂等性
一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用
二、使用地方
1、 前端重复提交表单:在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
2、 用户恶意进行刷单:例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
3、接口超时重复提交:很多时候 HTTP客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
4、 消息进行重复消费:当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
三、幂等性解决办法
1、token 机制实现 通过token 机制实现接口的幂等性,这是一种比较通用性的实现方法
2、基于 mysql唯一索引 实现 这种实现方式是利用mysql 唯一索引的特性。
3、基于 redis SETNX 命令实现 这种实现方式是基于 SETNX 命令实现的 SETNX key value:将key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。
四、采用token实现接口幂等性的具体做法(springboot)
例如:
1、点击提交订单按钮:
① 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。
② 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串,返回给前端一份,客户端拿到后应存到表单隐藏域中,后端存redis一份,以该 Token 作为 Redis 的键(注意设置过期时间)。
2、点击结算按钮
③ 客户端在执行提交表单时,把Token 存入到 Headers 中,执行业务请求带上该 Headers。
④ 服务端接收到请求后从 Headers 中拿到Token,然后根据 Token 到 Redis 中查找该 key 是否存
⑤ 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。
**那么该笔交易token只有一个,所以无论你点多少次结算,那我都能以此token去判断是否重复提交**
方法一、采用lua脚本,实现在redis中查找数据和删除数据的原子操作
①导包
<!--springboot集成redis时要导入的包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--分布式锁redisson导入的包-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.0</version>
</dependency>
配置yml
spring:
redis:
host: 127.0.0.1
port: 6379
②返回一个后端生成的token(比如:提交订单按钮)
/**
* 创建 Token 存入 Redis,并返回该 Token
* @param value 用于辅助验证的 value 值
* @return 生成的 Token 串
*/
public String generateToken(String value) {
String token = UUID.randomUUID().toString();
String key = IDEMPOTENT_TOKEN_PREFIX + token;
/**
* 在真实业务中 采用唯一标志 例如 流水号啊
*/
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
return token;
}
③执行业务逻辑(比如:点击结算按钮)
控制层:
/**
* 接口幂等性测试接口
*
* @param token 幂等 Token 串
* @return 执行结果
*/
@PostMapping("/test")
@ApiOperation(value = "测试幂等性")
public String test(HttpServletRequest request) {
String token = request.getHeader("token");
// 获取用户信息(这里使用模拟数据)
String userInfo = "mydlq";
// 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
boolean result = tokenUtilService.validToken(token, userInfo);
// 根据验证结果响应不同信息
if (result) {
/**
* 执行正常的逻辑
*/
log.info("执行正常的逻辑………………");
}
return result ? "正常调用" : "重复调用";
}
业务层:
/**
* 验证 Token 正确性
*
* @param token token 字符串
* @param value value 存储在Redis中的辅助验证信息
* @return 验证结果
*/
public Boolean validToken(String token, String value) {
// 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value,这段lua脚本的意思是获取redis的KEYS[1]的值,与KEYS[2]的值作比较,如果相等则返回KEYS[1]的值并删除redis中的KEYS[1],否则返回0
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;
}
方法二、分布式锁
分布式锁解决本地锁部属中的,分布式部署多服务会同时访问redis。例如:某服务分布式部署了3份,那么在redis为null,至少有3个请求会进入数据库(已使用本地锁的情况下)。理论应该一个区数据库查出来在放入redis。所以,为了解决这个问题,引入redisson
①导包
<!--springboot集成redis时要导入的包-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--分布式锁redisson导入的包-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.0</version>
</dependency>
②写配置类(这里也可以把地址配置到yml,方便管理)
@Configuration
public class MyRedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException {
//创建配置
Config config = new Config();
/**
*如果有密码:
*config.useSingleServer().setAddress("redis://120.78.179.242:6379").setPassword("123456")
**/
config.useSingleServer().setAddress("redis://120.78.179.242:6379");
//根据Config创建出RedissonClient示例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
③ 业务代码
controller层:
/**
* 分布式锁实现幂等性
*/
@PostMapping("/distributeLock")
@ApiOperation(value = "分布式锁实现幂等性")
public String distributeLock(HttpServletRequest request) {
String token = request.getHeader("token");
// 获取用户信息(这里使用模拟数据)
String userInfo = "mydlq";
RLock lock = redissonClient.getLock(token);
lock.lock(10, TimeUnit.SECONDS);
try {
Boolean flag = tokenUtilService.validToken2(token, userInfo);
// 根据验证结果响应不同信息
if (flag) {
/**
* 执行正常的逻辑
*/
log.info("执行正常的逻辑………………");
}
return flag ? "正常调用" : "重复调用";
} catch (Exception e) {
e.printStackTrace();
return "重复调用";
} finally {
lock.unlock();
}
}
service层:
public Boolean validToken2(String token, String userInfo) {
if (StringUtils.isBlank(token)) {
return false;
}
Boolean aBoolean = redisTemplate.hasKey(IDEMPOTENT_TOKEN_PREFIX + token);
if (aBoolean) {
//删除token
Boolean isDeleted = redisTemplate.delete(IDEMPOTENT_TOKEN_PREFIX + token);
if (!isDeleted) {
return false;
}
return true;
} else {
return false;
}
}
总结:lua脚本的母的是保证只有一个线程抢到分布式锁,并且获取后就删除,拿redis数据和删除redis数据是原子操作。分布式锁是分布式中只有一个拿到锁,并且删除redis数据,谁先占位谁执行业务,并删除redis,其他再进来redis已无数据,无需执行了,从而实现幂等性。
5. redisson看门狗自动续期源码
private void renewExpiration() {
RedissonLock.ExpirationEntry ee = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (ee != null) {
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
RedissonLock.ExpirationEntry ent = (RedissonLock.ExpirationEntry)RedissonLock.EXPIRATION_RENEWAL_MAP.get(RedissonLock.this.getEntryName());
if (ent != null) {
Long threadId = ent.getFirstThreadId();
if (threadId != null) {
RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", e);
} else {
if (res) {
RedissonLock.this.renewExpiration();
}
}
});
}
}
}
}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
}
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return this.commandExecutor.evalWriteAsync(this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(this.getName()), new Object[]{this.internalLockLeaseTime, this.getLockName(threadId)});
}