背景
一般我们在开发中经常使用Java的synchronized 和 ReentrantLock 来对多线程环境中控制对资源的并发访问,随着分布式的发展,本地加锁已经不能满足我们复杂场景的需要,于是有了分布式锁。
我们在日常的系统开发中经常会碰到一些比较棘手的场景问题。比如:
-
数据的正确性:使用分布式锁可以避免破坏数据的正确性,如果两个节点在同一条数据上操作,又比如多个节点对同一次定时任务进行处理,都有可能导致数据的不正确性。 -
功能的效率:使用分布式锁还可以避免不同节点重复相同的工作,比如用户付款后的短信处理,通过分布式锁避免发出多条短信的情况。
本文从基础理论知识开始介绍,再到分布式锁的实现。
什么是锁
在单进程系统中,当存在多线程可以同时改变某个变量时,就需要对变量或者代码块做同步,使其在修改这种变量时能够线性执行消除并发。
而同步的本质是通过锁来实现的。为了实现多线程在同一时刻同一个代码块只能有一个线程可执行,就需要做个标记,这个标记每个线程都能看到,标记可以理解为锁。
Java 中 synchronized 是在对象头设置标记, Lock 接口实现类基本上只是对一个volitile 修饰的int 变量 的可见性和原子修改。

什么是分布式
分布式CAP理论告诉我们:
任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。
目前大多数大型网站和应用都是分布式的,分布式场景中的数据一致性一直是一个比较重要的话题。在互联网领域绝大数的场景里,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。
分布式场景
我们为了保证数据的最终一致性,需要很多的技术方式来支撑,比如分布式锁、分布式事务。
-
分布式与单机的情况最大的不同在于其不是多线程而是多进程。 -
多线程可以共享堆内存,通过内存标记存储位置。进程之间需要将标记存储在一个所有进程都能看到的地方。
分布式锁
我们确定了在分布式场景的数据一致性,需要分布式锁来支撑,分布式有哪些特点呢:
-
互斥性:互斥是锁的基本特征,同一时刻只能被一个线程持有。 -
可重入性:一个线程在持有锁的情况可以再次获取这个锁。 -
锁超时:通过超时释放,可以避免死锁,防止不必要的线程等待和资的浪费。 -
阻塞性:未获取到锁可以阻塞等待获取锁,也可以在没有获取到锁直接快速返回获取锁失败。 -
高性能和高可用:加锁和释放锁过程性能开销尽可能低,同时也要保证高可用防止分布式锁意外失效。

分布式锁实现
基于数据库与Zookeeper都能实现分布式锁,但不满足本文的高性能,本文着重针对Redis锁的三种实现方案进行对比。
1.Redis 单节点
Redis单节点的分布式锁主要利用Redis 的 setnx命令。
-
加锁:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回识别。Key是锁的唯一表示,一般按业务进行区分命名。 -
解锁:DEL key,通过删除键值对释放锁。 -
锁超时:EXPIRE key timeout,设置key超时时间,以保证锁即使没有被显示释放,也能在一定时间后自动释放,避免资源永久被占用。
1.1 SETNX和EXPIRE 原子性
这里有个问题,就是加锁和设置过期时间不是原子操作,有两个方法:
-
在Redis 2.8之前我们需要使用Lua脚本来实现。 -
在Redis 2.8之后,Redis支持 nx 和 ex 操作是同一原子操作。
set key value ex 5 nx
1.2 锁误解除
如果线程A成功获取了锁,并设置过期时间5秒,但线程A执行时间超过5秒,锁过期自动释放,若此时B线程获取到了锁,随后A执行完成后,线程A使用DEL命令来释放锁。
可以通过value来标识当前线程的锁,在删除之前验证key对应的value释放当前线程持有的。通过lua脚本来验证和解锁操作。
SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
then return redis.call('del', KEYS[1])
else return 0
end
1.3 阻塞性
目前Reidis 单节点分布式的实现方式,都是立即返回结果的,暂不支持线程阻塞等待解锁。
2.RedLock
单节点分布式锁最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况。
正因为如此,Redis官方提出一种权威的基于Redis实现分布式锁的方式,名词就叫做RedLock。
这种方式比原先的单节点的方法更加安全,它可以确保以下特性:
-
互斥性:互斥访问,即永远只有一个client能拿到锁。 -
容错性:只要大部分Redis节点存活就可以正常提供服务。
Redlock算法
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
-
获取当前Unix时间,以毫秒为单位。 -
依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。 -
客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。 -
如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。 -
如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁。
尽量少用Redlock
Redlock 实在不是一个好的选择,对于需求性能的分布式锁应用它太重了且成本高;对于需求正确性的应用来说它不够安全。
如果你的应用只需要高性能的分布式锁不要求多高的正确性,那么单节点 Redis 够了;如果你的应用想要确保正确性,那么不建议 Redlock,建议使用一个合适的一致性协调系统,例如 Zookeeper,且保证存在 fencing token。
3.Redisson
Redisson与Jedis类似都是Redis的客户端,相比Jedis功能简单。Jedis简单使用阻塞I/O和Redis交互,Redisson通过Netty支持非阻塞I/O。

