Redis分布式锁——小心求证

Redis分布式锁

 

大胆假设,小心求证 —— 前辈们这样说道。

 

近期在项目中为了防止恶意并发操作,使用到了分布式锁。

几种常见的方案:

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并不能完全的实现锁的安全性。 举个例子来说:

  1. A客户端在master实例上获得一个锁。
  2. 在对象锁key传送到slave之前,master崩溃掉。
  3. 一个slave被选举成master。
  4. B客户端可以获取到同个key的锁,但A也已经拿到锁,导致锁失效。

由于在公司内部所有的节点都能保证系统时间一致,并且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));

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值