Redis實現併發阻塞鎖方案

程式設計師田同學發表於2022-04-28

由於使用者同時訪問線上的下訂單介面,導致在扣減庫存時出現了異常,這是一個很典型的併發問題,本篇文章為解決併發問題而生,採用的技術為Redis鎖機制+多執行緒的阻塞喚醒方法。

在實現Redis鎖機制之前,我們需要了解一下前置知識。

一、前置知識

1、多執行緒

將wait()、notifyAll()歸為到多執行緒的方法中略有一些不恰當,這兩個方法是Object中的方法。

① 當呼叫了wait()方法後,讓當前執行緒進入等待狀態,並且讓當前執行緒釋放物件鎖,等待既為阻塞狀態,等待notifyAll()方法的喚醒。

wait()方法和sleep()方法有一些相似之處,都是使當前執行緒阻塞,但他們實際是有一些區別的。

  1. 執行wait() 方法之前需要請求鎖,wait()方法執行的時候會釋放鎖,等待被喚醒的時候競爭鎖。
  2. sleep()只是讓當前執行緒休眠一段時間,無視鎖的存在。
  3. wait() 是Object類的方法 sleep()是Thread的靜態方法

② notifyAll()方法為喚醒wait()中的執行緒。

notifyAll() 和 notify() 方法都是可以喚醒呼叫了wait()方法,而陷入阻塞的執行緒。

但是notify()是隨機喚醒這個阻塞佇列中隨機的一個執行緒,而notifyAll()是喚醒所用的呼叫了wait()方法而陷入阻塞的執行緒,讓他們自己去搶佔物件鎖。

notifyAll() 和 notify() 也都是必須在加鎖的同步程式碼塊中被呼叫,它們起的是喚醒的作用,不是釋放鎖的作用,只用在當前同步程式碼塊中的程式執行完,也就是物件鎖自然釋放了,notifyAll() 和 notify()方法才會起作用,去喚醒執行緒。

wait()方法一般是和notify() 或者 notifyAll() 方法一起連用的。

以上為掌握本篇部落格必備的多執行緒知識,如果系統學習多執行緒的相關知識可查閱部落格 程式設計師田同學

2、Redis

加鎖的過程本質上就是往Redis中set值,當別的程式也來set值時候,發現裡面已經有值了,就只能放棄獲取稍後再試。

Redis提供了一個天然實現鎖機制的方法。

在Redis客戶端的命令為 setnx(set if not exists) 

在整合Springboot中採用的方法為:

redisTemplate.opsForValue().setIfAbsent(key, value);

如果裡面set值成功會返回True,如果裡面已經存在值就會返回False。

在我們實際使用的時候,setIfAbsent()方法並不是總是返回True和False。

如果我們的業務中加了事務,該方法會返回null,不知道這是一個bug還是什麼,這是Redis的一個巨坑,浪費了很長時間才發現了這個問題,如果解決此問題可以跳轉到第四章。

二、實現原理

分散式鎖本質上要實現的目標就是在 Redis 裡面佔一個位置,當別的程式也要來佔時,發現已經有人佔在那裡了,就只好放棄或者稍後再試。佔位一般是使用 setnx(set if not exists) 指令,只允許被一個客戶端佔位。先來先佔, 事辦完了,再呼叫 del 指令釋放茅坑。

其中,發現Redis中已經有值了,當前執行緒是直接放棄還是稍後再試分別就代表著,非阻塞鎖和阻塞鎖。

在我們的業務場景中肯定是要稍後再試(阻塞鎖),如果是直接放棄(非阻塞鎖)在資料庫層面就可以直接做,就不需要我們在程式碼大費周章了。

非阻塞鎖只能儲存資料的正確性,在高併發的情況下會丟擲大量的異常,當一百個併發請求到來時,只有一個請求成功,其他均會丟擲異常。

Redis非阻塞鎖和 MySQL的樂觀鎖,最終達到的效果是一樣的,樂觀鎖是採用CAS的思想。

樂觀鎖方法:表欄位 加一個版本號,或者別的欄位也可以!加版本號,可以知道控制順序而已!在update 的時候可以where後面加上version= oldVersion。資料庫,在任何併發的情況下,update 成功就是 1 失敗就是 0 .可以根據返回的 1 ,0 做相應的處理!

我們更推薦大家使用阻塞鎖的方式。

當獲取不到鎖時候,我們讓當前執行緒使用wait()方法喚醒,當持有鎖的執行緒使用完成後,呼叫notifyAll()喚醒所有等待的方法。

三、具體實現

以下程式碼為阻塞鎖的實現方式。

