rpc-tech-stack 系列的实践文章 [1],平日看了很多技术文章,看完之后给人一种我看完了我就会了的错觉~ 但其实什么都不会。俗话说“好记性不如烂笔头”,那我就把常用的技术点通过 demo 的方式来实现,并增强自己的记忆吧~
实践的效果
场景:请求不到锁超时等待,自旋至超过等待时间
场景:用户请求一个资源,请求成功一次之后,锁过期之前限制其再次消费,实现对资源调用的限流。
参数:
-
锁过期时间:10s
-
获取不到锁的等待时间:5s
-
等待重试时间:200ms
模拟用户第一次请求
因为key 不存在~所以直接成功,没什么好说的。
模拟用户 5 秒内的第二次获取锁
用户再次请求资源,此时上一次的锁还未过期释放,按照设计思路,锁会进行自旋,每间隔 200ms 再次发起获取锁的请求。
-
若请求到锁,则进行上锁。
-
若到达等待时间还未获取锁,则获取失败。
获取锁的流程图
-
最开始的服务通过组合命令直接获取锁,并设置过期时间,过期时间内其他线程无法获得锁。
-
随后的服务通过同样的指令尝试获取锁,获取失败就自旋,超过超时等待时间没拿到就失败~
Redis command
下面命令的语义:如果 key 不存在的话设置 key 与 value 并设置过期时间为 10 秒
set "key" "value" NX EX 10
拓展~
-
NX :仅当key不存在时,set才会生效
-
XX :仅当key存在时,set才会生效
-
EX :设置键的过期时间为 second 秒
-
PX :设置键的过期时间为 millisecond 毫秒
核心代码块
阻塞式获取锁
/**
* 阻塞式分布式锁
*
* @param key
* @param timeout 超时时间
* @param unit 超时单位
* @return
*/
public boolean tryLock(String key, long timeout, TimeUnit unit) {
log.info("[Redis Lock] 尝试获取阻塞式分布式锁 -- start time:{} -- key :{}", System.currentTimeMillis(), key);
// 获取系统此刻的时间,用毫秒来返回
final long startMillis = System.currentTimeMillis();
// 转换参数中的 timeout 为毫秒并返回
final Long millisToWait = unit.toMillis(timeout);
// 分布式锁的值
boolean lockValue;
// 上锁成功立即返回,上锁失败就直到等待超时还未上锁返回
int failed_cnt = 0;
while (true) {
// 拿到上锁结果 MAX_EXPIRE_TIME = 10s
lockValue = lock(key, MAX_EXPIRE_TIME, TimeUnit.SECONDS);
// 若上锁成功,就返回
if (lockValue) {
log.info("[Redis Lock] 尝试获取阻塞式分布式锁 | 成功 -- key :{}", key);
return true;
}
// 超时的判断 : 当前时间 - 开始时间 - 重试等待时间 > 超时时间
Long waitDiffTime = System.currentTimeMillis() - startMillis - retryAwait;
// 若上锁不成功,判断是否超时,若超时就返回
if (waitDiffTime > millisToWait) {
// 超时返回, 上锁失败
log.warn("[Redis Lock] 尝试获取阻塞式分布式锁{}次,耗时{}ms | 最终超时失败 -- key :{}"
, failed_cnt,waitDiffTime, key);
return false;
}
log.info("[Redis Lock] 尝试获取阻塞式分布式锁 | 失败{}次, 尝试超时等待:{}ms 后重新发起获取 -- key :{}",
++failed_cnt, retryAwait, key);
// 阻塞当前线程, 阻塞时长是重试等待时间
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(retryAwait));
}
}
非阻塞式获取锁
/**
* 无阻塞分布式锁
*
* @param key
* @param expireTime 过期时间
* @param unit 时间单位
* @return
*/
public boolean lock(String key, long expireTime, TimeUnit unit) {
// key 和 value 的序列化方式
RedisSerializer keySerializer = redisTemplate.getKeySerializer();
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
final String SET = "SET";
byte[] key_cmd = keySerializer.serialize(key);
byte[] value_cmd = valueSerializer.serialize("1");
final byte[] NX = encode("NX");
final byte[] EX = encode("EX");
byte[] expireTime_cmd = encode(String.valueOf(unit.toSeconds(expireTime)));
// 例 : set "key" "1" NX EX 3000
// 如果 key - "key" 不存在就设置, value 设置成 "1", 若存在就设置失败
return redisTemplate.execute((RedisCallback<Boolean>) connection ->
connection.execute(SET, key_cmd, value_cmd, NX, EX, expireTime_cmd) != null);
}
Mock 的获取资源并加锁
@Override
public boolean GetResourceAndLockSomeAWhile(String resourceName) {
if (StringUtils.isBlank(resourceName)){
return false;
}
RedisDistributedLock redisDistributedLock = new RedisDistributedLock(stringRedisTemplate);
log.info("[{}] 尝试获取资源锁 key:{},",this.getClass().getSimpleName(),resourceName);
boolean lock_res = redisDistributedLock.tryLock(resourceName,5, TimeUnit.SECONDS);
// todo 如果 lock_res == true 执行相关获取资源的逻辑
log.info("[{}] 尝试获取资源锁 key:{} | 结果:{},",this.getClass().getSimpleName(),resourceName,lock_res);
return lock_res;
}
存在的问题 (非常重要)
这个方案仅仅适用于对事务的持续时间短,对一致性不敏感的场景。存在的问题很多,我思考后提出了以下几个缺点,可以后续进行优化。
-
如果过期时间到了,事务还未结束怎么办?那不是提前释放锁了么,这就出现问题了
-
怎么判断在什么时候手动释放锁,什么时候过期释放锁?
-
在 Redis 2.6 之前,setnx 与 setex 是两个命令,若因某些原因(宕机)执行完 setnx 时 setex 未执行,则会导致永远不能释放锁,但是在 redis2.6 之后支持命令组合~还可以通过 lua 脚本保证多条命令的原子性