前言
在我们单体时代,当我们需要保证程序串行执行时,我们常用的是jvm中提供的锁,也就是synchronized与lock,它们能保证我们并发编程中的可见性,有序性,原子性,也就是能保证线程安全。但随着分布式时代来临,JVM锁只能锁住当前机器,如果出现一个服务部署在两个节点,此时我们就需要使用分布式锁来保证分布式系统的线程安全。
那一个分布式锁应该满足哪些特性呢?个人理解就是“能够锁多个节点的jvm锁” ,下面是我总结的一些分布式锁的特性
- 互斥,即保证不同节点不同线程的互斥访问。
- 高可用 锁服务器应该支持集群部署
- 高性能 加锁,释放锁都应该性能很高
- 超时机制,即超时设置,防止死锁。比如能够支持设置超时时间,防止其他节点一直无法获取到锁;同时需要防止获取锁的任务执行超时导致任务还没结束,锁超时自动释放而无法保证互斥性。
- 可重入性,同一节点同一条线程如果获取到锁可以再次获取锁。
- 提供阻塞和非阻塞接口,例如lock和tryLock(可选项)
- 像使用jvm锁一样使用分布式锁(可选项)
其中互斥,高可用为最基础的能力
常见方案
目前做分布式锁有三种成熟的方案,分别是数据库,redis,zookeeper。高可用(集群部署,三者皆可)与互斥功能(mysql的唯一键,redis的setnx指令,zookeeper节点的唯一性)三者都是满足的。至于其他需求可以通过自身提供的功能/我们的设计去完成。比如可重入,超时机制等特性。这样就不难理解为什么这三者可以做分布式锁,至于其他组件,大家可以自行按照上述特性去研究是否可行。 接下来将分别描述这三种方案使用的场景,各自不同方案的优缺点,使用场景。
数据库
数据库做分布式锁有两种方式。一是基于唯一键。二是基于排它锁。二者流程都是一样的。
- 获取锁时,判断insert语句是否插入成功,其中锁的key作为唯一键。
- 如果成功则获取锁,进行业务操作。
- 业务完成后,调用delete语句删除锁记录
- 如果没成功则返回/或自旋等待。
- 优点:简单,技术门槛低
基于唯一键
我们以常见的mysql为例子来详细说明。 单纯的使用唯一键来作为分布式锁(就只是加一个unique Index ),其实只满足我们互斥与高可用(如果集群部署),对于生产环境来说是远远不够的,但我们可以做一些扩展来使其满足其他特性
| 要点 | 解决方案 |
|---|---|
| 高性能 | 对应字段加索引 |
| 超时机制 | 创建时记录时间戳,设置阈值。定时任务清理数据 |
| 可重入性 | DB中冗余IP与thread信息 |
| 阻塞接口 | retry/while自旋 |
可以看到如果我们如果按照上述做法去改进后,唯一键是可以作为分布式锁在生产环境去使用。但我们似乎忽视了一个重要问题--成本。无论是人力成本与机器成本。人力成本包括设计与实现整个一套代码。机器成本包括整个db的集群费用。如果并发比较大的情况,db集群肯定要单独部署,成本过高
基于排它锁
也就是我们mysql中给我们提供的原语(for update),他能解决上述超时间,无法阻塞问题。但它却存在一个较大问题,长时间持有锁的线程占用连接,如果种类较多,连接池将被占满
DB使用场景
如果简单使用,缺点较多。如果完备使用,成本较大。权衡下来,个人认为数据库作为分布式锁还是着重于前者适合的场景。即“不需要太多成本(人力,机器),只要这个场景有分布式锁即可,不会有太大并发,即时偶然出错也无关痛痒”。我能想到的场景有1.分布式环境下的定时任务加锁(当然推荐xxl-job)2.一些流程日志。但总的来说,目前绝大部分系统的性能瓶颈都卡在数据库操作上,如果再往上加一层锁,可能性能会更差
redis
redis作为分布式锁是目前较为流行的一种方式,一是性能高,二是成本较与数据库低很多,不用单独的集群。(毕竟号称单台机器10万qps)。
setnx
setnx(set if not exists) 这是redis提供了一个互斥指令,只允许被一个客户端占用。它跟mysql唯一键一样,存在许多漏洞,但我们可以改善它。我们使用流程类似于如下伪代码
1. setnx mylock true
//假设返回Ok
2. do something
3. delete mylock
但如果我们业务流程出现异常,可能导致delete 指令没有被调用,这样就会出现死锁。这个解决办法很简单,以我们java代码为例,将delete语句写在finally中就可以。如果没有java中finally机制的代码,我们可以通过设置超时时间保证异常后锁自动释放。代码如下
1. setnx mylock value true
//假设返回Ok
2. expire mylock 5S
3. do something
4. delete mylock
其实以上代码还是有问题,如果步骤1.2之间由于某种原因服务器挂掉,此时还是会有死锁问题。根本原因是步骤1.2不是一个原子指令。为了解决这个问题,redis在2.8版本官方推出了” set mylock ex 5 nx “指令,使1.2步骤变成了原子指令。
- 超时机制
我们通过set mylock value ex 5 nx 设置超时时间,防止死锁,但仍然存在如果在加锁和释放锁之间的逻辑执行的太长,以至 于超出了锁的超时限制,因为这时候锁过期了,第二个线程重新持有了这把锁, 但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻 辑执行完之间拿到了锁。上段话描述了两个问题,第一是超时时间问题,二是锁被误删问题。
- 超时时间问题
- 一是预估业务的执行时间,将超时时间设置比预估时间大一些。但这个方案存在两个问题,一是业务执行时间如果存在rpc调用,不好预估。二是就算设置较大时间,理论上问题还是存在
- 第二种解决方案就是后端设置一个定时任务,对于将要过期的任务,自动续锁。
- 锁被误删问题
- 锁被误删的根本原因就是set指令的key相同,value也相同,无法区别是哪个线程。而我们key一般都为业务场景,比如createUser。那解决办法就是将value跟线程绑定,比如设置为threadId。删除时先匹配value是否一致,然后再删除key。但依然存在匹配value与删除key不是原子性问题,此时可以通过Lua脚本将二者变为原子操作。
- 超时时间问题
- 阻塞接口
- 可以使用redis自带的pub/sub模块使未抢到锁的线程进行等待。也可以使用轮询的方式等待
- 可重入性 可重入实现的关键就是每次加锁时,能识别到是哪个线程,java中可以通过Thread.currentThread().getId()获取线程id,使用redis的hash数据结构,key为线程id,value从1开始叠加。
redission(推荐)
redission是目前较为成熟的第三方组件,它其实就是将上述使用setnx所碰到的问题(还有一些没考虑到的)给解决了。例如超时问题,redission中就使用一个watchdog来续期,使用lua脚本保证几个命令的原子性,使用哈希结构(Key为线程id,value为重入次数)来保证互斥与可重入性,等等具体原理自行百度一下。并且提供了像lock一样的api,官方地址:github.com/redisson/re…
redlock
这是redis作者提出的一种分布式锁的实现方式。原文如下: redis.io/topics/dist… 它解决了上述两种方式当master宕机,一台slave升级成master,主从之前数据还未同步时,请求进来,此时就有2个client持有锁的问题。整体思路跟zk一样,大多数思想。大概流程如下:
- 部署N个实例(N>=5)
- 客户端尝试从N台机器发送set(key, value, nx=True, ex=xxx) 指令,只要过半节点 set 成功,那就认为加锁成功。每台机器尝试获取锁的时间限制为一个很短的时间,比如500ms。此时加锁时间=锁的时间-所有连接时间
- 如果加锁失败,则在所有Redis节点上执行释放锁操作
缺点
- 客户端延迟大,两个客户端拿到一把锁
- 客户端a持有锁,此时发生fullGc,导致锁失效,此时另外一个客户端获取锁成功
- 时钟飘移
- A,B,C,D,E5台集群,客户端1尝试获取锁,ABC获取到锁,但B的系统时间快2s,就会导致提前释放锁。此时客户端2尝试获取锁,B,d,E又表示可以获取到锁。此时就出现了一个集群出现两把锁
- 成本高
- 真正在生产中,如果本身系统redis并不需要集群,单单为了一个分布式锁搭建集群,成本过高
关于redlock的缺点,martin.kleppmann.com/2016/02/08/… 这里有一个大神对它的质疑。有兴趣的同学可以看看
zookeeper
原理就是基于ZK中临时节点的唯一性与watch机制。对不了解zk的同学简单这里解释一下。
- 临时节点
- zk整个数据模型是一棵树。以/进行分割路径。每一个节点就是Znode。每个znode都会存储数据。一个znode内部简单来说就是(path,data[],mode)这3个字段。其中path就是唯一的路径例如/user/lock,data就是里面的值,mode是节点类型。包括:持久,顺序持久,临时,顺序临时4种类型。其中临时节点生命周期与客户端session保持一致
- watch机制
- ZK允许用户在指定节点注册watcher,并且在特定事件触发后通知注册过的客户端(本质就是观察者模式)
大概流程
- 获取锁流程
- 用业务来作为znode的path。比如/user/create。客户端通过调用create()接口创建znode。如果创建失败,注册该节点的watcher,进入等待。
- 释放锁流程
- 业务流程执行完成后,调用删除方法
- 客户端宕机,zk自动删除。
优点:
- 高可用(集群部署),超时机制(临时节点),可重入性(znode中data用线程id)
缺点:
- 性能可能没有redis高 只是从理论方面去推论,毕竟每次创建,删除节点都要动态创建,删除节点。而且zk使用zab强一致性协议,每次都是通过leader去协调Follower去处理数据,频繁地网络通信。(有真正压测过的同学可以告知一下)
- 技术门槛高 相较于redis,db。zk的技术门槛明显要高一些,并且没有像redis有redission这种成熟的三方组件,如果出现问题,解决可能比较麻烦。 场景: 对锁可靠性要求极高的场景
总结
这三种方式没有绝对的好坏,具体的选择都是根据业务场景(并发量是否很高,能否容忍分布式锁出现Bug),项目同学技术能力,项目成本等因素考虑后进行选择。并且以我过往的项目经验来说。最好在使用分布式锁后,针对你选择的方案所带有的缺点,有一个兜底的方案。例如曾经有个项目为了防止在高并发情况下更新丢失,使用了redission作为分布式锁,但针对redission可能存在一个集群两把相同锁(虽然概率非常低,但业务人员强烈要求不要出现更新丢失的情况),所以后续db操作又加了一层乐观锁来兜底。
上述仅个人理解,如果有说的不对的地方欢迎讨论~