原理分析
最近看到好多博主都在推分散式鎖,實現方式很多,基於db、redis、zookeeper。zookeeper方式實現起來比較繁瑣,這裡我們就談談基於redis實現分散式鎖的正確實現方式。
背景
在很多網際網路產品應用中,有些場景需要加鎖處理,比如:秒殺,全域性遞增ID,樓層生成等等。大部分的解決方案是基於DB實現的,Redis為單程式單執行緒模式,採用佇列模式將併發訪問變成序列訪問,且多客戶端對Redis的連線並不存在競爭關係。 其次Redis提供一些命令SETNX,GETSET,可以方便實現分散式鎖機制。
Redis命令介紹
使用Redis實現分散式鎖,有兩個重要函式需要介紹。
SETNX命令(SET if Not Exists)
- 語法:
SETNX key value
複製程式碼
- 功能:
當且僅當 key 不存在,將 key 的值設為 value ,並返回1; 若給定的 key 已經存在,則 SETNX 不做任何動作,並返回0。
GETSET命令
- 語法:
GETSET key value
複製程式碼
- 功能:
將給定 key 的值設為 value ,並返回 key 的舊值 (old value), 當 key 存在但不是字串型別時,返回一個錯誤,當key不存在時,返回nil。
GET命令
- 語法:
GET key
複製程式碼
- 功能:
返回 key 所關聯的字串值,如果 key 不存在那麼返回特殊值 nil 。
DEL命令
- 語法:
DEL key [KEY …]
複製程式碼
- 功能:
刪除給定的一個或多個 key ,不存在的 key 會被忽略。
兵貴精,不在多。分散式鎖,我們就依靠這四個命令。但在具體實現,還有很多細節,需要仔細斟酌,因為在分散式併發多程式中,任何一點出現差錯,都會導致死鎖,hold住所有程式。
加鎖實現
SETNX 可以直接加鎖操作,比如說對某個關鍵詞foo加鎖,客戶端可以嘗試 SETNX foo.lock <current unix time>
。
- 如果返回1,表示客戶端已經獲取鎖,可以往下操作,操作完成後,通過
DEL foo.lock
命令來釋放鎖。 - 如果返回0,說明foo已經被其他客戶端上鎖,如果鎖是非堵塞的,可以選擇返回撥用。如果是堵塞呼叫,就需要進入下一個重試迴圈,直至成功獲得鎖或者重試超時。
理想是美好的,現實是殘酷的。僅僅使用SETNX加鎖帶有競爭條件的,在某些特定的情況會造成死鎖錯誤。
處理死鎖
在上面的處理方式中,如果獲取鎖的客戶端執行時間過長,程式被kill掉,或者因為其他異常崩潰,導致無法釋放鎖,就會造成死鎖。所以,需要對加鎖要做時效性檢測。
因此,我們在加鎖時,把當前時間戳作為value存入此鎖中,通過當前時間戳和redis中的時間戳進行對比,如果超過一定差值,認為鎖已經時效,防止鎖無限期的鎖下去。
但是,在大併發情況,如果同時檢測鎖失效,並簡單粗暴的刪除死鎖,再通過SETNX上鎖,可能會導致競爭條件的產生,即多個客戶端同時獲取鎖。
情景描述如下:
- C1獲取鎖,並崩潰。C2和C3呼叫SETNX上鎖返回0後,獲得foo.lock的時間戳,通過比對時間戳,發現鎖超時。
- C2 向foo.lock傳送DEL命令。
- C2 向foo.lock傳送SETNX獲取鎖。
- C3 向foo.lock傳送DEL命令,此時C3傳送DEL時,其實DEL掉的是C2的鎖。
- C3 向foo.lock傳送SETNX獲取鎖。
此時C2和C3都獲取了鎖,產生競爭條件,如果在更高併發的情況,可能會有更多客戶端獲取鎖。
所以,DEL鎖的操作,不能直接使用在鎖超時的情況下,幸好我們有GETSET方法,假設我們現在有另外一個客戶端C4,看看如何使用GETSET方式,避免這種情況產生。
- C1獲取鎖,並崩潰。C2和C3呼叫SETNX上鎖返回0後,呼叫GET命令獲得foo.lock的時間戳T1,通過比對時間戳,發現鎖超時。
- C4(呼叫SETNX上鎖返回0後,呼叫GET命令獲得foo.lock的時間戳T1,通過比對時間戳,發現鎖超時)向foo.lock傳送GESET命令,
GETSET foo.lock
並得到foo.lock中老的時間戳T2。- 如果T1=T2,說明C4獲得鎖。
- 如果T1!=T2,說明C4之前有另外一個客戶端C5通過呼叫GETSET方式獲取並更改了時間戳,C4未獲得鎖。只能進入下次迴圈中。
時間戳問題
我們看到foo.lock的value值為時間戳,所以要在多客戶端情況下,保證鎖有效,一定要同步各伺服器的時間。如果各伺服器間,時間有差異,時間不一致的客戶端,在判斷鎖超時,就會出現偏差,從而產生競爭條件。鎖的超時與否,嚴格依賴時間戳。
鎖覆蓋問題
現在唯一的問題是,C4設定foo.lock的新時間戳,是否會對C5獲取得鎖產生影響?
其實我們可以看到C4和C5只有在呼叫GET命令獲得foo.lock的時間戳,通過比對時間戳,發現鎖超時後,幾乎同時呼叫GETSET方式獲取鎖,執行的時間差值極小,並且寫入foo.lock中的都是有效時間戳,所以對鎖並沒有影響。
為了讓這個鎖更加強壯,獲取鎖的客戶端,應該在呼叫關鍵業務時,再次呼叫GET方法獲取T1,和寫入的T0時間戳進行對比,以免鎖因其他情況被執行DEL意外解開而不知。
鎖覆蓋會導致什麼問題呢?
當客戶端的鎖過期時間被覆蓋,會造成鎖不具有標識性,會造成客戶端無法釋放鎖(客戶端只能釋放明確自己持有的鎖)。
nil 問題
GET返回nil時應該走哪種邏輯?
一、第一種走迴圈走setnx邏輯
- C1客戶端獲取鎖,並且處理完後,DEL掉鎖。
- 在DEL鎖之前,C2通過SETNX向foo.lock設定時間戳T0失敗,發現有客戶端獲取鎖,進入GET操作。C2 向foo.lock傳送GET命令,獲取返回值T1(nil)(因為此時C1執行DEL刪除鎖)。
- C2 迴圈,進入下一次SETNX邏輯。
二、第二種走超時邏輯
- C1客戶端獲取鎖,並且處理完後,DEL掉鎖。
- 在DEL鎖之前,C2通過SETNX向foo.lock設定時間戳T0發現有客戶端獲取鎖,進入GET操作。C2 向foo.lock傳送GET命令,獲取返回值T1(nil)(因為此時C1執行DEL刪除鎖)。
- C2 通過 `T0 > T1 + expire` 對比,進入GETSET流程。
- C2呼叫GETSET向foo.lock傳送T0時間戳,返回foo.lock的原值T2,C2判斷如果T2=T1相等,獲得鎖,如果T2!=T1,未獲得鎖。
分析
兩種邏輯貌似都是OK,但是從邏輯處理上來說,當GET返回nil,表示鎖是被刪除的,而不是超時,應該走SETNX邏輯加鎖。
對於"第二種走超時邏輯"是否會造成死鎖,尚不清楚,不過推薦採用第一種方式。
GETSET返回nil時應該怎麼處理?
前提:假設C4客戶端獲取鎖後由於異常退出等原因未正常釋放鎖,導致鎖超時。此時,C1、C2和C3客戶端同時請求獲取鎖。C1、C2和C3客戶端呼叫GET介面,C1返回T1,此時C3網路情況更好,快速進入獲取鎖,並執行DEL刪除鎖,C2返回T2(nil)。C1進入超時處理邏輯。C2面臨上面提到「GET返回nil時應該走哪種邏輯?」的兩種選擇:1. 也進入超時處理邏輯;2. 繼續迴圈走setnx邏輯(推薦);
C1向foo.lock傳送GETSET命令,獲取返回值T11(nil)。C1比對C1和C11發現兩者不同,處理邏輯認為未獲取鎖,然後繼續迴圈走setnx邏輯。
C2有兩種選擇:
進入超時處理邏輯;
C2 向foo.lock傳送GETSET命令,獲取返回值T22(C1寫入的時間戳)。C2比對T2和T22發現兩者不同,處理邏輯認為未獲取鎖,然後繼續迴圈走setnx邏輯。繼續迴圈走setnx邏輯;
很明顯,C1和C2最終都會繼續迴圈走setnx邏輯,然後通過SETNX向foo.lock設定時間戳T0會失敗,這其實是因為在步驟1中C1執行GETSET命令導致的。此時C1和C2都認為未獲取鎖,其實C1是已經獲取鎖了,但是他的處理邏輯沒有考慮GETSET返回nil的情況,只是單純的用GET和GETSET值進行對比。
分析
至於為什麼會出現這種情況?就如上面設想的場景那樣,多客戶端時,每個客戶端連線redis後,發出的命令並不是連續的,導致從單客戶端看到的好像連續的命令,到redis server後,這兩條命令之間可能已經插入大量的其他客戶端發出的命令,比如DEL,SETNX等。
正確的處理方式就是GETSET返回nil時,獲取鎖成功。
總結
- 必要的超時機制:獲取鎖的客戶端一旦崩潰,一定要有過期機制,否則其他客戶端都降無法獲取鎖,造成死鎖問題。
- 分散式鎖,多客戶端的時間戳不能保證嚴格意義的一致性,所以在某些特定因素下,有可能存在問題。要適度的機制,可以承受小概率的事件產生。
- 只對關鍵處理節點加鎖,良好的習慣是,把相關的資源準備好,比如連線資料庫後,呼叫加鎖機制獲取鎖,直接進行操作,然後釋放,儘量減少持有鎖的時間。
- 在持有鎖期間要不要CHECK鎖,如果需要嚴格依賴鎖的狀態,最好在關鍵步驟中做鎖的CHECK檢查機制,但是根據我們的測試發現,在大併發時,每一次CHECK鎖操作,都要消耗掉幾個毫秒,而我們的整個持鎖處理邏輯才不到10毫秒,玩客沒有選擇做鎖的檢查。
- sleep學問,為了減少對redis的壓力,獲取鎖嘗試時,迴圈之間一定要做sleep操作。但是sleep時間是多少是門學問。需要根據自己的redis的QPS,加上持鎖處理時間等進行合理計算。如果redis的QPS足夠高,也可以考慮迴圈之間不sleep,迴圈一定次數/時間執行yeild,提高響應速度。
- 至於為什麼不使用Redis的muti,expire,watch等機制,可以查下參考資料,找下原因。
程式碼實現
程式碼庫
https://github.com/hutu92/distributed-lock
原始碼
package com.github.hutu92.concurrent.locks;
import com.alibaba.fastjson.JSON;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
/**
* Created by liuchunlong on 2018/8/31.
* <p>
* 基於redis的分散式鎖 v1
*
* 需要客戶端時間同步
*/
public class DistributedLock {
private static final long RETRY_BARRIER = 3 * 1000; // 請求鎖重試屏障,單位毫秒
private final JedisPool jedisPool; // redis連線池
private final String lockKey; // lock Key
private final long lockExpiryInNanos; // 鎖的過期時長,單位納秒
private static final ThreadLocal<Lock> lockThreadLocal = new ThreadLocal<Lock>();
/**
* 構造方法
*
* @param jedisPool redis連線池
* @param lockKey 鎖的Key
* @param lockExpiryInMillis 鎖的過期時長,單位毫秒
*/
public DistributedLock(JedisPool jedisPool, String lockKey, long lockExpiryInMillis) {
this.jedisPool = jedisPool;
this.lockKey = lockKey;
this.lockExpiryInNanos = lockExpiryInMillis * 1000;
}
/**
* 構造方法
* <p>
* 使用鎖預設的過期時長Integer.MAX_VALUE,即鎖永遠不會過期
*
* @param jedisPool redis連線池
* @param lockKey 鎖的Key
*/
public DistributedLock(JedisPool jedisPool, String lockKey) {
this(jedisPool, lockKey, Integer.MAX_VALUE);
}
/**
* 獲取鎖在redis中的Key標記
*
* @return locks key
*/
public String getLockKey() {
return this.lockKey;
}
/**
* 鎖的過期時長
*
* @return
*/
public long getLockExpiryInNanos() {
return lockExpiryInNanos;
}
/**
* 請求分散式鎖,不會阻塞,直接返回
*
* @param jedis redis 連線
* @return 成功獲取鎖返回true, 否則返回false
*/
private boolean tryAcquire(Jedis jedis) {
final Lock newLock = new Lock(System.nanoTime() + this.lockExpiryInNanos);
/**
* 將新鎖(newLock)寫入redis中。如果成功寫入,redis中不存在鎖,獲取鎖成功;否則,redis中已存在鎖,獲取鎖失敗;
*/
if (jedis.setnx(this.lockKey, newLock.toString()) == 1) {
lockThreadLocal.set(newLock);
return true;
}
/**
* 至此,說明redis中已存在鎖,獲取鎖失敗,則需要進行如下操作:
* 1. 判斷redis中已存在的鎖是否過期,如果過期則直接獲取鎖;
* 2. 否則,獲取鎖失敗;
*/
final String currentLockValue = jedis.get(lockKey);
// 特別的,當jedis.get()獲取已存在的鎖currentLockValue為空時,應該重新SETNX
if (currentLockValue == null || currentLockValue.length() == 0) {
tryAcquire(jedis);
}
final Lock currentLock = Lock.fromJson(currentLockValue); // redis中已存在的鎖
// 如果redis中已存在的鎖已超時,則重新獲取鎖
if (isExpired(currentLock)) {
String originLockValue = jedis.getSet(lockKey, newLock.toString());
/**
* 這裡還有個前置條件:
* 會對已存在的鎖進行校驗,jedis.get()和jedis.getSet()獲取的鎖必須是同一鎖,重新獲取鎖才成功
*/
// 特別的,當jedis.getSet()獲取已存在的鎖originLockValue為空時,則認定獲取鎖成功
if (originLockValue == null || originLockValue.length() == 0) {
lockThreadLocal.set(newLock);
return true;
}
if (originLockValue.equals(currentLockValue)) {
lockThreadLocal.set(newLock);
return true;
}
}
return false;
}
/**
* 請求分散式鎖,不會阻塞,直接返回
*
* @return 成功獲取鎖返回true, 否則返回false
*/
public boolean tryAcquire() {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
return tryAcquire(jedis);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
/**
* 超時請求分散式鎖,會阻塞
*
* 採用"自旋獲取鎖"的方式,直至獲取鎖成功或者請求鎖超時
*
* @param acquireTimeoutInMillis 鎖的請求超時時長
* @return
*/
public boolean acquire(long acquireTimeoutInMillis) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
long acquireTime = System.currentTimeMillis();
// 鎖的請求到期時間
long expiryTime = System.currentTimeMillis() + acquireTimeoutInMillis;
while (expiryTime >= System.currentTimeMillis()) {
boolean result = tryAcquire(jedis);
if (result) { // 獲取鎖成功直接返回,否則迴圈重試
return true;
}
if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
Thread.yield();
}
}
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
/**
* 釋放鎖
*/
public void release() {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
release(jedis);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
/**
* 釋放鎖
*
* @param jedis
*/
private void release(Jedis jedis) {
Lock currlock = lockThreadLocal.get();
if (currlock != null) {
final String currentLockValue = jedis.get(lockKey);
if (currentLockValue != null && currentLockValue.length() != 0) {
final Lock currentLock = Lock.fromJson(currentLockValue); // redis中已存在的鎖
if (currlock.equals(currentLock)) {
lockThreadLocal.remove();
jedis.del(lockKey);
}
}
}
}
/**
* 判斷當前執行緒是否持有鎖
*
* 未持有鎖或者鎖超時,返回false
*
* @return
*/
public boolean isLocked() {
Lock currlock = lockThreadLocal.get();
// 如果當前執行緒儲存的lock不為null,並且未超時,則當前執行緒必然持有鎖,鎖未被意外釋放
return currlock != null && !currlock.isExpired();
}
/**
* 判斷指定的lock是否是當前執行緒持有的鎖
*
* @return
*/
boolean isMine(final Lock lock) {
Lock currlock = lockThreadLocal.get();
return currlock != null && currlock.equals(lock);
}
/**
* 判斷鎖是否超時
*
* @param lock
* @return
*/
boolean isExpired(final Lock lock) {
return lock.isExpired();
}
/**
* 鎖
*/
protected static class Lock {
private long expiryTime; // 鎖的過期時間,注意,不是過期時長,單位納秒
Lock(long expiryTime) {
this.expiryTime = expiryTime;
}
/**
* 解析字串,根據解析出的過期時間構造Lock
*
* @param json
* @return
*/
static Lock fromJson(String json) {
return JSON.parseObject(json, Lock.class);
}
@Override
public String toString() {
return JSON.toJSONString(this, false);
}
public long getExpiryTime() {
return expiryTime;
}
/**
* 判斷鎖是否超時,如果鎖的過期時間小於當前系統時間,則判定鎖超時
*
* @return
*/
boolean isExpired() {
return this.expiryTime < System.nanoTime();
}
@Override
public boolean equals(Object obj) {
return obj != null
&& obj instanceof Lock
&& this.expiryTime == ((Lock) obj).getExpiryTime();
}
}
}
複製程式碼
優化
上面存在的鎖覆蓋問題是不可避免的,還有就是要求客戶端時間同步。下面我們進一步優化這一問題。
Redis命令介紹
SET
- 語法:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
複製程式碼
功能:
將字串值 value 關聯到 key 。
如果 key 已經持有其他值, SET 就覆寫舊值,無視型別。
對於某個原本帶有生存時間(TTL)的鍵來說, 當 SET 命令成功在這個鍵上執行時,這個鍵原有的 TTL 將被清除。可選引數
從 Redis 2.6.12 版本開始,SET 命令的行為可以通過一系列引數來修改:- EX second :設定鍵的過期時間為 second 秒。
SET key value EX second
效果等同於SETEX key second value
。 - PX millisecond :設定鍵的過期時間為 millisecond 毫秒。
SET key value PX millisecond
效果等同於PSETEX key millisecond value
。 - NX :只在鍵不存在時,才對鍵進行設定操作。
SET key value NX
效果等同於SETNX key value
。 - XX :只在鍵已經存在時,才對鍵進行設定操作。
- EX second :設定鍵的過期時間為 second 秒。
因為 SET 命令可以通過引數來實現和 SETNX 、 SETEX 和 PSETEX 三個命令的效果,所以將來的 Redis 版本可能會廢棄並最終移除 SETNX 、 SETEX 和 PSETEX 這三個命令。
- 返回值:
在 Redis 2.6.12 版本以前, SET 命令總是返回 OK 。
從 Redis 2.6.12 版本開始, SET 在設定操作成功完成時,才返回 OK 。
如果設定了 NX 或者 XX ,但因為條件沒達到而造成設定操作未執行,那麼命令返回空批量回復(NULL Bulk Reply)。
使用模式
命令 SET resource-name anystring NX EX max-lock-time
是一種在 Redis 中實現鎖的簡單方法。
客戶端執行以上的命令:
- 如果伺服器返回 OK ,那麼這個客戶端獲得鎖。
- 如果伺服器返回 NIL ,那麼客戶端獲取鎖失敗,可以在稍後再重試。
設定的過期時間到達之後,鎖將自動釋放。
可以通過以下修改,讓這個鎖實現更健壯:
- 不使用固定的字串作為鍵的值,而是設定一個不可猜測(non-guessable)的長隨機字串,作為口令串(token)。
- 不使用 DEL 命令來釋放鎖,而是傳送一個 Lua 指令碼,這個指令碼只在客戶端傳入的值和鍵的口令串相匹配時,才對鍵進行刪除。
這兩個改動可以防止持有過期鎖的客戶端誤刪現有鎖的情況出現。
以下是一個簡單的解鎖指令碼示例:
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
複製程式碼
原始碼
package com.github.hutu92;
import com.alibaba.fastjson.JSON;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
/**
* Created by liuchunlong on 2018/9/4.
* <p>
* 基於redis的分散式鎖 v2
* <p>
* 不需要客戶端時間同步
*/
public class DistributedLock {
private static final long RETRY_BARRIER = 600; // 重試屏障,單位毫秒
private static final long INTERVAL_TIMES = 200; // 下一次重試等待,單位毫秒
private final JedisPool jedisPool; // redis連線池
private final String lockKey; // lock Key
private final long lockExpiryInMillis; // 鎖的過期時長,單位納秒
private final ThreadLocal<Lock> lockThreadLocal = new ThreadLocal<Lock>();
/**
* 構造方法
*
* @param jedisPool redis連線池
* @param lockKey 鎖的Key
* @param lockExpiryInMillis 鎖的過期時長,單位毫秒
*/
public DistributedLock(JedisPool jedisPool, String lockKey, long lockExpiryInMillis) {
this.jedisPool = jedisPool;
this.lockKey = lockKey;
this.lockExpiryInMillis = lockExpiryInMillis;
}
/**
* 構造方法
* <p>
* 使用鎖預設的過期時長Integer.MAX_VALUE,即鎖永遠不會過期
*
* @param jedisPool redis連線池
* @param lockKey 鎖的Key
*/
public DistributedLock(JedisPool jedisPool, String lockKey) {
this(jedisPool, lockKey, Integer.MAX_VALUE);
}
/**
* 獲取鎖在redis中的Key標記
*
* @return locks key
*/
public String getLockKey() {
return this.lockKey;
}
/**
* 鎖的過期時長
*
* @return
*/
public long getLockExpiryInMillis() {
return lockExpiryInMillis;
}
/**
* can override
*
* @param jedis
* @return
*/
private String nextUid(Jedis jedis) {
// 可以考慮雪花演算法..
return UUID.randomUUID().toString();
}
private synchronized Jedis getClient() {
return jedisPool.getResource();
}
private synchronized void closeClient(Jedis jedis) {
jedis.close();
}
/**
* 請求分散式鎖,不會阻塞,直接返回
*
* @param jedis redis 連線
* @return 成功獲取鎖返回true, 否則返回false
*/
private boolean tryAcquire(Jedis jedis) {
final Lock nLock = new Lock(nextUid(jedis));
String result = jedis.set(this.lockKey, nLock.toString(), "NX", "PX", this.lockExpiryInMillis);
if ("OK".equals(result)) {
lockThreadLocal.set(nLock);
return true;
}
return false;
}
/**
* 請求分散式鎖,不會阻塞,直接返回
*
* @return 成功獲取鎖返回true, 否則返回false
*/
public boolean tryAcquire() {
Jedis jedis = null;
try {
jedis = getClient();
return tryAcquire(jedis);
} finally {
if (jedis != null) {
closeClient(jedis);
}
}
}
/**
* 超時請求分散式鎖,會阻塞
*
* 採用"自旋獲取鎖"的方式,直至獲取鎖成功或者請求鎖超時
*
* @param acquireTimeoutInMillis 鎖的請求超時時長
* @return
*/
public boolean acquire(long acquireTimeoutInMillis) throws InterruptedException {
Jedis jedis = null;
try {
jedis = getClient();
long acquireTime = System.currentTimeMillis();
long expiryTime = System.currentTimeMillis() + acquireTimeoutInMillis; // 鎖的請求到期時間
while (expiryTime >= System.currentTimeMillis()) {
boolean result = tryAcquire(jedis);
if (result) { // 獲取鎖成功直接返回,否則迴圈重試
return true;
}
Thread.sleep(INTERVAL_TIMES);
}
} finally {
if (jedis != null) {
closeClient(jedis);
}
}
return false;
}
/**
* 釋放鎖
*
* @return
*/
public boolean release() throws InterruptedException {
return release(Integer.MAX_VALUE);
}
/**
* 釋放鎖
*
* @return
*/
public boolean release(long releaseTimeoutInMillis) throws InterruptedException {
Jedis jedis = null;
try {
jedis = getClient();
return release(jedis, releaseTimeoutInMillis);
} finally {
if (jedis != null) {
closeClient(jedis);
}
}
}
/**
* 釋放鎖
*
* @param jedis
* @param releaseTimeoutInMillis
* @return
*/
private boolean release(Jedis jedis, long releaseTimeoutInMillis) throws InterruptedException {
Lock cLock = lockThreadLocal.get();
if (cLock == null) {
System.out.println("lock is null!");
}
if (cLock != null) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
long releaseTime = System.currentTimeMillis();
long expiryTime = System.currentTimeMillis() + releaseTimeoutInMillis; // 鎖的釋放到期時間
while (expiryTime >= System.currentTimeMillis()) {
Object result = jedis.eval(luaScript, Collections.singletonList(this.lockKey),
Collections.singletonList(cLock.toString()));
if (((Long) result) == 1L) {
lockThreadLocal.remove();
return true;
}
Thread.sleep(INTERVAL_TIMES);
}
}
return false;
}
/**
* 鎖
*/
protected static class Lock {
private String uid; // lock 唯一標識
Lock(String uid) {
this.uid = uid;
}
public String getUid() {
return uid;
}
@Override
public String toString() {
return JSON.toJSONString(this, false);
}
}
}
複製程式碼
效能調優
這裡我們使用ab效能測試工具來模擬測試。
由於沒有使用佇列,對高併發請求進行削峰,所以所有的壓力都會被打到redis上。為了測試方便我這裡只是本地啟動了單機redis,沒有做其它的調優配置。
我們併發測試場景是1000個併發請求,總共2000個請求。
ab -n 2000 -c 1000 "localhost:8080/lock/v2/seckill"
複製程式碼
上述的地址是一個介面,介面程式碼如下:
@RestController
@RequestMapping("/lock")
public class LockController {
private static LongAdder longAdder = new LongAdder();
private static Long ACQUIRE_TIMEOUT_IN_MILLIS = (long) Integer.MAX_VALUE;
private static Long stock = 100000L;
private static DistributedLock lock;
static {
longAdder.add(stock);
}
private final JedisPool jedisPool;
@Autowired
public LockController(JedisPool jedisPool) {
this.jedisPool = jedisPool;
lock = new DistributedLock(jedisPool, "seckillV2_" + UUID.randomUUID().toString());
}
@GetMapping("/v2/seckill")
public String seckillV2() throws InterruptedException {
boolean acquireResult = false;
try {
acquireResult = lock.acquire(ACQUIRE_TIMEOUT_IN_MILLIS);
if (!acquireResult) {
return "人太多了,換個姿勢操作一下!";
}
if (longAdder.longValue() == 0L) {
return "已搶光!";
}
doSomeThing(jedisPool);
longAdder.decrement();
System.out.println("已搶: " + (stock - longAdder.longValue()) + ", 還剩下: " + longAdder.longValue());
} finally {
if (acquireResult) {
boolean releaseResult = lock.release();
if (!releaseResult) {
System.out.println("釋放鎖失敗!");
}
}
}
return "OK";
}
private void doSomeThing(JedisPool jedisPool) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
jedis.incr("already_bought");
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
複製程式碼
那麼我們這裡說的效能調優指的是什麼呢?
仔細分析上面的原始碼你會發現,獲取鎖的邏輯是迴圈獲取的,再每次迴圈之間,應該怎麼去處理?如果不做任何處理,直接繼續下一個迴圈,表面上看能夠及時的獲取鎖,但這會給redis更大的壓力,如果redis扛不住,到最後只會適得其反;而如果sleep等待,那麼等待多久呢?等待久了,鎖的獲取和釋放就會不及時;使用yield如何?等等
No1
if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
Thread.yield();
}
複製程式碼
請求獲取鎖的前600毫秒內直接迴圈重試,如果超過600毫秒還未獲取到鎖則每次迴圈都將執行緒推遲到下一個時間片執行。
主要引數說明:
- Failed requests:失敗的請求
- Time per request:每個請求的平均耗時
No2
if ((System.currentTimeMillis() - acquireTime) > RETRY_BARRIER) {
Thread.sleep(INTERVAL_TIMES);
} else {
Thread.yield();
}
複製程式碼
請求獲取鎖的前600毫秒內每次迴圈重試都先將執行緒推遲到下一個時間片,如果超過600毫秒還未獲取到鎖則每次迴圈都將執行緒休眠200毫秒。
很明顯,出錯率降低了很多,每個請求的耗時也減少了一半,這是因為,No1中在600毫秒內的直接迴圈重試,會產生很多意義的請求,給redis造成了巨大的壓力,無法響應請求。
No3
Thread.sleep(INTERVAL_TIMES);
複製程式碼
請求獲取鎖的每次迴圈重試都將執行緒休眠200毫秒。
No4
Thread.sleep(INTERVAL_TIMES * 10);
複製程式碼
請求獲取鎖的每次迴圈重試都將執行緒休眠2秒。
很明顯,休眠時間過長,會使部分執行緒請求鎖的時間變長,不能夠及時獲取到鎖。
No5
Thread.yield();
複製程式碼
請求獲取鎖的每次迴圈重試都將執行緒推遲到下一個時間片執行。
總結
總的來說,No2與No3表現的都還可以。但是No2使用了Thread.yield();
也會給redis造成壓力,我可以對比下兩者的 Percentage of the requests served within a certain time (ms) 資料。可以看到No3的90%以下請求的使用者平均時間要明顯低於No2的。所以最終我們選擇No3策略。
當然你也可以根據你的redis的QPS自行調整策略。