话题介绍大家好,我是小龙。之前在《吃透Redis系列》专栏发表了第一篇文章《Redis基础篇(万丈高楼平地起):核心底层数据结构》,简单介绍了Redis,它的内部组织、核心数据结构和一般使用场景。还没看过的同学可以回头看看。接下来,我会继续给大家深入的了解。本文将介绍一个频繁使用Redis的场景——《使用Redis练习分布式锁》。大家一定知道,在遇到并发问题的时候,我们通常会使用锁来解决并发问题。这就是,有的同学可能会说:“这个我都知道,不就是用synchronized和Lock来实现吗?”是的你是对的。但你只对了一半。在“传统单机部署”的情况下,可以使用Java并发处理相关的API(如ReentrantLcok或synchronized)进行互斥控制。但是,在“分布式系统”中,由于分布式系统是“多线程”、“多进程”、“分布在不同机器上”的,这会使原来的单机并发控制锁策略失效。为了解决这个问题,一种“跨JVM互斥机制”来控制对共享资源的访问依赖于分布式锁。看穿锁的本质是我的看法:所有的锁本身都可以用一个变量来表示。例如:对于一个“单机运行”的多线程程序。取一个变量。当该变量为0时,表示没有线程获得锁;当该变量为1时,表示有线程获得了锁。加锁:线程调用加锁操作,检查变量是否为0,为0表示没有线程获取到锁,设置变量为1表示获取到锁;如果不为0,说明其他线程已经临时使用了锁,获取锁失败。解锁:同样的事情。在分布式环境下,分布式锁也可以理解为变量的形式。但是,不同于单机上的线程操作锁,在分布式场景下,“锁变量需要共享存储系统来维护”,只有这样,多个客户端才能通过访问共享存储系统来访问锁变量。相应的,“加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中锁变量的值”。《可见满足分布式锁的要求》:《锁操作的原子性》:分布式锁的加锁和释放锁的过程涉及到多个操作。因此,在实现分布式锁时,需要保证“锁操作的原子性”;“锁的可靠性”:共享存储系统保存锁变量。无法执行锁定操作。在实现分布式锁的时候,我们需要考虑保证“共享存储系统的可靠性”,然后才是“锁的可靠性”。上面我们提到了一个锁变量可以用来表示一把锁。其实你也可以理解为“占用”。只是分布式锁需要把这个坑挖出来放在“共享”的地方,从“共享的地方”各自查坑。占用一般使用setnx(setifnotexists)命令,只允许一个client占用。先来先占,用完后调用del命令释放厕所。//锁>setnxlock_key1OK//业务逻辑>(其他操作)//释放锁>dellock_key但是有个问题,如果逻辑执行过程中出现异常,可能会导致del命令调用不上,所以会“掉进死锁”,锁永远无法释放。所以我们拿到锁后,给锁加上一个过期时间,这样即使中间出现异常,也能保证锁到指定时间后会自动释放//Lock>setnxlock_key1OK>expirelock_key5//业务逻辑>(其他操作)//释放锁>dellock_key但是上面的逻辑还是有问题,如果server进程在setnx之间突然挂了还有expire,可能是因为机器掉电或者人为kill掉了,会导致expire没有执行而造成死锁。这个问题的根源在于setnx和expire是两条指令,而不是原子指令。你可能会想使用交易或其他东西来执行可爱,但这是不可能的,因为如果setnx没有抢到锁,就不应该执行expire。在Redis2.8版本中,作者加入了set命令的扩展参数,使得setnx和expire命令可以一起执行,彻底解决了分布式锁的乱象。setkeyvalue[EXseconds|PXmilliseconds][NX]除了上述基本的套路问题,还有这些“你可能没有考虑过的问题”:超时问题。Redis分布式锁无法解决超时问题。业务逻辑执行时间过长,超过了锁的超时限制,就会出现问题(就是锁已经过期,你的业务逻辑还没执行完)。因为此时锁过期,第二个客户端B再次持有锁,但是随后客户端A执行完业务逻辑,释放锁,客户端C执行完客户端B上的逻辑就拿到了锁。为避免此问题,Redis分布式锁不应用于较长的任务。为了处理这个问题,我们需要能够区分来自不同客户端的锁操作。怎么做?为了解决这个问题,我们可以想办法在命令中加入一些技巧。可以在lock变量的值上想办法。在使用SETNX命令加锁的方法中,我们将lock变量的值设置为1或0来表示是否加锁成功。1和0只有两种状态,不能表示哪个client进行了锁操作。因此,我们在进行锁操作时,可以“让每个客户端为锁变量设置一个唯一的值”,这里的唯一值可以用来标识当前操作的客户端。释放锁操作时,客户端需要判断锁变量的当前值是否等于自己的唯一标识。只有当它们相等时,才能释放锁。这样就不会出现不小心释放锁的问题。因此,我们的命令可以这样写://Lock,unique_value作为客户端的唯一标识SETlock_keyunique_valueNXPX5000其中,unique_value是客户端的唯一标识,可以用随机生成的字符串表示,PX5000表示lock_key会在5s后Expires,防止客户端在此期间因为异常而无法释放锁。因为在加锁操作中,每个client都使用唯一标识,所以在“释放锁操作”中,我们需要“判断锁变量的值”,是否等于执行锁释放的client的唯一标识操作,如下所示,可以使用Lua脚本保证原子性//释放锁并比较unique_values避免意外释放ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0endreentrancy重入是指线程在持有锁的情况下再次请求加锁。如果一个锁支持同一个线程的多个锁,那么这个锁就是可重入的。比如Java语言中有一个ReentrantLock,就是可重入锁。如果Redis分布式锁需要支持重入,可以封装客户端的set方法,使用线程的Threadlocal变量来存储当前持有锁的计数。这里就不过多介绍了,估计也不会问了。有兴趣的可以上网查书看书。补充以上课外内容,是一个基于单Redis节点的分布式锁。当我们要实现“高可靠的分布式锁”时,不能仅仅依靠单一的命令操作,需要按照一定的步骤和规则进行加锁和解锁操作,否则,锁可能会失效。“某些步骤和规则”是什么意思?其实就是分布式锁的算法。这里简单介绍一下Redlock算法的执行步骤。Redlock算法的实现需要N个独立的Redis实例。接下来,我们可以分3步完成加锁操作。1、客户端获取当前时间。2.客户端依次尝试获取各个Master实例中的锁。在获取锁的过程中,为每次锁操作设置一个快速的失败时间(如果想获取10秒的锁,则将每次锁操作的失败时间设置为5-50ms)。这样可以避免client与一个故障Master通信的时间过长,通过fastfailure尽快完成与集群中其他节点的锁操作。3.客户端计算与master获取锁过程中消耗的时间,》当且仅当客户端获取锁消耗的时间小于锁的生存期,且获取锁的时间超过超过一半的主节点。”则认为客户端已成功获取锁。4.如果已经获取到锁,“客户端执行任务的时间窗口是锁的存活时间减去获取锁消耗的时间”。5.如果客户端获取到的锁数量少于一半,或者获取锁的时间超时,则认为获取锁失败。客户端“需要尝试释放所有主节点中的锁,即使在第二步没有成功获取到主节点中的锁,也必须进行释放操作”。
