浅谈redis(二)——分布式锁实现

一、什么是分布式锁

简单而言,在分布式系统中,当多个进程之间争抢同一个资源时,需要对多个进程加锁,只能让一个进程抢到锁,这就是分布式锁的概念。多个进程抢夺的这一个资源,可以是数据库中的同一条数据等。

要对多个进程加锁,就要借助外部系统,且这个外部系统有互斥的功能,即多个请求过来,只能给一个请求返回成功,其他请求返回失败。以此来实现加锁。

这个外部系统,可以是mysql,也可以是redis或zookeeper。但是为了追求性能,通常使用redis或zookeeper实现分布式锁。这里讲解redis实现分布式锁的原理。

二、redis分布式锁原理

上面提到,实现分布式锁,一定要有互斥的功能。那么redsi有什么互斥的功能呢?
redis的setnx命令具有互斥的功能,表示 set if not exists。如果key不存在,才能设置成功,否则设置失败。又因为redis命令是单线程执行的,所以,必定只有最先执行setnx命令的客户端能返回成功,其他客户端都返回失败。以此来表示客户端是否抢到了锁。
等抢到锁的客户端执行完逻辑后,调用del命令,删除key,相当于释放锁,这样,其他客户端就可以继续争抢这把锁了。
这就是redis实现分布式锁的大体思路。

三、redis分布式锁优化完善

上面提到的redis实现分布式锁的原理,在正常运行的情况下,是完全没问题的。但是在一些极端情况下,会出现问题。
1.死锁问题:
如果抢到锁的客户端因处理业务异常,没有及时释放锁,或者抢到锁的进程挂了,没有释放锁,那么,这个锁就一直存在,其他客户端就无法抢到锁,造成死锁问题。

解决方案:
设置锁的过期时间,一段时间后,自动过期,释放锁,来解决上述死锁的问题。
如果使用如下命令设置过期时间:

SETNX lock 1 // 加锁 
EXPIRE lock 10 // 10s 后自动过期

那还是有问题。为什么呢?因为上面的语句,加锁和设置过期时间,是两条命令,如果第一条命令执行成功,第二条命令执行失败,那就相当于没有加过期时间,还是会造成死锁的问题。为了避免这种问题,必须使上面两个命令具有原子性,一个执行成功,另一个也必须执行成功。使用以下命令,可以保持其原子性:

SET lock 1 EX 10 NX

此命令在redis2.6.12版本之后才支持。

2.释放其他客户端的锁问题
加了过期时间后,解决了死锁问题,但是,又带来了新的问题。
比如,锁设置了10秒钟的过期时间。但是因为种种原因,抢到锁的客户端15秒才执行完业务逻辑。那么在第10秒的时候,锁就被自动释放了。此时,第二个客户端已经抢到了锁,开始执行业务。而第一个客户端在第15秒执行完业务后,手动调用了del key的操作来释放锁。而此时,锁已经被第二个客户端抢到了,且才执行了5秒的时间,锁就被第一个客户端给释放了。那么此时,第三个客户端就可以去抢到这把锁了,这样造成的问题就是第二个客户端和第三个客户端同时执行了被锁住的代码,那么这个锁就失效了,没有对公共资源起到保护的作用。

造成上述问题的原因有两个:一个是设置的过期时间不合理,业务代码没执行完,锁就过期了。但是,在复杂的运行环境下,我们到底设置多少的过期时间,才能保证任何突发情况下都保证不提前过期呢?这好像是一个无解的答案。

第二个原因就是我们在释放锁的时候,没有加入唯一标识,来判断这把锁到底是不是我抢到的那把锁。如果是我抢到的锁,才有资格释放,如果不是我抢到的锁,那就没有资格释放。这样就解决了释放其他进程的锁的问题。
具体的实现思路如下:
每个客户端加锁时,锁的value值设置一个uuid,代表唯一标识,如下:

SET lock $uuid EX 20 NX

在释放锁时,先获取到锁,判断value是否是自己生成的uuid,如果是,那么执行del key命令,释放锁。伪代码如下;

if redis.get("lock") == $uuid: 
redis.del("lock")

同样的,这两条命令,需要保证原子性。因为如果不保证原子性,执行完第一条命令后,获取到的返回值是自己生成的uuid,而此时,正好锁业自动过期了,然后第二个客户端抢到了锁,并设置了新的uuid。此时,才执行第一个客户端的del key操作,那么,删除的还是第二个客户端的锁。还是有问题。所以,必须保证上述两个命令的原子性。不能让其他命令插入到两者中间执行。
如何保证这两条命令的原子性呢?就用到lua脚本了。将这两条命令写到lua脚本中,可以保证其原子性。这样,就解决了释放其他客户端锁的问题。
java客户端实现此思路的代码如下:

/**
 * 分布式锁的实现
 */
@Component
public class RedisDistLock implements Lock {
    
    

