什么是分布式锁?
单体系统中,在高并发的场景下多线程访问共享资源时,我们通过都会加锁方式来保证共享资源并发访问的安全性,确保同一时刻只能有一个线程对共享变量进行操作,通常我们都会使用java提供的synchronizedy以及reentrantlock等锁。
随着业务的不断发展,单体应用会集群部署多个实例或拆分为微服务,每个微服务部署到多个实例,高并发下请求就会在不同的实例中处理共享资源,原来单体应用中JVM级别的加锁方式在分布式场景下不能满足共享资源的并发访问要求。因此分布式锁就随之诞生。
分布式锁 是分布式环境下对共享资源并发控制的一种机制,控制某个共享资源同意时刻只能被一个进程应用所使用。
根据上图简单分析一下分布式锁的工程流程
- 假设高并发场景同一时刻有三个相同的请求分别转发到服务1,服务2,服务3,由线程1,线程2,线程3进行处理
- 在执行业务前,每个请求都会尝试获取独占锁
- 线程1成功获取到锁后,其它线程都会处于阻塞状态,等待锁释放
- 线程1释放锁后,其它线程会继续抢占锁
- 重复执行最后三步逻辑
分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | setnx命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
下面主要介绍的是使用redis实现分布式锁
redis分布式锁的演进过程
简单方案
使用 Redis 的 setnx 命令来实现简单的分布式锁。
命令格式:setnx key value
命令解释:当前key不存在时,key的值设为value,返回1。若key已经存在时,不做任何操作,返回0。
用法:可以将setnx用于加锁,如果 setnx 返回 1 ,说明客户端已经获得了锁,并且可以指定锁的有效时间。如果 setnx返回 0 ,说明key已经被其他客户端上锁了
- 原理图
执行流程分析:
- 高并发场景下多线程获取redis锁,也就是执行setnx命令,假设线程A执行命令成功,则会获取到锁并设置锁的过期时间(获取锁并设置锁过期时间必须为原子操作)。设置锁的超时时间目的是:当setnx占锁成功之后,业务代码或服务器宕机,没有执行删除锁的逻辑,则会造成死锁
获取锁并设置锁过期时间实例代码:
- 其他线程都会获取锁失败,并处于阻塞状态,等待A线程释放锁。
- 线程 A 执行完自己的业务后,释放锁。
- 其他线程则继续抢占redis锁。
方案缺陷:假设A线程获取到锁,由于某种原因导致 执行业务所需要的时间 大于 锁的过期时间,所以在锁自动释放后可能A线程业务逻辑还没执行完,此时线程B就已经获取到锁,等线程A执行完业务后释放了B线程的锁。
唯一锁标识方案
唯一锁标识方案:加锁时设置value为唯一标识(uuid),释放锁时需要判断value是否能够对应上uuid,如果相等则说明是当前线程加的锁可以释放。
示例代码:
方案缺陷:获取锁和释放锁不是原子操作,最终也会导致释放其他线程的锁
- 线程1获取锁执行业务,获取锁标识并判断是否一致。
- 由于某些原因(Full GC)导致程序执行阻塞,并且锁到了过期时间自动释放。
- 线程2获取到锁。
- 这时,线程1不再阻塞恢复执行状态,线程1拿到的还是自己的锁标识,并执行删除锁逻辑(删除了线程2的锁)。
最终方案
在唯一锁标识方案上,使用Lua脚本保证获取锁和释放锁这两个步骤为原子操作则可作为最终方案(Lua脚本功能:在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性)。
使用setnx实现的分布式锁存在下面的问题
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
Redssion
Redisson是一个在Redis的基础上实现的Java驻内存数据网格。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种完善的分布式锁实现。
redssion分布式锁使用redis的hash结构实现,key:分布式锁名称 field:线程id value:可重入次数
Redssion分布式锁实现原理
注:ttl为锁的最大等待时间(等待期间会重试),leaseTime为锁自动释放时间,-1是没有设置
Redisson解决了使用setnx实现的分布式锁的弊端
- 可重入:利用redis的hash结构记录线程id和可重试次数(和Reentrantlock的可重入实现原理类似)。
- 重试机制:获取锁失败并且等待锁时间没有过期会订阅释放锁的信号量,当锁被释放时通过PubSub发送信号量,线程会再次尝试获取锁(重试),如果超过等待锁时间会获取锁失败。
- 超时自动续期:watchDog机制,当没有设置锁超时时间时,锁超时时间默认为30s,当线程获取到锁时会启动一个定时器,定时器的执行时间为(锁超时时间 / 3),相当于10s会重置一下超时时间(递归重置),这就是一个超时自动续期的过程。