基于redis的分布式鎖【JAVA實現】

最近做一個統計日志的業務(log+redis),本來在單機環境正常開車的,突然上線就掉坑了...
流程:去redis中取出當日的數據 --> 各種計算后累加計算結果,并再次存入redis -->定時任務每日24點將redis數據存入日志文件中。

1.問題引出

先上代碼 (單機環境)

  private synchronized void addRecord() {
        try {
          //獲取redis記錄
            DeliveryQueryRecord record = backRedisService.getDeliveryQueryRecord();
            if (record == null) {
                record = new DeliveryQueryRecord();
            }
            // xxx數據操作
            //將數據再次保存到redis中
            backRedisService.setDeliveryQueryRecord(record);
        } catch (Exception e) {
            e.printStackTrace();
        }  
    }

由于使用了synchronized 關鍵字可以保證每個線程都會同步阻塞的執行,也就是說一個線程獲取redis記錄并且各種操作寫入redis之后其他線程才能讀和寫(針對addRecord方法的,不是真正意義上的讀寫)進而保證數據正確性。
但是這么寫單機環境下ok,可是線上是分布式集群環境,當不止一臺機器同時多線程執行addRecord方法就可能存在數據同步問題。例如:A機器上有一個線程1去獲取redis記錄,這時同時機器B也有一個線程去獲取redis記錄,雖然我們知道 redis是單線程同步阻塞 的,但是極端情況下,在線程1還沒寫入redis之前,線程2可能會去獲取數據,而此時的數據和線程1獲取的是一模一樣的,而不是獲取線程1處理之后的數據,也就是說兩個線程不是同步阻塞獲取那么數據就是有問題的。

2.解決方法

(多機環境)
問題:

多機環境下 synchronized關鍵字不能保證所有的線程是同步阻塞運行的,也就是addRecord方法不再具備原子性

解決思路:

我們利用redis模擬一個線程鎖,保證在多機環境下addRecord方法任然具備原子性,那這個線程鎖如何實現呢?
我們可以利用redis的特性單線程原子性做一個特殊的KEY,這個KEYaddRecord方法真正執行前,要求線程先去查詢KEY是否存在。

存在條件:
A:如果KEY不存在,說明當前沒有任何線程占有鎖,當前線程可以獲取鎖(寫入KEY),隨后進行數據操作,業務代碼完畢后一定要釋放鎖(最好在finally{}代碼塊中實現),隨后別的線程可以競爭鎖。注意在這個期間為了保證原子性,不允許任何其他線程獲取鎖,只能等待當前線程釋放鎖后,其他線程才能競爭獲取。
B:如果KEY存在,說明有線程正在操作數據(獲取鎖),需要等待獲得鎖的線程操作完畢并釋放鎖后,再和其他等待的線程一起競爭(非公平鎖),來獲取鎖。
總結:線程執行addRecord方法前必須獲取鎖,方法執行完畢后要釋放鎖,保證操作的原子性。

工具籌備

工欲善其事,必先利其器。有了牛X的工具代碼,業務代碼也就迎刃而解。但是在展示利器之前先展示下錯誤的“坑”。

加鎖坑:( 錯誤示例)

既然已經理清了redis分布式鎖實現的思路,那就上代碼

public boolean tryGetLock(String lockKey, String value, int expireTime) {
        Jedis redis = null;
        try {
            redis = redisManager.redisPool.getResource();
            boolean flag = redis.exists(key);//判斷key是否存在
            if (!flag) {//不存在則設置key
                String result = redis.set(lockKey,expireTime, value);
                if (LOCK_SUCCESS.equals(result)) {
                    return true;
                }
                return false;
            }

        } finally {
            if (null != redis) {
                redis.close();
            }
        }
    }

此坑的問題細心的童鞋已經發現,boolean flag = redis.exists(key);//判斷key是否存在String result = redis.set(lockKey, value);是兩步操作。也就意味著不是原子操作,可能會存在多個線程在沒有執行redis.set(lockKey, value);之前,通過boolean flag = redis.exists(key);可能都會得到key不存在,這樣多個線程都可以繼續寫入key,造成了多個線程同時獲得鎖,不能形成同步阻塞執行。

