Redis應用(二) --分散式鎖以及壓測介紹

weixin_34234823發表於2018-09-19

Spring-boot 整合Redis應用(二) --分散式鎖以及壓測介紹

一.基礎環境

  • jdk 1.8

  • maven 3.5.3

  • spring-boot 2.0.4

  • redis 4.0.11

  • ApacheBench 2.3

    以上工具需要提前安裝以及熟悉基本操作,在本文中不會講解如何安裝

二.基本介紹

相信各位小夥伴在學習Redis時,都瞭解到Redis不僅僅是一個記憶體中的資料結構儲存系統,它可以用作資料庫、快取和訊息中介軟體。上一篇整合應用已經簡單的介紹了 Redis作為訊息佇列配合基於Servlet 3的非同步請求處理的簡單示例。本篇將介紹Redis在單機部署的場景下的分散式鎖。分散式鎖的思想來源於Redis官網,下面給出中文翻譯相當棒連結,方便大家瞭解分散式鎖。《Redis官方文件》用Redis構建分散式鎖。在這就不講解中心思想了。那麼我會以一個模擬秒殺系統的簡單Demo來一步一步的展示redis分散式鎖的應用。

三.無布式鎖時的秒殺程式碼以及壓測結果

元件依賴

由於是無redis鎖的情況下的秒殺demo,則只需要引入spring-boot基礎依賴即可

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>
程式碼

由於是一個簡單的秒殺下單的demo程式碼,那麼只需要兩個介面,一個是下單介面(order),一個是查詢訂單介面(query),Controller層程式碼如下:

/**
 * @author Neal
 * 測試無分散式鎖controller 層
 */
@RestController
@RequestMapping("/order")
public class NoDistributeController {

    //無分散式鎖service
    @Autowired
    NoDistributeService redisDistributeService;

    /**
     * 查詢剩餘訂單結果介面
     * @param pid  訂單編號
     * @return
     */
    @GetMapping("/query/{pid}")
    public String query(@PathVariable String pid) {
        return redisDistributeService.queryMap(pid);
    }

    /**
     * 下單介面
     * @param pid  訂單編號
     * @return
     */
    @GetMapping("/{pid}")
    public String order(@PathVariable String pid) {
        redisDistributeService.order(pid);
        return redisDistributeService.queryMap(pid);
    }
}

service層程式碼如下:

/**
 * @author Neal
 * 測試無分散式鎖service 層
 */
@Service
public class NoDistributeService {

    //模擬商品資訊表
    private static Map<String,Integer> products;

    //模擬庫存表
    private static Map<String,Integer> stock;

    //模擬訂單表
    private static Map<String,String> orders;

    static {
        products = new HashMap<>();
        stock = new HashMap<>();
        orders = new HashMap<>();
        //模擬訂單表資料 訂單編號 112233 庫存 100000
        products.put("112233",100000);
        //模擬庫存表資料 訂單編號112233 庫存100000
        stock.put("112233",100000);
    }

    /**
     * 模擬查詢秒殺成功返回的資訊
     * @param pid 商品編號
     * @return  返回拼接的秒殺商品結果字串
     */
    public String queryMap(String pid) {
        return "秒殺商品限量:" +  products.get(pid) + "份,還剩:"+stock.get(pid) +"份,成功下單:"+orders.size() + "人";
    }

    /**
     * 下單方法
     * @param pid  商品編號
     */
    public void order(String pid) {
        //從庫存表中獲取庫存餘量
        int stockNum = stock.get(pid);
        //如果庫存為0 則輸出庫存不足
        if(stockNum == 0) {
            System.out.println("商品庫存不足");
        }else{ //如果有庫存
            //往訂單表中插入資料 生成UUID作為使用者ID pid
            orders.put(UUID.randomUUID().toString(),pid);
            //執行緒休眠 模擬其他操作
            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //減庫存操作
            stock.put(pid,stockNum-1);
        }
    }
}

程式碼上有註釋,我就不過多的囉嗦解釋了,那麼讓我們來模擬秒殺環境,使用 ApacheBench 來模擬併發,來看看結果如何:

  1. 進入到 ApacheBench 的bin目錄下,我的路徑是 Apache24\bin。

  2. 輸入命令以及引數:ab -n 1000 -c 100 http://127.0.0.1:8080/order/112233 該命令的含義是 向指定的URL傳送 1000次請求 100 併發量。 結果如圖

    13837765-57dd9734e2670f25.PNG
    無鎖壓測圖1

