單機
- 方案比較多,synchronized和juc很豐富
分布式鎖
- 互斥性:在任意時刻,只有一個客戶端能持有鎖
- 不會發生死鎖:即有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證后續其他客戶端能加鎖
文章來源:https://www.cnblogs.com/guozp/p/10341337.html
常見方案
- 基於數據庫
- 基於分布式緩存(redis、tair等)
- 基於zk
要基於你的業務場景選擇合適方案
數據庫(mysql)
基於數據庫的ACID以及MVCC(多版本並發控制機),MVCC是通過保存數據在某個時間點的快照來實現的,不同存儲引擎的MVCC實現是不同的,典型的有樂觀並發控制和悲觀並發控制
-
基於悲觀鎖(for update)
select * from table where *** for update
-
基於樂觀鎖(version)
樂觀鎖是基於數據的版本號實現的,表增加一個字段version,每次讀取的時候,將version取出,更新的時候,比較version是否一致,一致,處理完后把version加1;不一致,本次未拿到鎖
-
表定義(根據需求增加)
id resource status expire version 1 1 2 2019-01-01 12:00:00 1 2 2 2 2019-01-01 12:00:01 1 -
含義
- resource:代表資源
- status:鎖定狀態
- expire:過期時間,根據需求看是否需要增加使用
-
執行流程:
- 執行查詢操作獲取當前數據的數據版本號,例如:select id, resource, state,version from table where state=1 and id=1;
- 執行更新:update table set state=2, version=上次+1 where resource=1 and state=1 and version=1
- 上述執行影響1行,加鎖成功,影響0行,自己加鎖失敗,其它人已經加鎖鎖定
-
tair
Tair沒有直接提供分布式鎖的api,但是可以借助提供的其他api實現分布式鎖。
-
incr/decr(不可重入鎖)
-
原理:通過計數api的上下限值約束來實現(增加/減少計數。可設置最大值和最小值)
-
api:
- 增加計數(加鎖):
Result<Integer> incr(int namespace, Serializable key, int value, int defaultValue, int expireTime, int lowBound, int upperBound)
- 減少計數(釋放鎖):
Result<Integer> decr(int namespace, Serializable key, int value, int defaultValue, int expireTime, int lowBound, int upperBound)
- 關鍵參數解釋:
defaultValue: 第一次調用incr時的key的count初始值,第一次返回的值為defaultValue + value, decr第一次返回defaultValue - value lowBound: 最小值 upperBound: 最大值
- 增加計數(加鎖):
-
-
使用
- 線程一調用incr加鎖,加鎖后,key的值變成1,而key的上限值為1,其他線程再調用該接口時會報錯COUNTER_OUT_OF_RANGE
- 待線程一使用完成后,調用decr解鎖,此時key已經有值1,返回 1-1=0,解鎖成功。多次調用會失敗,因為范圍是0~1。
- 通過0、1的來回變化,達到分布式鎖的目的,當key為1時獲取到鎖,為0時釋放鎖
-
Get/Put
-
原理:使用put的version校驗實現
-
api
- put
ResultCode put(int namespace, Serializable key, Serializable value, int version, int expireTime)`
一定要設置過期參數expireTime,否則鎖執行過程中進程crash,鎖不會釋放,會長期占有,影響業務,加上后,業務至少可以自行恢復
-
關鍵參數解釋:
version - 為了解決並發更新同一個數據而設置的參數。當version為0時,表示強制更新 這里注意: 此處version,除了0、1外的任何數字都可以,傳入0,tair會強制覆蓋;而傳入1,第一個client寫入會成功,但是新寫入時服務端的version以0開始計數啊,所以此時version也是1,所以下一個到來的client寫入也會成功,這樣造成了沖突。
-
-
實現
這里針對網絡等問題做了重試,同時改造支持可重入鎖,不可重入鎖,目前這里可重入沒有做計數以及重新設置過期時間,使用的各位可以根據實際情況進行改造
@Override
public boolean tryLock(String lockKey, int expireTime, boolean reentrant) {
if (expireTime <= 0) {
expireTime = DEFAULT_EXPIRE_TIME;
}
int retryGet = 0;
try {
Result<DataEntry> result = tairManager.get(NAMESPACE, lockKey);
while (retryGet++ < LOCK_GET_MAX_RETRY && result != null && isError(result.getRc())) {
result = tairManager.get(NAMESPACE, lockKey);
}
if (result == null) {
log.error("tryLock error, maybe Tair service is unavailable");
return false;
}
if (ResultCode.DATANOTEXSITS.equals(result.getRc())) {
// version 2表示為空,若不是為空,則返回version error
ResultCode code = tairManager.put(NAMESPACE, lockKey, getLockValue(), DEFAULT_VERSION, expireTime);
if (ResultCode.SUCCESS.equals(code)) {
return true;
} else if (retryPut.get() < LOCK_PUT_MAX_RETRY && isError(code)) {
retryPut.set(retryPut.get() + 1);
return tryLock(lockKey, expireTime);
}
} else if (reentrant && result.getValue() != null && getLockValue().equals(result.getValue().getValue())) {
return true;
}
} catch (Exception e) {
log.error("try lock is error, msg is {}", e);
} finally {
retryPut.remove();
}
return false;
}
@Override
public void unlock(String lockKey) {
unlock(lockKey, false);
}
@Override
public boolean unlock(String lockKey, boolean reentrant) {
if (!reentrant) {
ResultCode invalid = tairManager.invalid(NAMESPACE, lockKey);
return invalid != null && invalid.isSuccess();
}
Result<DataEntry> result = tairManager.get(NAMESPACE, lockKey);
if (result != null && result.isSuccess() && result.getValue() != null) {
String value = result.getValue().getValue().toString();
if (getLockValue().equals(value)) {
ResultCode rc = tairManager.invalid(NAMESPACE, lockKey);
if (rc != null && rc.isSuccess()) {
return true;
} else {
log.error("unlock failed, tairLockManager.invalidValue fail, key is {}, ResultCode is {}",
lockKey, rc);
return false;
}
} else {
log.warn("unlock failed,value is not equal lockValue, key is {}, lockValue is {}, value is {}",
lockKey, getLockValue(), value);
return false;
}
}
return false;
}
@Override
public boolean lockStatus(String lockKey) {
Result<DataEntry> result = tairManager.get(NAMESPACE, lockKey);
if (result != null && result.isSuccess() && result.getValue() != null) {
return true;
}
return false;
}
private boolean isError(ResultCode code) {
return code == null || ResultCode.CONNERROR.equals(code) || ResultCode.TIMEOUT.equals(code) || ResultCode.UNKNOW
.equals(code);
}
private String getLockValue() {
return NetUtils.getLocalIp() + "_" + Thread.currentThread().getName();
}
redis
-
正確的加鎖邏輯
-
API:
- 加鎖
SET key value [EX seconds] [PX milliseconds] [NX|XX]
- 釋放鎖
EVAL script numkeys key [key ...] arg [arg ...]
- 加鎖
-
關鍵參數解釋
加鎖
``` 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 :只在鍵已經存在時,才對鍵進行設置操作。
>釋放
script 參數是一段 Lua 5.1 腳本程序,它會被運行在 Redis 服務器上下文中,這段腳本不必(也不應該)定義為一個 Lua 函數。
numkeys 參數用於指定鍵名參數的個數。
鍵名參數 key [key ...] 從 EVAL 的第三個參數開始算起,表示在腳本中所用到的那些 Redis 鍵(key),這些鍵名參數可以在 Lua 中通過全局變量 KEYS 數組,用 1 為基址的形式訪問( KEYS[1] , KEYS[2] ,以此類推)。
在命令的最后,那些不是鍵名參數的附加參數 arg [arg ...] ,可以在 Lua 中通過全局變量 ARGV 數組訪問,訪問的形式和 KEYS 變量類似( ARGV[1] 、 ARGV[2] ,諸如此類)。 -
實現
/** *1. 當前沒有鎖(key不存在),那么就進行加鎖操作,並對鎖設置個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不做任何操作 **/ public boolean tryLock(String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } public boolean unlock(String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; }
- 首先,set()加入了NX參數,可以保證如果key已存在,則函數不會調用成功,即只有一個客戶端能持有鎖。其次,由於我們對鎖設置了過期時間,即使鎖的持有者后續發生crash而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最后,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那么在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。
- 釋放鎖,這段Lua代碼的功能:首先獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)。那么為什么要使用Lua語言來實現呢?因為lua可以確保上述操作是原子性的。
-
-
tair的rdb引擎目前不支持上述命令,所以需要寫成兩行命令(或許新版本支持了,因為我使用的的還是舊版本,所以rdb的實現方式:
支持可重入鎖,不可重入鎖,目前這里可重入沒有做計數以及重新設置過期時間,使用的各位可以根據實際情況進行改造
/** * rdb 不支持多參數,所以使用兩個命令 * * @param lockKey * @param expireTime 超時時間 * @param reentrant 是否可重入,重入后會延長時間 * @return */ @Override public boolean tryLock(String lockKey, int expireTime, boolean reentrant) { if (expireTime <= 0) { expireTime = DEFAULT_EXPIRE_TIME; } boolean result = redisRepo.setNx(lockKey, getLockValue(), expireTime); if (!reentrant) { return result; } String value = redisRepo.get(lockKey); if (getLockValue().equals(value)) { result = redisRepo.setNx(lockKey, getLockValue(), expireTime); } return result; } /** * 版本不支持lua,所以使用兩個命令 * * @param lockKey * @param reentrant 是否可以釋放其它人創建的鎖 * @return */ @Override public boolean unlock(String lockKey, boolean reentrant) { if (!reentrant) { return redisRepo.delKeys(lockKey) > 0; } long result = 0; String value = redisRepo.get(lockKey); if (getLockValue().equals(value)) { result = redisRepo.delKeys(lockKey); } return result > 0; } @Override public boolean lockStatus(String lockKey) { String value = redisRepo.get(lockKey); return StringUtils.isNotBlank(value); } private String getLockValue() { return NetUtils.getLocalIp() + "_" + Thread.currentThread().getName(); }
-
錯誤的加鎖示例
-
setnx()方法作用就是SET IF NOT EXIST,expire()方法就是給鎖加一個過期時間。乍一看好像和前面的set()方法結果一樣,但是由於這是兩條Redis命令,不具有原子性,如果程序在執行完setnx()之后crash,由於鎖沒有設置過期時間,將會發生死鎖
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) { Long result = jedis.setnx(lockKey, requestId); if (result == 1) { jedis.expire(lockKey, expireTime); } }
-
- 通過setnx()方法嘗試加鎖,如果當前鎖不存在,返回加鎖成功。
- 如果鎖存在則獲取鎖過期時間,和當前時間比較,如果鎖已經過期,則設置新的過期時間,返回加鎖成功
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) { long expires = System.currentTimeMillis() + expireTime; String expiresStr = String.valueOf(expires); if (jedis.setnx(lockKey, expiresStr) == 1) { return true; } String currentValueStr = jedis.get(lockKey); if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { String oldValueStr = jedis.getSet(lockKey, expiresStr); if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { return true; } } return false;
}
上述代碼問題出在哪里? * 由於是客戶端自己生成過期時間,所以強制要求每個客戶端的時間必須同步 * 當鎖過期的時候,如果多個客戶端同時執行jedis.getSet()方法,那么雖然最終只有一個客戶端可以加鎖,但是這個客戶端的鎖的過期時間可能被其他客戶端覆蓋。 * 鎖不具備擁有者標識,即任何客戶端都可以解鎖(看個人業務)
-
-
錯誤的鎖釋放示例
- 使用jedis.del()方法刪除鎖,這種不先判斷鎖的擁有者而直接解鎖的方式,會導致任何客戶端都可以隨時進行解鎖
``` public static void wrongReleaseLock1(Jedis jedis, String lockKey) { jedis.del(lockKey); } ``` 2. 以下代碼分成兩條命令去執行,如果調用jedis.del()的時候,鎖已經不屬於當前客戶端的時,會解除他人加的鎖 ``` public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) { // 判斷加鎖與解鎖是不是同一個客戶端 if (requestId.equals(jedis.get(lockKey))) { // 若在此時,這把鎖過期不屬於這個客戶端的,則會誤解鎖 jedis.del(lockKey); } } ```
redis官方鎖
Redis的官方曾提出了一個容錯的分布式鎖算法:RedLock,只要有超過一半的緩存服務器能夠正常工作,系統就可以保證分布式鎖的可用性。詳情參考
zk
有機會或者留言需要的在寫吧, 略略略
文章來源:https://www.cnblogs.com/guozp/p/10341337.html
方案比較(從低到高)
-
從理解的難易程度角度:數據庫 > 緩存 > Zookeeper
-
從實現的復雜性角度:Zookeeper >= 緩存 > 數據庫
-
從性能角度:緩存 > Zookeeper >= 數據庫
-
從可靠性角度:Zookeeper > 緩存 > 數據庫