redis分布式锁的层层推导

redisson

      • 项目依赖和配置
      • 场景与问题
      • 使用setnx做锁
      • 业务出现异常
      • 宕机
      • 抽取方法
      • 可重入锁
      • 自旋
      • 续命

项目依赖和配置

pom.xml

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.0.RELEASE</version>
</parent>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>2.7.0</version>
    </dependency>
</dependencies>

我们使用的redis客户端库是springboot的。

application.yml

spring:
  redis:
    database: 0
    host: 192.168.8.125
    port: 6379
    jedis:
      pool:
        max-active: 8

场景与问题

我们肯定要讲多个线程同时购买某件商品的例子。

多个线程同时进入我们的order()方法,一定会有并发问题,这个问题的结果就是:超卖。

@RestController
public class ShopCartController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @RequestMapping(value = "/order")
    public String order() {

        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));

        if (stock > 0) {
            stock -= 1;
            stringRedisTemplate.opsForValue().set("stock", stock + "");
            System.out.println("succeed in deduction! stock: " + stock);
        } else {
            System.out.println("no more stock");
        }
        return "operation ends!";
    }
}

因为有自动装配,所以StringRedisTemplate可以直接用。

我们可以用synchronized来解决并发问题。

但是实际情况是,服务部署在多个tomcat上,也就是说,有多个jvm,这时synchronized是不起作用的。

所以我们要在redis上面做文章。


使用setnx做锁

在redis命令中,有

setnx的含义是set if not exists。

如果key不存在,则可以set;否则不能set。

我们可以用这个特性来做一把锁。

 public static final String product = "computer";

    @RequestMapping(value = "/order")
    public String orderClothes() {
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(product, "ocean");

        if(!lock){
            return "locked by others";
        }

        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));

        if (stock > 0) {
            stock -= 1;
            stringRedisTemplate.opsForValue().set("stock", stock + "");
            System.out.println("succeed in deduction! stock: " + stock);
        } else {
            System.out.println("no more stock");
        }
   		stringRedisTemplate.delete(product);
        return "operation ends!";
    }

比如A操作成功了,那么他就会执行业务代码。其他人就会得到locked by others这样的反馈。

最后我们一定要将用来做锁的key给删掉。这样子其他人操作setIfAbsent就会成功了。

业务出现异常

如果在执行业务是抛出了异常:


        if (stock > 0) {
            stock -= 1;
            
            //exception fired when executing service
            
            stringRedisTemplate.opsForValue().set("stock", stock + "");
            System.out.println("succeed in deduction! stock: " + stock);

这时stringRedisTemplate.delete(product);就不会执行,锁永远没有释放,出现了死锁

我们的解决办法是try catch。

 try {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));

            if (stock > 0) {
                stock -= 1;
                //exception fired when executing service
                stringRedisTemplate.opsForValue().set("stock", stock + "");
                System.out.println("succeed in deduction! stock: " + stock);
            } else {
                System.out.println("no more stock");
            }
        }catch (Exception e){
            
        }finally {
            stringRedisTemplate.delete(product);
        }

有了finally,jvm就会保证给你把锁释放了。

宕机

如果宕机了,jvm直接就挂了,即使写了finally也无济于事。

那么,我们就只能指望redis了。

redis的超时机制会帮我们把锁删掉。

   Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(product, "ocean",30, TimeUnit.SECONDS);

在设置锁的时候就给它30秒的过期时间,这样就不用担心宕机了。

抽取方法

基于上面的讨论,我们将方法抽取出来继续研究。

接口:

public interface RedisLock {

    boolean tryLock(String key, long timeout, TimeUnit timeUnit);

    void releaseLock(String key);
}

实现类:

@Component
@Scope("singleton")
public class RedisLockImpl implements RedisLock {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    @Override
    public boolean tryLock(String key, long timeout, TimeUnit timeUnit) {
        String uuid = UUID.randomUUID().toString();
        threadLocal.set(uuid);
        boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
        return lock;
    }

    @Override
    public void releaseLock(String key) {
        if (threadLocal.get().equals(stringRedisTemplate.opsForValue().get(key))) {
            stringRedisTemplate.delete(key);
        }
    }
}

这就是前面讨论的逻辑的抽取。

那么为什么要用threadLocal呢?就是为了让生成的uuid可以在同一个线程中传递,并且保证线程之间的隔离。

为什么要传递呢?

因为我们在releaseLock的时候,需要从threadLocal中取出在tryLock中存的uuid。然后和redis中存的比较。如果是一样的,那就释放锁。

