Redis实现分布式锁

程序媛和她的猫

共 11373字,需浏览 23分钟

 ·

2021-04-17 20:12

一、锁用来解决什么问题?

当我们编写的应用程序存在竞争资源的问题时,需要引入锁来保证共享资源安全。

比如,在淘宝、京东等电商系统中,买家下单购买商品这个业务场景,首先需要查询相应商品的库存是否足够,只有在商品库存数量足够的前提下,才能让用户下单。下单时,我们需要在库存数量中减去下单的商品数量,并将最新的库存数量更新到数据库中。

如果不加锁,就会出现问题,假设某个商品库存只剩一件了,两个买家同时抢购这个商品,同时去读取库存,买家A读取库存是1,可以下单,买家B读取库存也是1,可以下单,就造成一件商品下单两次,这只是两个人,如果很多人同一时间抢购呢,就会造成很不好的影响。

电商系统加锁保证共享数据安全性

二、synchronized这类锁和分布式锁有什么区别?既然已经有synchronized这类锁,为什么还需要Redis分布式锁呢?

1、synchronized等锁

我们知道,当运行一个Java程序时,会启动一个JVM进程来运行我们的应用程序,synchronized保证JVM进程中的多个线程访问公有资源的安全性,所以synchronized也叫JVM锁。

synchronized是针对一个实例的。

2、分布式锁

分布式架构是将应用程序部署在多个不同的JVM实例中,此时需要使用分布式锁保证多个JVM进程访问公有资源的安全性。

分布式锁是针对多个实例的。

三、Redis实现分布式锁

1、简单分布式锁
(1)、Redis如何实现分布式锁?

Redis实现分布式锁主要利用Redis的setnx命令,setnx的意思是set if not exists(如果不存在则set)。

加锁操作setnx key value,如果key不存在,设置value,表示加锁成功,如果key已经存在,则无法设置,表示加锁失败。也就是说,这个key就是分布式锁,哪个客户端抢先给这个key赋值,谁就获取到了锁。

解锁操作del key通过删除value来释放锁,释放锁之后,其他客户端可以通过setnx命令进行加锁。

key可以根据业务场景设置,在下单模块中,是想锁住商品,使得只有一个客户端对商品做操作,此时key可以命名为商品编号,value可以使用uuid保证唯一,用于标识加锁的客户端。

(2)、简单分布式锁示例
public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");

/**
 * 下单接口
 * @param productId 商品ID
 * @return
 */

public String submitOrder(String productId) throws OrderException {
    // 加锁操作,key:商品编码,value:请求客户端的ID。
    Boolean isLocked = jedis.setnx(PRODUCT_ID, requestId);
    // 如果没有拿到锁,返回下单失败
    if(!isLock){
        return "Order failed!";
    }

    int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
    if(stock > 0){// 如果库存足够
        stock -= 1;// 库存减1
        update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
        logger.debug("库存扣减成功,当前库存为:{}", stock);
    }else{
        logger.debug("库存不足,扣减库存失败");
        throw new OrderException("库存不足,扣减库存失败");
    }

    // 解锁操作:业务执行完成,删除key。
    jedis.delete(PRODUCT_ID);
    // 返回下单成功
    return "Order success!";
}
(3)、上述简单分布式锁示例有什么问题?

上述代码有个问题,假设一个客户端获取到锁,然后执行业务代码,但是执行业务代码时,抛出了异常,方法直接退出,导致 del 指令没有被调用,锁未被释放,其他客户端就无法获取到锁,导致后续的所有下单操作都会失败,这就是分布式场景下的死锁问题,锁永远得不到释放。

2、引入try-finally代码块保证锁会被释放掉

说到这,大家可能会想到引入try...finally,将释放锁的操作放入finally块中,保证释放锁的操作肯定会被执行到。

public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");

/**
 * 下单接口
 * @param productId 商品ID
 * @return
 */

public String submitOrder(String productId) throws OrderException {
    // 加锁操作,key:商品编号,value:请求客户端的ID。
    Boolean isLocked = jedis.setnx(PRODUCT_ID, requestId);
    // 如果没有拿到锁,返回下单失败
    if(!isLock){
        return "Order failed!";
    }

    try{
        int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
        if(stock > 0){
            stock -= 1;// 库存减1
            update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
            logger.debug("库存扣减成功,当前库存为:{}", stock);
        }else{
            logger.debug("库存不足,扣减库存失败");
            throw new OrderException("库存不足,扣减库存失败");
        }
    }finally {
        // 解锁操作:业务执行完成,删除key。
        jedis.delete(PRODUCT_ID);
    }
    // 返回下单成功
    return "Order success!";
}

实际上,上述代码还是无法真正解决死锁的问题。从代码逻辑来说,确实解决了死锁问题,但是生产环境是非常复杂的,代码逻辑毫无漏洞,但是不排除机器故障等问题啊。

如果客户端在成功加锁之后,执行业务代码时,还没来得及执行释放锁的代码,此时,该客户端所在的服务器宕机了。后续其他客户端进入提交订单的方法时,因之前客户端没有释放锁,这些客户端无法获取锁,导致下单失败。

