了解Redis分布式锁

63 阅读5分钟

背景分析

我们在进行代码开发过程中,很容易遇到一种情况,就是在新增或者修改数据的时候,如果用户频繁点击,导致客户端向服务器在同一时间或者是时间间隔很短的情况下发送多次请求,如果数据没有被加锁的情况下,很容易导致数据异常等问题的出现,特别是涉及到金额、库存数量等敏感数据,更是会因此对用户或者公司造成利益损失。

如何解决

为了解决这种数据异常问题,我们通常是要对资源进行加锁,对于锁大家肯定不陌生,在Java中的synchronized关键字和ReetrantLock可重入锁在我们代码是非常常见的,以及Mysql乐观锁和悲观锁;但是像synchronized关键字的实现在根本上是基于线程之间共享内存实现的,也就是说在同一进程下,当某个方法或者代码块使用锁,在同一时刻下仅有一个线程可以执行该方法或者代码块;但是随着分布式系统的快速发展以及微服务框架的广泛使用,现在的系统部署都是线上多个节点进行部署,也就是说会有多个进程,所以synchronized关键字就会失去作用,这时候就需要用到分布式锁

什么是分布式

分布式的CAP理论

1)一致性(Consistency):一致性是指数据在多个副本之间能否保持一致的特性。例如一个数据在某个分区节点更新之后,在其他分区节点读出来的数据也是更新之后的数据。

2)可用性(Availability):可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。这里的重点是"有限时间内"和"返回结果"。

3)分区容错性(Partition tolerance):分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务。

基于CAP理论,在系统的设计之初就要对三者做出取舍,因为分布式系统只能满足其二,无法这三点全部满足,在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用,系统往往只需要保证最终一致性

什么是分布式锁

分布式锁是控制分布式系统同步访问共享资源的一种方式,保证多线程和多进程的情况下数据的安全性,仅有一个线程可以访问共享资源

分布式锁的特点:

1)互斥性:分布式锁需要保证不同节点的不同线程之间互斥

2)可重入性:同一个节点的同一个线程如果获取了锁之后也可以再次获取锁

3)高可用:加锁和解锁需要高效 高可用

4)支持阻塞和非阻塞

5)支持公平锁和非公平锁:公平锁就是按照请求加锁的顺序获取锁,非公平锁就是无序的,一般都是公平锁

实现分布式锁的方式

1)Mysql分布式锁

2)Zookeeper

3)Redis

4)Redisson

使用Redis实现分布式锁

加锁

使用redis来实现分布式锁的加锁操作,我们需要用到两条指令,分别是setnx和expire

1)setnx:就是set if not exist,将一个key值进行缓存设置,如果存在则不set,不存在则set,对应的返回值是成功返回1,失败返回0。

2)expire:将对应的key设置过期时间

为什么在加锁的过程中需要设置过期时间呢?因为在设置key的时候可能会出现redis宕机等问题出现,假如说该key没有设置过期时间,那么key将会一直存在缓存中,锁永远不会释放,当服务恢复之后,如果还有线程来争夺相同的资源,就会出现无法获取锁的情况,导致出现死锁,所以设置过期时间是为了让key在过期时间之后自动释放

这时候又会引发出来一个问题,如果在setnx之后,redis还没来得及通过expire设置过期时间就已经宕机了,那么这个key还是没有设置对应的过期时间,同样也会造成死锁,那么该如何解决呢?

其实我们需要保证setnx和expire两条指令的原子性,让这两条指令在同一时间内一起完成就可以解决,所以我们需要用到lua脚本来保证redis指令的原子性

public boolean tryLock_with_lua(String key, String UniqueId, int seconds) {
    String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" + "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
    List keys = new ArrayList<>();
    List values = new ArrayList<>();
    keys.add(key);
    values.add(UniqueId);
    values.add(String.valueOf(seconds));
    Object result = jedis.eval(lua_scripts, keys, values); 
    //判断是否成功 return result.equals(1L); 
}

解锁 解锁的过程就是将key进行删除,但是这也不能随便删除key对应的value值,为了保证解锁操作的原子性,也是通过lua脚本来完成,先判断当前锁的字符串是否与传入的值相等,是的话就删除key,解锁成功

public boolean releaseLock_with_lua(String key,String value) {
    String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then "
            + "return redis.call('del',KEYS[1]) else return 0 end";
    return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}

这时候又会引发出来一个问题,举个栗子,假如接口的响应时间普遍都在10s内,key的过期时间我们设置为10s,但是难免会出现网络波动等问题,导致了接口的响应时间大于10s,这时候接口还没响应完成,但是锁已经释放了,其他线程过来争夺锁的时候同样也可以获取到锁,这个要怎么解决呢?

其实可以使用看门狗的机制的,就是通过定时任务来实时监听对应的key,判断key是否存在,如果存在的话则延长过期时间,也就是说对key进行续命