近期在项目中为了防止恶意并发操作,使用到了分布式锁。
1.Mysql乐观锁。
2.缓存
3.zookeeper。
mysql由于要走磁盘io,读写性能较差。
redis内存读写快。
zookeeper文件系统相对于内存来说还是稍有不足。
mysql和redis都比较简单,公司也有组件支持。
zookeeper由于公司不支持安装配置,需要自己搭建集群,难度较大。
综上所述,结合一下实际场景,这里我便使用了redis作为分布式锁实现的工具。
1.互斥性,在任意时刻,只有一个客户端能够持有当前锁。
2.不会发生死锁,即使有一个客户端在拿到锁之后崩溃没有主动释放锁,也能保证之后的客户端能正常持有锁。
3.唯一性,加锁和解锁必须是同一个客户端,A客户端不能去释放B客户端的锁。
网上有许多前辈说过redis实现分布式锁是坑很多,那么我们就一步一步的看下,坑在哪,如何把坑填上吧~
public static boolean lock(Jedis jedis, String key, String requestId, int expireTime) {
if(jedis.setnx(key, requestId) == 1) {
//在这个地方,客户端崩溃了~ o(╥﹏╥)o
jedis.expire(key, expireTime);
return true;
}
return false;
}
一般的写法都是通过jedis.setnx()来获取锁,之后再对锁设置过期时间。
这种写法不满足分布式锁的第二个特性,也就是说如果在如图所示的地方崩溃了,客户端获取锁之后还没来得及给锁设置过期时间,那么这个锁就一直在redis中并且不会释放,导致后续客户端永远拿不到锁。
那么,我们得想办法把坑填上去,于是补充了一段判断,并且把过期时间的设置改成了value值:
public static boolean lock( String key, long timeout ){
//先获取当前系统时间
long currentTime = System.currentTimeMillis();
//获取锁,并且设置锁到期时间=(当前时间+过期时间)
if( jedis.setnx( key, String.valueOf( currentTime + timeout ) ) == 1 ){
return true;
}else{
//如果获取锁失败,再次获取当前系统时间
currentTime = System.currentTimeMillis();
//拿到锁之前设置的到期时间
long oldTime = NumberUtils.toLong( jedis.get( key ));
//判断当前系统时间是否大于到期时间,如果大于锁的到期时间,说明锁本应该被释放
if( currentTime > oldTime ){
//那么重新给锁设置到期时间=(当前客户端的系统时间+过期时间)
long getSetTime = NumberUtils.toLong( jedis.getSet( key, String.valueOf( currentTime + timeout )));
//这里再次判断当前系统时间是否大于锁的过期时间
//假设一下,如果两个客户端同时进行了上一步操作,都重新给锁设置了新的到期时间,A先设置,B在设置,这时A拿到的getSetTime应该是之前的的oldTime,B拿到的getSetTime则为A设置的新的过期时间。那么加上这个判断就能保证只有一个客户端能真正拿到锁,返回true。
if( currentTime > getSetTime ){
return true;
}
}
}
return false;
}
当然,这种实现方式的问题在于
1.如果客户端的系统时间不一致,那么互斥性也不会满足,
2.如果redis节点是主从分布的,由于主从切换是异步同步数据的,所以redis并不能完全的实现锁的安全性。 举个例子来说:
由于在公司内部所有的节点都能保证系统时间一致,并且redis节点是集群分布的,所以我采用了这种实现方式。
public static void releaseLock(Jedis jedis, String Key) {
//A客户端能解B客户端的锁。
jedis.del(Key);
}
改进一下:
public static void releaseLock(Jedis jedis, String lockKey, String requestId) {
//判断当前客户端是不是加锁的客户端
if (requestId.equals(jedis.get(lockKey))) {
//如果在这一时刻,A客户端的锁突然过期了,那么B客户端获得了锁,A客户端就释放了B客户端的锁。
jedis.del(lockKey);
}
}
在释放锁阶段,一定要保证锁拥有者的唯一性,即只有当前客户端能释放自己的锁。
填坑,结合在加锁阶段的填坑方式,保证当前拿到锁的客户端不会自动释放锁的前提:
public static boolean unlock( String key ){
if( System.currentTimeMillis() < NumberUtils.toLong( jedis.get( key ) ) && jedis.del( key ) <= 0 ){
return false;
}
return true;
}
由于加锁填坑的地方并没有对锁设置过期时间,而是把时间当做了它的一个value值,那么就不存在判断是当前客户端之后锁自动释放的问题。
通过这样的一个组合方式,能很好的解决redis实现分布式锁中间遇到由于原子性的问题导致的各种坑。
当然,针对于redis集群来实现分布式锁现在还有更好的方式,可以直接使用redis官方实现的Redlock,或者通过以下方法加锁保证设置key和过期时间两个操作的原子性,并且释放锁的时候使用lua代码交由redis来执行,实现判断与删除的原子性:
加锁:
jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
解锁:
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));