利用 redis 实现分布式可阻塞锁

redis 因为读写原子性的特性,很多人会选择利用其来实现分布式锁,例如 setnx 这样的命令。

这并没有什么问题,也足以满足大部分业务,比如在秒杀场景中限制单个用户刷单。但有的场景下,不可阻塞的锁往往会面临一些问题。

假设有这么一个业务场景,你需要去请求某个平台的token,然后拿着这个token去请求这个平台的其他接口。该token有效期为两小时,且一日只能被请求20次(不要吐槽这么奇葩的条件,举个例子而已)。这个时候必须将token缓存起来,如果过期则重新请求并缓存,以防止请求次数超限。由于token是公共资源,调用请求token的接口的操作也面临着并发的情况,所以请求token接口的操作必须加锁,以保证同时间只会有一个线程请求到了token并放入缓存中。

这种情况下如果采用的分布式锁是非阻塞锁,当然可以实现功能。但如果参与竞争锁资源的线程没有抢到锁,那么该怎么办呢?等待抢到锁的线程请求到token并放入缓存中?显然不靠谱吧?如果没有竞争到锁的资源可以被阻塞住,那么在竞争到锁的资源释放锁之后,不就可以直接拿到缓存中的token了吗?


场景已经有了,那就开干!

现在问题来了,redis分布式锁,如何能阻塞呢?redis有什么命令能让客户端阻塞住呢?

BL/BRPOP

// 如果指定的列表 key LIST 存在数据则会返回第一个元素,否则在等待 5 秒后会返回 nil 。
BLPOP LIST 5

既然blpop/brpop可以在列表没有元素的时候阻塞住若干时间,那我们制定一个列表,列表中只有一个元素。抢锁的时候使用此命令,只会有一个线程成功读取到数据,其他的线程则都会被阻塞住!解锁的时候在往指定的列表里面随便塞一条数据,不就行了吗?

利用 redis 实现分布式可阻塞锁_第1张图片

总体思路已经明确。现在只剩下最后一个问题,作为锁竞争的列表资源何时指定?总不能在上线前手动在redis里面添加一个列表吧?看来得增强一下逻辑。

利用 redis 实现分布式可阻塞锁_第2张图片

嗯,看样子是可以编码实施了。

走起!


定义 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 redisChoke() {
    DefaultRedisScript 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 redisChoke;

    private final RedisScript redisChokeUnlock;

    private final RedisTemplate redisTemplate;

    public RedisChokeLocks(
            RedisScript redisChoke,
            RedisScript redisChokeUnlock,
            RedisTemplate 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次,结果

利用 redis 实现分布式可阻塞锁_第3张图片

可以看到,第一个打印的语句比其他打印的语句要快2秒,表明阻塞生效了。

-- 我是 Keguans,一名生于 99 年的菜鸡研发

你可能感兴趣的:(java,redis,mysql)