1 秒杀场景下的数据一致性问题
某商品库存10,A想买6,B想买5。
1.1 做梦
A先买走6,库存剩4,此时B应该无法购买5,给出数量不足提示
1.2 现实
AB获取到商品都剩10,A买走6,在A更新库存前,B又买走5,此时B更新库存,商品还剩5。
1.3 想当然地解决方案
给共享资源或对共享资源的操作加锁,来保证对资源的访问互斥。利用ReentrantLcok
或者synchronized
即可。
但是在分布式系统中,由于分布式系统的分布性,这两种锁将失去原有锁的效果。
必须使用分布式锁。
2 分布式锁的要求
- 获取/释放锁的性能好
- 获得锁必须是原子性的
- 网络抖动或者宕机等原因导致无法释放锁时,锁必须被清除,不然会发生死锁
- 可重入
- 阻塞锁和非阻塞锁,阻塞锁即没有获取到锁,则继续等待获取锁;非阻塞锁即没有获取到锁后,不继续等待,直接返回锁失败。
3 分布式锁实现方式
一、数据库锁
- 基于MySQL锁表
完全依靠数据库唯一索引来实现,当想要获得锁时,即向数据库中插入一条记录,释放锁时就删除这条记录 - 这种方式存在以下问题:
锁没有失效时间,解锁失败会导致死锁,其他线程无法再获取到锁,因为唯一索引insert都会返回失败
只能是非阻塞锁,insert失败直接就报错了,无法进入队列进行重试
不可重入,同一线程在没有释放锁之前无法再获取到锁
采用乐观锁
增加版本号,根据版本号来判断更新之前有没有其他线程更新过,如果被更新过,则获取锁失败
二、缓存锁
这里主要是几种基于redis的
基于setnx、expire
基于setnx(set if not exist)的特点,当缓存里key不存在时,才会去set,否则直接返回false
如果返回true则获取到锁,否则获取锁失败,为了防止死锁,我们再用expire命令对这个key设置一个超时时间来避免。
但是这里看似完美,实则有缺陷,当我们setnx成功后,线程发生异常中断,expire还没来的及设置,那么就会产生死锁。
解决上述问题有两种方案
采用redis2.6.12版本以后的set,它提供了一系列选项
EX seconds – 设置键key的过期时间,单位时秒
PX milliseconds – 设置键key的过期时间,单位时毫秒
NX – 只有键key不存在的时候才会设置key的值
XX – 只有键key存在的时候才会设置key的值
第二种采用setnx(),get(),getset()
(1) 线程Asetnx,值为超时的时间戳(t1),如果返回true,获得锁。
(2) 线程B用get 命令获取t1,与当前时间戳比较,判断是否超时,没超时false,如果已超时执行步骤3
(3) 计算新的超时时间t2,使用getset命令返回t3(这个值可能其他线程已经修改过),如果t1==t3,获得锁,如果t1!=t3说明锁被其他线程获取了
(4) 获取锁后,处理完业务逻辑,再去判断锁是否超时,如果没超时删除锁,如果已超时,不用处理(防止删除其他线程的锁)
RedLock算法
redlock算法是redis作者推荐的一种分布式锁实现方式
(1) 获取当前时间;
(2) 尝试从5个相互独立redis客户端获取锁
(3) 计算获取所有锁消耗的时间,当且仅当客户端从多数节点获取锁,并且获取锁的时间小于锁的有效时间,认为获得锁
(4) 重新计算有效期时间,原有效时间减去获取锁消耗的时间
(5) 删除所有实例的锁
redlock算法相对于单节点redis锁可靠性要更高,但是实现起来条件也较为苛刻
(1) 必须部署5个节点才能让Redlock的可靠性更强
(2) 需要请求5个节点才能获取到锁,通过Future的方式,先并发向5个节点请求,再一起获得响应结果,能缩短响应时间,不过还是比单节点redis锁要耗费更多时间
然后由于必须获取到5个节点中的3个以上,所以可能出现获取锁冲突,即大家都获得了1-2把锁,结果谁也不能获取到锁,这个问题,redis作者借鉴了raft算法的精髓,通过冲突后在随机时间开始,可以大大降低冲突时间,但是这问题并不能很好的避免,特别是在第一次获取锁的时候,所以获取锁的时间成本增加了
如果5个节点有2个宕机,此时锁的可用性会极大降低,首先必须等待这两个宕机节点的结果超时才能返回,另外只有3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,难度也加大了
如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况
介于这种情况,下面我们来看一种更可靠的分布式锁zookeeper锁