分布式锁
这次我们来谈一下redis在分布式场景下的一个重要作用,那就是分布式锁。我们之前的单体架构中,实现锁可以用synchronized关键字,但是他只是针对本地jvm加锁,但是当分布式时,多台的机器提供同一个服务,在不同机器之间,是无法用synchronized进行加锁的,就会出现超卖问题。当然玩具分布式项目是很难出现超卖的,所以可以用jmeter来模拟高并发的场景,他的功能包括例如5秒内进行200次请求,可以设置持续压测,可以获得压测报告。有了jmeter之后,我们就能观察到超卖的问题出现了,接下来我们就要用redis来解决这个问题。

首先来说最基本的分布式锁,那就是用redis的setnx+delete来实现,使用方法就是key为很普通的字符串lock,value就是随便一个数字,当拿到锁并执行完自己的业务逻辑之后,用jedis.delete(key)释放锁。setnx的特性就是如果set的成功与否会有一个返回值,这样就能放在if中当判断条件是否成功获得了锁,而一般的set是没有返回值的,而之所以能用setnx当锁,是因为redis是单线程的,每个setnx都要串性执行,不管多么高的并发,每个setnx都会有一个先后顺序的,会在redis中排队执行,而redis只会给第一个setnx返回true,也就是获得锁。但是有一个很明显的问题,就是假如在setnx和delete之间如果抛出异常,就导致delete不能被执行,也就是锁无法被释放,导致其他服务器无法获得锁,导致死锁。后面的一系列优化,都是为了能够让锁顺利释放。

针对中途异常,解决方法是用try,catch,finally来处理业务逻辑的异常,将delete放到finally中,来保证delete不被异常干扰,但又有问题,无法处理服务器中断,也就是说在执行业务代码时服务器宕机,或者被我们kill掉,就导致连finally都失效,导致死锁。

针对中途宕机,解决方法是expire设置超时时间,即使中途宕机,也不会无限死锁下去。也就是setnx+expire+delete构成分布式锁的获取和释放,但是还有问题,setnx和expire不具有原子性,也就是宕机可能出现在setnx和expire之间,导致没有锁无法失效,永久死锁。

针对setnx和expire无原子性,解决方法是赋予原子性,可以用事务或者lua脚本,但是redis官方给我们其实已经提供好了一个api方便我们使用,那就是stringRedisTemplate.opsForValue().setIfAbsent(key,value,10,TimeUnit,SECONDS)他将setnx和expire封装在了一起,具有原子性。但还有问题,我们的expire设置的过期时间应该是多长呢?就算测试时没问题,但如果高并发下,服务器资源网络带宽等被占用后,导致业务代码的执行时间变长,还是会导致锁失效。新的线程拿到了前一个执行很慢的线程的锁,而这个执行得很慢的线程也终会执行完,他释放锁时,其实他的锁早就失效了,会释放后来线程的锁,这就是锁失效,混乱释放,效果是锁跟没有一样。该线程和后面的线程后混乱了。

为了解决锁失效混乱释放,解决方法就是给锁的value不再那么随意,而是赋予意义,将线程客户端的uuid,这样在delete之前先get拿到uuid,和自己的uuid进行equals对比,也就是区分别人的锁和自己的锁,但加入这个get命令和equals后,有需要按之前的分析那样,需要将这三个操作原子化,此时没有redis官方的方便api了,需要我们手动用lua脚本进行原子化。但这种方案并没有解决业务代码的执行时间和锁失效时间的匹配。而是解决了锁失效后的后果,所以我们需要像一个办法来解决锁失效时间的设置。

为了解决锁失效时间的设置,解决方法并不是硬要设置得和服务时间差不多,因为服务时间是无法确定的,所以另辟蹊径,用一个线程来给锁续命。当我们拿到一个锁的时候,开启一个线程,用timer定时任务,当超时时间过去三分之一的时候,检测一下锁是否还存在,若存在,则给他重置锁的过期时间,若不存在,则说明业务已经执行完毕,定时任务线程直接结束。

但这样,我们加了太多的逻辑,属性获取锁,虽然有官方的api,但是为了续命,我们要手动启动一个定时任务线程,再就是释放锁的时候,需要get,equals,delete的lua脚本。没用到一个锁,都要用到上面的流程,太麻烦了。

为了解决代码太重复,解决方法就是使用redisson。他和jedis一样,都是redis的客户端,虽然他支持的命令不如jedis全,但是适合分布式场景下使用。他提供的RLock其实就是上面所说的,set和expire原子封装,启动线程续命,delete和get和equals原子封装,只不过封装好了方便使用,不用写那么多的重复代码。使用时先导入maven依赖redisson,需要注册@Bean,在Bean中进行配置,最后就可以使用了,类就是RLock,api就是lock()和unLock()。底层就是大量使用lua脚本。

接下来说一下redis所支持的lua脚本,和mset相似,lua脚本可以将多个命令一次性发送给redis,减少网络开销,更可以替代redis的事务。除此之外,某些场景下我们可以把java代码翻译成lua脚本来执行,就比如超卖问题,我们可以把有并发问题的获取库存,比较库存和客户需要的件数,返回boolean说明能否成功减库存这一段逻辑放入lua脚本,交给redis来执行判断,他们的特征是数据来源是redis,且业务逻辑简单,可以避免并发问题,缺点是不要大量使用,而是只在并发问题的时候使用,因为脚本的执行时间远比原生命令长,为了执行脚本,redis会阻塞较长的时间。

虽然redis确实能够实现分布式锁,但吹毛求疵得说,没有zookeeper实现的好,原因就在于,redis集群时的数据不是强一致性的,当key在mater处set的时候,master挂了,数据没有同步到slaver,所以get不到锁。而zookeeper的数据是强一致性的,更加可靠,但性能没有redis高。其实为了强一致性,redisson做了努力,和RLock相似的,redisson还提供了RedLock,他的原理和zookeeper一样,他的优化的点就在与set时,不止是往一个master发送数据,而是往所有的master发命令让他们进行set,只有半数以上的master响应,才能算是成功获取锁,显然效率不高,网络异常时,还要进行回滚,总之就是先分再聚,和redis的高性能相矛盾。

最后说一下分布式锁的优化,实现高并发分布式锁。之前的锁解决了安全性,但因为redis的特性,并没有解决并发性。再回顾一下,分布式锁的作用在同一个服务的不同机器之间,代替synchronized,多个客户在抢购的时候,虽然都是同一个代码块,只不过位置不同,但是他们的商品id是相同的,redis为了提供多把锁,他的key肯定是服务名加商品id构成的,而分布式锁具有redis的solt槽位机制,所以同一个物品的抢购的服务名相同,商品id也相同,所带来的setnx由不同的机器发到同一个master,导致这个master要处理很多的请求,并发qps也就仅仅是一台机器的qps。优化的思路就是,把setnx分发到不同的master中,方案就是分段锁和库存分key存储,其中的库存分库存储好说,setnx时的key变成了服务名加商品id加库编号,所以达到了将请求分发给多个master的效果,至于分段式,参考ConcurrentHashMap,我还没总结。

redis不光是能做分布式锁,而且多个系统要按顺序修改redis时也是有问题的。做法是从mysql只能中查并缓存时,带上时间戳,每次写之前,先判断一下当前这个value的时间戳是否够新,是则可以写,否则就不能用旧的数据覆盖。