java架構之路-(Redis專題)簡單聊聊redis分佈式鎖

  這次我們來簡單說說分佈式鎖,我記得過去我也過一篇JMM的內存一致性算法,就是說拿到鎖的可以繼續操作,沒拿到的自旋等待。

思路與場景

  我們在Zookeeper中提到過分佈式鎖,這裏我們先用redis實現一個簡單的分佈式鎖,這裏是我們一個簡單的售賣減庫存的小實例,剩餘庫存假設存在數據庫內。

@GetMapping(value = "/getLock")
public String getLock() {
    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
    if (stock > 0) {
        int realStock = stock - 1;
        stringRedisTemplate.opsForValue().set("stock", realStock + "");
        System.out.println("售賣成功,剩餘" + realStock + "");
        return "success";
    }else{
        System.out.println("剩餘庫存不足");
        return "fail";
    }
}

  這樣簡單的實現了一個售賣的過程,現在看來確實沒什麼問題的,但是如果是一個併發下的場景就可能會出現超賣的情況了,我們來改造一下代碼。

@GetMapping(value = "/getLock")
public String getLock() {
    synchronized (this) {
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + "");
            System.out.println("售賣成功,剩餘" + realStock + "");
            return "success";
        } else {
            System.out.println("剩餘庫存不足");
            return "fail";
        }
    }
}

  貌似這回就可以了,可以抗住高併發了,但是新的問題又來了,我們如果是分佈式的場景下,synchronized關鍵字是不起作用的啊。也就是說還是會出現超賣的情況的啊,我們再來改造一下

@GetMapping(value = "/getLock")
public String getLock() {
    String lockKey = "lock";

    Boolean bool = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xiaocai");//相當於我們的setnx命令
    if(!bool){
        return "error";
    }

    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
    if (stock > 0) {
        int realStock = stock - 1;
        stringRedisTemplate.opsForValue().set("stock", realStock + "");
        System.out.println("售賣成功,剩餘" + realStock + "");
        stringRedisTemplate.delete(lockKey);
        return "success";
    } else {
        System.out.println("剩餘庫存不足");
        stringRedisTemplate.delete(lockKey);
        return "fail";
    }
}

  這次我們看來基本可以了,使用我們的setnx命令來做一次唯一的限制,萬一報錯了呢?解鎖怎麼辦?再來改造一下。

@GetMapping(value = "/getLock")
public String getLock() {
    String lockKey = "lock";
    Boolean bool = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "xiaocai", 10, TimeUnit.SECONDS);//相當於我們的setnx命令
    try {
        if (!bool) {
            return "error";
        }

        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + "");
            System.out.println("售賣成功,剩餘" + realStock + "");

            return "success";
        } else {
            System.out.println("剩餘庫存不足");
            return "fail";
        }
    } finally {
        if (bool) {
            stringRedisTemplate.delete(lockKey);
        }
    }
}

    這次貌似真的可以了,可以加鎖,最後在finally解鎖,如果解鎖還是不成功,我們還設置了我們的超時時間,貌似完美了,我們再來提出一個場景。

 

     就是什麼意思呢?我們的線程來爭搶鎖,拿到鎖的線程開始執行,但是我們並不知道何時執行完成,我們只是設定了10秒自動釋放掉鎖,如果說我們的線程10秒還沒有結束,其它線程會拿到鎖資源,開始執行代碼,但是過了一段時間(藍色線程還未執行完成),這時我們的綠色線程執行完畢了,開始釋放鎖資源,他釋放的其實已經不是他自己的鎖了,他自己的鎖超時了,自動釋放了,實則綠色線程釋放的藍色的資源,這也就造成了釋放其它的鎖,其它的線程又會重複的拿到鎖,重複執行該操作。明顯有點亂了,這不合理,我們來改善一下。

@GetMapping(value = "/getLock")
public String getLock() {
    String lockKey = "lock";
    String lockValue = UUID.randomUUID().toString();
    Boolean bool = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);//相當於我們的setnx命令
    try {
        if (!bool) {
            return "error";
        }

        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + "");
            System.out.println("售賣成功,剩餘" + realStock + "");

            return "success";
        } else {
            System.out.println("剩餘庫存不足");
            return "fail";
        }
    } finally {
        if (lockValue.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
            stringRedisTemplate.delete(lockKey);
        }
    }
}

  這次再來看一下流程,我們設置一個UUID,設置爲鎖的值,也就是說,每次上鎖的UUID都是不一致的,我們的線程A的鎖這次只能由我們的線程A來釋放掉,不會造成釋放其它鎖的問題了,還是上次的圖,我們回過頭來看一下,10秒?真的合理嗎?萬一10秒還沒有執行完成呢?有的人還會問,那設置100秒?萬一執行到delete操作的時候,服務宕機了呢?是不是還要等待100秒纔可以釋放鎖。別說那只是萬一,我們的代碼希望達到我們能力範圍之內的最嚴謹。這次來說一下我們本節的其中一個重點,Lua腳本,後面會去說,我們來先用我們這次博文的Redisson吧

