Redis應(yīng)用(二) --分布式鎖以及壓測介紹

Spring-boot 集成Redis應(yīng)用(二) --分布式鎖以及壓測介紹

一.基礎(chǔ)環(huán)境

  • jdk 1.8

  • maven 3.5.3

  • spring-boot 2.0.4

  • redis 4.0.11

  • ApacheBench 2.3

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

二.基本介紹

相信各位小伙伴在學(xué)習(xí)Redis時(shí),都了解到Redis不僅僅是一個(gè)內(nèi)存中的數(shù)據(jù)結(jié)構(gòu)存儲(chǔ)系統(tǒng),它可以用作數(shù)據(jù)庫、緩存和消息中間件。上一篇集成應(yīng)用已經(jīng)簡單的介紹了 Redis作為消息隊(duì)列配合基于Servlet 3的異步請(qǐng)求處理的簡單示例。本篇將介紹Redis在單機(jī)部署的場景下的分布式鎖。分布式鎖的思想來源于Redis官網(wǎng),下面給出中文翻譯相當(dāng)棒鏈接,方便大家了解分布式鎖。《Redis官方文檔》用Redis構(gòu)建分布式鎖。在這就不講解中心思想了。那么我會(huì)以一個(gè)模擬秒殺系統(tǒng)的簡單Demo來一步一步的展示redis分布式鎖的應(yīng)用。

三.無布式鎖時(shí)的秒殺代碼以及壓測結(jié)果

組件依賴

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

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

由于是一個(gè)簡單的秒殺下單的demo代碼,那么只需要兩個(gè)接口,一個(gè)是下單接口(order),一個(gè)是查詢訂單接口(query),Controller層代碼如下:

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

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

    /**
     * 查詢剩余訂單結(jié)果接口
     * @param pid  訂單編號(hào)
     * @return
     */
    @GetMapping("/query/{pid}")
    public String query(@PathVariable String pid) {
        return redisDistributeService.queryMap(pid);
    }

    /**
     * 下單接口
     * @param pid  訂單編號(hào)
     * @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<>();
        //模擬訂單表數(shù)據(jù) 訂單編號(hào) 112233 庫存 100000
        products.put("112233",100000);
        //模擬庫存表數(shù)據(jù) 訂單編號(hào)112233 庫存100000
        stock.put("112233",100000);
    }

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

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

代碼上有注釋,我就不過多的啰嗦解釋了,那么讓我們來模擬秒殺環(huán)境,使用 ApacheBench 來模擬并發(fā),來看看結(jié)果如何:

  1. 進(jìn)入到 ApacheBench 的bin目錄下,我的路徑是 Apache24\bin。

  2. 輸入命令以及參數(shù):ab -n 1000 -c 100 http://127.0.0.1:8080/order/112233 該命令的含義是 向指定的URL發(fā)送 1000次請(qǐng)求 100 并發(fā)量。 結(jié)果如圖

    無鎖壓測圖1

無鎖壓測圖2

雖然只耗時(shí)了2.596秒,但是處理的結(jié)果卻不盡如人意。

  1. 然后使用查詢接口查詢一下下單和庫存結(jié)果是否一致,請(qǐng)求查詢接口:http://localhost:8080/order/query/112233。結(jié)果如圖
    無鎖查詢結(jié)果

可以看出下單人數(shù)和庫存余量明顯不符,就這就是無鎖時(shí),在高并發(fā)環(huán)境中會(huì)引起的問題。

Redis分布式鎖下的秒殺代碼以及壓測結(jié)果

前言

網(wǎng)上看了很多的例子,都是使用redis的SETNX命令來實(shí)現(xiàn)的,Redis 官網(wǎng)并不推薦使用SETNX命令,而是推薦使用SET,因?yàn)閺?.6.12版本以后,Redis對(duì)SET命令增加了一系列的選項(xiàng)。

  • 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 – 設(shè)置鍵key的過期時(shí)間,單位時(shí)秒

  • PX milliseconds – 設(shè)置鍵key的過期時(shí)間,單位時(shí)毫秒

  • NX – 只有鍵key不存在的時(shí)候才會(huì)設(shè)置key的值

  • XX – 只有鍵key存在的時(shí)候才會(huì)設(shè)置key的值

    注意: 由于SET命令加上選項(xiàng)已經(jīng)可以完全取代SETNX, SETEX, PSETEX的功能,所以在將來的版本中,redis可能會(huì)不推薦使用并且最終拋棄這幾個(gè)命令。 原文地址

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

加鎖:使用命令 SET resource_name my_random_value NX PX 30000 這個(gè)命令的作用是在只有這個(gè)key不存在的時(shí)候才會(huì)設(shè)置這個(gè)key的值(NX選項(xiàng)的作用),超時(shí)時(shí)間設(shè)為30000毫秒(PX選項(xiàng)的作用) 這個(gè)key的值設(shè)為“my_random_value”。這個(gè)值必須在所有獲取鎖請(qǐng)求的客戶端里保持唯一。

解鎖: 使用LUA腳本語言

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

這段腳本的意思是:刪除這個(gè)key當(dāng)且僅當(dāng)這個(gè)key存在而且值是我期望的那個(gè)值。

LUA腳本的原子性 原文鏈接

Redis 使用單個(gè) Lua 解釋器去運(yùn)行所有腳本,并且, Redis 也保證腳本會(huì)以原子性(atomic)的方式執(zhí)行:當(dāng)某個(gè)腳本正在運(yùn)行的時(shí)候,不會(huì)有其他腳本或 Redis 命令被執(zhí)行。這和使用 MULTI / EXEC 包圍的事務(wù)很類似。在其他別的客戶端看來,腳本的效果(effect)要么是不可見的(not visible),要么就是已完成的(already completed)。

另一方面,這也意味著,執(zhí)行一個(gè)運(yùn)行緩慢的腳本并不是一個(gè)好主意。寫一個(gè)跑得很快很順溜的腳本并不難,因?yàn)槟_本的運(yùn)行開銷(overhead)非常少,但是當(dāng)你不得不使用一些跑得比較慢的腳本時(shí),請(qǐng)小心,因?yàn)楫?dāng)這些蝸牛腳本在慢吞吞地運(yùn)行的時(shí)候,其他客戶端會(huì)因?yàn)榉?wù)器正忙而無法執(zhí)行命令。

組件依賴

相關(guān)jedis依賴

<!--Jedis 相關(guān)依賴-->
<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。網(wǎng)上有很多配置jedis的例子,我就給一個(gè)簡單的配置實(shí)現(xiàn)。我使用的是自定義的配置,首先在 application.properties中添加Jedis配置參數(shù)

#jedis相關(guān)配置
#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中的配置參數(shù)。

/**
 * @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的相關(guān)配置參數(shù)配置到JedisPoolConfig并且利用JedisPool的構(gòu)造方法來聲明對(duì)象。

/**
 * @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的構(gòu)造方法 生成 jedispool
         */
        JedisPool jedisPool = new JedisPool(jedisPoolConfig,myJedisBean.getHost(),myJedisBean.getPort(),myJedisBean.getTimeout()*1000,myJedisBean.getPassword(),0);
        return jedisPool;
    }

}

