【分布式】分布式锁
本文为分布式学习笔记,参考了JavaGuide
各种锁概念介绍:
- 可重入锁:允许线程在持有锁的情况下多次获取同一个锁,而不会被自己所持有的锁所阻塞,这种特性也被称为锁的可重入性。
- 自旋锁:与传统的互斥锁不同,自旋锁不会将线程挂起(进入阻塞状态),而是在获取锁时不断循环尝试获取,直到成功为止,因此称为“自旋”。
自旋锁优缺点:
- 优点:自旋锁适用于短期内资源占用的情况,不涉及线程上下文切换,避免了上下文切换开销。
- 缺点:不适用长期资源占用情况,长时间自旋时会导致其他线程无法获取CPU时间片,导致CPU资源浪费。
- 公平锁:公平锁是一种锁的获取机制,它确保线程按照请求锁的顺序来获取锁,即先到先得的原则。在使用公平锁时,如果有多个线程在等待同一个锁,那么锁会被分配给等待时间最长的线程。
特点:按顺序获取锁、避免线程饥饿(即某个线程一直无法获取到锁)、性能相对较低
- 多重锁:多重锁通常指的是一种锁的嵌套使用或者多个锁的同时持有。
- 红锁:红锁是一种分布式锁的算法,旨在解决在分布式环境下的锁竞争和故障恢复问题。
红锁步骤如下:
- 客户端尝试在 N 个 Redis 实例上获取锁,每个实例使用相同的锁名称和唯一的随机值作为锁的值,同时设置相同的过期时间(TTL)。
- 客户端在大多数(大于等于 N/2+1) Redis 实例上成功获取到锁时,认为获取锁成功;否则认为获取锁失败。
- 如果客户端获取锁失败,会在所有 Redis 实例上释放已经获取到的锁。
- 当客户端释放锁时,会在所有 Redis 实例上释放锁。
特点:高可用性、容错性、存在一定性能开销
- 高可用性:通过在多个 Redis 实例上获取锁,提高了锁的可用性和可靠性,即使部分 Redis 实例宕机或者网络分区,也能够继续提供服务。
- 容错性:红锁算法能够容忍部分 Redis 实例获取锁失败的情况,只要大多数实例成功获取到锁即可认为获取锁成功。
- 性能开销:尽管红锁算法提高了锁的可用性和可靠性,但是在获取锁和释放锁的过程中需要访问多个 Redis 实例,可能会带来一定的性能开销。
- 读写锁:与传统的互斥锁不同,读写锁允许多个线程同时读取共享资源,但在写操作时需要独占访问。
读写锁特点:
- 读取共享资源的并发性:读取操作是非互斥的,多个线程可以同时持有读锁并读取共享资源,这样可以提高并发性能。
- 写入共享资源的互斥性:写入操作是互斥的,只有当没有其他线程持有读锁或写锁时,写入操作才能够执行。
- 写锁优先级高于读锁:当一个线程持有写锁时,其他线程无法获取读锁或写锁,确保写入操作的一致性。
分布式锁
在多线程访问共享资源时,会发生数据竞争,这时就需要锁。
常规的锁,比如ReetrantLock
类、synchronized
关键字等本地锁都是在同一个JVM虚拟机中,用来控制对共享资源的访问。
但是在分布式的情况下,不同的线程不在同一个JVM中,甚至不在同一台机器上,那么在对共享资源进行访问控制时,就需要分布式锁。
一个分布式锁应具备以下基本要求:
- 互斥:任意时刻,锁只能被同一线程持有
- 高可用:当一个锁出现问题时,能够自动切换到另外一个锁服务。并且客户端锁释放出现问题时,锁最终也能够被释放(一般通过超时机制完成)
- 可重入:一个节点获取该锁后,可以再次获取该锁
除了上述基本条件,一个优秀的锁还应该:
- 高性能:锁的获取和释放应该在短时间完成,不会对系统有太大影响
- 非阻塞:如果获取不到锁,不能无限等待
常见实现方式
分布式锁一般有以下几种常见实现:
- 基于关系型数据库,例如MySQL实现分。
- 基于分布式协调服务,例如ZooKeeper实现。
- 基于分布式键值存储中间件,例如Redis、Etcd实现。
数据库
关系型数据库一般通过唯一索引或者排他锁实现,不过一般不会使用这种方式,因为性能太差、不具备锁失效机制。
Redis
不论是本地锁还是分布式锁,核心都在于互斥。
Redis中,SETNX
可以实现互斥(SET if Not eXists),类似Java中的setIfAbsent
,如果key不存在就设置,并返回1,存在则直接返回0。
本地:db0> setnx lock_key lock_value
1
本地:db0> setnx lock_key lock_value
0
本地:db0>
释放锁只需要使用DEL
命令直接删除对应的key即可:
本地:db0> del lock_key
1
本地:db0>
为了防止误删到其他锁,建议使用lua脚本根据key对应的value值去删除。
使用lua脚本是因为redis在执行lua脚本时,可以以原子性方式执行:
-- 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这种方式比较简单高效,但是可能存在锁无法释放的问题(比如客户端线程突然挂掉)。
给锁设置过期时间
为了避免锁无法被释放,可以给锁设置一个过期时间:
本地:db0> SET lock_key lock_value EX 30 NX
OK
本地:db0> SET lock_key lock_value EX 30 NX
本地:db0>
EX
代表过期时间,示例为30秒,NX
代表不存在才设置。
一定要保证设置指定 key 的值和过期时间是一个原子操作!!!不然仍旧会存在锁无法被释放的情况。
这种方式虽然避免了锁无法被释放,但是可能会出现锁提前被释放的问题,而且过期时间也不好判断。
如果对共享资源的操作未完成时,锁能够自动续期就好了。
锁的续期
Java中常用的是Redisson,其他语言也可以在Redis官方文档中找到对应方案。
Redisson中的分布式锁自带续期功能,原理就是提供了一个专门用来监控和续期锁的Watch Dog(看门狗),如果操作共享资源的线程还没有结束,看门狗会自动续期。
Redisson默认创建一个30秒的锁,每次续期10秒钟
这里以 Redisson 的分布式可重入锁 RLock
为例来说明如何使用 Redisson 实现分布式锁:
// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lock");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
lock.unlock();
只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);
锁的可重入
可重入的意思就是,持有锁的线程再次获取锁时仍然可以获取该锁。
Java中的ReentrantLock
和synchronized
都是可重入的,并且lock几次,同时也需要unlock几次(重入计数器)。
项目中的分布式锁推荐使用Redisson,它内置了多种类型的锁:可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。
Redis如何解决集群情况下分布式锁的可靠性
为了避免单点故障,生产环境中Redis一般都是集群部署。
集群情况下分布式锁一般使用红锁RedLock,这种方式直接操作Redis节点,但是实现复杂,性能较差,一般也不推荐使用。
ZooKeeper也可以实现分布式锁
……