基于Redis实现分布式锁

分布式锁除了使用之前介绍的基于数据库相关的操作之外,还可以使用Redis来实现分布式锁操作,使用Redis来实现分布式锁操作也是在实际开发中经常使用的方式。

下面我们就来介绍关于如何使用Redis的原子操作来实现分布式锁相关的内容。

Redis需要解决的几个问题

  • 互斥性

  • 锁超时

  • 支持阻塞与非阻塞

  • 可重入性

  • 高可用

互斥性

在一个电商系统中,如果一个客户进行下单操作,那么对应的要对实际的库存进行减扣操作,基于这样的一个场景,就会出现超卖的问题。那么什么是超卖问题,通过下面的演示来看一下什么是超卖问题?

package com.auto.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@Slf4j
@RestController
public class RedisController {
    private final static String product = "apple";

    @Resource(name = "redisTemplate")
    private RedisTemplate<String, Object> redisTemplate;

    @GetMapping("/get")
    public void get(){
        //超卖问题,实现一个简单的超卖逻辑
        int stock = Integer.parseInt((String) redisTemplate.opsForValue().get(product));
        if(stock > 0){
            //执行下单逻辑
            stock = stock - 1;
            redisTemplate.opsForValue().set(product, String.valueOf(stock));
            log.info("进行库存的扣减,现在库存是{}", stock);
        }else {
            log.error("库存扣减失败,库存不足");
        }
    }
}

上面的代码逻辑是说,当我们访问/get 请求的时候说明用户进行了下单的操作,所以就要获取到当前的库存量,然后进行减一操作。将减一之后的结果更新到库存中。

现在问题来了在单用户操作的场景下没有任何问题,或者说再单线程的场景下没有任何问题,那么一个电商系统并不是简单的就支持一个用户,它需要支持的高并发。也就是说支持多个用户同时下单的操作。那么这个时候就会出现问题。下面就通过Jmeter来模拟;

从上面截图中可以看到49、48、47、44、42、37等,交易成功了很多订单。显而易见出现了并发扣减。出现这种问题,我们需要怎么解决呢?我们想到了多线程,线程安全的问题,可以通过synchronized关键字来解决的。于是将我们的代码改成了如下的效果

@Slf4j
@RestController
public class RedisController {
    private final static String product = "apple";

    @Resource(name = "redisTemplate")
    private RedisTemplate<String, Object> redisTemplate;

    @GetMapping("/get")
    public void get() {
        //通过加锁
        synchronized (product) {
            //超卖问题,实现一个简单的超卖逻辑
            int stock = Integer.parseInt((String) redisTemplate.opsForValue().get(product));
            if (stock > 0) {
                //执行下单逻辑
                stock = stock - 1;
                redisTemplate.opsForValue().set(product, String.valueOf(stock));
                log.info("进行库存的扣减,现在库存是{}", stock);
            } else {
                log.error("库存扣减失败,库存不足");
            }
        }
    }
}

通过结果观察看上去是没有什么问题的,但是真的没有问题么?这个一个值得商榷的问题,在很多的电商网站,后台的应用程序并不是简单的应用程序来进行操作,后台是挂载了很多的相同的应用程序,这些相同的应用程序组成的叫做集群,对于一个应用来说,并不是一台机器。而是多台机器,通过Nginx将负载请求转发到不同的机器上。如果是这样会出现什么结果呢?

我们使用集群的模式将应用程序,通过nginx转发到不同的机器上;

从上面的两个机器的结果上就可以看出,两台机器都有一个扣减42的操作,这里只是并发200个,如果更大的,会有更多扣减重复的情况。

这里就有人说在代码逻辑中不是已经加入synchronized关键字了么,为什么还会出现这样的问题。这里就要理解一下synchronized的原理,它是对于当前虚拟机而言的。对于两个实例它是运行到两个不一样的JVM中的。所以说它们两者之间是相互独立的,也就是不能保证两个独立的JVM之间加锁的。这个时候就需要一个公共的锁来提供服务。也就出现了分布式锁。通过分布式锁来解决保证互斥操作

分布式锁原始模型

下面就来分析一下这个代码,首先我们知道,在一个代码执行过程中都是从上到下进行执行的,那么如果上面这段代码在执行删减库存操作的时候出现了问题,那么就会导致最后释放锁的逻辑没有被执行到。那么其他的线程进来访问这个操作的时候就会出问题。所以这里做的第一步优化是对这个业务逻辑的异常进行捕获。并且将释放锁的逻辑放入到finally中,如下

锁超时

