使用redis實現分佈式鎖

簡介:
當高併發訪問某個接口的時候,如果這個接口訪問的數據庫中的資源,並且你的數據庫事務級別是可重複讀(Repeatable read)的話,確實是沒有線程問題的,因爲數據庫鎖的級別就夠了;但是如果這個接口需要訪問一個靜態變量、靜態代碼塊、全局緩存的中的資源或者redis中的資源的時候,就會出現線程安全的問題。

案例:
github地址: https://github.com/mzd123/mywy/tree/master/src/main/java/com/mzd/mywy/service

@RestController
public class MsController {
    @Autowired
    private MsService msService;
    @RequestMapping("/select_info.do")
    public String select_info(String product_id) {
        return msService.select_info(product_id);
    }
    @RequestMapping("/order.do")
    public String order(String product_id) throws CongestionException {
        return msService.order1(product_id);
    }
}

@Service
public class MsService {

    @Autowired
    private RedisLock redisLock;
    //商品詳情
    private static HashMap<String, Integer> product = new HashMap();
    //訂單表
    private static HashMap<String, String> orders = new HashMap();
    //庫存表
    private static HashMap<String, Integer> stock = new HashMap();

    static {
        product.put("123", 10000);
        stock.put("123", 10000);
    }

    public String select_info(String product_id) {
        return "限量搶購商品XXX共" + product.get(product_id) + ",現在成功下單" + orders.size()
                + ",剩餘庫存" + stock.get(product_id) + "件";
    }

    /**
     * 下單
     *
     * @param product_id
     * @return
     */
    public String order1(String product_id) {
        if (stock.get(product_id) == 0) {
            return "活動已經結束了";
            //已近買完了
        } else {
            //還沒有賣完
            try {
                //模擬操作數據庫
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            orders.put(MyStringUtils.getuuid(), product_id);
            stock.put(product_id, stock.get(product_id) - 1);
        }
        return select_info(product_id);
    }
}

如上圖所述,我現在需要限購10000個商品id爲123的商品,如果什麼操作也不做,直接訪問靜態資源你們覺得會有問題嗎?我們使用apache_ab來模擬一下高併發情況,下面是發起100個請,併發量是50的情況:

問題: 可以看到,下單數和庫存加起來明顯超過了商品總數,這是一種超賣現象,在java角度來說就是線程不安全現象。

解決1: 學過javase的小夥伴應該都能想到使用synchronized關鍵字,強行同步。