利器代碼

    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "EX"; //px:毫秒 ex秒
    private static final Long RELEASE_SUCCESS = 1L;

    @Autowired
    RedisManager redisManager;

    /**
     * 嘗試獲取分布式鎖
     *
     * @param lockKey    鎖
     * @param requestId  請求標識
     * @param expireTime 超期時間
     * @return 是否獲取成功
     */
    public boolean tryGetLock(String lockKey, String requestId, int expireTime) {
        Jedis redis = null;
        try {

            redis = redisManager.redisPool.getResource();
            String result = redis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
            if (LOCK_SUCCESS.equals(result)) {
                return true;
            }
            return false;
        } finally {
            if (null != redis) {
                redis.close();
            }
        }
    }

在redisxxx.xxx版本之后,redis支持set多參數方法:set(KEY,VALUE,SET_IF_NOT_EXIST,SET_WITH_EXPIRE_TIME,EXPIRE_TIME)
KEY,VALUE 不必多說
SET_IF_NOT_EXIST 處理模式:NX表示如果KEY不存在則插入成功,否則不插入數據此處正好是把 兩步操作合并,1查詢key是否存在 2修改數據,這樣由于redis的單線程阻塞,保證了兩步操作的原子性,即阻塞執行
SET_WITH_EXPIRE_TIME 過期時間單位: px:毫秒 ex秒
EXPIRE_TIME 過期時間:保證在宕機等其他原因不能正常刪除KEY釋放鎖)時,利用過期刪除,從而避免KEY沒有刪除而造成其他線程永久等待的死鎖。

此方法完美解決了上鎖坑的問題。

放鎖坑:(錯誤示例)

     public void unLock(String lockKey) {
       Jedis redis = null;
       try {
           redis = redisManager.redisPool.getResource();
           redis.del(lockKey);
       } finally {
           if (null != redis) {
               redis.close();
           }
       }
   }

對于redis分布式鎖簡單放鎖,直接刪除。很明顯要是能這么簡單,就不會有這一節了。那么這么操作存在什么問題呢?其實這個問題是比較隱蔽的。想象一個極端的例子:在線程1執行代碼時耗時比較久,甚至超過了KEY的有效期。此時線程1還沒有釋放鎖,線程2發現KEY沒了,可以獲取鎖了,于是線程2寫入KEY獲取了鎖,這時線程1執行完畢,開始釋放鎖KEY進行刪除。但問題是KEY鎖的擁有者已經不是線程1,而是線程2。這時刪除KEY后,線程2的操作將不再具備原子性,其他線程又可以競爭鎖了。

利器代碼

    /**
     * 釋放分布式鎖
     * @param lockKey   鎖
     * @param requestId 請求標識
     * @return 是否釋放成功
     */
    public boolean unLock(String lockKey, String requestId) {
        Jedis redis = null;
        try {
            redis = redisManager.redisPool.getResource();
            //lua腳本
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            //執行lua腳本
            Object result = redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
            if (RELEASE_SUCCESS.equals(result)) {
                return true;
            }
            return false;
        } finally {
            if (null != redis) {
                redis.close();
            }
        }
    }

注意這次我們刪除KEY時,不僅傳進來了key還有value。這時有童鞋要問了,為什么要這么麻煩?解釋一下lua腳本就明白了。
首先獲取KEY在redis中的value值,如果結果和傳入的value相同,則刪除KEY,否則返回0;
這里我們比較了value值來保證刪除的是當前線程所擁有的鎖,而不是別的線程鎖。
注意:我們這里采用的是“腳本形式”,獲取與刪除在一個redis單線程中同時進行,也是保證操作的原子性,確保刪除的一定是value值對應的KEY,而不是別的線程設定的KEY

其他問題:

看到這里我們發現似乎還有一個問題:我們為了防止死鎖而設置了KEY的過期時間,可是我們如果不能確保業務代碼一定能在KEY過期之前操作完畢,那么就會出現獲取鎖的線程還沒釋放鎖,其他線程就已經搶到鎖,這樣就不能保證原子操作,從而不能保證數據的一致性等。這里我們很難想到一個合適的KEY過期時間,而避免這種問題。有沒有更好的解決辦法呢?答案是肯定的,有。

