怎样才是一个好的分布式锁
实现分布式锁一般有三种方式:
- 数据库乐观锁;
- 基于Redis实现;
- 基于Zookeeper实现(其他类ZK的组件,如Etcd等);
考虑架构来说,一般从性能和可靠性考虑,综合而言,一般选用Redis来实现分布式锁。
那如何从可靠性来评估一个好的分布式锁呢?
互斥性 : 互斥性指在同一时刻,有且仅有一个客户端能拿到这把锁;
防止死锁 : 需要保证,任何情况下拿到锁的客户端崩溃,没有释放锁,这把锁也能主动释放,给别的客户端使用;
加锁解锁必须是一个人 : 任何情况下,主动解锁的必须是加锁的客户端,一个客户端不能释放另外客户端持有的锁(业务处理情况只有自己客户端清楚)
可重入 可重入指的是同一个客户端可以在持有锁的时候重复调用锁而不发生死锁的特性,不是必须特性,在一些场景下必要;
代码实现
基于上面的要求,我们来实现一把分布式锁:
先引入redis客户端;
加锁代码
先看代码:
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁标识
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryLock(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客户端来实现,spring的redis客户端同理:
首先,set的时候需要指定key,通常在我们的业务场景下,key可能是订单号,资金流水单号,数据库唯一键这类的业务参数,来满足互斥性;
其次我们需要指定value,可能有人会说一般的实现指定了key就是唯一的,确实是这样的;但是我们需要满足“加锁解锁都是同一人”的目的,光指定key就不够了,这里我们还指定了requestId,当然也可以是我们请求的traceId,能唯一标识一个请求的都可以作为value;这样在加锁解锁时就能判断客户端的身份了。同时,因为我们指定了requestId,那么在使用锁的时候可以很方便的改造成可重入锁。
然后我们需要设置SET_IF_NOT_EXIST,顾名思义,在这个key不存在时设置key的值,这里保证了只有一个客户端可以拿到锁;
最后我们需要同时指定过期时间,来保证客户端在一定时间内没有释放锁的时候,redis可以主动释放锁资源,防止死锁;这里的过期时间需要考量业务处理流程的rt来设置,通常10s就可以。
这段代码只会有两个结果;该key不存在,则setkey成功,同时设置了过期时间;该key存在,则获取锁失败,则业务重试即可;
这里的代码思想主要是一点:需要保证判断锁存在,加锁,设置过期这三个操作的原子性,所以在同一个语句中同时执行了这三个操作,尽可能的保证了操作的原子性。
错误示例:
public static void wrongTryLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
这是一种比较常见的加锁方式,设置redis的key,设置成功了我们就设置缓存时间;这里的问题在于如果setnx()执行完毕后系统崩溃了,那这个key就没有过期时间,这样就产生了死锁。
解锁代码
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean unLock(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 (1L.equals(result)) {
return true;
}
return false;
}
同加锁的思想一致,我们也需要保证解锁过程中,判断锁存在,判断key的值是否正确,释放锁的操作的原子性;
故而我们使用了Lua脚本来实现;我们先定义了一行脚本:获取这个key的value,如果value和传入ARGV参数相等,则删除这个key;
然后我们将这个脚本提交给jedis客户端,jedis.eval()执行这个脚本,同时完成判断和删除操作来保证操作的原子性;
这里利用了eval的特性,整个脚本会被当成一个命令来执行,直到该命令执行完成,redis才会执行下一个命令。
错误示例:
public static void wrongUnLock(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}
}
通常我们释放锁的方式是这样的,但是这里有个问题,操作不能原子,在并发的情况下,有可能if条件刚执行完成,锁突然过期了,这个时候正好另外一个客户端正好获取了锁,那么del语句释放的就可能是别人的锁,造成紊乱。
组合使用
获取锁失败就结束的场景:
final String key = UUID.randomUUID().toString(); // 业务逻辑key
// 注意:先成功获取锁,再尝试释放锁。
if (this.tryLock(key)) {
try {
// do something...
} finally {
this.unLock(key); // 执行完业务逻辑后,显示释放锁。注意:整个过程须同步操作!
}
}
这里需要注意的点就是释放锁的操作一定要在finally里面来做,且必须是同步来释放,否则会有死锁问题。
需要自旋获取锁的场景:
final String key = UUID.randomUUID().toString(); // 业务逻辑key
boolean lock = false;
//自旋加锁,可以添加次数限制来break循环
do{
Thread.sleep(100);
}while (tryLock(key));
if (lock) {
try {
// do business...
} finally {
this.unLock(""); // 执行完业务逻辑后,显示释放锁。注意:整个过程须同步操作!
}
}
自旋一般不建议一直执行下去,可以通过次数限制来中断掉,抛出异常供业务处理。
总结
这里的核心思想是在尽可能保证性能的同时,让加锁和解锁的操作成为一个原子操作,来保证上面所说的分布式锁的可靠性条件;当然也可以给加解锁的代码加锁来实现,但是会有性能的牺牲。
理解了这个思想,我们去理解一些常用的分布式锁中间件也会更容易,例如Redis官方推荐的RedisSon、Apache的Shared Lock等,避免重复造轮子。