業務層:

    public String test() throws InterruptedException {

        lock("lockKey");
        System.out.println("11");
        System.out.println("22");
        System.out.println(Thread.currentThread().getName()+"***********");
        Thread.sleep(2000);
        System.out.println("33");
        System.out.println("44");
        System.out.println("55");
        unlock("lockKey");
        return "String";
    }

鎖的工具類:

主要是加鎖和解鎖的兩個方法。

 //每一個redis的key對應一個阻塞物件
    private static HashMap<String, Object> blockers = new HashMap<>();

    //當前獲得鎖的執行緒
    private static Thread curThread;

    public static RedisTemplate redisTemplate = (RedisTemplate) SpringUtils.getBean("redisTemplate") ;

    /**
     * 加鎖
     * @param key
     * @throws InterruptedException
     */

    public static void lock(String key) {
        //迴圈判斷是否能夠建立key, 不能則直接wait釋放CPU執行權

        //放不進指說明鎖正在被佔用
        System.out.println(key+"**");

        while (!RedisUtil.setLock(key,"1",3)){

            synchronized (key) {

                blockers.put(key, key);
                //wait釋放CPU執行權
                try {
                    key.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
        blockers.put(key, key);
        //能夠成功建立,獲取鎖成功記錄當前獲取鎖執行緒
        curThread = Thread.currentThread();
    }

    /**
     * 解鎖
     * @param key
     */
    public static void unlock(String key) {
        //判斷是否為加鎖的執行緒執行解鎖, 不是則直接忽略
        if( curThread == Thread.currentThread()) {
            RedisUtil.delete(key);
            //刪除key之後需要notifyAll所有的應用, 所以這裡採用發訂閱訊息給所有的應用
          //  RedisUtil.publish("lock", key);

            //notifllall其他執行緒
            Object lock = blockers.get(key);
            if(lock != null) {
                synchronized (lock) {
                    lock.notifyAll();
                }
            }

        }
    }

當我們在不加鎖時候,使用介面測試工具測試時,12345並不能都是順序執行的,會造成輸出順序不一致,如果是在我們的實際場景中,這是輸入換成了資料庫的select和update,資料出現錯亂也是很正常的情況了。

當我們加上鎖以後,12345都是順序輸出,併發問題順利解決了。

四、附錄

1、Redis存在的bug

本來lock()方法是直接呼叫 "Redis.setIfAbsent()" 方法,但是在使用時候一直報空指標異常,最終定位問題為Redis.setIfAbsent()方法存在問題。

在我的實際業務中,下訂單的方法使用了@Transflastion增加了事務,導致該方法返回null,我們手寫一個實現setIfAbsent()的作用。

 /**
     * 只有key不存在時,才設定值, 返回true, 否則返回false
     *
     * @param key     key 不能為null
     * @param value   value 不能為null
     * @param timeout 過期時長, 單位為妙
     * @return
     */
    public static Boolean setLock(String key,String value, long timeout) {

        SessionCallback<Boolean> sessionCallback = new SessionCallback<Boolean>() {
            List<Object> exec = null;
            @Override
            @SuppressWarnings("unchecked")
            public Boolean execute(RedisOperations operations) throws DataAccessException {
                operations.multi();

                redisTemplate.opsForValue().setIfAbsent(key, value);
                redisTemplate.expire(key,timeout, TimeUnit.SECONDS);

                exec = operations.exec();

                if(exec.size() > 0) {
                    return (Boolean) exec.get(0);
                }
                return false;
            }
        };
        return (Boolean) redisTemplate.execute(sessionCallback);
    }

方便對比,以下貼上原本的setIfAbsent()方法。

 /**
   * 只有key不存在時,才設定值, 返回true, 否則返回false [警告:事務或者管道情況下會報錯-可使用 setLock方法]
   *
   * @param key     key 不能為null
   * @param value   value 不能為null
   * @param timeout 過期時長, 單位為妙
   * @return
   */
  @Deprecated
  public static <T> Boolean setIfAbsent(String key, T value, long timeout) {

     // redisTemplate.multi();
      ValueOperations<String, T> valueOperations = redisTemplate.opsForValue();
      Boolean aBoolean = valueOperations.setIfAbsent(key, value, timeout, TimeUnit.SECONDS);
     // redisTemplate.exec();
    return aBoolean;
  }

2、MySQL的鎖機制

在併發場景下MySQL會報錯,報錯資訊如下:

### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
; SQL []; Lock wait timeout exceeded; try restarting transaction; nested exception is com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction

問題出現的原因是,某一種表頻繁被鎖表,導致另外一個事務超時,出現問題的原因是MySQL的機制。

MySQL更新時如果where欄位存在索引會使用行鎖,否則會使用表鎖。

我們使用navichat在where欄位上加上索引,問題順利的迎刃而解。

相關文章