为了保证同一时间只有一个线程访问某一代码块,Java中可以使用synchronized语法和ReentrantLock等本地锁的方式。但是在分布式环境下,需要使用分布式锁来保证不同节点的线程同步执行。
常用的分布式锁实现包括以下几种:
- 基于数据库的分布式锁:使用数据库的事务和行级锁来实现分布式锁,通过在数据库中创建一张锁表来记录锁的状态。
- 基于Redis的分布式锁:利用Redis的原子操作和过期时间特性,使用SETNX命令来获取锁,使用DEL命令来释放锁。
- 基于Zookeeper的分布式锁:利用Zookeeper的有序节点和watch机制,通过创建临时有序节点来实现锁的竞争和释放。
三种分布式锁对比
优点 | 缺点 | |
数据库 | 简单,使用方便,不需要引入Redis、zookeeper等中间件 | 不适合高并发的场景 db操作性能较差,有锁表的风险 |
redis | 性能好,适合高并发场景 较轻量级 较好的框架支持,如Redisson | 过期时间不好控制。 需要考虑锁被别的线程误删场景 |
zookeeper | 有较好的性能和可靠性。 有封装较好的框架,如Curator | 性能不如redis实现的分布式锁 比较重的分布式锁 |
【基于Redis实现的分布式锁】
早期版本实现
目前Redis版本已经发布到7.x,生产项目应该不会再使用2.x的版本,这里主要是为了更好的理解各种情况。
在redis2.6.12之前,是通过setnx与expire两个命令配合使用来实现的。setNX命令代表当key不存在时返回成功,否则返回失败,即锁已被其他线程占用。
setnx(key,value);
expire(key,seconds)
这种实现方式把加锁和设置过期时间的步骤分成两步,并不是原子操作,如果加锁成功之后程序崩溃、服务宕机等异常情况,导致没有设置过期时间,那么就会导致死锁的问题,其他线程永远都无法获取这个锁。
如何避免上述问题呢?早期redis版本,可以使用lua脚本,之后的版本,则也可以利用redis命令的扩展参数来实现。继续...
Lua脚本
可以使用Lua脚本来保证原子性(包含setnx和expire两条指令),lua脚本如下:
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then redis.call('expire',KEYS[1],ARGV[2])
return 1
else
return 0
end
加锁代码如下:
// 使用lua脚本 保证原子性
public boolean tryLock_with_lua(String key, String UniqueId, int seconds) {
String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
List<String> keys = new ArrayList<>();
List<String> values = new ArrayList<>();
keys.add(key);
values.add(UniqueId);
values.add(String.valueOf(seconds));
Object result = jedis.eval(lua_scripts, keys, values);
return result.equals(1L);
}
SET的扩展命令(SET EX PX NX)
除了使用,使用Lua脚本,保证SETNX + EXPIRE两条指令的原子性,我们还可以使用redis的SET指令扩展参数,它也是原子性的!
SET key value[EX seconds][PX milliseconds][NX|XX]
- EX seconds: 设定过期时间,单位为秒。
- PX milliseconds: 设定过期时间,单位为毫秒。
- NX: 表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
- XX: 仅当key存在时设置值。
代码如下:
public boolean tryLock_with_set(String key, String UniqueId, int seconds) {
return "OK".equals(jedis.set(key, UniqueId, "NX", "EX", seconds));
}
value必须要具有唯一性,可以用UUID来做,设置随机字符串保证唯一性。为什么要保证唯一呢?继续...
释放锁
虽然我们在加锁的时候,设置了默认过期时间,但我们肯定不能等着过期再释放锁。当前线程执行完任务之后,需要手动删除key,即释放锁。
// 错误的解锁方法—直接删除key
public void unlock_with_del(Jedis jedis,String key) {
jedis.del(key);
}
通过del命令直接删除,是否可行呢?结合下图我们来分析一下:
线程A加锁同时设置超时时间5秒,结果5s之后程序逻辑还没有执行完成,锁已经释放。线程B此时也来尝试加锁并获得了锁,这时线程A业务执行完成,释放锁,结果释放了线程B持有的锁。也就是说锁被别的线程误删了。如何解决呢?这里就用到了前面提到的UUID,给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下。同时,判断是不是当前线程加的锁和释放锁也要保证原子性。
// 使用Lua脚本进行解锁操纵,解锁的时候验证value值
public boolean unlock(Jedis jedis,String key,String value) {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}
完成了锁的获取、锁的释放,这就OK了嘛?然而,并非如此。继续...
Redission
(1)超时释放
在分析锁误删问题时提到,线程A设置了5秒超时,但5秒内,业务并未执行完成,而锁已超时释放,从而导致了线程A和B同时持有了锁。如何解决呢?把锁过期时间设置长一些,算是一种解决方案,有没有更好的呢?其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。这其实就是开源框架Redission采用的思路。
只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用watch dog解决了锁过期释放,业务没执行完问题。
(2)可重入
另外还有一个问题,上述实现并非可重入锁。所谓可重入锁,即当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。Redission中也有相关的实现。
如果key不存在,通过hash的方式保存,同时设置过期时间,反之如果存在就是+1。对应的就是'hincrby', KEYS[1], ARGV[2], 1这段命令,对hash结构的锁重入次数+1。
Redlock+Redisson
如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock(Redission中也有相关的实现)。Redlock核心思想是这样的:
搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。
RedLock的实现步骤如下:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)