Redis分布式锁
背景
在集群环境下,由于我们的服务是部署在多个机器上的,就会出现多个进程同时操作一份数据的情况,比如说,创建订单的服务是部署在A、B两台机器上,小明和小王买苹果的订单请求分别打到A、B两台机器,收到请求后,A、B两台机器都会去查询数据库,稍后又会对数据做一些修改,很显然,这种情况是需要锁的,不然会出现超卖事故。
加锁可不可以加本地锁呢?像常用的lock和synchronized,当然是不可以的。目前的场景是来自不同机器的请求要去修改数据,假如机器A加了一个本地锁,那机器B它也不知道你加了锁呀,因为你们是在不同的JVM里面。因此这个时候就需要有分布式锁了。
分布式锁的实现方式有很多种,这里记录下redis是如何实现分布式锁的。
redis实现分布式锁
set lock true ex 5 nx
do something...
del lock
指令表示的是,如果不存在key = lock,我们就添加一个键值对(key, true),过期时间为5s,指令执行完后会返回1,客户端就知道自己加锁成功,可以继续操作,如果加锁失败,则会返回0,客户端该干啥干啥。注意这里的key是任意的,但命名通常与业务有关。
这里存在一个问题,就是超时问题,假如机器上的进程A加锁成功,然后开始do something,但是这个something有点长,超过了5s,锁就会过期,这个时候,另一台机器上的进程B会拿到这把锁,也开始do something,很大几率上,他们两个会同时操作临界区的资源;此外,更严重的是,当进程A执行完后,由于它并不知道它的锁已经过期被自动释放了,它再一次释放锁,然而这个锁是进程B加的,而此时进程B的逻辑可能还没有执行完,但因为锁被释放了,其它的进程又可以加锁成功,形成了一个恶性循环。
利用jedis实现的分布式锁
public class JedisDistributeLocker implements DistributeLocker {
public static int LOCKEXPIRETIME = 3000;
JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);;
@Override
public boolean tryLock(String key, String tag) {
return tryGetLock(key, tag, LOCKEXPIRETIME);
}
private boolean tryGetLock(String key, String tag, int lockexpiretime) {
Jedis jedisCli = jedisPool.getResource();
long now = System.currentTimeMillis();
try {
String result = jedisCli.set(key, tag, "NX", "EX", lockexpiretime);
if ("OK".equalsIgnoreCase(result)) {
System.out.println(Thread.currentThread().getName() +
": 加锁成功, key = " + key + ", value = " + tag + ", 耗时: " + (System.currentTimeMillis() - now));
return true;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedisCli == null) {
jedisCli.close();
}
}
return false;
}
@Override
public void lock(String key, String tag, long timeoutMills) {
long now = System.currentTimeMillis();
try {
while (System.currentTimeMillis() - now <= timeoutMills) {
if (tryLock(key, tag)) {
return;
}
}
throw new Exception("超时: " + (System.currentTimeMillis() - now));
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
@Override
public void unlock(String key, String tag) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Jedis jedisCli = jedisPool.getResource();
try {
Object obj = jedisCli.eval(script, Collections.singletonList(key), Collections.singletonList(tag));
System.out.println(obj);
System.out.println(Thread.currentThread().getName() +
": 解锁成功, key = " + key + ", value = " + tag);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedisCli == null) {
jedisCli.close();
}
}
}
}
lua脚本的作用是在del分布式锁的时候,判断是不是我之前加的锁,避免将别的服务加的锁del掉(可能服务的执行逻辑过长,导致锁过期失效,让别的服务有可乘之机)。
Redisson分布式锁
相比如redis的setnx命令,Redisson这里又进一步的对分布式锁做了封装,可以很好的解决超时导致锁失效的问题。
引入redisson依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.0</version>
</dependency>
测试demo
public class RedissonDemo {
public static void main(String[] args) throws InterruptedException {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("anyKey");
long tId = Thread.currentThread().getId();
lock.lock();
Thread.sleep(10000L);
lock.unlock();
redisson.shutdown();
}
}
从lock断点进入到可重入锁的实现,,在类RedissonLock的tryLockInnerAsync方法入参中可以找到它的加锁脚本。
KEYS[1]:就是key
ARGV[1]:该锁的过期时间
ARGV[2]:key对应的哈希key,与线程名有关,是线程独有的
脚本的意思是:
首先判断key存不存在,如果不存在,设置一个key,哈希值为1,然后设置过期时间;
如果key存在,并且哈希key也一样,说明是我之前加的锁,将哈希值加一,设置过期时间(这是一个可重入锁);
如果key存在,并且是别线程设置的,则返回该锁当前的失效时间。
"if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);"
如果仅仅是这样,那和jedis也没什么区别,事实上,redisson是可以解决超时问题的。
首先,超时问题可能带来的后果是别的进程获取到了锁,两个进程同时操作数据。如果一开始你指定了超时时间,那么在系统运行正常的情况下,你就应该在超时时间内完成,不然你没有必要设置时间;如果设置了时间,并且可以在超时时间内完成,但系统宕机了,没关系,即使别的进程拿到了锁,但还是只有一个进程在操作。
redisson是在未指定超时时间时,会给锁自动续期,防止锁失效。在未指定锁过期时间时,默认是30s,当加锁成功后,每过10s会执行以下脚本,也就是给锁续期。
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;