redis的分布式鎖實作
1.分布式鎖介紹
在計算機系統中,鎖作為一種控制并發的機制無處不在。
單機環境下,作業系統能夠在程序或線程之間通過本地的鎖來控制并發程式的行為。而在如今的大型複雜系統中,通常采用的是分布式架構提供服務。
分布式環境下,基于本地單機的鎖無法控制分布式系統中分開部署用戶端的并發行為,此時分布式鎖就應運而生了。
一個可靠的分布式鎖應該具備以下特性:
1.互斥性:作為鎖,需要保證任何時刻隻能有一個用戶端(使用者)持有鎖
2.可重入: 同一個用戶端在獲得鎖後,可以再次進行加鎖
3.高可用:擷取鎖和釋放鎖的效率較高,不會出現單點故障
4.自動重試機制:當用戶端加鎖失敗時,能夠提供一種機制讓用戶端自動重試
2.分布式鎖api接口
複制代碼
/**
-
分布式鎖 api接口
*/
public interface DistributeLock {
/**
* 嘗試加鎖
* @param lockKey 鎖的key
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lock(String lockKey);
/**
* 嘗試加鎖 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param expireTime 過期時間 機關:秒
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lock(String lockKey, int expireTime);
/**
* 嘗試加鎖 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param requestID 使用者ID
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lock(String lockKey, String requestID);
/**
* 嘗試加鎖 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param requestID 使用者ID
* @param expireTime 過期時間 機關:秒
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lock(String lockKey, String requestID, int expireTime);
/**
* 嘗試加鎖,失敗自動重試 會阻塞目前線程
* @param lockKey 鎖的key
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lockAndRetry(String lockKey);
/**
* 嘗試加鎖,失敗自動重試 會阻塞目前線程 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param requestID 使用者ID
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lockAndRetry(String lockKey, String requestID);
/**
* 嘗試加鎖 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param expireTime 過期時間 機關:秒
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lockAndRetry(String lockKey, int expireTime);
/**
* 嘗試加鎖 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param expireTime 過期時間 機關:秒
* @param retryCount 重試次數
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lockAndRetry(String lockKey, int expireTime, int retryCount);
/**
* 嘗試加鎖 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param requestID 使用者ID
* @param expireTime 過期時間 機關:秒
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lockAndRetry(String lockKey, String requestID, int expireTime);
/**
* 嘗試加鎖 (requestID相等 可重入)
* @param lockKey 鎖的key
* @param expireTime 過期時間 機關:秒
* @param requestID 使用者ID
* @param retryCount 重試次數
* @return 加鎖成功 傳回uuid
* 加鎖失敗 傳回null
* */
String lockAndRetry(String lockKey, String requestID, int expireTime, int retryCount);
/**
* 釋放鎖
* @param lockKey 鎖的key
* @param requestID 使用者ID
* @return true 釋放自己所持有的鎖 成功
* false 釋放自己所持有的鎖 失敗
* */
boolean unLock(String lockKey, String requestID);
}
3.基于redis的分布式鎖的簡單實作
3.1 基礎代碼
目前實作版本的分布式鎖基于redis實作,使用的是jedis連接配接池來和redis進行互動,并将其封裝為redisClient工具類(僅封裝了demo所需的少數接口)
redisClient工具類:
View Code
所依賴的工具類:
初始化lua腳本 LuaScript.java:
在分布式鎖初始化時,使用init方法讀取lua腳本
View Code
單例的RedisDistributeLock基礎屬性
public final class RedisDistributeLock implements DistributeLock {
/**
* 無限重試
* */
public static final int UN_LIMIT_RETRY = -1;
private RedisDistributeLock() {
LuaScript.init();
}
private static DistributeLock instance = new RedisDistributeLock();
/**
* 持有鎖 成功辨別
* */
private static final Long ADD_LOCK_SUCCESS = 1L;
/**
* 釋放鎖 失敗辨別
* */
private static final Integer RELEASE_LOCK_SUCCESS = 1;
/**
* 預設過期時間 機關:秒
* */
private static final int DEFAULT_EXPIRE_TIME_SECOND = 300;
/**
* 預設加鎖重試時間 機關:毫秒
* */
private static final int DEFAULT_RETRY_FIXED_TIME = 3000;
/**
* 預設的加鎖浮動時間區間 機關:毫秒
* */
private static final int DEFAULT_RETRY_TIME_RANGE = 1000;
/**
* 預設的加鎖重試次數
* */
private static final int DEFAULT_RETRY_COUNT = 30;
/**
* lockCount Key字首
* */
private static final String LOCK_COUNT_KEY_PREFIX = "lock_count:";
public static DistributeLock getInstance(){
return instance;
}
3.2 加鎖實作
使用redis實作分布式鎖時,加鎖操作必須是原子操作,否則多用戶端并發操作時會導緻各種各樣的問題。詳情請見:Redis分布式鎖的正确實作方式。
由于我們實作的是可重入鎖,加鎖過程中需要判斷用戶端ID的正确與否。而redis原生的簡單接口沒法保證一系列邏輯的原子性執行,是以采用了lua腳本來實作加鎖操作。lua腳本可以讓redis在執行時将一連串的操作以原子化的方式執行。
加鎖lua腳本 lock.lua
-- 擷取參數
local requestIDKey = KEYS[1]
local lockCountKey = KEYS[2]
local currentRequestID = ARGV[1]
local expireTimeTTL = ARGV[2]
-- setnx 嘗試加鎖
local lockSet = redis.call('setnx',requestIDKey,currentRequestID)
if lockSet == 1
then
-- 加鎖成功 設定過期時間和重入次數
redis.call('expire',requestIDKey,expireTimeTTL)
redis.call('set',lockCountKey,1)
redis.call('expire',lockCountKey,expireTimeTTL)
return 1
else
-- 判斷是否是重入加鎖
local oldRequestID = redis.call('get',requestIDKey)
if currentRequestID == oldRequestID
then
-- 是重入加鎖
redis.call('incr',lockCountKey)
-- 重置過期時間
redis.call('expire',requestIDKey,expireTimeTTL)
redis.call('expire',lockCountKey,expireTimeTTL)
return 1
else
-- requestID不一緻,加鎖失敗
return 0
end
end
加鎖方法實作:
加鎖時,通過判斷eval的傳回值來判斷加鎖是否成功。
@Override
public String lock(String lockKey) {
String uuid = UUID.randomUUID().toString();
return lock(lockKey,uuid);
}
@Override
public String lock(String lockKey, int expireTime) {
String uuid = UUID.randomUUID().toString();
return lock(lockKey,uuid,expireTime);
}
@Override
public String lock(String lockKey, String requestID) {
return lock(lockKey,requestID,DEFAULT_EXPIRE_TIME_SECOND);
}
@Override
public String lock(String lockKey, String requestID, int expireTime) {
RedisClient redisClient = RedisClient.getInstance();
List<String> keyList = Arrays.asList(
lockKey,
LOCK_COUNT_KEY_PREFIX + lockKey
);
List<String> argsList = Arrays.asList(
requestID,
expireTime + ""
);
Long result = (Long)redisClient.eval(LuaScript.LOCK_SCRIPT, keyList, argsList);
if(result.equals(ADD_LOCK_SUCCESS)){
return requestID;
}else{
return null;
}
}
3.3 解鎖實作
解鎖操作同樣需要一連串的操作,由于原子化操作的需求,是以同樣使用lua腳本實作解鎖功能。
解鎖lua腳本 unlock.lua
-- 判斷requestID一緻性
if redis.call('get', requestIDKey) == currentRequestID
-- requestID相同,重入次數自減
local currentCount = redis.call('decr',lockCountKey)
if currentCount == 0
then
-- 重入次數為0,删除鎖
redis.call('del',requestIDKey)
redis.call('del',lockCountKey)
return 1
else
return 0 end
return 0 end
解鎖方法實作:
public boolean unLock(String lockKey, String requestID) {
List<String> keyList = Arrays.asList(
lockKey,
LOCK_COUNT_KEY_PREFIX + lockKey
);
List<String> argsList = Collections.singletonList(requestID);
Object result = RedisClient.getInstance().eval(LuaScript.UN_LOCK_SCRIPT, keyList, argsList);
// 釋放鎖成功
return RELEASE_LOCK_SUCCESS.equals(result);
}
3.4 自動重試機制實作
調用lockAndRetry方法進行加鎖時,如果加鎖失敗,則目前用戶端線程會短暫的休眠一段時間,并進行重試。在重試了一定的次數後,會終止重試加鎖操作,進而加鎖失敗。
需要注意的是,加鎖失敗之後的線程休眠時長是"固定值 + 随機值",引入随機值的主要目的是防止高并發時大量的用戶端在幾乎同一時間被喚醒并進行加鎖重試,給redis伺服器帶來周期性的、不必要的瞬時壓力。
@Override
public String lockAndRetry(String lockKey) {
String uuid = UUID.randomUUID().toString();
return lockAndRetry(lockKey,uuid);
}
@Override
public String lockAndRetry(String lockKey, String requestID) {
return lockAndRetry(lockKey,requestID,DEFAULT_EXPIRE_TIME_SECOND);
}
@Override
public String lockAndRetry(String lockKey, int expireTime) {
String uuid = UUID.randomUUID().toString();
return lockAndRetry(lockKey,uuid,expireTime);
}
@Override
public String lockAndRetry(String lockKey, int expireTime, int retryCount) {
String uuid = UUID.randomUUID().toString();
return lockAndRetry(lockKey,uuid,expireTime,retryCount);
}
@Override
public String lockAndRetry(String lockKey, String requestID, int expireTime) {
return lockAndRetry(lockKey,requestID,expireTime,DEFAULT_RETRY_COUNT);
}
@Override
public String lockAndRetry(String lockKey, String requestID, int expireTime, int retryCount) {
if(retryCount <= 0){
// retryCount小于等于0 無限循環,一直嘗試加鎖
while(true){
String result = lock(lockKey,requestID,expireTime);
if(result != null){
return result;
}
// 休眠一會
sleepSomeTime();
}
}else{
// retryCount大于0 嘗試指定次數後,退出
for(int i=0; i<retryCount; i++){
String result = lock(lockKey,requestID,expireTime);
if(result != null){
return result;
}
// 休眠一會
sleepSomeTime();
}
return null;
}
}
4.使用注解切面簡化redis分布式鎖的使用
通過在方法上引入RedisLock注解切面,讓對應方法被redis分布式鎖管理起來,可以簡化redis分布式鎖的使用。
切面注解 RedisLock
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedisLock {
/**
* 無限重試
* */
int UN_LIMIT_RETRY = RedisDistributeLock.UN_LIMIT_RETRY;
String lockKey();
int expireTime();
int retryCount();
RedisLock 切面實作
@Component
@Aspect
public class RedisLockAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(RedisLockAspect.class);
private static final ThreadLocal<String> REQUEST_ID_MAP = new ThreadLocal<>();
@Pointcut("@annotation(annotation.RedisLock)")
public void annotationPointcut() {
}
@Around("annotationPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
Method method = methodSignature.getMethod();
RedisLock annotation = method.getAnnotation(RedisLock.class);
boolean lockSuccess = lock(annotation);
if(lockSuccess){
Object result = joinPoint.proceed();
unlock(annotation);
return result;
}
return null;
}
/**
* 加鎖
* */
private boolean lock(RedisLock annotation){
DistributeLock distributeLock = RedisDistributeLock.getInstance();
int retryCount = annotation.retryCount();
String requestID = REQUEST_ID_MAP.get();
if(requestID != null){
// 目前線程 已經存在requestID
distributeLock.lockAndRetry(annotation.lockKey(),requestID,annotation.expireTime(),retryCount);
LOGGER.info("重入加鎖成功 requestID=" + requestID);
return true;
}else{
// 目前線程 不存在requestID
String newRequestID = distributeLock.lockAndRetry(annotation.lockKey(),annotation.expireTime(),retryCount);
if(newRequestID != null){
// 加鎖成功,設定新的requestID
REQUEST_ID_MAP.set(newRequestID);
LOGGER.info("加鎖成功 newRequestID=" + newRequestID);
return true;
}else{
LOGGER.info("加鎖失敗,超過重試次數,直接傳回 retryCount={}",retryCount);
return false;
}
}
}
/**
* 解鎖
* */
private void unlock(RedisLock annotation){
DistributeLock distributeLock = RedisDistributeLock.getInstance();
String requestID = REQUEST_ID_MAP.get();
if(requestID != null){
// 解鎖成功
boolean unLockSuccess = distributeLock.unLock(annotation.lockKey(),requestID);
if(unLockSuccess){
// 移除 ThreadLocal中的資料
REQUEST_ID_MAP.remove();
LOGGER.info("解鎖成功 requestID=" + requestID);
}
}
}
使用例子
@Service("testService")
public class TestServiceImpl implements TestService {
@Override
@RedisLock(lockKey = "lockKey", expireTime = 100, retryCount = RedisLock.UN_LIMIT_RETRY)
public String method1() {
return "method1";
}
@Override
@RedisLock(lockKey = "lockKey", expireTime = 100, retryCount = 3)
public String method2() {
return "method2";
}
5.總結
5.1 目前版本缺陷
主從同步可能導緻鎖的互斥性失效
在redis主從結構下,出于性能的考慮,redis采用的是主從異步複制的政策,這會導緻短時間内主庫和從庫資料短暫的不一緻。
試想,當某一用戶端剛剛加鎖完畢,redis主庫還沒有來得及和從庫同步就挂了,之後從庫中新選拔出的主庫是沒有對應鎖記錄的,這就可能導緻多個用戶端加鎖成功,破壞了鎖的互斥性。
休眠并反複嘗試加鎖效率較低
lockAndRetry方法在用戶端線程加鎖失敗後,會休眠一段時間之後再進行重試。當鎖的持有者持有鎖的時間很長時,其它用戶端會有大量無效的重試操作,造成系統資源的浪費。
進一步優化時,可以使用釋出訂閱的方式。這時加鎖失敗的用戶端會監聽鎖被釋放的信号,在鎖真正被釋放時才會進行新的加鎖操作,進而避免不必要的輪詢操作,以提高效率。
不是一個公平的鎖
目前實作版本中,多個用戶端同時對鎖進行搶占時,是完全随機的,既不遵循先來後到的順序,用戶端之間也沒有加鎖的優先級差別。
後續優化時可以提供一個建立公平鎖的接口,能指定加鎖的優先級,内部使用一個優先級隊列維護加鎖用戶端的順序。公平鎖雖然效率稍低,但在一些場景能更好的控制并發行為。
5.2 經驗總結
前段時間看了一篇關于redis分布式鎖的技術文章,發現自己對于分布式鎖的了解還很有限。紙上得來終覺淺,為了更好的掌握相關知識,決定嘗試着自己實作一個demo級别的redis分布式鎖,通過這次實踐,更進一步的學習了lua語言和redis相關内容。
這篇部落格的完整代碼在我的github上:
https://github.com/1399852153/RedisDistributedLock,存在許多不足之處,請多多指教。
原文位址
https://www.cnblogs.com/xiaoxiongcanguan/p/10718202.html