使用Redis实现分布式锁,就不得不先了解一个指令,setnx key value
。这个指令的功能是set 之前先检查redis中师傅存在该key,若不存在,则set key value,返回1;若存在,则什么都不做,返回0。
多个setnx请求同时发到redis,只有一个请求会返回1。根据这个特性,可以实现分布式锁。比如多个线程同时像操作id为1的订单,在操作redis该订单数据前,先向redis发送setnx order:1:lock value
指令,value最好为一个唯一标识。这时只有一个线程返回1,set成功。这时我们认为这个线程抢到了锁。可以操作订单数据,操作完成之后删掉order:1:lock
这个key。
下图显示分布式锁流程:
下面用jedis代码实现一个简单的分布式锁:
先定义分布式锁接口,定义两个简单的加锁和解锁的 方法
DistributeLock
public interface DistributeLock {
boolean lock(String lockKey,String lockvalue,int expire);
boolean unlock(String lockKey,String lockvalue);
}
然后写它的实现:
RedisLock
public class RedisLock implements DistributeLock {
private Jedis jedis;
public RedisLock() {}
public RedisLock(Jedis jedis) {
this.jedis = jedis;
}
@Override
public boolean lock(String lockKey, String lockvalue, int expire) {
Long setnx = jedis.setnx(lockKey, lockvalue);
if (1L == setnx){
jedis.expire(lockKey, expire);
return true;
}
return false;
}
@Override
public boolean unlock(String lockKey,String lockvalue) {
return jedis.del(lockKey) == 1L;
}
}
定义lock方法的时候,在参数中加了expire超时时间。这是为了防止如果del失败,lockKey一直存在于redis中,其他线程无法获取到锁。
这个lockKey需要能表示要加锁的数据,比如具体某一个订单。
原则上我们对lockValue值没有要求,但是一般我们会设置一个唯一标识作为value。比如线程号,或者时间戳等。
这一版代码非常简单,也有一定的问题。如果加锁过程中setnx成功,expire指令由于某种原因,比如redis挂掉,没能执行,那么lockKey就不会过期,可能永久留在redis里,造成死锁。所以这里需要保证setnx和expire指令的原子性。
保证指令的原子性的方式有多种,redis事务机制,lua脚本,以及redis的set key value [EX seconds] [PX milliseconds] [NX|XX]
指令。其中,后两种方式比较简单也常用。而redis的事务机制非常的不友好,存在一定缺陷,尽量不要使用。
因为redis支持lua脚本,并且可以将脚本缓存起来重复使用,性能还不错。lua脚本会包含多条指令,随着脚本要么都执行,要么都不执行。下面先看看lua脚本的方式保证set和expire指令的原子性。
public class RedisLock2 implements DistributeLock {
private Jedis jedis;
public static final Long SUCCESS_CODE_1 = 1L;
public RedisLock2() {}
public RedisLock2(Jedis jedis) {
this.jedis = jedis;
}
@Override
public boolean lock(String lockKey, String lockvalue, int expire) {
String scrip = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then return redis.call('expire',KEYS[1], ARGV[2]) end return 0";
ArrayList<String> params = new ArrayList<String>();
params.add(lockvalue);
params.add(String.valueOf(expire));
Object eval = jedis.eval(scrip, Collections.singletonList(lockKey), params);
return SUCCESS_CODE_1.equals(eval);
}
@Override
public boolean unlock(String lockKey,String lockvalue) {
String scrip = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object eval = jedis.eval(scrip, Collections.singletonList(lockKey), Collections.singletonList(lockvalue));
return SUCCESS_CODE_1.equals(eval);
}
}
通常把lua脚本放到静态文件中,这里就简单硬编码到代码里了。
出了lua脚本,另外redis提供的set key value [EX seconds] [PX milliseconds] [NX|XX]
指令也能保证set和expire的原子性。
public class RedisLock3 implements DistributeLock {
private Jedis jedis;
public static final String SUCCESS_CODE_OK = "OK";
public static final Long SUCCESS_CODE_1 = 1L;
public RedisLock3() {}
public RedisLock3(Jedis jedis) {
this.jedis = jedis;
}
@Override
public boolean lock(String lockKey, String lockvalue, int expire) {
String result = jedis.set(lockKey, lockvalue, "NX", "EX", 10);
return SUCCESS_CODE_OK.equals(result);
}
@Override
public boolean unlock(String lockKey,String lockvalue) {
String scrip = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object eval = jedis.eval(scrip, Collections.singletonList(lockKey), Collections.singletonList(lockvalue));
return SUCCESS_CODE_1.equals(eval);
}
}
这种加锁方式就更加方便。
下面再看测试代码。
下面开5个线程同时抢锁,设置过期时间10秒
public class TestTask {
// Runable任务
class Task implements Runnable{
// 使用CountDownLatch控制多个线程同时start
CountDownLatch countDownLatch;
public Task(CountDownLatch countDownLatch){
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
// 每个线程都有各自的redis连接
Jedis jedis = new Jedis("127.0.0.1", 6379);
DistributeLock lock = new RedisLock3(jedis);
// 循环抢锁
while(true){
// 用线程名做lockValue,便于清楚看到哪个线程抢到了锁,锁过期时间10秒
if (lock.lock("order:1:lock",Thread.currentThread().getName(),10)){
try {
// 等待其他线程准备好
countDownLatch.await();
Thread.sleep(100);
System.out.println("√√√ 【"+Thread.currentThread().getName()+"】拿到订单1的");
System.out.println("处理业务5秒后释放锁。。。");
// 模拟操作共享数据的过程,也就是占用锁时间
Thread.sleep(5000);
if (lock.unlock("order:1:lock",Thread.currentThread().getName())){
System.out.println("××× 【"+Thread.currentThread().getName()+"】释放订单1的");
Thread.sleep(2000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
private void doTest(){
final int N = 5; //开5个线程跑任务
CountDownLatch countDownLatch = new CountDownLatch(N);
for(int i=0;i<N;i++){
new Thread(new Task(countDownLatch)).start();
// 控制多个线程run方法同时执行
countDownLatch.countDown();
}
};
public static void main(String[] args) {
TestTask testTask = new TestTask();
testTask.doTest();
}
}
测试结果:
√√√ 【Thread-1】拿到订单1的
处理业务5秒后释放锁。。。
××× 【Thread-1】释放订单1的
√√√ 【Thread-4】拿到订单1的
处理业务5秒后释放锁。。。
××× 【Thread-4】释放订单1的
√√√ 【Thread-2】拿到订单1的
处理业务5秒后释放锁。。。
××× 【Thread-2】释放订单1的
√√√ 【Thread-1】拿到订单1的
处理业务5秒后释放锁。。。
××× 【Thread-1】释放订单1的
√√√ 【Thread-3】拿到订单1的
处理业务5秒后释放锁。。。
××× 【Thread-3】释放订单1的
√√√ 【Thread-4】拿到订单1的
处理业务5秒后释放锁。。。
由代码控制拿到锁则可操作共享数据。Redis可以做分布式锁还是基于Redis的零号性能,以及基础指令的天然原子性。以上分布式锁解决方案可以结合AOP用于实际业务中。