那么我们知道finally中的代码一定会执行么,我认为是不一定的,在Java中Exception是可以被捕获的。机房停电,kill -9 等操作,导致整个的逻辑还没有来得及执行finally就出现问题。也就导致了刚刚的错误。这个时候就会想到,其实在setnx操作之后还可以对这个所进行一个超时时间的设置。也就是说进行了如下的一个优化。

通过过期时间设置之后,即使在操作过程中出现了kill -9 这样的操作,也会在30秒之后自动的将锁进行释放,这样的话后续的操作线程还是可以获取到锁进行操作。

这个代码现在就已经没有其他问题了吗?

这里就涉及到Redis调用过程中的消耗问题。这段代码看上去没有问题,但是实际上,如果每一个线程进入之后都进行请求获取锁操作,也就是在RedisSession中存在大量的get set操作,而这些get set操作之间都是独立的,在一定程度上非常消耗IO和网络资源。也就是每个线程请求进入之后都要占用,如果进入的线程量大的话就会导致问题。那么这个改进方案,就是在获取锁的同时就进行加入超时的操作。如下,看上去大功告成,但是实际上作为一个公共组件,最好是单独的进行处理操作。

公共组件化处理

作为一个公共组件来说,首先需要做的事情就是实现某种规则,那么这种规则如何实现,实现什么样的规则就需要使用到接口,对于一个锁来说,最主要的两个操作就是锁的获取,以及锁的释放操作。如下。

定义一个锁操作的接口

package com.auto.redisLock;

public interface Lock {
    //获取锁
    boolean getLock();
    //释放锁
    boolean releaseLock();
}

锁操作的实现类,注册到IOC容器中


import com.auto.redisLock.Lock;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Component
public class RedisLock implements Lock {

    private static final String lock = "lock";
    
    @Resource(name = "redisTemplate")
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public boolean getLock() {
        Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(lock, lock, 30, TimeUnit.SECONDS);
        return aBoolean;
    }

    @Override
    public boolean releaseLock() {
        return redisTemplate.delete(lock);
    }
}

这个操作完成之后就需要考虑一下了,在整个的操作过程中,多线程访问,怎么能保证这个线程进入之后和其他线程进入之后获取到的就是自己拿到的那把锁,或者说,这锁应该在整个的应用中是单例存在的。这样如何保证呢?因为我们知道在多线程执行过程中会涉及到一个指令重排序的操作,第一个线程进来之后正常的获取到锁了,注意这个是对Redis的操作,在Redis中设置了一个lock,那么其他线程进入的时候其实已经开始准备执行release的操作了那么这个时候就会导致在Redis不存在lock,后续的线程就会进行重新的设置。导致整个系统的死锁,或者是服务假死。那么就要保证当前线程操作当前锁。这里先做了如下的一个操作

首先从代码逻辑的角度上来说好像是可以保证了当前线程操作当前锁,那么带来的另外的问题就是这个UUID会被覆盖,这样就出现了线程安全问题,有人就说了可不可以考虑用一个不被覆盖的内容来进行操作呢?线程安全并且还可以在随着线程传递点东西,那么首选的就是ThreadLocal。下面对代码进行如下的改进

上面这个代码就保证了,其他线程不会拿到当前线程的UUID,也就不会对UUID产生覆盖了。既然是这样的操作,可以保证了线程安全。那么会不会有其他问题呢?我们知道在使用ThreadLocal的时候在第一个线程使用过之后,如果没有进行清理,当复用线程的时候在当前线程的ThreadLocal中就会有脏数据。这个时候就会出现其他线程也可以对锁进行释放。这个时候就需要将整个的ThreadLocal在使用完成之后进行remove操作。

到这里看上去我们的分布式锁应该是没有问题了,那么就要结合正常的使用场景来进行分析了

阻塞或者非阻塞

在这里可以看到如果后续线程没有获取到锁之后会直接进行返回操作,在一定场景下并不是太满足场景业务要求。那么就需要进行一个优化操作,实现一个阻塞与非阻塞的调配。例如一个抢购的场景,抢购的商品就只有100个,而正好从各个地方过来的请求也只有100个,那么当第一个人下单的时候,这个时候其他人获取锁的时候都是false,意味着后进入的99个用户其实是没有买到商品的。那么这个时候就需要让锁支持一个阻塞操作。也就是说如果后续没有拿到锁,就一直尝试获取这个锁,而不是直接返回。对于直接返回的操作就可以看作是一个非阻塞的,而对于等待获取就是一个阻塞的。

这里就需要对分布式锁组件可以进行阻塞操作。这里采用了自旋的方式来实现。也就是先在外面定义一个锁标识,如果这个锁标识为获取到锁则直接返回,如果没有获取到锁则进入到一个死循环中,一直尝试去获取这个锁,直到获取到锁之后跳出本次的循环

