分布式锁使用场景
在同一个JVM内部,大家往往采用synchronized或者Lock的方式来解决多线程间的安全问题。但在分布式集群工作的开发场景中,在分布式架构下,在JVM之间,那么就需要一种更加高级的锁机制,来处理种跨JVM进程之间的线程安全问题,解决方案就是:使用分布式锁。
分布式锁组成要素
setnx: set key if not exist
expire: set expiration for a key
两者结合就可以组成一个分布式锁
原理解析
1. key不存在时创建,并设置value和过期时间,返回值为1;成功获取锁;
2. 如key存在时直接返回0,抢锁失败;
3. 持有锁的线程释放锁时,手动删除锁;或者过期时间到,可以自动删除,锁释放。
如果某线程加锁后没有释放锁,锁就会被永久占用。解决方案有两种:
1. 使用set的命令时,同时设置过期时间,命令:set lock "1234" ex 100 nx
2. 使用lua脚本,将加锁的命令放在lua脚本中执行,lua脚本中的命令会被一起执行,所以有原子性。
eval命令
EVAL script numkeys key [key ...] arg [arg ...]
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second
其中 "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 是被求值的 Lua 脚本,数字 2 指定了键名参数的数量, key1 和 key2 是键名参数,分别使用 KEYS[1] 和 KEYS[2] 访问,而最后的 first 和 second 则是附加参数,可以通过 ARGV[1] 和 ARGV[2] 访问它们。
call命令
> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 foo bar
Redis实现分布式锁
引入Jedis包
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
用Jedis加锁
@Slf4j
@AllArgsConstructor
public class JedisCommandLock {
private RedisTemplate redisTemplate;
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
在Jedis的set函数中:
- key:加锁的key
- value:可以使用
UUID.randomUUID().toString()
,代表加锁的客户端请求标识 - nxxx:NX,表示SET IF NOT EXIST
- expx:PX,表示毫秒
- time:key的过期时间。
set函数在满足设置lock的key值和ttl的同时,在value中设置requestID,可以识别加锁的客户端,防止其他客户端删锁。
用Jedis解锁
错误示例1:
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
这个错得很明显,不管谁加的锁,上来就删。
错误示例2:
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
//在这两行代码的时间差,可以已经被其他代码解锁,而引发错误解锁
jedis.del(lockKey);
}
}
这个例子的缺陷在于判断是否是自己加的锁和解锁不是原子操作,从原理上来说,存在解错锁的风险,即当判断时还是我的锁,但等到解锁时可能已经不是我的锁了,可是我还要解,结果就把别人的锁给解了。这种错误非常微妙,一旦发生很难debug。
正确的解锁姿势:
@Slf4j
@AllArgsConstructor
public class RedisCommandLock {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
上面的例子用lua脚本执行的原子性来解锁。
锁过期问题
预估业务操作10秒,锁设置20秒,但出于各种原因,比如STW,业务操作执行超过了20秒,业务会在无锁状态下运行,就会发生数据紊乱。
解决方案
1. 使用乐观锁。模拟CAS乐观锁的方式,增加版本号。
这个方法的缺点在于需要修改代码才能在写请求中增加版本号。
2. 使用Watch Dog自动延期机制。客户端1加锁的key默认生存时间为30秒。一旦加锁成功,就会启动一个watch dog的后台线程,在客户端1持有锁超过30秒后,watch dog每隔10秒就检查一下客户端1(这里有点像心跳机制),如果客户端1依然持有锁,那么就会延长锁的生存时间,直到客户端1完成操作释放锁为止。
Credit:刘雪松老师的讲义。