分布式锁
什么是锁
- 在单线程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量
- 而同步的本质是通过锁实现的。为了实现多个线程在一个时刻同一代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。
- 不同地方实现锁的方式也不一样,只要能满足所有线程都能看到标记即可。如java中synchroniuze是 在对象头设置标记,Lock接口的实现类基本上都只是某一个volitile修饰的int型变量其保证每个线程都能拥有对该int的可见性和原子性修改,linux内核中也是利用互斥量或信号量等内存数据标记
- 除了利用内存数据做锁其他任何互斥的都能做锁(只考虑互斥情况),如流水表中流水号与实践结合做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等。只需要满足在对标记进行修改能保证原子性和内存可见性即可。
什么是分布式
分布式的CAP理论告诉我们:
任何一个分布式系统都无法同时满足一致性(Consistency)、 可用性(Avaiability)和分区容错性(Partition tolerance),最多只能同时满足两项。
目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。基于CAP理论,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性
分布式场景
在许多的场景中,我们为了保证数据的最终一致性,需要很多技术方案来支持,比如分布式事务,分布式锁等。很多时候我们需要保证一个方法在同一时间只能被同一线程执行。在单机环境中,通过java提供的并发API我们可以解决,但是在分布式环境下,就没有那么简单。
- 分布式与单机情况下最大的不同在于其不是多线程而是多进程
- 多线程由于共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一物理机上,因此需要将标记存储在一个所有进程都能看到的地方
什么是分布式锁
- 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数
- 与单机模型下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。
- 分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如 Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行
我们需要怎样的分布式锁
- 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
- 这把锁要是一把可重入锁(避免死锁)
- 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
- 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
- 有高可用的获取锁和释放锁功能
- 获取锁和释放锁的性能要好
基于表主键唯一做分布式锁
利用主键的特性,如果多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以任务操作成功的那个县城获得该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可
上面这种简单的实现有以下几个问题:
- 这把锁依赖数据库的可用性,数据库是一个单点,一旦数据库挂了,会导致业务系统不可用
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获取锁
- 这把锁只能是非阻塞的,因为 数据insert操作,一旦插入失败就会直接报错。没有获得锁的线程比不会进入排队队列,想要再次获得锁就要再次触发获得锁操作。
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
- 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
- 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。
我们也可以有其他方式解决上面的问题
- 数据库是单点? 搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上
- 没有失效时间? 只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍
- 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。
- 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
- 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
- 比较好的办法是在程序中生产主键进行防重。
基于Redis做分布式锁
基于redis 的 setnx()、expire()方法做分布式锁
setnx()
setnx 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。
expire()
expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。
使用步骤
1、setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
2、expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
3、执行完业务代码后,可以通过 delete 命令删除 key。
这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。比如,如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用 redis 的 setnx()、get() 和 getset() 方法来实现分布式锁。
基于 redis 的 setnx()、get()、getset()方法做分布式锁
这个方案的背景主要是在 setnx() 和 expire() 的方案上针对可能存在的死锁问题,做了一些优化。
getset()
这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。假设 key 原来是不存在的,那么多次执行这个命令,会出现下边的效果:
- getset(key, “value1”) 返回 null 此时 key 的值会被设置为 value1
- getset(key, “value2”) 返回 value1 此时 key 的值会被设置为 value2
- 依次类推!
使用步骤
- setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。
- get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。
- 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。
- 判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
- 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。
基于 ZooKeeper 做分布式锁
zookeeper 锁相关基础知识
- zk一般由多个节点构成(单数),采用zab一致性协议。因此可以将zk看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改后才提供查询服务
- zk的数据以目录树的形式,每个目录称为znode,znode中可存储数据(一般不超过1M),还可以在其中增加子节点
- 子节点有三种类型。序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。一旦创建这个 znode 的客户端与服务器失去联系,这个 znode 也将自动删除。最后就是普通节点。
- Watch 机制,client 可以监控每个节点的变化,当产生变化会给 client 产生一个事件。
zk基本锁
- 原理:利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。
- 缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。
zk 锁优化
- 原理:上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则 watch 序号比本身小的前一个节点 (公平锁)。
- 步骤:
- 在 /lock 节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)。
- 判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点。
- 当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小。
- 取锁成功则执行代码,最后释放锁(删除该节点)。