单个Redis分布式锁
说到分布式锁,大多数人都知道分别可以用Redis和Zookeeper来实现,而用Redis来实现的话,那么最经典的就是SET <key> <value> PX <timeout> NX
命令+Lua脚本来实现
我们通常使用Redis的SET命令来加锁,SET命令可以设置一个字符串键值对,PX表示键值对(锁)的过期时间,NX:当键值对(锁)不能存在时才能设置(加锁)
解锁操作为了保证原子性,通常用Lua脚本来实现,然后通过eval命令执行lua脚本
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
java实现的demo:
package redis;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.Collections;
import java.util.UUID;
/**
* @author zhangkun
* @date 2020/5/31 17:39
*/
public class RedisLockDemo {
/**
* 锁key
*/
private static final String LOCK_KEY = "lock";
/**
* 加锁是否成功
*/
private static final String OPERATOR_SUCCESS = "OK";
/**
* 释放锁是否成功
*/
private static final Long RELEASE_SUCCESS = 1L;
/**
* 过期时间(ms)
*/
private static final Long EXPIRE_TIME = 10 * 1000L;
public static void main(String[] args) {
// 多个线程争夺锁
MyThread thread1 = new MyThread("A");
new Thread(thread1).start();
MyThread thread2 = new MyThread("B");
new Thread(thread2).start();
}
/**
* 尝试获取锁
*/
public static boolean tryGetLock(Jedis jedis, String lockKey, String requestId, Long expireTime) {
// 一定要在锁不存在时设置,并且设置过期时间,避免死锁
// nx():不存在时设置
// px(timeout):过期时间(ms)
SetParams setParams = SetParams.setParams().nx().px(expireTime);
// 加锁
String result = jedis.set(lockKey, requestId, setParams);
// 返回 OK 设置key-value成功
if (OPERATOR_SUCCESS.equals(result)) {
return true;
}
// 否则获取锁失败,有可能是锁已存在或者redis服务宕了甚至网络问题
return false;
}
/**
* 释放锁
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
// 通过lua脚本来保证redis操作的原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 第一个List是KEYS数组,第二个List是ARGV数组
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
static class MyThread implements Runnable {
/**
* 随便起个名字,方便演示是哪个线程获取了锁
*/
private String name;
/**
* redis客户端
*/
private Jedis jedis = new Jedis("192.168.159.128", 6379);
MyThread(String name) {
this.name = name;
}
@Override
public void run() {
// value=uuid,在解锁时先判断客户端传过去的uuid是否等于设置的uuid,避免a把b的锁释放了
while (true) {
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
// 加锁
boolean lock = tryGetLock(jedis, LOCK_KEY, uuid, EXPIRE_TIME);
if (lock) {
System.out.println(this.name + ":获取锁 Success");
try {
// 假装自己需要3s来处理需要加锁才能处理的业务,获取锁之后3秒释放锁
Thread.sleep(3000);
releaseDistributedLock(jedis, LOCK_KEY, uuid);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
System.out.println(this.name + ":获取锁 Error");
}
try {
// 每隔1s获取一次锁
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
有的同学可能疑问,为什么B先执行,却获取锁失败呢。因为是A线程先获取的锁,就在A正准备执行System.out.println
,A的时间片用完了,切换到B了,所以导致了B先打印了log。
可以看到A获取到锁之后,B就一直获取锁失败,等到A过3秒释放锁的时候,B才有机会获取到锁,同理,A也一样。
但是这种实现也有缺点,因为加锁只能作用在一个Redis节点上,如果这个Redis宕机了,那么会丢锁。即使Redis能通过Sentinel(哨兵机制)来保证单节点的高可用,那么在发生主从切换的时候也可能会导致丢锁的情况。
- Redis的master拿到了锁
- master宕机了,但是这个锁还没有同步到slave上
- slave成为master,锁丢失
正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock
Redlock实现
假如有5个master,就有5个redis集群,这些master之间是不会同步数据的,也就是说他们不会同步锁状态
也就是说RedLock的实现方式不仅不会去解决主从切换导致锁丢失的问题,而是采取了另一种思路:完全放弃了锁同步的工作,类似于投票选举(少数服从多数)的算法来决定是否能够获取锁
客户端在获取锁时需要按照以下步骤:
- 获取当前Unix时间(ms) -> startTime
- 顺序从5个master获取锁,使用相同的key和value(UUID)。客户端在获取锁的时候应该设置一个响应超时时间,这个超时时间应该小于锁过期的时间(如果锁的过期时间是5s,超时时间是10s,过了9s才获取到锁,那么刚获取到的锁就已经过期了。避免获取到一个已经过期了的锁)
- 客户端再次获取当前时间 -> endTime,endTime-startTime=获取锁花的时间(acquireTime)。
- 只有满足了两个条件,锁才算获取成功。
1. N/2+1个节点都获取成功,比如这里有5个节点,有3个节点都获取到了
,2. acquireTime < 锁失效的时间(和之前一样,为了避免获取一个已过期的锁)
- 如果锁获取成功了,那么这个锁真正的有效时间其实是设置的锁的有效时间-acquireTime
- 如果获取锁失败的情况,应该在所有master上进行解锁(即使某些master根本没有获取到锁,为了防止某些master实际上已经获取到了锁,但是客户端没有得到响应,而导致下一次获取锁的时候发现当前节点已经被锁了从而获取锁失败)
使用Redisson库中已经封装好的方法
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.3.2</version>
</dependency>
示例代码:
Config config = new Config();
// 配置三个master节点的位置,设置好账号密码
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389").setMasterName("masterName").setPassword("password").setDatabase(0);
// Redis客户端
RedissonClient redissonClient = Redisson.create(config);
// 获取分布式锁对象
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
// 锁标识,用来判断是否获取成功
boolean isLock;
try {
// 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
if (isLock) {
// 获取锁成功,处理业务
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
}
保证键值对中Value的唯一性
可以使用UUID+ThreadID,也就是
UUID.randomUUID().toString() + Thread.currentThread().getId()