加锁核心代码
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
这里就是底层的调用栈了,直接操作命令,整合成lua脚本后,调用netty的工具类跟redis进行通信,从而实现获取锁的功能。
这段脚本命令还是有点意思的,简单解读一下:
-
先用 exists key
命令判断是否锁是否被占据了,没有的话就用hset
命令写入,key为锁的名称,field为“客户端唯一ID:线程ID”,value为1; -
锁被占据了,判断是否是当前线程占据的,是的话value值加1; -
锁不是被当前线程占据,返回锁剩下的过期时长;
命令的逻辑并不复杂,用了redis的Hash结构存储数据,如果发现当前线程已经持有锁了,就用hincrby
命令将value值加1,value的值将决定释放锁的时候调用解锁命令的次数,达到实现锁的可重入性效果。

Redisson RedLock
以上就是Redisson分布式锁的加锁原理,就是用lua脚本整合基本的set
命令实现锁的功能,这也是很多Redis分布式锁工具的设计原理。除此之外,Redisson还支持用”RedLock算法”来实现锁的效果,这个工具类就是RedissonRedLock
RedLock能一定程度上能有效防止Redis实例单点故障的问题,但并不完全可靠,不管是哪种设计,光靠Redis本身都是无法保证锁的强一致性的。
选型方案对比
方案 | 优点 | 缺点 |
---|---|---|
Redis单节点 | 实现简单,易于理解 | 不支持阻塞,需要自己实现;在redis集群下会出现锁丢失的问题。 |
Redlock | 解决Redis单点故障问题 | Redlock本质上是建立在一个同步模型 |
Redisson | 基于非阻塞Netty模型;官方分布式锁方案,支持阻塞;采用官方RedLock集群化方案,支持Redis集群 | 工具包有一定复杂度,需要熟悉底层逻辑才能很好的使用 |
Curator Zookeeper | 官方分布式锁方案,支持阻塞;安全性高,ZK可以持久化且实时监听持有锁的客户端的状态 | Zookeeper的强一致性需要同步所有集群节点,有性能损耗 |
结语
如果要分布式锁强一致性,可以采用 Zookeeper 的分布式锁, 因为它底层的 ZAB协议(原子广播协议),天然满足CP,但这就意味着性能的下降。
若项目没有强依赖 ZK,选择使用 Redis 就可以,没必要引入一个新的组件增加项目复杂度。
鱼和熊掌不可兼得,性能和一致性方面也往往如此,Redis强大的性能和使用的方便足以满足日常的分布式锁需求,如果业务场景对锁的安全隐患无法忍受的话,最保底的方式就是在业务层做幂等处理。
附录
-
Distributed Locks with Redis (https://redis.io/docs/reference/patterns/distributed-locks/)
-
基于Redis的分布式锁到底安全吗 (http://zhangtielei.com/posts/blog-redlock-reasoning.html)
-
通过 Redlock 实现分布式锁(https://blog.brickgao.com/2018/05/06/distributed-lock-with-redlock/)
-
Redisson分布式锁原理(https://jishuin.proginn.com/p/763bfbd3c6a5)
原文始发于微信公众号(程序猿阿南):分布式系统 – 分布式锁
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/22300.html