13837765-1a91f9cb8701dc46.PNG
無鎖壓測圖2

雖然只耗時了2.596秒,但是處理的結果卻不盡如人意。

  1. 然後使用查詢介面查詢一下下單和庫存結果是否一致,請求查詢介面:http://localhost:8080/order/query/112233。結果如圖
    13837765-17bd96881b36f059.PNG
    無鎖查詢結果

可以看出下單人數和庫存餘量明顯不符,就這就是無鎖時,在高併發環境中會引起的問題。

Redis分散式鎖下的秒殺程式碼以及壓測結果

前言

網上看了很多的例子,都是使用redis的SETNX命令來實現的,Redis 官網並不推薦使用SETNX命令,而是推薦使用SET,因為從2.6.12版本以後,Redis對SET命令增加了一系列的選項。

  • EX seconds – Set the specified expire time, in seconds.

  • PX milliseconds – Set the specified expire time, in milliseconds.

  • NX – Only set the key if it does not already exist.

  • XX – Only set the key if it already exist.

  • EX seconds – 設定鍵key的過期時間,單位時秒

  • PX milliseconds – 設定鍵key的過期時間,單位時毫秒

  • NX – 只有鍵key不存在的時候才會設定key的值

  • XX – 只有鍵key存在的時候才會設定key的值

    注意: 由於SET命令加上選項已經可以完全取代SETNX, SETEX, PSETEX的功能,所以在將來的版本中,redis可能會不推薦使用並且最終拋棄這幾個命令。 原文地址

所以本例也是使用上述文章所推薦的加鎖解鎖方法。

加鎖:使用命令 SET resource_name my_random_value NX PX 30000 這個命令的作用是在只有這個key不存在的時候才會設定這個key的值(NX選項的作用),超時時間設為30000毫秒(PX選項的作用) 這個key的值設為“my_random_value”。這個值必須在所有獲取鎖請求的客戶端裡保持唯一。

解鎖: 使用LUA指令碼語言

if redis.call("get",KEYS[1]) == ARGV[1] then
        return redis.call("del",KEYS[1])
    else
        return 0
    end

這段指令碼的意思是:刪除這個key當且僅當這個key存在而且值是我期望的那個值。

LUA指令碼的原子性 原文連結

Redis 使用單個 Lua 直譯器去執行所有指令碼,並且, Redis 也保證指令碼會以原子性(atomic)的方式執行:當某個指令碼正在執行的時候,不會有其他指令碼或 Redis 命令被執行。這和使用 MULTI / EXEC 包圍的事務很類似。在其他別的客戶端看來,指令碼的效果(effect)要麼是不可見的(not visible),要麼就是已完成的(already completed)。

另一方面,這也意味著,執行一個執行緩慢的指令碼並不是一個好主意。寫一個跑得很快很順溜的指令碼並不難,因為指令碼的執行開銷(overhead)非常少,但是當你不得不使用一些跑得比較慢的指令碼時,請小心,因為當這些蝸牛指令碼在慢吞吞地執行的時候,其他客戶端會因為伺服器正忙而無法執行命令。

元件依賴

相關jedis依賴

<!--Jedis 相關依賴-->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
   <exclusions>
      <exclusion>
         <groupId>io.lettuce</groupId>
         <artifactId>lettuce-core</artifactId>
      </exclusion>
   </exclusions>
</dependency>
<dependency>
   <groupId>redis.clients</groupId>
   <artifactId>jedis</artifactId>
</dependency>
程式碼

1.配置jedis。網上有很多配置jedis的例子,我就給一個簡單的配置實現。我使用的是自定義的配置,首先在 application.properties中新增Jedis配置引數

#jedis相關配置
#Redis IP
jedis.host=192.168.56.101
#Redis 埠
jedis.port=6379
#Redis 密碼
jedis.password=123456
jedis.timeout=3
jedis.poolMaxTotal=10
jedis.poolMaxIdle=10
jedis.poolMaxWait=3

2.宣告自定義配置Bean,使用springboot 註解ConfigurationProperties來載入application.properties中的配置引數。

