一、前言
在我們?nèi)粘9ぷ髦校薙pring和Mybatis外,用到最多無(wú)外乎分布式緩存框架——Redis但骨。但是很多工作很多年的朋友對(duì)Redis還處于一個(gè)最基礎(chǔ)的使用和認(rèn)識(shí)。所以我就像把自己對(duì)分布式緩存的一些理解和應(yīng)用整理一個(gè)系列玻淑,希望可以幫助到大家加深對(duì)Redis的理解嗽冒。本系列的文章思路先從Redis的應(yīng)用開(kāi)始呀伙。再解析Redis的內(nèi)部實(shí)現(xiàn)原理补履。最后以經(jīng)常會(huì)問(wèn)到Redist相關(guān)的面試題為結(jié)尾。
二剿另、分布式鎖的實(shí)現(xiàn)要點(diǎn)
?為了實(shí)現(xiàn)分布式鎖箫锤,需要確保鎖同時(shí)滿足以下四個(gè)條件:
互斥性贬蛙。在任意時(shí)刻,只有一個(gè)客戶端能持有鎖
不會(huì)發(fā)送死鎖谚攒。即使一個(gè)客戶端持有鎖的期間崩潰而沒(méi)有主動(dòng)釋放鎖阳准,也需要保證后續(xù)其他客戶端能夠加鎖成功
加鎖和解鎖必須是同一個(gè)客戶端,客戶端自己不能把別人加的鎖給釋放了馏臭。
容錯(cuò)性野蝇。只要大部分的Redis節(jié)點(diǎn)正常運(yùn)行,客戶端就可以進(jìn)行加鎖和解鎖操作括儒。
三绕沈、Redis實(shí)現(xiàn)分布式鎖的錯(cuò)誤姿勢(shì)
3.1?加鎖錯(cuò)誤姿勢(shì)
? 在講解使用Redis實(shí)現(xiàn)分布式鎖的正確姿勢(shì)之前,我們有必要來(lái)看下錯(cuò)誤實(shí)現(xiàn)方式帮寻。
首先乍狐,為了保證互斥性和不會(huì)發(fā)送死鎖2個(gè)條件,所以我們?cè)诩渔i操作的時(shí)候固逗,需要使用SETNX指令來(lái)保證互斥性——只有一個(gè)客戶端能夠持有鎖浅蚪。為了保證不會(huì)發(fā)送死鎖,需要給鎖加一個(gè)過(guò)期時(shí)間烫罩,這樣就可以保證即使持有鎖的客戶端期間崩潰了也不會(huì)一直不釋放鎖惜傲。
為了保證這2個(gè)條件,有些人錯(cuò)誤的實(shí)現(xiàn)會(huì)用如下代碼來(lái)實(shí)現(xiàn)加鎖操作:
/**
? ? * 實(shí)現(xiàn)加鎖的錯(cuò)誤姿勢(shì)
? ? * @param jedis
? ? * @param lockKey
? ? * @param requestId
? ? * @param expireTime
? ? */
? ? public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
? ? ? ? Long result = jedis.setnx(lockKey, requestId);
? ? ? ? if (result == 1) {
? ? ? ? ? ? // 若在這里程序突然崩潰贝攒,則無(wú)法設(shè)置過(guò)期時(shí)間操漠,將發(fā)生死鎖
? ? ? ? ? ? jedis.expire(lockKey, expireTime);
? ? ? ? }
? ? }
可能一些初學(xué)者還沒(méi)看出以上實(shí)現(xiàn)加鎖操作的錯(cuò)誤原因。這樣我們解釋下饿这。setnx?和expire是兩條Redis指令浊伙,不具備原子性,如果程序在執(zhí)行完setnx之后突然崩潰长捧,導(dǎo)致沒(méi)有設(shè)置鎖的過(guò)期時(shí)間嚣鄙,從而就導(dǎo)致死鎖了。因?yàn)檫@個(gè)客戶端持有的所有不會(huì)被其他客戶端釋放串结,持有鎖的客戶端又崩潰了哑子,也不會(huì)主動(dòng)釋放。從而該鎖永遠(yuǎn)不會(huì)釋放肌割,導(dǎo)致其他客戶端也獲得不能鎖卧蜓。從而其他客戶端一直阻塞。所以針對(duì)該代碼正確姿勢(shì)應(yīng)該保證setnx和expire原子性把敞。
實(shí)現(xiàn)加鎖操作的錯(cuò)誤姿勢(shì)2弥奸。具體實(shí)現(xiàn)如下代碼所示
/**
? ? * 實(shí)現(xiàn)加鎖的錯(cuò)誤姿勢(shì)2
? ? * @param jedis
? ? * @param lockKey
? ? * @param expireTime
? ? * @return
? ? */
? ? public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
? ? ? ? long expires = System.currentTimeMillis() + expireTime;
? ? ? ? String expiresStr = String.valueOf(expires);
? ? ? ? // 如果當(dāng)前鎖不存在,返回加鎖成功
? ? ? ? if (jedis.setnx(lockKey, expiresStr) == 1) {
? ? ? ? ? ? return true;
? ? ? ? }
? ? ? ? // 如果鎖存在奋早,獲取鎖的過(guò)期時(shí)間
? ? ? ? String currentValueStr = jedis.get(lockKey);
? ? ? ? if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
? ? ? ? ? ? // 鎖已過(guò)期盛霎,獲取上一個(gè)鎖的過(guò)期時(shí)間赠橙,并設(shè)置現(xiàn)在鎖的過(guò)期時(shí)間
? ? ? ? ? ? String oldValueStr = jedis.getSet(lockKey, expiresStr);
? ? ? ? ? ? if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
? ? ? ? ? ? ? ? // 考慮多線程并發(fā)的情況,只有一個(gè)線程的設(shè)置值和當(dāng)前值相同愤炸,它才有權(quán)利加鎖
? ? ? ? ? ? ? ? return true;
? ? ? ? ? ? }
? ? ? ? }
? ? ? ? // 其他情況期揪,一律返回加鎖失敗
? ? ? ? return false;
? ? }
這個(gè)加鎖操作咋一看沒(méi)有毛病對(duì)吧。那以上這段代碼的問(wèn)題毛病出在哪里呢规个?
1.?由于客戶端自己生成過(guò)期時(shí)間凤薛,所以需要強(qiáng)制要求分布式環(huán)境下所有客戶端的時(shí)間必須同步。
2.?當(dāng)鎖過(guò)期的時(shí)候诞仓,如果多個(gè)客戶端同時(shí)執(zhí)行jedis.getSet()方法枉侧,雖然最終只有一個(gè)客戶端加鎖,但是這個(gè)客戶端的鎖的過(guò)期時(shí)間可能被其他客戶端覆蓋狂芋。不具備加鎖和解鎖必須是同一個(gè)客戶端的特性榨馁。解決上面這段代碼的方式就是為每個(gè)客戶端加鎖添加一個(gè)唯一標(biāo)示,已確保加鎖和解鎖操作是來(lái)自同一個(gè)客戶端帜矾。
3.2?解鎖錯(cuò)誤姿勢(shì)
分布式鎖的實(shí)現(xiàn)無(wú)法就2個(gè)方法翼虫,一個(gè)加鎖,一個(gè)就是解鎖屡萤。下面我們來(lái)看下解鎖的錯(cuò)誤姿勢(shì)珍剑。
錯(cuò)誤姿勢(shì)1.
/**
? ? * 解鎖錯(cuò)誤姿勢(shì)1
? ? * @param jedis
? ? * @param lockKey
? ? */
? ? public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
? ? ? ? jedis.del(lockKey);
? ? }
上面實(shí)現(xiàn)是最簡(jiǎn)單直接的解鎖方式,這種不先判斷擁有者而直接解鎖的方式死陆,會(huì)導(dǎo)致任何客戶端都可以隨時(shí)解鎖招拙。即使這把鎖不是它上鎖的。
錯(cuò)誤姿勢(shì)2:
/**
? ? * 解鎖錯(cuò)誤姿勢(shì)2
? ? * @param jedis
? ? * @param lockKey
? ? * @param requestId
? ? */
? ? public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
? ? ? ? // 判斷加鎖與解鎖是不是同一個(gè)客戶端
? ? ? ? if (requestId.equals(jedis.get(lockKey))) {
? ? ? ? ? ? // 若在此時(shí)措译,這把鎖突然不是這個(gè)客戶端的别凤,則會(huì)誤解鎖
? ? ? ? ? ? jedis.del(lockKey);
? ? ? ? }
既然錯(cuò)誤姿勢(shì)1中沒(méi)有判斷鎖的擁有者,那姿勢(shì)2中判斷了擁有者领虹,那錯(cuò)誤原因又在哪里呢规哪?答案又是原子性上面。因?yàn)榕袛嗪蛣h除不是一個(gè)原子性操作塌衰。在并發(fā)的時(shí)候很可能發(fā)生解除了別的客戶端加的鎖诉稍。具體場(chǎng)景有:客戶端A加鎖,一段時(shí)間之后客戶端A進(jìn)行解鎖操作時(shí)最疆,在執(zhí)行jedis.del()之前杯巨,鎖突然過(guò)期了,此時(shí)客戶端B嘗試加鎖成功努酸,然后客戶端A再執(zhí)行del方法服爷,則客戶端A將客戶端B的鎖給解除了。從而不也不滿足加鎖和解鎖必須是同一個(gè)客戶端特性。解決思路就是需要保證GET和DEL操作在一個(gè)事務(wù)中進(jìn)行层扶,保證其原子性。
四烙荷、Redis實(shí)現(xiàn)分布式鎖的正確姿勢(shì)
剛剛介紹完了錯(cuò)誤的姿勢(shì)后镜会,從上面錯(cuò)誤姿勢(shì)中,我們可以知道终抽,要使用Redis實(shí)現(xiàn)分布式鎖戳表。加鎖操作的正確姿勢(shì)為:
使用setnx命令保證互斥性
需要設(shè)置鎖的過(guò)期時(shí)間,避免死鎖
setnx和設(shè)置過(guò)期時(shí)間需要保持原子性昼伴,避免在設(shè)置setnx成功之后在設(shè)置過(guò)期時(shí)間客戶端崩潰導(dǎo)致死鎖
加鎖的Value?值為一個(gè)唯一標(biāo)示匾旭。可以采用UUID作為唯一標(biāo)示圃郊。加鎖成功后需要把唯一標(biāo)示返回給客戶端來(lái)用來(lái)客戶端進(jìn)行解鎖操作
解鎖的正確姿勢(shì)為:
1.?需要拿加鎖成功的唯一標(biāo)示要進(jìn)行解鎖价涝,從而保證加鎖和解鎖的是同一個(gè)客戶端
2.?解鎖操作需要比較唯一標(biāo)示是否相等,相等再執(zhí)行刪除操作持舆。這2個(gè)操作可以采用Lua腳本方式使2個(gè)命令的原子性色瘩。
Redis分布式鎖實(shí)現(xiàn)的正確姿勢(shì)的實(shí)現(xiàn)代碼:
public interface DistributedLock {
? ? /**
? ? * 獲取鎖
? ? * @author zhi.li
? ? * @return 鎖標(biāo)識(shí)
? ? */
? ? String acquire();
? ? /**
? ? * 釋放鎖
? ? * @author zhi.li
? ? * @param indentifier
? ? * @return
? ? */
? ? boolean release(String indentifier);
}
/**
* @author zhi.li
* @Description
* @created 2019/1/1 20:32
*/
@Slf4j
public class RedisDistributedLock implements DistributedLock{
? ? private static final String LOCK_SUCCESS = "OK";
? ? private static final Long RELEASE_SUCCESS = 1L;
? ? private static final String SET_IF_NOT_EXIST = "NX";
? ? private static final String SET_WITH_EXPIRE_TIME = "PX";
? ? /**
? ? * redis 客戶端
? ? */
? ? private Jedis jedis;
? ? /**
? ? * 分布式鎖的鍵值
? ? */
? ? private String lockKey;
? ? /**
? ? * 鎖的超時(shí)時(shí)間 10s
? ? */
? ? int expireTime = 10 * 1000;
? ? /**
? ? * 鎖等待,防止線程饑餓
? ? */
? ? int acquireTimeout? = 1 * 1000;
? ? /**
? ? * 獲取指定鍵值的鎖
? ? * @param jedis jedis Redis客戶端
? ? * @param lockKey 鎖的鍵值
? ? */
? ? public RedisDistributedLock(Jedis jedis, String lockKey) {
? ? ? ? this.jedis = jedis;
? ? ? ? this.lockKey = lockKey;
? ? }
? ? /**
? ? * 獲取指定鍵值的鎖,同時(shí)設(shè)置獲取鎖超時(shí)時(shí)間
? ? * @param jedis jedis Redis客戶端
? ? * @param lockKey 鎖的鍵值
? ? * @param acquireTimeout 獲取鎖超時(shí)時(shí)間
? ? */
? ? public RedisDistributedLock(Jedis jedis,String lockKey, int acquireTimeout) {
? ? ? ? this.jedis = jedis;
? ? ? ? this.lockKey = lockKey;
? ? ? ? this.acquireTimeout = acquireTimeout;
? ? }
? ? /**
? ? * 獲取指定鍵值的鎖,同時(shí)設(shè)置獲取鎖超時(shí)時(shí)間和鎖過(guò)期時(shí)間
? ? * @param jedis jedis Redis客戶端
? ? * @param lockKey 鎖的鍵值
? ? * @param acquireTimeout 獲取鎖超時(shí)時(shí)間
? ? * @param expireTime 鎖失效時(shí)間
? ? */
? ? public RedisDistributedLock(Jedis jedis, String lockKey, int acquireTimeout, int expireTime) {
? ? ? ? this.jedis = jedis;
? ? ? ? this.lockKey = lockKey;
? ? ? ? this.acquireTimeout = acquireTimeout;
? ? ? ? this.expireTime = expireTime;
? ? }
? ? @Override
? ? public String acquire() {
? ? ? ? try {
? ? ? ? ? ? // 獲取鎖的超時(shí)時(shí)間逸寓,超過(guò)這個(gè)時(shí)間則放棄獲取鎖
? ? ? ? ? ? long end = System.currentTimeMillis() + acquireTimeout;
? ? ? ? ? ? // 隨機(jī)生成一個(gè)value
? ? ? ? ? ? String requireToken = UUID.randomUUID().toString();
? ? ? ? ? ? while (System.currentTimeMillis() < end) {
? ? ? ? ? ? ? ? String result = jedis.set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
? ? ? ? ? ? ? ? if (LOCK_SUCCESS.equals(result)) {
? ? ? ? ? ? ? ? ? ? return requireToken;
? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? try {
? ? ? ? ? ? ? ? ? ? Thread.sleep(100);
? ? ? ? ? ? ? ? } catch (InterruptedException e) {
? ? ? ? ? ? ? ? ? ? Thread.currentThread().interrupt();
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? } catch (Exception e) {
? ? ? ? ? ? log.error("acquire lock due to error", e);
? ? ? ? }
? ? ? ? return null;
? ? }
? ? @Override
? ? public boolean release(String identify) {
if(identify == null){
? ? ? ? ? ? return false;
? ? ? ? }
? ? ? ? String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
? ? ? ? Object result = new Object();
? ? ? ? try {
? ? ? ? ? ? result = jedis.eval(script, Collections.singletonList(lockKey),
? ? ? ? ? ? ? ? Collections.singletonList(identify));
? ? ? ? if (RELEASE_SUCCESS.equals(result)) {
? ? ? ? ? ? log.info("release lock success, requestToken:{}", identify);
? ? ? ? ? ? return true;
? ? ? ? }}catch (Exception e){
? ? ? ? ? ? log.error("release lock due to error",e);
? ? ? ? }finally {
? ? ? ? ? ? if(jedis != null){
? ? ? ? ? ? ? ? jedis.close();
? ? ? ? ? ? }
? ? ? ? }
? ? ? ? log.info("release lock failed, requestToken:{}, result:{}", identify, result);
? ? ? ? return false;
? ? }
}
下面就以秒殺庫(kù)存數(shù)量為場(chǎng)景居兆,測(cè)試下上面實(shí)現(xiàn)的分布式鎖的效果。具體測(cè)試代碼如下:
public class RedisDistributedLockTest {
? ? static int n = 500;
? ? public static void secskill() {
? ? ? ? System.out.println(--n);
? ? }
? ? public static void main(String[] args) {
? ? ? ? Runnable runnable = () -> {
? ? ? ? ? ? RedisDistributedLock lock = null;
? ? ? ? ? ? String unLockIdentify = null;
? ? ? ? ? ? try {
? ? ? ? ? ? ? ? Jedis conn = new Jedis("127.0.0.1",6379);
? ? ? ? ? ? ? ? lock = new RedisDistributedLock(conn, "test1");
? ? ? ? ? ? ? ? unLockIdentify = lock.acquire();
? ? ? ? ? ? ? ? System.out.println(Thread.currentThread().getName() + "正在運(yùn)行");
? ? ? ? ? ? ? ? secskill();
? ? ? ? ? ? } finally {
? ? ? ? ? ? ? ? if (lock != null) {
? ? ? ? ? ? ? ? ? ? lock.release(unLockIdentify);
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? };
? ? ? ? for (int i = 0; i < 10; i++) {
? ? ? ? ? ? Thread t = new Thread(runnable);
? ? ? ? ? ? t.start();
? ? ? ? }
? ? }
}
運(yùn)行效果如下圖所示竹伸。從圖中可以看出泥栖,同一個(gè)資源在同一個(gè)時(shí)刻只能被一個(gè)線程獲取,從而保證了庫(kù)存數(shù)量N的遞減是順序的勋篓。
五吧享、總結(jié)
這樣是不是已經(jīng)完美使用Redis實(shí)現(xiàn)了分布式鎖呢?答案是并沒(méi)有結(jié)束譬嚣。上面的實(shí)現(xiàn)代碼只是針對(duì)單機(jī)的Redis沒(méi)問(wèn)題耙蔑。但是現(xiàn)實(shí)生產(chǎn)中大部分都是集群的或者是主備的。但上面的實(shí)現(xiàn)姿勢(shì)在集群或者主備情況下會(huì)有相應(yīng)的問(wèn)題孤荣。這里先買一個(gè)關(guān)子甸陌,在后面一篇文章將詳細(xì)分析集群或者主備環(huán)境下Redis分布式鎖的實(shí)現(xiàn)方式。
在此我向大家推薦一個(gè)架構(gòu)學(xué)習(xí)交流圈:830478757 幫助突破瓶頸 提升思維能力