工具

 /**
     * @Description: 獲取鎖剩余時間
     * @author: wz
     * @date: 2019/8/2 14:17
     */
    public Long getLockExpire(String key) {
        Jedis redis = null;
        try {
            redis = redisManager.redisPool.getResource();
            return redis.ttl(key);
        } finally {
            if (null != redis) {
                redis.close();
            }
        }
    }

    /**
     * @Description: 設置key 新的過期時間
     * @author: wz
     * @date: 2019/8/2 11:23
     */
    public boolean setLockExpire(String key, int newExpire) {
        Jedis redis = null;
        try {
            redis = redisManager.redisPool.getResource();
            return redis.expire(key, newExpire) > 0;
        } finally {
            if (null != redis) {
                redis.close();
            }
        }
    }

守護線程代碼

 //守護線程
    public class DeliveryDaeThread implements Runnable {
        private boolean openDaeThread = true;
        public void open() {
            openDaeThread = true;
        }
        //關閉守護線程
        public void close() {
            openDaeThread = false;
        }
        @Override
        public void run() {
            while (openDaeThread) {
                try {
                    System.out.println("wait...");
                    //在過期時間之前 2s 續命
                    if (getLockExpire() <= 2) {
                        System.out.println("=============>續命");
                        if (openDaeThread) {
                            setLockExpire();
                        }
                    }
                    Thread.sleep(1000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

核心加鎖代碼

 /**
     * @Description: 對修改redis記錄前進行加鎖, 如果不能立即獲取到鎖,則循環等待
     * @author: wz
     * @date: 2019/8/2 14:58
     */
    public DeliveryDaeThread lock(String lockId) throws InterruptedException {
        //嘗試獲取分布式鎖的超時時間 1min
        int timeout = 100;
        boolean locked = this.lockDeliveryRecord(lockId);
        //沒取到鎖 繼續嘗試取鎖
        while (!locked) {
            Thread.sleep(600);
            Thread thread = Thread.currentThread();
            logger.info(thread.getId() + "--" + thread.getName() + " 嘗試獲取分布式鎖");
            locked = this.lockDeliveryRecord(lockId);
            timeout--;
            //超時
            if (timeout <= 0) {
                break;
            }
        }
        //如果獲取到鎖,開啟守護線程
        if (locked) {
            DeliveryDaeThread daeThread = new DeliveryDaeThread();
            Thread thread = new Thread(daeThread);
            thread.setDaemon(true);
            thread.start();
            return daeThread;
        }
        //沒有獲取到鎖
        return null;
    }

我們可以看到,通過getLockExpire方法可以獲取到KEY剩余過期時間,setLockExpire方法可以設置KEY新的過期時間。于是我們可以理下思路:我們可以在線程獲取到KEY鎖之后開啟一個守護線程,這個線程在單位時間就去獲取KEY的過期時間,如果即將過期,則給KEY“續命”增加過期時間。注意:這里我們依然享有鎖的原子性,其他線程在等待,不會去影響KEY過期時間。由于獲得鎖的線程和守護線程在同一個進程,一旦線程出問題停下守護線程也會停下,鎖到了超時的時候,不能繼續續命,也就自動釋放了。
于是我們可以保證在擁有鎖的線程執行完業務代碼釋放鎖之前,任何線程不能獲取鎖。

還有問題?

我們注意到在JDK線程鎖里有會有方法設置線程等待超時時間,如果我們要求redis分布式鎖的性能就不得不考慮:擁有鎖的線程執行完業務代碼釋放鎖之前,任何線程不能獲取鎖。這個條件造成的阻塞。想像一個極端條件:如果一個線程超久不釋放鎖那么整個分布式系統等待獲取該鎖的線程都將等待超久。所以這里我們還需要設置一個等待超時時間,如果線程超時不能獲取到鎖,難么就會返回null,同時繼續執行下面代碼。當然超時時間需要根據具體業務代碼來設定一個比較中肯合適的值。

結語

終于到結語了?。」P者此刻的感受就是肩膀超級酸有木有?。。?!從發現問題到解決問題(1d)再到碼字寫簡書(一動不動2h)
遇到問題要充滿驚喜,多思考,多動手,最終解決掉,嗯。


將來有打算利用springAOP將redis分布式鎖設計成注解模式減少與業務代碼的耦合,當然那都是后話了。
如果還有些不明白可以移步到此處:分布式鎖
以上。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容