/**
 * @author Neal
 * 自定義Jedis配置bean
 */
@Component
@ConfigurationProperties(prefix = "jedis")
public class MyJedisBean {

    private String host;

    private int port;

    private String password;

    private int timeout;

    private int poolMaxTotal;

    private int poolMaxIdle;

    private int poolMaxWait;

    public String getHost() {
        return host;
    }

    public void setHost(String host) {
        this.host = host;
    }

    public int getPort() {
        return port;
    }

    public void setPort(int port) {
        this.port = port;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public int getTimeout() {
        return timeout;
    }

    public void setTimeout(int timeout) {
        this.timeout = timeout;
    }

    public int getPoolMaxTotal() {
        return poolMaxTotal;
    }

    public void setPoolMaxTotal(int poolMaxTotal) {
        this.poolMaxTotal = poolMaxTotal;
    }

    public int getPoolMaxIdle() {
        return poolMaxIdle;
    }

    public void setPoolMaxIdle(int poolMaxIdle) {
        this.poolMaxIdle = poolMaxIdle;
    }

    public int getPoolMaxWait() {
        return poolMaxWait;
    }

    public void setPoolMaxWait(int poolMaxWait) {
        this.poolMaxWait = poolMaxWait;
    }
}

3.生成JedisPool元件bean

這裡就是把JedisPool的相關配置引數配置到JedisPoolConfig並且利用JedisPool的構造方法來宣告物件。

/**
 * @author Neal
 * 初始化jedis 連線池
 */
@Component
public class MyJedisConfig {

    /**
     * 自定義jedis配置bean
     */
    @Autowired
    private MyJedisBean myJedisBean;

    @Bean
    public JedisPool jedisPoolFactory() {

        //宣告jedispool 配置類
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(myJedisBean.getPoolMaxIdle());
        jedisPoolConfig.setMaxTotal(myJedisBean.getPoolMaxTotal());
        jedisPoolConfig.setMaxWaitMillis(myJedisBean.getPoolMaxWait() *1000);
        /**
         * 利用Jedis的構造方法 生成 jedispool
         */
        JedisPool jedisPool = new JedisPool(jedisPoolConfig,myJedisBean.getHost(),myJedisBean.getPort(),myJedisBean.getTimeout()*1000,myJedisBean.getPassword(),0);
        return jedisPool;
    }

}

4.實現Redis分散式鎖方法

該類中只有加鎖(redisLock)和解鎖(redisUnlock)兩個方法。 宣告的靜態變數 都是根據Redis原生命令 SET resource_name my_random_value NX PX 30000 宣告的命令字串。在加鎖和解鎖時 resource_name 對應的是 商品ID, my_random_value 對應的是我們用UUID 生成的模擬使用者ID。

/**
 * @author Neal
 * 分散式鎖
 */
@Component
public class MyRedisLock {
    //Only set the key if it does not already exist.
    private static final String IF_NOT_EXIST = "NX";
    // Set the specified expire time, in milliseconds.
    private static final String SET_EXPIRE_TIME = "PX";
    //超時時間為 500毫秒
    private static final int EXPIRE_TIME = 500;
    //加鎖成功後返回的標識
    private static final String ON_LOCK = "OK";
   //LUA 解鎖指令碼
    private static final String LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    /**
     * 獲取配置好的jedis pool 元件
     */
    @Autowired
    private JedisPool jedisPoolFactory;

    /***
     * redis 加鎖方法
     * @param id  商品ID
     * @param uuid  模擬使用者ID
     * @return 返回 true 加鎖成功 false 解鎖成功
     */
    public boolean redisLock(String id,String uuid) {
        //從 jedis 連線池中 獲取jedis
        Jedis jedis = jedisPoolFactory.getResource();
        boolean locked = false;
        try{
            //使用Jedis 加鎖
            locked = ON_LOCK.equals(jedis.set(id,uuid,IF_NOT_EXIST,SET_EXPIRE_TIME,EXPIRE_TIME));
        }finally {
            //將連線放回連線池
            jedis.close();
        }
        return locked;
    }

