使用redis实现分布式锁的一些坑

随着分布式技术的发展,分布式锁的使用越来越广泛,jvm锁可以控制对jvm内部资源的独占访问,对于分布式资源却无能为力,这时候就需要采用分布式锁来实现。分布式锁的实现方式有很多种:可以使用关系数据库(比如mysql的select for update悲观锁;当然乐观锁也可以,但实现较复杂);可以使用zookeeper;也可以使用memcached、redis等支持原子操作的缓存系统;不一而足。当然每种实现都有可以探讨的细节,今天我们就来深入redis实现分布式锁的一些细节以及容易掉坑的地方,最后针对redis分布式锁给出一些优化建议。

以Jedis客户端为例,给定一个key要获取分布式锁,我们一般会这样调用:

String status = jedis.set(key, value, "nx", "ex", expireSeconds);
if ("OK".equals(status)) {//抢到锁
    try {
        //do something
    }finally{
        jedis.del(key);//释放锁
    }
}

第一行是个原子操作,可以确保只有一个线程能执行成功,返回状态码OK表示获取到了分布式锁,接下来可以在锁保护下做些工作,最后在finally代码块中删除掉这个key来释放锁。

可能大部分人都是这么写的,初看没什么问题(绝大部分情况下确实可以很好地运行),但其实这个代码在极端情况下有并发问题:

注意点1:很多人在使用上面的代码时,一般都会忽略value,要么把value设为空字符串,要么随意设置个常量,试想如下调用顺序:

  1. 线程1获取锁成功(过期时间5秒),执行被锁保护的代码并执行完毕(花了约5秒);
  2. 5秒的时候,redis里该key超时自动失效,线程2获取锁成功,开始执行被锁保护的代码;
  3. 线程1调用redis del删除key,也就是说会把线程2的锁给释放掉,而这个工作本该是线程2来执行的。
  4. 由于redis里已经没有这个key,线程3成功拿到锁,并和线程2同时执行被保护代码块。

虽然上面这种情况需要很多巧合才能发生,但是根据摩尔定律,如果一件事情可能会发生,那么它一定会发生,尽早修复这个问题非常必要,那么怎么修复呢?

答案是利用value:在set的时候使用uuid作为value,在del的时候判断value等于之前设定的uuid的时候,才删除这个key(Compare And Delete),这样就能避免一个线程删除掉其他线程设置的key。然而redis并没有直接提供这样的比较并删除的命令,幸好redis支持自定义lua脚本,由于redis服务端是单线程的,能确保一个lua脚本执行是原子的。lua脚本长这样:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

这个问题其实redis官方文档已经提及:https://redis.io/commands/set

注意点2:代码第四行在锁保护内部执行的代码花费的时间(标记为t1)必须小于key的过期时间(标记为t2),否则在key自动过期之后,其他线程会进入被锁保护的代码,导致同时有两个线程执行被保护代码,考虑到网络耗时的影响,一般t2要在t1的基础上稍微留些余量,比如t2 = max(t1) + 100ms。

代码经过以上两步改造可以获得更好的鲁棒性。

另外由于redis是网络调用,存在一定开销,尤其是在高并发场景下,对同一个key的抢占只会有一个线程成功,其他线程很可能会获取锁失败,为了降低高并发场景下对redis的访问压力,可以在获取redis锁之前先尝试获取jvm内部锁,jvm内部锁获取成功了再去尝试获取redis锁,确保每个jvm只会有一个线程去获取锁。(如果并发量很高,更好的办法是使用zookeeper来获取分布式锁,zookeeper支持注册监听,当key变化的时候获取到通知,这样可以避免频繁的网络调用。)

 

转载于:https://my.oschina.net/u/1998527/blog/909249

你可能感兴趣的:(使用redis实现分布式锁的一些坑)