文章目錄
- 分布式鎖
- 基本原理和實現方式對比
- Redis分布式鎖的實現核心思路
- 實現分布式鎖版本一
- Redis分布式鎖誤刪情況說明
- 解決Redis分布式鎖誤刪問題
- 分布式鎖的原子性問題
- 分布式鎖-Redission
- 分布式鎖-redission可重入鎖原理
- 分布式鎖-redission鎖重試和WatchDog機制
- 分布式鎖-redission鎖的MutiLock原理
分布式鎖
基本原理和實現方式對比
分布式鎖:滿足分布式系統或集群模式下多進程可見并且互斥的鎖。
分布式鎖的核心思想就是讓大家都使用同一把鎖,只要大家使用的是同一把鎖,那么我們就能鎖住線程,不讓線程進行,讓程序串行執行,這就是分布式鎖的核心思路
那么分布式鎖他應該滿足的條件呢?
可見性:多個線程都能看到相同的結果,注意:這個地方說的可見性并不是并發編程中指的內存可見性,只是說多個進程之間都能感知到變化的意思
互斥:互斥是分布式鎖的最基本的條件,使得程序串行執行
高可用:程序不易崩潰,時時刻刻都保證較高的可用性
高性能:由于加鎖本身就讓性能降低,所有對于分布式鎖本身需要他就較高的加鎖性能和釋放鎖性能
安全性:安全也是程序中必不可少的一環
常見的分布式鎖有三種
-
Mysql:mysql本身就帶有鎖機制,但是由于mysql性能本身一般,所以采用分布式鎖的情況下,其實使用mysql作為分布式鎖比較少見
-
Redis:redis作為分布式鎖是非常常見的一種使用方式,現在企業級開發中基本都使用redis或者zookeeper作為分布式鎖,利用setnx這個方法,如果插入key成功,則表示獲得到了鎖,如果有人插入成功,其他人插入失敗則表示無法獲得到鎖,利用這套邏輯來實現分布式鎖
-
Zookeeper:zookeeper也是企業級開發中較好的一個實現分布式鎖的方案
Redis分布式鎖的實現核心思路
實現分布式鎖時需要實現的兩個基本方法:
-
獲取鎖:
- 互斥:確保只能有一個線程獲取鎖
- 非阻塞:嘗試一次,成功返回true,失敗返回false
-
釋放鎖:
- 手動釋放
- 超時釋放:獲取鎖時添加一個超時時間
核心思路:
我們利用redis 的setNx 方法,當有多個線程進入時,我們就利用該方法,第一個線程進入時,redis 中就有這個key 了,返回了1,如果結果是1,則表示他搶到了鎖,那么他去執行業務,然后再刪除鎖,退出鎖邏輯,沒有搶到鎖的哥們,等待一定時間后重試即可
實現分布式鎖版本一
- 加鎖邏輯
鎖的基本接口
SimpleRedisLock
利用setnx方法進行加鎖,同時增加過期時間,防止死鎖,此方法可以保證加鎖和增加過期時間具有原子性
我們的方法,是把存在線程中的用戶的id作為redis中的中的鍵,這樣我們就可以作為為每一個用戶設置單獨的鎖,而且我們也會為每個鎖設置單的過期時間從而防止死鎖,具體代碼,可以看下面:
private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {// 獲取線程標示String threadId = Thread.currentThread().getId()// 獲取鎖Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);
}
Redis分布式鎖誤刪情況說明
邏輯說明:
持有鎖的線程在鎖的內部出現了阻塞,導致他的鎖自動釋放,這時其他線程,線程2來嘗試獲得鎖,就拿到了這把鎖,然后線程2在持有鎖執行過程中,線程1反應過來,繼續執行,而線程1執行過程中,走到了刪除鎖邏輯,此時就會把本應該屬于線程2的鎖進行刪除,這就是誤刪別人鎖的情況說明
解決方案:解決方案就是在每個線程釋放鎖的時候,去判斷一下當前這把鎖是否屬于自己,如果屬于自己,則不進行鎖的刪除,假設還是上邊的情況,線程1卡頓,鎖自動釋放,線程2進入到鎖的內部執行邏輯,此時線程1反應過來,然后刪除鎖,但是線程1,一看當前這把鎖不是屬于自己,于是不進行刪除鎖邏輯,當線程2走到刪除鎖邏輯時,如果沒有卡過自動釋放鎖的時間點,則判斷當前這把鎖是屬于自己的,于是刪除這把鎖。
解決Redis分布式鎖誤刪問題
需求:修改之前的分布式鎖實現,滿足:在獲取鎖時存入線程標示(可以用UUID表示)
在釋放鎖時先獲取鎖中的線程標示,判斷是否與當前線程標示一致
- 如果一致則釋放鎖
- 如果不一致則不釋放鎖
核心邏輯:在存入鎖時,放入自己線程的標識,在刪除鎖時,判斷當前這把鎖的標識是不是自己存入的,如果是,則進行刪除,如果不是,則不進行刪除。
具體代碼如下:加鎖
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {// 獲取線程標示String threadId = ID_PREFIX + Thread.currentThread().getId();// 獲取鎖Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);
}
釋放鎖
public void unlock() {// 獲取線程標示String threadId = ID_PREFIX + Thread.currentThread().getId();// 獲取鎖中的標示String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);// 判斷標示是否一致if(threadId.equals(id)) {// 釋放鎖stringRedisTemplate.delete(KEY_PREFIX + name);}
}
分布式鎖的原子性問題
更為極端的誤刪邏輯說明:
線程1現在持有鎖之后,在執行業務邏輯過程中,他正準備刪除鎖,而且已經走到了條件判斷的過程中,比如他已經拿到了當前這把鎖確實是屬于他自己的,正準備刪除鎖,但是此時他的鎖到期了,那么此時線程2進來,但是線程1他會接著往后執行,當他卡頓結束后,他直接就會執行刪除鎖那行代碼,相當于條件判斷并沒有起到作用,這就是刪鎖時的原子性問題,之所以有這個問題,是因為線程1的拿鎖,比鎖,刪鎖,實際上并不是原子性的,我們要防止剛才的情況發生,
這個問題可以使用lua腳本實現,但是在java中我們一般會用redission這個第三方庫。
分布式鎖-Redission
引入依賴:
<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.13.6</version>
</dependency>
配置Redisson客戶端:
@Configuration
public class RedissonConfig {@Beanpublic RedissonClient redissonClient(){// 配置Config config = new Config();config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");// 創建RedissonClient對象return Redisson.create(config);}
}
使用Redission的分布式鎖
@Resource
private RedissionClient redissonClient;@Test
void testRedisson() throws Exception{//獲取鎖(可重入),指定鎖的名稱RLock lock = redissonClient.getLock("anyLock");//嘗試獲取鎖,參數分別是:獲取鎖的最大等待時間(期間會重試),鎖自動釋放時間,時間單位boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);//判斷獲取鎖成功if(isLock){try{System.out.println("執行業務"); }finally{//釋放鎖lock.unlock();}}}
業務代碼更改
在 VoucherOrderServiceImpl
注入RedissonClient
@Resource
private RedissonClient redissonClient;@Override
public Result seckillVoucher(Long voucherId) {// 1.查詢優惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2.判斷秒殺是否開始if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 尚未開始return Result.fail("秒殺尚未開始!");}// 3.判斷秒殺是否已經結束if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 尚未開始return Result.fail("秒殺已經結束!");}// 4.判斷庫存是否充足if (voucher.getStock() < 1) {// 庫存不足return Result.fail("庫存不足!");}Long userId = UserHolder.getUser().getId();//創建鎖對象 這個代碼不用了,因為我們現在要使用分布式鎖//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);RLock lock = redissonClient.getLock("lock:order:" + userId);//獲取鎖對象boolean isLock = lock.tryLock();//加鎖失敗if (!isLock) {return Result.fail("不允許重復下單");}try {//獲取代理對象(事務)IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);} finally {//釋放鎖lock.unlock();}}
分布式鎖-redission可重入鎖原理
在Lock鎖中,他是借助于底層的一個voaltile的一個state變量來記錄重入的狀態的,比如當前沒有人持有這把鎖,那么state=0,假如有人持有這把鎖,那么state=1,如果持有這把鎖的人再次持有這把鎖,那么state就會+1 ,如果是對于synchronized而言,他在c語言代碼中會有一個count,原理和state類似,也是重入一次就加一,釋放一次就-1 ,直到減少成0 時,表示當前這把鎖沒有被人持有。
在redission中,我們的也支持支持可重入鎖
在分布式鎖中,他采用hash結構用來存儲鎖,其中大key表示表示這把鎖是否存在,用小key表示當前這把鎖被哪個線程持有,所以接下來我們一起分析一下當前的這個lua表達式
這個地方一共有3個參數
KEYS[1] : 鎖名稱
ARGV[1]: 鎖失效時間
ARGV[2]: id + “:” + threadId; 鎖的小key
exists: 判斷數據是否存在 name:是lock是否存在,如果==0,就表示當前這把鎖不存在
redis.call(‘hset’, KEYS[1], ARGV[2], 1);此時他就開始往redis里邊去寫數據 ,寫成一個hash結構
Lock{
? id + “:” + threadId : 1
}
如果當前這把鎖存在,則第一個條件不滿足,再判斷
redis.call(‘hexists’, KEYS[1], ARGV[2]) == 1
此時需要通過大key+小key判斷當前這把鎖是否是屬于自己的,如果是自己的,則進行
redis.call(‘hincrby’, KEYS[1], ARGV[2], 1)
將當前這個鎖的value進行+1 ,redis.call(‘pexpire’, KEYS[1], ARGV[1]); 然后再對其設置過期時間,如果以上兩個條件都不滿足,則表示當前這把鎖搶鎖失敗,最后返回pttl,即為當前這把鎖的失效時間
分布式鎖-redission鎖重試和WatchDog機制
搶鎖過程中,獲得當前線程,通過tryAcquire進行搶鎖,該搶鎖邏輯和之前邏輯相同
1、先判斷當前這把鎖是否存在,如果不存在,插入一把鎖,返回null
2、判斷當前這把鎖是否是屬于當前線程,如果是,則返回null
所以如果返回是null,則代表著當前已經搶鎖完畢,或者可重入完畢,但是如果以上兩個條件都不滿足,則進入到第三個條件,返回的是鎖的失效時間,同學們可以自行往下翻一點點,你能發現有個while( true) 再次進行tryAcquire進行搶鎖
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {return;
}
接下來會有一個條件分支,因為lock方法有重載方法,一個是帶參數,一個是不帶參數,如果帶帶參數傳入的值是-1,如果傳入參數,則leaseTime是他本身,所以如果傳入了參數,此時leaseTime != -1 則會進去搶鎖,搶鎖的邏輯就是之前說的那三個邏輯
if (leaseTime != -1) {return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
如果是沒有傳入時間,則此時也會進行搶鎖, 而且搶鎖時間是默認看門狗時間 commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()
ttlRemainingFuture.onComplete((ttlRemaining, e) 這句話相當于對以上搶鎖進行了監聽,也就是說當上邊搶鎖完畢后,此方法會被調用,具體調用的邏輯就是去后臺開啟一個線程,進行續約邏輯,也就是看門狗線程
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {if (e != null) {return;}// lock acquiredif (ttlRemaining == null) {scheduleExpirationRenewal(threadId);}
});
return ttlRemainingFuture;
此邏輯就是續約邏輯,注意看commandExecutor.getConnectionManager().newTimeout() 此方法
Method( new TimerTask() {},參數2 ,參數3 )
指的是:通過參數2,參數3 去描述什么時候去做參數1的事情,現在的情況是:10s之后去做參數一的事情
因為鎖的失效時間是30s,當10s之后,此時這個timeTask 就觸發了,他就去進行續約,把當前這把鎖續約成30s,如果操作成功,那么此時就會遞歸調用自己,再重新設置一個timeTask(),于是再過10s后又再設置一個timerTask,完成不停的續約
那么大家可以想一想,假設我們的線程出現了宕機他還會續約嗎?當然不會,因為沒有人再去調用renewExpiration這個方法,所以等到時間之后自然就釋放了。
private void renewExpiration() {ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());if (ee == null) {return;}Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {@Overridepublic void run(Timeout timeout) throws Exception {ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());if (ent == null) {return;}Long threadId = ent.getFirstThreadId();if (threadId == null) {return;}RFuture<Boolean> future = renewExpirationAsync(threadId);future.onComplete((res, e) -> {if (e != null) {log.error("Can't update lock " + getName() + " expiration", e);return;}if (res) {// reschedule itselfrenewExpiration();}});}}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);ee.setTimeout(task);
}
分布式鎖-redission鎖的MutiLock原理
為了提高redis的可用性,我們會搭建集群或者主從,現在以主從為例
此時我們去寫命令,寫在主機上, 主機會將數據同步給從機,但是假設在主機還沒有來得及把數據寫入到從機去的時候,此時主機宕機,哨兵會發現主機宕機,并且選舉一個slave變成master,而此時新的master中實際上并沒有鎖信息,此時鎖信息就已經丟掉了。
為了解決這個問題,redission提出來了MutiLock鎖,每個節點的地位都是一樣的, 這把鎖加鎖的邏輯需要寫入到每一個主叢節點上,只有所有的服務器都寫入成功,此時才是加鎖成功,假設現在某個節點掛了,那么他去獲得鎖的時候,只要有一個節點拿不到,都不能算是加鎖成功,就保證了加鎖的可靠性。