一、背景
還在爲不瞭解分佈式鎖而煩惱嗎?還在爲衆多微服務接口不冪等而發愁嗎?如果是,並且有興趣同我一起學習,那請接着看本文,通過本文能夠學習到分佈式鎖的基本原理、如何實現分佈式鎖以及使用分佈式鎖解決接口冪等問題。
二、基礎知識
本文是通過使用 Redis 實現分佈式鎖,當然也可用使用各大數據庫,比如 Mysql、Oracle 自持的行級鎖、大廠的 Zookeeper 等方案實現。
-
分佈式鎖的基本思想
我們既然稱其爲“鎖”,那就是說只有唯一的一把鑰匙才能將鎖打開,將這種思想放到我們軟件設計上來,那就是在同一時間內只有同一個進程或者線程擁有這個”鑰匙“來鎖住資源,防止其他進程或線程用”鑰匙“鎖住同一個資源。當然,這是一種通俗的理解,在我們軟件工程中,“鎖”是要更復雜,更難以掌握的。 -
Redis 實現分佈式鎖原理
Redis 主要是利用命令redis.call()
、SETNX
和PEXPIRE
實現分佈式鎖的,但是因爲是兩個分開的命令,單獨執行這兩個命令肯定是非原子性,根據答墨菲定理未來一定會發生非原子的操作。好在一點是的 Redis 可以使用 Lua 腳本將單獨的多個命令統一順序執行,命令EVAL
。通過 EVAL 命名可以執行多個命令,這些命名要麼都成功,要麼都失敗(這就是我們想要的事務的原子性啊)。關於 Lua 腳本如何使用,Redis 官網有示例,可以點擊 Lua 腳本使用 學習。如果覺得 Lua 太難,那就感謝 Redis 幫我們實現了分佈式鎖框架Redisson
吧,Redisson 實現分佈式鎖。另外Redisson
幫我們實現了更多細節問題,例如,通過加入watchdog
監控鎖的狀態,當實例還在運行時自動幫你續約(實際就是通過命令PEXPIRE
重新設定過期時間)。
三、解決方案
爲了能夠在多場景下複用,避免重複造輪子的現象,我們可以藉助 Spring AOP 技術,通過自定義註解 @ApiIdempotent
來實現,寫好後在打成 jar 放到我們的中央倉庫,在項目上引入 jar ,再在需要控制接口冪等的 Controller 方法上加上我們的註解即可,方便快捷。我這下面自定義一個接口冪等的註解:
/**
* 自定義接口冪等註解
* @author ouyang
* @version 1.0
* @date 2020/4/20 11:21
**/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
/**
* 過期時間,單位:ms。 默認2000
*/
long expire() default 2000;
/**
* 重試次數,默認0
*/
int retryTimes() default 0;
/**
* 重試間隔時間,單位:ms,默認100
*/
long retryInterval() default 100;
}
本註解 @ApiIdempotent
支持自定義鎖時間、重試加鎖次數及重試間隔設置。
/**
* @author ouyang
* @version 1.0
* @date 2020/4/20 11:21
**/
@Aspect
@Component
public class ApiIdempotentAspect {
private final Logger logger = LoggerFactory.getLogger(ApiIdempotentAspect.class);
private final RedisLockUtil redisLockUtil;
@Autowired
public ApiIdempotentAspect(RedisLockUtil redisLockUtil) {
this.redisLockUtil = redisLockUtil;
}
@Pointcut("@annotation(com.gridsum.techpub.apiidempotent.annotation.ApiIdempotent)")
public void apiIdempotentPointCut() { }
@Around("apiIdempotentPointCut()")
public Object apiIdempotentAround(ProceedingJoinPoint point) throws Throwable {
// TODO lock
Object result = point.proceed();
// TODO unlock
return result;
}
}
- 基於 lua 腳本實現分佈式鎖解決接口冪等方案
/**
* 鎖
* @author ouyang
* @version 1.0
* @date 2020/4/20 11:21
**/
@Component
public class RedisLockUtil {
private final Logger logger = LoggerFactory.getLogger(RedisLockUtil.class);
private static final String KEY_PREFIX = "apiIdempotent:";
//定義獲取鎖的lua腳本
private static final DefaultRedisScript<Long> LOCK_LUA_SCRIPT = new DefaultRedisScript<>(
"if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end"
, Long.class
);
//定義釋放鎖的lua腳本
private static final DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return -1 end"
, Long.class
);
private static final Long LOCK_SUCCESS = 1L;
private static final Long LOCK_EXPIRED = -1L;
private RedisTemplate<String, Object> redisTemplate;
@Autowired
public RedisLockUtil(@Qualifier("customRedisTemplate") RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 加鎖
* @param key 鎖的 key
* @param value value ( key + value 必須保證唯一)
* @param expire key 的過期時間,單位 ms
* @param retryTimes 重試次數,即加鎖失敗之後的重試次數
* @param retryInterval 重試時間間隔,單位 ms
* @return 加鎖 true 成功
*/
public boolean lock(String key, String value, long expire, int retryTimes, long retryInterval) {
key = KEY_PREFIX + key;
logger.info("locking... redisK = {}", key);
try {
//執行腳本
Object result = redisTemplate
.execute(LOCK_LUA_SCRIPT, Collections.singletonList(key), value, expire);
//存儲本地變量
if(LOCK_SUCCESS.equals(result)) {
logger.info("locked... redisK = {}", key);
return true;
} else {
//重試獲取鎖
int count = 0;
while(count < retryTimes) {
try {
Thread.sleep(retryInterval);
result = redisTemplate
.execute(LOCK_LUA_SCRIPT, Collections.singletonList(key), value, expire);
if(LOCK_SUCCESS.equals(result)) {
logger.info("locked... redisK = {}", key);
return true;
}
logger.warn("{} times try to acquire lock", count + 1);
count++;
} catch (Exception e) {
logger.error("acquire redis occurred an exception", e);
}
}
logger.info("fail to acquire lock {}", key);
return false;
}
} catch (Throwable e1) {
logger.error("acquire redis occurred an exception", e1);
}
return false;
}
/**
* 釋放KEY
* @param key 釋放本請求對應的鎖的key
* @param value 釋放本請求對應的鎖的value
* @return 釋放鎖 true 成功
*/
public boolean unlock(String key, String value) {
key = KEY_PREFIX + key;
logger.info("unlock... redisK = {}", key);
try {
// 使用lua腳本刪除redis中匹配value的key
Object result = redisTemplate
.execute(UNLOCK_LUA_SCRIPT, Collections.singletonList(key), value);
//如果這裏拋異常,後續鎖無法釋放
if (LOCK_SUCCESS.equals(result)) {
logger.info("release lock success. redisK = {}", key);
return true;
} else if (LOCK_EXPIRED.equals(result)) {
logger.warn("release lock exception, key has expired or released");
} else {
//其他情況,一般是刪除KEY失敗,返回0
logger.error("release lock failed");
}
} catch (Throwable e) {
logger.error("release lock occurred an exception", e);
}
return false;
}
}
缺點:
基於 Lua 腳本實現的分佈式鎖,鎖的失效時間是自己設定的,需要根據接口的響應時間評個人經驗設定合理的值,如果設定的失效時間過短,將可能導致該鎖失效。
- 基於 Redisson 實現分佈式鎖解決接口冪等方案
/**
* 鎖
* @author ouyang
* @version 1.0
* @date 2020/4/20 11:21
**/
@Component
public class RedisLockUtil {
private final Logger logger = LoggerFactory.getLogger(RedisLockUtil.class);
private final RedissonClient redissonClient;
@Autowired
public RedisLockUtil(@Qualifier("customRedisson") RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
/**
* 加鎖
* @param key 鎖的 key
* @param value value ( key + value 必須保證唯一)
* @param expire key 的過期時間,單位 ms
* @param retryTimes 重試次數,即加鎖失敗之後的重試次數
* @param retryInterval 重試時間間隔,單位 ms
* @return 加鎖 true 成功
*/
public RLock lock(String key, String value, long expire, int retryTimes, long retryInterval) {
logger.info("locking... redisK = {}", key);
RLock fairLock = redissonClient.getFairLock(key + ":" + value);
try {
boolean tryLock = fairLock.tryLock(0, expire, TimeUnit.MILLISECONDS);
if (tryLock) {
logger.info("locked... redisK = {}", key);
return fairLock;
} else {
//重試獲取鎖
logger.info("retry to acquire lock: [redisK = {}]", key);
int count = 0;
while(count < retryTimes) {
try {
Thread.sleep(retryInterval);
tryLock = fairLock.tryLock(0, expire, TimeUnit.MILLISECONDS);
if(tryLock) {
logger.info("locked... redisK = {}", key);
return fairLock;
}
logger.warn("{} times try to acquire lock", count + 1);
count++;
} catch (Exception e) {
logger.error("acquire redis occurred an exception", e);
break;
}
}
logger.info("fail to acquire lock {}", key);
}
} catch (Throwable e1) {
logger.error("acquire redis occurred an exception", e1);
}
return fairLock;
}
/**
* 釋放KEY
* @param fairLock 分佈式公平鎖
* @return 釋放鎖 true 成功
*/
public boolean unlock(RLock fairLock) {
try {
//如果這裏拋異常,後續鎖無法釋放
if (fairLock.isLocked()) {
fairLock.unlock();
logger.info("release lock success");
return true;
}
} catch (Throwable e) {
logger.error("release lock occurred an exception", e);
}
return false;
}
}
四、實驗
- 通過 jmeter 進行壓測(15 線程 * 循環 10 次),結果達到預期。
五、說在最後的話
-
本文必須保證 key + value 的唯一性。
-
如有說錯的地方,還請在下方評論留言(每個評論我都會去認真查看),不吝賜教,我也是一直在學習中。