3、给key设置过期时间解决死锁问题

我们给key设置一个过期时间,比如 5s,这样即使获取锁的客户端没有及时释放锁,过了 5s 之后,锁会自动释放,其他客户端可以正常获取锁。

public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");

/**
 * 下单接口
 * @param productId 商品ID
 * @return
 */

public String submitOrder(String productId) throws OrderException {

    // 加锁操作,key:商品编号,value:请求客户端的ID。
    Boolean isLocked = jedis.setnx(PRODUCT_ID, requestId);
    // 如果没有拿到锁,返回下单失败
    if(!isLock){
        return "Order failed!";
    }

    try{
        // 给锁加上30s的过期时间
        jedis.expire("ORDER_REDIS_LOCK"30, TimeUnit.SECONDS);
        int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
        if(stock > 0){
            stock -= 1;// 库存减1
            update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
            logger.debug("库存扣减成功,当前库存为:{}", stock);
        }else{
            logger.debug("库存不足,扣减库存失败");
            throw new OrderException("库存不足,扣减库存失败");
        }
    }finally {
        // 解锁操作:业务执行完成,删除key。
        jedis.delete(PRODUCT_ID);
    }
    // 返回下单成
    return "Order success!";
}
4、在 setnx 和 expire 之间服务器宕机,导致死锁
(1)、我们既用了finally,又用了锁超时机制,为什么还会发生死锁?

此时我们既用了finally,又用了锁超时机制,但是还是无法真正避免死锁问题的发生。还有在哪种情况下会发生死锁呢?下面我们来讲解一下。

当一个客户端执行完setnx这段代码后,正要执行expire这段代码时,该客户端所在的机器宕机了,key超时没设置上,该客户端又没有删除key,所以key会一直存在,这样的话,后续其他客户端执行setnx失败,这些客户端无法获取锁,导致下单失败。

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire 是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 if else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。

为了解决这个问题,Redis 2.8中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,彻底解决了分布式锁的这个问题。

Redis 2.8新增加的指令如下:

set key value [ex seconds] [px milliseconds] [nx|xx]
几个参数的含义:
ex seconds:为键设置秒级过期时间。
px milliseconds:为键设置毫秒级过期时间。
nx:键必须不存在,才可以设置成功,用于添加。
xx:与nx相反,键必须存在,才可以设置成功,用于更新。
(2)、使用Redis 2.8增加的setnx和expire合并的命令,彻底解决死锁问题。
public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");

/**
 * 下单接口
 * @param productId 商品ID
 * @return
 */

public String submitOrder(String productId) throws OrderException {
    // 加锁操作,key:商品编号,value:请求客户端的ID。
    // 并且给锁加上30s的过期时间
    Boolean isLocked = jedis.set(PRODUCT_ID, requestId, "NX""EX"30);
    // 如果没有拿到锁,返回下单失败
    if(!isLock){
        return "Order failed!";
    }

    try{
        int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
        if(stock > 0){
            stock -= 1;// 库存减1
            update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
            logger.debug("库存扣减成功,当前库存为:{}", stock);
        }else{
            logger.debug("库存不足,扣减库存失败");
            throw new OrderException("库存不足,扣减库存失败");
        }
    }finally {
        // 解锁操作:业务执行完成,删除key。
        jedis.delete(PRODUCT_ID);
    }
    // 返回下单成功
    return "Order success!";
}
5、可重入分布式锁
(1)、为什么需要保证可重入性?

在上面的代码中,当一个客户端获取锁之后,其他的线程再去获取锁,就会返回失败。

有一种业务场景:在提交订单的接口方法中,调用了另一个服务,而另一个服务中存在对同一个商品的加锁和解锁操作。

这样的话,提交订单的接口方法加锁成功之后,再调用另一个服务,另一个服务就不能加锁了,程序无法往下继续执行了。也就是说,目前实现的分布式锁没有可重入性

伪代码如下:

// 提交订单的接口方法
public String submitOrder(){
  // 提交订单接口方法中的加锁操作
  
  // 调用另一个服务
  
  // 继续
}

// 另一个服务
public void anotherService(){
  // 对同一件商品进行加锁操作
  
}

为了当出现这种业务场景时,程序能够正常运行,我们需要多考虑一层,设计的分布式锁要具有可重入性。

(2)、什么是可重入性?

简单点来说,就是同一个线程,能够多次获取同一把锁,并且能够按照顺序进行解决操作。在分布式系统中,就是同一个客户端,能够多次获取同一把锁。

(3)、如何实现分布式锁的可重入性?

我们如何支持同一个客户端能够多次获取到锁呢?实现分布式锁的可重入有多种方式,这里只提供一个思路(要下班啦,今天就先懒懒地不写啦,后续有时间再讲!)。

value中多存储一个全局唯一的requestId,代表客户端请求标识,获取锁后将这个客户端请求标识保存在ThreadLocal中,当另一个客户端尝试获取锁的时候,将其客户端标识和ThreadLocal中的客户端标识作比较,如果相同表示同一客户端多次获取同一把锁,即实现可重入锁。


浏览 21
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报