redis 因为读写原子性的特性,很多人会选择利用其来实现分布式锁,例如 setnx 这样的命令。
这并没有什么问题,也足以满足大部分业务,比如在秒杀场景中限制单个用户刷单。但有的场景下,不可阻塞的锁往往会面临一些问题。
假设有这么一个业务场景,你需要去请求某个平台的token,然后拿着这个token去请求这个平台的其他接口。该token有效期为两小时,且一日只能被请求20次(不要吐槽这么奇葩的条件,举个例子而已)。这个时候必须将token缓存起来,如果过期则重新请求并缓存,以防止请求次数超限。由于token是公共资源,调用请求token的接口的操作也面临着并发的情况,所以请求token接口的操作必须加锁,以保证同时间只会有一个线程请求到了token并放入缓存中。
这种情况下如果采用的分布式锁是非阻塞锁,当然可以实现功能。但如果参与竞争锁资源的线程没有抢到锁,那么该怎么办呢?等待抢到锁的线程请求到token并放入缓存中?显然不靠谱吧?如果没有竞争到锁的资源可以被阻塞住,那么在竞争到锁的资源释放锁之后,不就可以直接拿到缓存中的token了吗?
场景已经有了,那就开干!
现在问题来了,redis分布式锁,如何能阻塞呢?redis有什么命令能让客户端阻塞住呢?
// 如果指定的列表 key LIST 存在数据则会返回第一个元素,否则在等待 5 秒后会返回 nil 。
BLPOP LIST 5
既然blpop/brpop可以在列表没有元素的时候阻塞住若干时间,那我们制定一个列表,列表中只有一个元素。抢锁的时候使用此命令,只会有一个线程成功读取到数据,其他的线程则都会被阻塞住!解锁的时候在往指定的列表里面随便塞一条数据,不就行了吗?
总体思路已经明确。现在只剩下最后一个问题,作为锁竞争的列表资源何时指定?总不能在上线前手动在redis里面添加一个列表吧?看来得增强一下逻辑。
嗯,看样子是可以编码实施了。
走起!
定义 lua 脚本。
redisChokeLock.lua
// KEY[1]是参数,这里会传指定列表名称。
// 先判断该列表是否存在,如果不存在返回 false
// 如果存在,创建列表并添加一个元素,返回 true
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('lpush', KEYS[1], "1");
redis.call('expire', KEYS[1], KEYS[2]);
return true;
end;
return false;
将这lua加载一下
@Bean
public RedisScript<Boolean> redisChoke() {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("lua\\redisChokeLock.lua"));
redisScript.setResultType(Boolean.class);
return redisScript;
}
redis分布式阻塞所工具类 RedisChokeLocks
/**
* redis 分布式阻塞锁
*/
@Component
public class RedisChokeLocks {
private final RedisScript<Boolean> redisChoke;
private final RedisScript<Object> redisChokeUnlock;
private final RedisTemplate<Object, Object> redisTemplate;
public RedisChokeLocks(
RedisScript<Boolean> redisChoke,
RedisScript<Object> redisChokeUnlock,
RedisTemplate<Object, Object> redisTemplate
) {
this.redisChoke = redisChoke;
this.redisChokeUnlock = redisChokeUnlock;
this.redisTemplate = redisTemplate;
}
/**
* 加锁
*
* @param key 指定列表名
* @param time 阻塞时间
* @param lockExpire 锁超时时间,默认30s
* @return
*/
public boolean lock(String key, Long time, Long lockExpire) {
Boolean aBoolean = redisTemplate.execute(
redisChoke,
Arrays.asList(key, Objects.isNull(lockExpire) ? "30" : lockExpire.toString())
);
if (Objects.nonNull(aBoolean) && aBoolean) {
return this.lock(key, time, lockExpire);
}
Object o = redisTemplate.boundListOps(key).rightPop(time, TimeUnit.SECONDS);
return Objects.nonNull(o);
}
/**
* 解锁
* 将锁资源放回队列
*
* @param key 指定列表名
* @param lockExpire 锁超时时间 ,默认30s
*/
public void unlock(String key, Long lockExpire) {
redisTemplate.execute(
redisChoke,
Arrays.asList(key, Objects.isNull(lockExpire) ? "30" : lockExpire.toString())
);
}
}
大功告成!
做个小测试
/**
* 测试请求 token
*/
public void token() {
// 先查看缓存中的token是否存在
Object id = redisTemplate.boundValueOps("id").get();
if (Objects.nonNull(id)) {
System.out.println(new Date() + " : " + id);
return;
}
// 如果不存在,抢锁
boolean list = this.lock("list", 30L, 60L);
try {
if (list) {
// 抢锁成功,再次查询缓存中的token是否存在
id = redisTemplate.boundValueOps("id").get();
// 不存在,生成 token,放入缓存
if (Objects.isNull(id)) {
id = UUID.randomUUID().toString();
System.out.println(new Date() + " : " + id);
// 阻塞两秒
TimeUnit.SECONDS.sleep(2);
redisTemplate.boundValueOps("id").set(id);
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 解锁
this.unlock("list", 60L);
}
}
开启线程池多线程并发请求500次,结果
可以看到,第一个打印的语句比其他打印的语句要快2秒,表明阻塞生效了。
-- 我是 Keguans,一名生于 99 年的菜鸡研发