一 锁和分布式锁
1.1 锁
我们一般所说的锁,就是指单进程多线程的锁机制。在单进程中,如果有多个线程并发访问某个某个全局资源,存在并发修改的问题。如果要避免这个问题,我们需要对资源进行同步,同步其实就是可以加一个锁来保证同一时刻只有一个线程能操作这个资源。
在不同的场景,可能使用的锁得方式不一样,有的用的是synchronized关键字实现,有的使用可重入锁ReentrantLock,有的是基于volatile来实现。但追究根本,都是要满足所有的线程都能知道这个锁的存在,否则这个锁就没有任何意义。而且这个时候的锁都是在内存中的。
1.2 分布式锁
涉及到分布式环境,以集群为例,就是多个实例,也就是多个进程,而且这些进程完全可能不在同一个机器上。我们知道多线程可以共享父进程的资源,包括内存。所以多线程可以看见锁,但是多进程之间无法共享资源,甚至都不在一台机器上,所以这时候分布式环境下,就需要其他的方式来让所有进程都可以知道这个锁,来控制对全局资源的并发修改。
为了解决分布式的问题,我们可以把这个锁放入所有进程都可以访问的地方,比如数据库,redis,memcached或者是zookeeper。这些也是目前实现分布式锁的主要实现方式。
二 分布式锁设计要点
2.1 死锁的问题
如果当加锁成功,需要执行的代码还没有执行完毕,获取锁的节点忽然down机,那么这时候无法释放锁,会造成其他节点上的线程也无法获取锁。
2.2 性能问题
设计的锁不能成为性能瓶颈或者
2.3 单点问题
涉及到锁不能有单点问题,如果有单点问题,那么都获取不到锁,导致业务异常。
2.4 考虑锁的可重入性
如有必要的业务场景,也需要考虑锁的可重入性,什么是可重入锁?
即同一个线程再次进入同步代码块的时候,可以使用自己已经获取的锁。如果获取不到,这里就是死锁了。
2.5 释放锁之后,通知其他等待锁的节点
如果不通知,处于等待状态的锁,或许只能根据一个预定的时间再次申请锁
三 基于数据库表实现分布式锁
我们可以将我们分布式要操作的资源都定义成表,表结构定义t_lock如下:
id:
resource: 资源
status: 状态 0|1
add_time: 添加时间
update_time: 更新时间
version: 如果采用乐观锁,使用版本号,对当前资源的状态进行更新就加1
大致流程就是:
select id, resource, status,version fromt_lock where status=0 and id=xxxx;
如果查到了说明没有数据,可以进行update
update t_lock set status=1, version=1,update_time=now() where id=xxxx and status=0 and version=0
否则,说明该锁被其他线程持有,还没有释放
缺点:
更新之前会多一次查询,增加了数据库的操作
数据库链接资源宝贵,如果并发量太大,数据库的性能有影响
如果单个数据库存在单点问题,所以最好是高可用的。
四 基于Redis实现分布式锁
4.1 基于Redis实现的分布式的基本实现
通过Redis的setnx key命令,如果不存在某个key就设置值,设置成功表示获取锁。
缺点:如果设置成功后,还没有释放锁,对应的业务节点就挂掉了,那么这时候锁就没有释放。其他业务节点也无法获取这个锁。
4.2 基于Redis实现的分布式的优化实现
在使用setnx设置命令成功后,则使用expire命令设置到期时间,就算业务节点还没有释放锁就挂掉了,但是我们还是可以保证这个锁到期就会释放。
缺点:
# setnx 和 expire不是原子操作,即设置了setnx还没有来得及设置到期时间,业务节点就挂了。
# 而且key到期了,业务节点业务还没有执行完,怎么办?
4.3 基于Redis实现的分布式的再优化实现
4.3.1 使用set命令 我们知道set命令格式如下:
set key value [EX seconds] [PX milliseconds][NX|XX]
即首先可以根据这个key不存在,则设置值,即使用NX。然后可以设置到期时间,EX表示秒数,PX表示毫秒数,这个操作就是原子性的,解决了上述问题。
缺点:锁到期了,业务节点业务还没有执行完,怎么办?
4.3.2 基于Redis实现的分布式的Lua实现
通过Lua脚本,将setnx和expire变成一个原子操作。
五 基于Zookeeper实现分布式锁
排它锁:指的是一个线程独占一把锁,又称写锁或者独占锁。在zookeeper是通过数据节点来表示一个锁,比如/exclusive_lock/lock节点就表示一个锁
获取锁:
在需要获取排它锁的时候,所有客户端都试图通过调用create接口在对应的节点下(假设节点/exclusive_lock)创建临时子节点,假设是(/exclusive_lock/lock)。Zookeeper会保证在所有的客户端中只有一个客户端能够创建成功,那么就可以认为该客户端获取了锁。
同时没有获取锁的客户端需要在/exclusive_lock节点上注册一个监听子节点变化的Watcher,以便实时监听到lock节点的变化。
释放锁:
# 当前获取锁的客户端发生宕机,那么zookeeper这个临时节点就会被移除
# 正常业务逻辑执行完毕,客户端就会主动将自己创建的节点删除
缺点:所有获取锁失败的客户端都会在父节点上创建对子节点的监听,当持有锁的客户端释放锁之后,所有等待的进程一起来创建节点,并发量很大。
优化方案:上锁的时候改为创建临时的有序节点,即EPHEMERAL_SEQUENTIAL,判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后watch序号比本身小的前一个节点。