    /***
     * redis 解鎖方法
     * @param id  商品ID
     * @param uuid  模擬使用者ID
     * @return  由於是使用LUA指令碼,則會保證原子性的特質
     */
    public void redisUnlock(String id,String uuid) {
        //從 jedis 連線池中 獲取jedis
        Jedis jedis = jedisPoolFactory.getResource();
        try{
            //使用Jedis 的 eval解鎖
            Object result = jedis.eval(LUA_SCRIPT, Collections.singletonList(id),Collections.singletonList(uuid));
            if(1L == (Long)result) {
                System.out.println("客戶ID為:《" + uuid + "》   解鎖成功!");
            }
        }finally {
            jedis.close();
        }
    }
}

核心的加鎖程式碼已經介紹完了,下面就是關於 在秒殺service層加鎖與解鎖相關的程式碼了。

5.Controller層

controller層與之前的無鎖controller沒有變化,還是一樣的程式碼。

/**
 * @author Neal
 * 測試分散式鎖controller 層
 */
@RestController
@RequestMapping("/distribute")
public class RedisDistributeController {

    @Autowired
    private RedisDistributeService redisDistributeService;

    @Autowired
    private JedisPool jedisPoolFactory;

    @GetMapping("/query/{pid}")
    public String query(@PathVariable String pid) {
        return redisDistributeService.queryMap(pid);
    }

    @GetMapping("/{pid}")
    public String order(@PathVariable String pid) {
        redisDistributeService.order(pid, UUID.randomUUID().toString());
        return redisDistributeService.queryMap(pid);
    }
}

6.service層

在service層中的下單方法(order)中加入了 加鎖與解鎖的操作。

/**
 * @author Neal
 * 測試分散式鎖service 層
 */
@Service
public class RedisDistributeService {


    //模擬商品資訊表
    private static Map<String,Integer> products;

    //模擬庫存表
    private static Map<String,Integer> stock;

    //模擬訂單表
    private static Map<String,String> orders;

    //redis 鎖元件
    @Autowired
    MyRedisLock myRedisLock;

    static {
        products = new HashMap<>();
        stock = new HashMap<>();
        orders = new HashMap<>();
        products.put("112233",100000);
        stock.put("112233",100000);
    }

    /**
     * 模擬查詢秒殺成功返回的資訊
     * @param pid 商品名稱
     * @return
     */
    public String queryMap(String pid) {
        return "秒殺商品限量:" +  products.get(pid) + "份,還剩:"+stock.get(pid) +"份,成功下單:"+orders.size() + "人";
    }

    /**
     * 下單方法
     * @param pid  商品名稱
     */
    public void order(String pid,String uuid) {

        //redis 加鎖
        if(!myRedisLock.redisLock(pid,uuid)) {  //如果沒獲得鎖則直接返回,不執行下面的程式碼
            System.out.println("客戶ID為:《"+ uuid +"》未獲得鎖");
            return;
        }

        System.out.println("客戶ID為:《"+ uuid +"》獲得鎖");
        //從庫存表中獲取庫存餘量
        int stockNum = stock.get(pid);
        if(stockNum == 0) {
            System.out.println("商品庫存不足");
        }else{
            //往訂單表中插入資料
            orders.put(uuid,pid);
            //執行緒休眠 模擬其他操作
            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //減庫存操作
            stock.put(pid,stockNum-1);
        }

        //redis 解鎖
        myRedisLock.redisUnlock(pid,uuid);
    }
}

<u>程式碼已經介紹完了那麼下面開始壓測,並看看結果是否跟沒加鎖的程式碼有區別</u>

7.壓測

壓測方式跟上面講述的一樣,我就不在囉嗦了,直接上結果圖。


13837765-edaac11e3d7ffd71.PNG
有鎖壓測圖1
13837765-e48583607c40eeb3.PNG
有鎖壓測圖2
13837765-b7e404bdff731e2f.PNG
結果

由圖中可以看出,同樣的壓測命令,秒殺的結果卻只有13個秒殺成功,但是庫存餘量與秒殺的數量是對應的上的,不會出現庫存與秒殺數量不一致問題。

結論:

在寫Redis鎖的時候看了很多前輩的博文以及教學視訊,發現之前用的都是基於SETNX,解鎖也是各有千秋,最後還是參考了 Redis的官方實踐。
DEMO程式碼地址
Redis命令大全

相關文章