    private final static int LOCK_TIME = 5*1000;
    private final static String RS_DISTLOCK_NS = "tdln:";
    /*
     if redis.call('get',KEYS[1])==ARGV[1] then
        return redis.call('del', KEYS[1])
    else return 0 end
     */
    private final static String RELEASE_LOCK_LUA =
            "if redis.call('get',KEYS[1])==ARGV[1] then\n" +
                    "        return redis.call('del', KEYS[1])\n" +
                    "    else return 0 end";
    /*保存每个线程的独有的ID值*/
    private ThreadLocal<String> lockerId = new ThreadLocal<>();

    /*解决锁的重入*/
    private Thread ownerThread;
    private String lockName = "lock";

    @Autowired
    private JedisPool jedisPool;

    public String getLockName() {
    
    
        return lockName;
    }

    public void setLockName(String lockName) {
    
    
        this.lockName = lockName;
    }

    public Thread getOwnerThread() {
    
    
        return ownerThread;
    }

    public void setOwnerThread(Thread ownerThread) {
    
    
        this.ownerThread = ownerThread;
    }

    @Override
    public void lock() {
    
    
        while(!tryLock()){
    
    
            try {
    
    
                Thread.sleep(100);
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
    
    
        throw new UnsupportedOperationException("不支持可中断获取锁!");
    }

    @Override
    public boolean tryLock() {
    
    
        Thread t = Thread.currentThread();
        if(ownerThread==t){
    
    /*说明本线程持有锁*/
            return true;
        }else if(ownerThread!=null){
    
    /*本进程里有其他线程持有分布式锁*/
            return false;
        }
        Jedis jedis = null;
        try {
    
    
            String id = UUID.randomUUID().toString();
            SetParams params = new SetParams();
            params.px(LOCK_TIME);
            params.nx();
            synchronized (this){
    
    /*线程们,本地抢锁*/
                if((ownerThread==null)&&
                "OK".equals(jedis.set(RS_DISTLOCK_NS+lockName,id,params))){
    
    
                    lockerId.set(id);
                    setOwnerThread(t);
                    return true;
                }else{
    
    
                    return false;
                }
            }
        } catch (Exception e) {
    
    
            throw new RuntimeException("分布式锁尝试加锁失败!");
        } finally {
    
    
            jedis.close();
        }
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
    
    
        throw new UnsupportedOperationException("不支持等待尝试获取锁!");
    }

    @Override
    public void unlock() {
    
    
        if(ownerThread!=Thread.currentThread()) {
    
    
            throw new RuntimeException("试图释放无所有权的锁!");
        }
        Jedis jedis = null;
        try {
    
    
            jedis = jedisPool.getResource();
            Long result = (Long)jedis.eval(RELEASE_LOCK_LUA,
                    Arrays.asList(RS_DISTLOCK_NS+lockName),
                    Arrays.asList(lockerId.get()));
            if(result.longValue()!=0L){
    
    
                System.out.println("Redis上的锁已释放!");
            }else{
    
    
                System.out.println("Redis上的锁释放失败!");
            }
        } catch (Exception e) {
    
    
            throw new RuntimeException("释放锁失败!",e);
        } finally {
    
    
            if(jedis!=null) jedis.close();
            lockerId.remove();
            setOwnerThread(null);
            System.out.println("本地锁所有权已释放!");
        }
    }

    @Override
    public Condition newCondition() {
    
    
        throw new UnsupportedOperationException("不支持等待通知操作!");
    }

}

上面提到的设置过期时间,无法很好的评估准确的过期时间。这个问题如何解决呢?
是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一 个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享 资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。 这确实一种比较好的方案。Redisson 把这些工作都封装好了。 在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守 护线程我们一般也把它叫做「看门狗」线程。除此之外,这个 SDK 还封装了很 多易用的功能:可重入锁、乐观锁、公平锁、读写锁、Redlock(红锁)。它提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式 锁。
相关地址:
redission github地址
redission官网地址

3.单节点redis挂掉后带来的问题
我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做 的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升 为主库,继续提供服务,以此保证可用性。 那当「主从发生切换」时,这个分布锁会依旧安全吗?
试想这样的场景: 客户端 1 在主库上执行 SET 命令,加锁成功 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的) 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了! 可见,当引入 Redis 副本后,分布锁还是可能会受到影响。
针对这个问题如何解决呢?redis作者设计了RedLocak红锁,来解决这个问题。红锁的大体思路是需要5个redis主节点。这5个主节点互无关系,加锁的时候往5个主节点都加锁,来保证锁的高可用。当时对于RedLock的解决方案,业界存在很大的争论。而且5台redis主节点,成本也很高。

由此可见,采用redis实现分布式锁,目前来看还没有一个十全十美的方案。分布式系统中,很多东西,都没有一个十全十美的解决方案,而是根据具体的业务场景,设计出最适合的解决方案

猜你喜欢

转载自blog.csdn.net/qq1309664161/article/details/125270861