为什么要这么做呢?这是为了保证先tryLockreleaseLock的顺序。如果在releaseLock时没有这个比较的限制,有的程序员可能就不小心先调用了releaseLock,出现锁的误删。

可重入锁

如果我还有其他业务:

public class ServiceA {
    private ServiceB serviceB;

    public void methodA(){
        serviceB.methodB();
    }

}
public class ServiceB {
    private RedisLock redisLock;

    public void methodB() {
        String key = "TV";

        boolean lock = redisLock.tryLock(key, 20, TimeUnit.SECONDS);

        if(!lock){
            return;
        }

        try{
            //TODO
            //service code
        }finally {
            redisLock.releaseLock(key);
        }
    }
}

我在加锁之后又执行了methodB()方法会怎样?

原有的order()方法变为:


            if (stock > 0) {
                stock -= 1;
                //exception fired when executing service
                stringRedisTemplate.opsForValue().set("stock", stock + "");
                System.out.println("succeed in deduction! stock: " + stock);

                //other services
                new ServiceA().methodA();
                ......

现在的问题是,methodB()里面能够拿到锁吗?

显然是不行的,里面的boolean lock = redisLock.tryLock(key, 20, TimeUnit.SECONDS);会返回false

这就是不可重入锁。

我们希望锁是能够重入的,也就是说,同一条线程能够两次获得锁。

    @Override
    public boolean tryLock(String key, long timeout, TimeUnit timeUnit) {
        boolean lock;
        //if it's the first time to obtain the lock
        if(threadLocal.get()==null) {
            String uuid = UUID.randomUUID().toString();
            threadLocal.set(uuid);
            lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
        }else{
            //if the lock has been obtained
            lock = true;
        }
        return lock;
    }

想一下,到了methodB(),它会直接走else,也能够拿到锁。

但是还有问题。

想像一下:

  if (stock > 0) {
                stock -= 1;
                //exception fired when executing service
                stringRedisTemplate.opsForValue().set("stock", stock + "");
                System.out.println("succeed in deduction! stock: " + stock);

                //other services
                new ServiceA().methodA();

            } else {
                System.out.println("no more stock");
            }

other services走完之后,methodB()redisLock.releaseLock(key);,这时候就把锁的那个key给清掉了。如果此时有一个线程过来拿锁,当然是能够成功获得的。

也就是在ShopCartController里:

   public String order() {
        Boolean lock = redisLock.tryLock(product,30,TimeUnit.SECONDS);
........

能返回true

这当然是不对的,因为我第一个线程还没完事呢。

所以我们要在RedisLockImpl中加入记录获取锁次数的属性:

  private static int lockCount = 0;

    @Override
    public boolean tryLock(String key, long timeout, TimeUnit timeUnit) {
        boolean lock;
        //if it's the first time to obtain the lock
        if(threadLocal.get()==null) {
            String uuid = UUID.randomUUID().toString();
            threadLocal.set(uuid);
            lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
        }else{
            //if the lock has been obtained
            lock = true;
        }

        if(lock){
            lockCount++;
        }
        return lock;
    }

    @Override
    public void releaseLock(String key) {
        lockCount--;
        if(lockCount==-1) {
            if (threadLocal.get().equals(stringRedisTemplate.opsForValue().get(key))) {
                stringRedisTemplate.delete(key);
            }
        }
    }

每次获取锁,lockCount加一。

只有当lockCount为-1时才删掉key。这就保证了只能在order()方法里才能释放锁。

自旋

没有获取到锁的线程只是单纯的

 if(!lock){
            return "locked by others";
        }

这是阻塞锁。

我们要非阻塞锁,或者说,要乐观锁。

这时没获取锁的线程就不断去获取,这是自旋:

  if(lock){
            lockCount++;
        }else {
            while (true){
                lock = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, timeout, timeUnit);
                if(lock){
                    break;
                }
            }
        }

没有获取锁就一直去拿,知道lock为true

这些知识点,和ReentrantLock一模一样。

续命

还有一个问题,如果第一个拿到锁的线程的执行时间超过了30秒怎么办?

30秒后key就失效了,其他线程就能够拿到锁了,这是不对的。

所以,我们要定时去续这个key的命:

tryLock代码中添加一个异步逻辑:

   new Thread(() -> {
           stringRedisTemplate.expire(key,30, TimeUnit.SECONDS);
            try {
                TimeUnit.SECONDS.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

每隔10秒,我去把这个key的时间重新设置成30秒。


你可能感兴趣的:(redis)