分布式锁
单机应用架构中,秒杀案例使用ReentrantLcok或者synchronized来达到秒杀商品互斥的目的。然而在分布式系统中,会存在多台机器并行去实现同一个功能。也就是说,在多进程中,如果还使用以上JDK提供的进程锁,来并发访问数据库资源就可能会出现商品超卖的情况。因此,需要我们来实现自己的分布式锁。
实现一个分布式锁应该具备的特性:
高可用、高性能的获取锁与释放锁
在分布式系统环境下,一个方法或者变量同一时间只能被一个线程操作
具备锁失效机制,网络中断或宕机无法释放锁时,锁必须被删除,防止死锁
具备阻塞锁特性,即没有获取到锁,则继续等待获取锁
具备非阻塞锁特性,即没有获取到锁,则直接返回获取锁失败
具备可重入特性,一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁
在之前的秒杀案例中,我们曾介绍过关于分布式锁几种实现方式:
基于数据库实现分布式锁
基于 Redis 实现分布式锁
基于 Zookeeper 实现分布式锁
前两种对于分布式生产环境来说并不是特别推荐,高并发下数据库锁性能太差,Redis在锁时间限制和缓存一致性存在一定问题。这里我们重点介绍一下 Zookeeper 如何实现分布式锁。
用分布式锁建模问题是最简单的方式,理解简单,实现起来更简单。对于更复杂的情况,那就只能去找「分布式事务」,或者更完善的解决分布式一致性的基础设施(比如zookeeper,比如etcd)解决问题了。
具体实现往往是借助一些强一致性的数据库设施,比如mysql。游戏圈的服务端比较喜欢这种方案,毕竟游戏服务端也并不需要提供什么可用性保证,节点挂掉往往就没办法fail over了。
服务A和服务B(下面称为Proposer)处理client的请求a和请求b的时候都依赖资源R。
Proposer接到请求后,都需要先向某个中间人(下面称为Acceptor)请求资源R的互斥访问权Mr,请求成功,才能进行后续处理,否则阻塞。
Proposer处理完请求,修改状态,最后释放Mr。
对于Proposer来说,就是三个API:
Prepare,用来获取互斥访问权,参数是资源名称,返回值表示成功或失败。
Accept,用来修改状态。
Release,用来释放互斥访问权。
如何实现可抢占的互斥访问权?
跟单机环境的乐观锁实现类似,一种方案是基于时间戳的,一种方案是基于版本号的。
由于我们已经将问题简化为单实例维护共享状态,那两种方案其实没太大区别,无非就是看需求是「先到先得」,还是「后到先得」。
我们先来看一种基于时间戳的,后到先得的方案。
Proposer向Acceptor申请访问权时需要指定epoch(可以理解为一种时间戳),获取到访问权之后,才能修改状态。
Acceptor一旦收到更大的新epoch的申请,马上让旧的访问权失效,给新的epoch访问权。
API修改如下:
Prepare,参数需要额外指定一个epoch。Acceptor处理时不再考虑小于等于记录的prepared_epoch的Proposer。
Accept,参数需要额外指定Proposer的经过Prepare API check的epoch。只有跟记录的prepared_epoch一致时,才会修改状态。
Release不再需要。
出现这个需求情景的两个关键词:非幂等的无状态服务、共享状态。
再详细描述一下:
请求的流程是client发到无状态服务,无状态服务从redis取出client的数据,修改(比如递增),存回。
client连续发了两次有效请求,节点A处理中还没存回,节点B存回了,然后节点A处理完存回,这时相当于节点B的修改失效。
Prepare阶段和Accept阶段都需要Acceptor做一些状态的维护,redis在这方面就特别好用。我们可以借助redis对lua的支持,直接hook住服务对redis的数据读写,比如需要进行互斥访问的时候,读取会额外拿到一个redis分配的lockId,更新数据时会检查lockId。
贴一个简单的同时更新多key的lua脚本例子:
local begin =1
locali=1
whileKEYS[i]do local key = KEYS[i]local lockId = ARGV[i]ifnot lockId then
return-1elselocal savedLockId =redis.call('HGET', key,'_lockId')
ifsavedLockId ~= lockId then -- todo error
return-2endendi=i+1
end
local keyPos =1
whileARGV[i]do local fieldsNum = ARGV[i]i=i+1forj=1,fieldsNum do local fieldName = ARGV[i]local fieldValue = ARGV[i+1]redis.call('HSET', KEYS[keyPos], fieldName, fieldValue)
i=i+2endkeyPos = keyPos +1
end
return0
KEYS表示需要更新的一组key,ARGV表示每个key对应的lockId以及key的field和value。
由单一redis实例维护访问权状态,能应付大多数的业务情景,但是正如之前所说,这只是一个高度简化的问题形式。
如果这唯一的redis实例挂掉,锁状态的正确性会在一段短暂时间内无法保证;即使开了主备,由于redis的replica是异步的,也不能做到绝对的正确性保证。
这类问题其实就是之前的Acceptor-Proposer问题复杂化一下,多Acceptor维护互斥访问权状态。redis官方的解决方案是redlock。多redis实例维护锁状态,client利用时间和随机量请求多数锁。
其实现就跟redis的其他一些机制(比如广为吐槽的key expire)一样,充满了工程中的trick。因此后来kleppmann对此也有批判,相关的文章以及翻译随手搜一下就有,简单来说,其依据就是分布式环境中相比于节点宕机的更小概率事件:集群形成网络分化(network partition),以及不同机器的物理时钟有可能不同步。
不过,用多redis实例维护锁状态来解决数据不一致问题确实也不太合适。如果是需要100%正确性,那就不能用redis这种简单、高性能、但又充满了工程trick的实现,与其自己重新实现一次paxos或者raft,不如简单地借助zookeeper或者etcd来做协调。
通过分析第三方开源工具实现的分布式锁方式,收获还是满满的。学习本身就是一个由浅入深的过程,从如何调用API,到理解其代码逻辑实现。想要更深入可以去挖掘Zookeeper的核心算法ZAB协议。
最后为了方便大家学习,小结了学习过程中遇到的几个关键词:重入锁、自旋锁、有序节点、阻塞、非阻塞、监听,希望对大家有所帮助。