 /**
     * 下單
     *
     * @param product_id
     * @return
     */
    public synchronized String order2(String product_id) {
        if (stock.get(product_id) == 0) {
            return "活動已經結束了";
            //已近買完了
        } else {
            //還沒有賣完
            try {
                //模擬操作數據庫
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            orders.put(MyStringUtils.getuuid(), product_id);
            stock.put(product_id, stock.get(product_id) - 1);
        }
        return select_info(product_id);
    }

缺點:
1、我們可以明顯的看到速度變慢了,從原來的0.535秒變到了10.956秒,那是因爲synchronized放這個方法只允許單線程訪問了。
2、synchronized是粗粒度的控制了線程安全,即:如果我這個商品id不一樣的線程,理論上是可以同時訪問這個方法的,但是加上了synchronized之後,無論商品id是否一樣,兩個線程都是沒法同時訪問這個方法的。

解決2: 使用redis分佈式鎖(主要使用了redis中的setnx和getset方法,這兩個方法在redisTemplate分別是setIfAbsent和getAndSet方法)實現線程安全,因爲redis是單線程,能保證線程的安全性,而且redis強大的讀寫能力能提高效率。

   /**
     * 高併發沒問題,效率還行
     *
     * @param product_id
     * @return
     */
    public String order3(String product_id) throws CongestionException {
        /**
         * redis加鎖
         */
        String value = System.currentTimeMillis() + 10000 + "";
        if (!redisLock.lock1(product_id, value)) {
           //系統繁忙,請稍後再試
            throw new CongestionException();
        }
        //##############################業務邏輯#################################//
        if (stock.get(product_id) == 0) {
            return "活動已經結束了";
            //已近買完了
        } else {
            //還沒有賣完
            try {
                //模擬操作數據庫
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            orders.put(MyStringUtils.getuuid(), product_id);
            stock.put(product_id, stock.get(product_id) - 1);
        }
       //##############################業務邏輯#################################//
        /**
         * redis解鎖
         */
        redisLock.unlock(product_id, value);
        return select_info(product_id);
    }

/**
 * 用redis實現分佈式鎖
 */
@Component
public class RedisLock {
    @Autowired
    private StringRedisTemplate redisTemplate;
   //加鎖
    public boolean lock1(String key, String value) {
        //setIfAbsent相當於jedis中的setnx,如果能賦值就返回true,如果已經有值了,就返回false
        //即:在判斷這個key是不是第一次進入這個方法
        if (redisTemplate.opsForValue().setIfAbsent(key, value)) {
            //第一次,即:這個key還沒有被賦值的時候
            return true;
        }
        return false;
    }
    //解鎖
    public void unlock(String key, String value) {
        try {
            if (MyStringUtils.Object2String(redisTemplate.opsForValue().get(key)).equals(value)) {
                redisTemplate.opsForValue().getOperations().delete(key);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

缺點: 這個方法看上去沒什麼問題,而且只有商品id相同的兩個線程同時訪問這個方法的時候纔會出現線程問題,這似乎是很完美了。但是有沒有想過,萬一處理業務邏輯的代碼塊中出現了異常,直接拋了出去,那解鎖的代碼就再也不會被執行了,也就是出現了死鎖現象。

改進1:

    public boolean lock2(String key, String value) {
        //setIfAbsent相當於jedis中的setnx,如果能賦值就返回true,如果已經有值了,就返回false
        //即:在判斷這個key是不是第一次進入這個方法
        if (redisTemplate.opsForValue().setIfAbsent(key, value)) {
            //第一次,即:這個key還沒有被賦值的時候
            return true;
        }
        String current_value = redisTemplate.opsForValue().get(key);//①
        if (!MyStringUtils.Object2String(current_value).equals("")
                //超時了
                && Long.parseLong(current_value) < System.currentTimeMillis()) {;//②
            //返回true就能解決死鎖
            return true;
        }
        return false;
    }

缺點: 使用超時時間來解決死鎖問題,但是又出現新的問題,就是當有兩個商品id相同的線程同時執行到了②這一行代碼,這時候兩個線程同時獲取鎖,這樣一來任然存在線程安全問題了。。。

改進2:

 /**
     * 加鎖
     */
    public boolean lock3(String key, String value) {
        //setIfAbsent相當於jedis中的setnx,如果能賦值就返回true,如果已經有值了,就返回false
        //即:在判斷這個key是不是第一次進入這個方法
        if (redisTemplate.opsForValue().setIfAbsent(key, value)) {
            //第一次,即:這個key還沒有被賦值的時候
            return true;
        }
        String current_value = redisTemplate.opsForValue().get(key);
        if (!MyStringUtils.Object2String(current_value).equals("")
                //超時了
                && Long.parseLong(current_value) < System.currentTimeMillis()) {//①
            String old_value = redisTemplate.opsForValue().getAndSet(key, value);//②
            if (!MyStringUtils.Object2String(old_value).equals("")
                    && old_value.equals(current_value)) {
                return true;
            }
        }
        return false;
    }

解釋: 如果兩個線程同時調用這個方法,當同時走到①的時候,無論怎麼樣都有一個線程會先執行②這一行,假設線程1先執行②這行代碼,那redis中key對應的value就變成了value,然後線程2再執行②這行代碼的時候,獲取到的old_value就是value,那麼value顯然和他上面獲取的current_value是不一樣的,則線程2是沒法獲取鎖的。


說明: 雖然100個請求只有2個成功下單的,但是耗時卻明顯變小了,而且線程也是安全的,只是絕大部分因爲沒有拿到鎖而沒有搶到限購的商品,但也做了人性化的提醒,個人覺得還是可以接受的!
————————————————
版權聲明:本文爲CSDN博主「一隻仰望天空的菜鳥」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/tuesdayma/article/details/82751790

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