使用Redis完成分佈式鎖

實現原理

分佈式的CAP理論告訴我們“任何一個分佈式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多隻能同時滿足兩項。”所以,很多系統在設計之初就要對這三者做出取捨。在互聯網領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在用戶可以接受的範圍內即可。

爲了保證數據的最終一致性,需要很多的技術方案來支持,比如分佈式事務、分佈式鎖等。

使用Redis實現鎖的原因

  1. Redis有很高的性能;
  2. Redis命令對此支持較好,實現起來比較方便。

主要利用到的命令

SETNX

SETNX key val
當且僅當key不存在時,set一個key爲val的字符串,返回1;若key存在,則什麼都不做,返回0。

expire

expire key timeout
爲key設置一個超時時間,單位爲second,超過這個時間鎖會自動釋放,避免死鎖。

delete

delete key
刪除key

實現思想

  • 獲取鎖的時候,使用setnx加鎖,並使用expire命令爲鎖添加一個超時時間,超過該時間則自動釋放鎖,保證key一致,通過此在釋放鎖的時候進行判斷。
  • 獲取鎖的時候還設置一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
  • 釋放鎖的時候,當前時間小於超時時間,則執行delete進行鎖釋放。

代碼結構

package com.devframe.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * <b>redis分佈式鎖的實現</b></br>
 * 還有一些失敗機制沒處理,以後在使用測試階段,完善。
 *
 * @author Zhang Kai
 * @version 1.0
 * @since <pre>2017/11/20 9:22</pre>
 */
public class RedisLock implements Lock {
    private final static Logger logger = LoggerFactory.getLogger(RedisLock.class);
    /**
     * redis連接
     */
    private final Jedis jedis;
    /**
     * 鎖定資源名,鎖key,保證唯一。
     */
    private final String lockName;
    /**
     * 資源上鎖的最長時間,超時自動解鎖單位秒,</br>
     * 建議設置成死的,如果設置不當容易影響效率,嚴重造成死鎖。
     */
    private final int expireTime = Integer.valueOf(PropertyUtil.get("redisLock.expireTime"));
    /**
     * 線程獲取不到鎖,休眠的時間,單位ms
     * 避免系統資源浪費
     */
    private final long sleepTime = Long.valueOf(PropertyUtil.get("redisLock.sleepTime"));
    /**
     * 當前鎖超時的時間戳,單位毫秒
     */
    private long expireTimeOut = 0;
    /**
     * 獲取鎖狀態,鎖中斷狀態</br>
     * 值爲false的時候中斷獲取鎖</br>
     */
    private boolean interrupted = true;


    /**
     * 構造方法
     *
     * @param jedis    redis連接
     * @param lockName 上鎖key,唯一標識
     */
    public RedisLock(Jedis jedis, String lockName) {
        if (lockName == null) {
            throw new NullPointerException("lockName is required");
        }
        this.jedis = jedis;
        // 重命名的前綴,可以不加,也可以自定義,保證唯一即可。
        this.lockName = "lock" + lockName;
    }

    /**
     * 獲取鎖。如果鎖已被其他線程獲取,則進行等待,直到拿到鎖爲止。
     */
    @Override
    public void lock() {
        while (true) {
            this.lockCheck();
            long id = jedis.setnx(lockName, lockName);
            if (id == 0L) {
                try {
                    /**
                     * 沒有獲取到鎖則進行等待睡眠時間,再去重新獲取鎖</br>
                     * 這裏使用隨機時間可能會好一點,可以防止飢餓進程的出現,即,當同時到達多個進程,
                     * 只會有一個進程獲得鎖,其他的都用同樣的頻率進行嘗試,後面有來了一些進行,
                     * 也以同樣的頻率申請鎖,這將可能導致前面來的鎖得不到滿足.
                     * 使用隨機的等待時間可以一定程度上保證公平性
                     */
                    Thread.sleep(this.sleepTime);
                } catch (InterruptedException e) {
                    logger.error("Thread is interrupted", e);
                }
            } else {
                expireTimeOut = System.currentTimeMillis() + expireTimeOut * 1000 + 1;
                //設置redis中key的過期時間
                jedis.expire(this.lockName, expireTime);
                break;
            }
        }
    }

    /**
     * 中斷鎖獲取
     *
     * @throws InterruptedException 中斷異常
     */
    @Override
    public void lockInterruptibly() throws InterruptedException {
        this.interrupted = false;
    }

    /**
     * 它表示用來嘗試獲取鎖,會立即返回,如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線程獲取),則返回false,</br>
     * 也就說這個方法無論如何都會立即返回。在拿不到鎖時不會一直在那等待。
     *
     * @return boolean
     */
    @Override
    public boolean tryLock() {
        this.lockCheck();
        //嘗試獲取鎖
        long id = jedis.setnx(lockName, lockName);
        //返回結果爲0 則已經存在key,已經存在鎖。
        if (id == 0L) {
            return false;
        } else {
            expireTimeOut = System.currentTimeMillis() + expireTimeOut * 1000 + 1;
            //設置redis中key的過期時間
            jedis.expire(this.lockName, expireTime);
            return true;
        }
    }