4.實(shí)現(xiàn)Redis分布式鎖方法

該類中只有加鎖(redisLock)和解鎖(redisUnlock)兩個(gè)方法。 聲明的靜態(tài)變量 都是根據(jù)Redis原生命令 SET resource_name my_random_value NX PX 30000 聲明的命令字符串。在加鎖和解鎖時(shí) resource_name 對(duì)應(yīng)的是 商品ID, my_random_value 對(duì)應(yīng)的是我們用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";
    //超時(shí)時(shí)間為 500毫秒
    private static final int EXPIRE_TIME = 500;
    //加鎖成功后返回的標(biāo)識(shí)
    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腳本,則會(huì)保證原子性的特質(zhì)
     */
    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();
        }
    }
}

核心的加鎖代碼已經(jīng)介紹完了,下面就是關(guān)于 在秒殺service層加鎖與解鎖相關(guān)的代碼了。

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)) {  //如果沒獲得鎖則直接返回,不執(zhí)行下面的代碼
            System.out.println("客戶ID為:《"+ uuid +"》未獲得鎖");
            return;
        }

        System.out.println("客戶ID為:《"+ uuid +"》獲得鎖");
        //從庫存表中獲取庫存余量
        int stockNum = stock.get(pid);
        if(stockNum == 0) {
            System.out.println("商品庫存不足");
        }else{
            //往訂單表中插入數(shù)據(jù)
            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>代碼已經(jīng)介紹完了那么下面開始?jí)簻y,并看看結(jié)果是否跟沒加鎖的代碼有區(qū)別</u>

7.壓測

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


有鎖壓測圖1
有鎖壓測圖2
結(jié)果

由圖中可以看出,同樣的壓測命令,秒殺的結(jié)果卻只有13個(gè)秒殺成功,但是庫存余量與秒殺的數(shù)量是對(duì)應(yīng)的上的,不會(huì)出現(xiàn)庫存與秒殺數(shù)量不一致問題。

結(jié)論:

在寫Redis鎖的時(shí)候看了很多前輩的博文以及教學(xué)視頻,發(fā)現(xiàn)之前用的都是基于SETNX,解鎖也是各有千秋,最后還是參考了 Redis的官方實(shí)踐。
DEMO代碼地址
Redis命令大全

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容