Redisson

  剛纔我們提到了我們鎖的時間設置,多長才是合理的,100秒?可能宕機,造成等待100秒自動釋放,1秒?線程可能執行不完,我們可不可以這樣來做呢?我們設置一個30秒,或者說設置10秒,然後我們給予一個固定時間來檢查我們的主線程是否執行完成,執行完成再釋放我們的鎖,思路有了,但是代碼實現起來並不簡單,彆着急,我們已經有了現成的包供我們使用的,就是我們的Redisson,首先我們來引入我們的依賴,修改一下pom文件。

<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.11.4</version>
</dependency>

然後通過@Bean的方式注入容器,三種方式我都寫在上面了。

@Bean
public Redisson redisson(){
    Config config = new Config();
    //主從(單機)
    config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
    //哨兵
//    config.useSentinelServers().setMasterName("mymaster");
//    config.useSentinelServers().addSentinelAddress("redis://192.168.1.1:26379");
//    config.useSentinelServers().addSentinelAddress("redis://192.168.1.2:26379");
//    config.useSentinelServers().addSentinelAddress("redis://192.168.1..3:26379");
//    config.useSentinelServers().setDatabase(0);
//    //集羣
//    config.useClusterServers()
//            .addNodeAddress("redis://192.168.0.1:8001")
//            .addNodeAddress("redis://192.168.0.2:8002")
//            .addNodeAddress("redis://192.168.0.3:8003")
//            .addNodeAddress("redis://192.168.0.4:8004")
//            .addNodeAddress("redis://192.168.0.5:8005")
//            .addNodeAddress("redis://192.168.0.6:8006");
//    config.useSentinelServers().setPassword("xiaocai");//密碼設置
    return (Redisson) Redisson.create(config);
}

如果我們的是springboot也可以通過配置來實現的。

application.properties

## 因爲springboot-data-redis 是用到了jedis,所已這裏得配置
spring.redis.database=10
spring.redis.pool.max-idle=8
spring.redis.pool.min-idle=0
spring.redis.pool.max-active=8
spring.redis.pool.max-wait=-1
## jedis 哨兵配置
spring.redis.sentinel.master=mymaster
spring.redis.sentinel.nodes=192.168.1.241:26379,192.168.1.241:36379,192.168.1.241:46379
spring.redis.password=admin
## 關鍵地方 redisson
spring.redis.redisson.config=classpath:redisson.json
redisson.json
## redisson.json 文件
{
  "sentinelServersConfig":{
    "sentinelAddresses": ["redis://192.168.1.241:26379","redis://192.168.1.241:36379","redis://192.168.1.241:46379"],
    "masterName": "mymaster",
    "database": 0,
    "password":"admin"
  }
} 

  這樣我們就建立了我們的Redisson的連接了,我們來看一下如何使用吧。

package com.redisclient.cluster;

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
public class RedisCluster {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private Redisson redisson;

    @GetMapping(value = "/getLock")
    public String getLock() {
        String lockKey = "lock";
        RLock redissonLock = redisson.getLock(lockKey);
        try {
            redissonLock.lock();
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set("stock", realStock + "");
                System.out.println("售賣成功,剩餘" + realStock + "");

                return "success";
            } else {
                System.out.println("剩餘庫存不足");
                return "fail";
            }
        } finally {
            redissonLock.unlock();
        }
    }
}

  使用也是超級簡單的,Redisson還有重入鎖功能等等,有興趣的可以去Redisson查看,地址:https://redisson.org/ 國外的地址打開可能會慢一些。Redis的分佈式鎖使用就差不多說到這裏了,我們來回到我們剛纔說到的Lua腳本這裏。

Lua腳本和管道

Lua腳本

   lua腳本就是一個事務控制的過程,我們可以在lua腳本中寫一些列的命令,一次性的塞入到我們的redis客戶端,保證了原子性,要麼都成功,要麼都失敗。好處在於減少與reidis的多次連接,可以替代redis的事務操作以及保證我們的原子性。

String luaString = "";//Lua腳本
jedis.eval(luaString, Arrays.asList("keysList"),Arrays.asList("valueList"));

  腳本我就不寫了(我也不熟悉),我來解釋一下eval的三個參數,第一個是我們的寫好的腳本,然後我們的腳本可能傳參數的,也就是我們KEYS[1]或者是ARGV[4],意思就是我們的KEYS[1]就是我們的ArrayList("keysList")中的第一項,ARGV[4]就是我們的ArrayList("valueList")的第四項。

管道

  管道和我們的和我們的Lua腳本差不多,不一樣就是管道不會保證我們的事務,也就是說我們現在塞給管道10條命令 ,我們執行到第三條時報錯了,後面的依然會執行,前面執行過的兩條還是生效的。雖然可以減少我們的網絡開銷,也別一次塞太多命令進去,畢竟redis的是單線程的,不建議使用管道來操作redis,想深入瞭解的可以參照https://www.runoob.com/redis/redis-pipelining.html

  redis的分佈式鎖差不多就說這麼多了,關鍵是實現思路,使用Redisson倒是很簡單的,還有我們的Lua腳本和管道,Lua腳本可以保證事務,管道一次性可以執行多條命令,減少網絡開銷,但不建議使用,下次我們來說下,大廠用redis的一些使用注意事項和優化吧。

 

最進弄了一個公衆號,小菜技術,歡迎大家的加入

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章