    /**
     * 它表示用來嘗試獲取鎖,如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線程獲取),則返回false,</br>
     * 這個方法在拿不到鎖時會等待一定的時間,在時間期限之內如果還拿不到鎖,就返回false。</br>
     * 如果如果一開始拿到鎖或者在等待期間內拿到了鎖,則返回true。</br>
     *
     * @param time 等待時間
     * @param unit 時間單位
     * @return boolean
     * @throws InterruptedException 中斷異常
     */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time == 0) {
            return false;
        }
        if (unit == null) {
            throw new NullPointerException("TimeUnit is required.");
        }
        long now = System.currentTimeMillis();
        long timeOutAt = now + calcSeconds(time, unit);
        while (true) {
            this.lockCheck();
            long id = jedis.setnx(this.lockName, this.lockName);
            // id = 0 表示加鎖失敗
            if (id == 0) {
                // 獲取鎖超時
                if (System.currentTimeMillis() > timeOutAt) {
                    return false;
                }
                // 休眠一段時間,線程再繼續獲取鎖。
                Thread.sleep(this.sleepTime);
            } else {
                //獲取鎖成功,設置鎖過期時間戳
                expireTimeOut = System.currentTimeMillis() + expireTimeOut * 1000 + 1;
                jedis.expireAt(this.lockName, expireTimeOut);
                return true;
            }
        }
    }

    /**
     * <b>釋放鎖<b/>
     * 當前時間小於過期時間,則鎖未超時,刪除鎖,</br>
     * 過了超時時間,redis已經刪除了該key。
     */
    @Override
    public void unlock() {
        if (System.currentTimeMillis() < expireTimeOut) {
            jedis.del(lockName);
        }
    }

    @Override
    public Condition newCondition() {
        //TODO 涉及到 Condition 例外一個重要內容,以後再實現這個方法
        throw new UnsupportedOperationException("did not supported.");
    }

    /**
     * 檢查當前線程資源redis連接和鎖的狀態
     */
    private void lockCheck() {
        if (jedis == null) {
            throw new NullPointerException("Jedis is required.");
        }
        if (!interrupted) {
            throw new RuntimeException("Thread is interrupted.");
        }
    }

    /**
     * TimeUnit單位時間轉換成毫秒
     *
     * @param time 時間
     * @param unit 時間單位
     * @return long
     */
    private long calcSeconds(long time, TimeUnit unit) {
        if (unit == TimeUnit.DAYS) {
            return time * 24 * 60 * 60 * 1000;
        }
        if (unit == TimeUnit.HOURS) {
            return time * 60 * 60 * 1000;
        }
        if (unit == TimeUnit.MINUTES) {
            return time * 60 * 1000;
        }
        if (unit == TimeUnit.SECONDS) {
            return time * 1000;
        }
        if (unit == TimeUnit.MILLISECONDS) {
            return time;
        } else {
            //後面的不實現了,基本上用不到。
            throw new UnsupportedOperationException("cannot be resolved.");
        }
    }
}

配置

# redis lock
# s
redisLock.expireTime=1
# ms
redisLock.sleepTime=100

測試

測試就選用最經典的秒殺系統吧,使用分佈式鎖可以控制資源。

下面模擬500人秒殺100件商品。

package com.devframe.util;

import org.junit.Test;
import redis.clients.jedis.Jedis;

/**
 * @author Zhang Kai
 * @version 1.0
 * @since <pre>2017/11/20 14:12</pre>
 */
public class RedisLockTest {
    /**
     * 100件物品
     */
    public static int goodsNum = 100;
    /**
     * 500人
     */
    private static int personNum = 500;

    /**
     * 不加鎖的情況
     */
    @Test
    public void test() {
        for (int i = 0; i < personNum; i++) {
            new Thread(() -> {
                if (goodsNum > 0) {
                    System.out.println(Thread.currentThread().getName() + "獲取了鎖");
                    System.out.println("商品剩餘:" + --goodsNum);
                }
            }).start();
        }

    }

    /**
     * 加上分佈鎖
     * @param args
     */
    public static void main(String[] args) {
        for (int i = 0; i < personNum; i++) {
            new Thread(() -> {
                Jedis jedis = RedisUtil.getJedis();
                //初始化鎖,key保持一致
                Lock lock = new RedisLock(jedis, "aa");
                try {
                    lock.lock();
                    if (goodsNum > 0) {
                        System.out.println(Thread.currentThread().getName() + "獲取了鎖");
                        System.out.println("商品剩餘:" + --goodsNum);
                    }
                } finally {
                    //釋放鎖,並且釋放redis連接
                    lock.unlock();
                    RedisUtil.returnResource(jedis);

                }
            }).start();
        }
    }
}

不加鎖的部分結果:

Thread-100獲取了鎖
商品剩餘:-3
Thread-99獲取了鎖
商品剩餘:5
商品剩餘:6
Thread-98獲取了鎖
商品剩餘:-5
商品剩餘:7
商品剩餘:-4
商品剩餘:0
商品剩餘:1
Thread-105獲取了鎖
商品剩餘:-6

上鎖的結果:

Thread-8獲取了鎖
商品剩餘:5
Thread-238獲取了鎖
商品剩餘:4
Thread-72獲取了鎖
商品剩餘:3
Thread-137獲取了鎖
商品剩餘:2
Thread-402獲取了鎖
商品剩餘:1
Thread-337獲取了鎖
商品剩餘:0

總結

  1. 併發量大的時候,需要考慮鎖時間;
  2. 考慮失敗情況,上鎖了,但是設置超時時間失敗(redis崩潰等各種情況),鎖一致都沒有釋放,導致死鎖的情況發生,現在需要做的是,把key的value設置成超時的時間,每次上鎖失敗都去檢查一次,超時的就覆蓋,可以避免死鎖。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章