分布式锁一般有三种实现方式:
- 数据库乐观锁
- 基于Redis的分布式锁
- 基于ZooKeeper的分布式锁
保证分布式锁可靠性的四个条件
- 互斥性。在任意时刻,只有一个客户端能持有锁
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁
- 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了
加锁的正确姿势
public class RedisLock {
private static final String LOCK_SUCCESS = "OK";
/** 当key不存在时,进行set操作;若key已经存在,则不做任何操作 */
private static final String SET_IF_NOT_EXIST = "NX";
/** 给key加一个过期的设置 */
private static final String SET_WITH_EXPIRE_TIME = "PX";
@Autowired
@Qualifier("redisClient")
protected static JedisCommands jedis;
/**
* @param lockKey 锁, key是唯一的, 所以可以用来当锁
* @param requestId 请求标识, 知道是哪个请求加的锁, 在解锁的时候才能有依据, 满足可靠性条件四: 解铃还须系铃人
* @param expireTime 过期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(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;
}
}
set()方法的两种结果:
- 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端
- 已有锁存在,不做任何操作
满足可靠性的条件:
- NX参数, 保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性
- 过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁
- 将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端
- 暂时只考虑Redis单机部署的场景,不考虑容错性
加锁错误示例1, jedis.setnx()和jedis.expire()组合加锁
/**
* 错误示例, 使用jedis.setnx()和jedis.expire()组合加锁
* 两条Redis命令,不具有原子性
* 如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间, 将会发生死锁
*
* 错误原因: 低版本的jedis并不支持多参数的set()方法
*
* @param lockKey
* @param requestId
* @param expireTime
*/
public static void tryGetDistributedLock(String lockKey, String requestId, int expireTime) {
/** setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间 */
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
/** 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁 */
jedis.expire(lockKey, expireTime);
}
}
加锁错误示例2
/**
* 使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间
*
* @param lockKey
* @param expire
* @return
*/
public static boolean tryGetDistributedLock(String lockKey, int expire) {
String expireTime = String.valueOf(System.currentTimeMillis() + expire);
// 1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功
if (jedis.setnx(lockKey, expireTime) == 1) {
return true;
}
// 2. 如果锁已经存在则获取锁的过期时间,和当前时间比较
String curExpireTime = jedis.get(lockKey);
if (StringUtils.isNotBlank(curExpireTime) &&
Long.parseLong(curExpireTime) < System.currentTimeMillis()) {
// 3. 如果锁已经过期,获取上一个锁的过期时间,并设置现在锁的过期时间,返回加锁成功
String oldExpireTime = jedis.getSet(lockKey, expireTime);
if (StringUtils.isBlank(oldExpireTime) && StringUtils.equals(oldExpireTime, curExpireTime)) {
// 考虑多线程并发的情况,只有一个线程的设置值oldExpireTime和当前值curExpireTime相同,它才有权利加锁
return true;
}
}
// 其他情况,一律返回加锁失败 (锁未过期, 加锁失败)
return false;
}
代码问题
- 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步
- 当锁过期的时候,如果多个客户端同时执行
jedis.getSet()
方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖 – 存在并发问题 - 锁不具备拥有者标识,即任何客户端都可以解锁
解锁的正确姿势
public class RedisLock {
private static final Long RELEASE_SUCCESS = 1L;
public boolean releaseDistributedLock(String lockKey, String requestId) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// eval()方法是将Lua代码交给Redis服务端执行, 可以确保原子性
// 参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId
// 获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
Object result = jedis.eval(luaScript,
Collections.singleton(lockKey),
Collections.singleton(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
解锁错误示例1
不先判断锁的拥有者而直接解锁
导致任何客户端都可以随时进行解锁
public void releaseDistributedLock(String lockKey) {
jedis.del(lockKey);
}
解锁错误示例2
public void releaseDistributedLock(String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁 -- 并发问题
jedis.del(lockKey);
}
}
客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()
之前,锁突然过期了
此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了
建议
如果项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁,这是Redis官方提供的Java组件