对于阻塞与非阻塞在很多场景中需要做一定的取舍,因为在阻塞状态下的死循环其实是一个非常消耗CPU内存的一个操作。所以要在这个地方做一个优化。

可重入性

在有些业务场景中,存在这样一个场景,就是A调用B,B调用C,而在使用C服务的时候已经拿到了着锁,这个时候就会导致当前线程操作无法获取到这个锁,但是实际上从某种角度上讲,C服务的操作与当前操作是有关联的,我们需要让这个锁被当前服务也可以获取到

这个时候上面这种场景就需要支持这个锁的可重用,类似于ReentrantLock。为什么会有这个内容呢?就是因为在使用synchronized关键字的时候,在外部进行加锁操作之后,在内部继续使用synchronized关键字进行加锁,这个内部的锁是不起作用的。从上面的调用可以看到,在整个的业务逻辑中其实并不是这么简单的操作,如果使用这样的一个锁操作,导致整个的C获取不到锁,导致程序无法继续执行。那么就需要对锁操作进行优化。通过ThreadLocal的UUID标识判断来获取当前是否是第一次获取锁,如果是则执行新逻辑,如果不是则直接返回已经获取过锁,保证了锁的重入性。

高可用

如果第一个请求过来了开始获取锁,所有的对于锁的请求问题都已经解决了,并且设置了30秒的过期时间,但是这里一定可以保证这个锁拿到之后,后续的业务操作就一定是低于30秒进行返回么,如果超过30秒那么就导致在释放锁的时候没有该锁这个一个问题,30秒之后这个锁失效了,其他线程就会继续进入到该操作中,但是实际上当前操作并没有完成。那么这样这个问题怎么解决?其实这个就是可以看做一个锁穿透,几乎可以看做一个操作超时导致其他操作超时,整个所有的锁都是不起作用的。但是如果这个时间设置太长的话,就会影响整个的系统性能。这个时候就需要在低性能的情况下达到Redis分布式锁的高可用。继续对这个问题进行优化

上面代码中可以看到在获取锁的过程中加入了一个while的死循环,也就是说在获取完之后每隔10秒为这个锁做一个自动的延期操作,但是实际上这地方是有问题的,没有对后续的获取锁操作进行释放,但是这个延期操作,在这个锁存在的过程中有必须得一直进行,这个时候就需要考虑到用多线程的来解决。也就是做一个异步操作

package com.auto.redisLock.impl;

import com.auto.redisLock.Lock;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class RedisLock implements Lock {

    private static final String lock = "lock";

    private ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();

    @Resource(name = "redisTemplate")
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public boolean getLock() {
        //默认没有获取到锁
        boolean lockTemp = false;
        //判断ThreadLocal中是否存在锁,不存在则获取
        if (stringThreadLocal.get() == null) {
            String uuid = UUID.randomUUID().toString();
            stringThreadLocal.set(uuid);
            //是否获取到锁
            lockTemp = redisTemplate.opsForValue().setIfAbsent(lock, lock, 30, TimeUnit.SECONDS);
            if (!lockTemp) {
                //没有获取到锁,一直获取,知道获取到锁为止
                while (true) {
                    lockTemp = redisTemplate.opsForValue().setIfAbsent(lock, lock, 30, TimeUnit.SECONDS);
                    if (lockTemp) {
                        break;
                    }
                }
            }
            Thread thread = new Thread(){
                @Override
                public void run(){
                    while (true) {
                        //这里的操作就是不断的执行当前锁每隔10秒设置一个过期时间,这个过期时间就是30秒
                        redisTemplate.expire(lock, 30, TimeUnit.SECONDS);
                        try {
                            TimeUnit.SECONDS.sleep(10000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            };

            thread.start();

        } else if (stringThreadLocal.get().equals(redisTemplate.opsForValue().get(lock))) {
            return true;
        }
        return lockTemp;
    }

    @Override
    public boolean releaseLock() {
        String uuid = stringThreadLocal.get();
        if (uuid.equals(redisTemplate.opsForValue().get(lock))) {
            redisTemplate.delete(lock);
            stringThreadLocal.remove();
            return true;
        }
        return false;
    }
}

做完异步操作之后需要在释放的时候把这个线程停止,那么怎么实现呢?这个时候需要考虑一个问题,线程与子线程的问题。这里会看到,如果将调用组件的线程看作是父线程,那么新创建的这个线程应该是属于它的子线程,也就是说通过某种手段,在不影响父线程的情况下把子线程给做停止操作。这里手段很多,这里借助于ThreadLocal来标识是当前线程的子线程。

总结

到这里整个的分布式的逻辑就完成了。当然这里使用自己编写的方式来实现分布式锁,但是在很多的框架中都有所实现,这里比较常用的就是RedisSession

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值