實現原理
分佈式的CAP理論告訴我們“任何一個分佈式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多隻能同時滿足兩項。”所以,很多系統在設計之初就要對這三者做出取捨。在互聯網領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在用戶可以接受的範圍內即可。
爲了保證數據的最終一致性,需要很多的技術方案來支持,比如分佈式事務、分佈式鎖等。
使用Redis實現鎖的原因
- Redis有很高的性能;
- Redis命令對此支持較好,實現起來比較方便。
主要利用到的命令
SETNX
SETNX key val
當且僅當key不存在時,set一個key爲val的字符串,返回1;若key存在,則什麼都不做,返回0。
expire
expire key timeout
爲key設置一個超時時間,單位爲second,超過這個時間鎖會自動釋放,避免死鎖。
delete
delete key
刪除key
實現思想
- 獲取鎖的時候,使用setnx加鎖,並使用expire命令爲鎖添加一個超時時間,超過該時間則自動釋放鎖,保證key一致,通過此在釋放鎖的時候進行判斷。
- 獲取鎖的時候還設置一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
- 釋放鎖的時候,當前時間小於超時時間,則執行delete進行鎖釋放。
代碼結構
package com.devframe.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* <b>redis分佈式鎖的實現</b></br>
* 還有一些失敗機制沒處理,以後在使用測試階段,完善。
*
* @author Zhang Kai
* @version 1.0
* @since <pre>2017/11/20 9:22</pre>
*/
public class RedisLock implements Lock {
private final static Logger logger = LoggerFactory.getLogger(RedisLock.class);
/**
* redis連接
*/
private final Jedis jedis;
/**
* 鎖定資源名,鎖key,保證唯一。
*/
private final String lockName;
/**
* 資源上鎖的最長時間,超時自動解鎖單位秒,</br>
* 建議設置成死的,如果設置不當容易影響效率,嚴重造成死鎖。
*/
private final int expireTime = Integer.valueOf(PropertyUtil.get("redisLock.expireTime"));
/**
* 線程獲取不到鎖,休眠的時間,單位ms
* 避免系統資源浪費
*/
private final long sleepTime = Long.valueOf(PropertyUtil.get("redisLock.sleepTime"));
/**
* 當前鎖超時的時間戳,單位毫秒
*/
private long expireTimeOut = 0;
/**
* 獲取鎖狀態,鎖中斷狀態</br>
* 值爲false的時候中斷獲取鎖</br>
*/
private boolean interrupted = true;
/**
* 構造方法
*
* @param jedis redis連接
* @param lockName 上鎖key,唯一標識
*/
public RedisLock(Jedis jedis, String lockName) {
if (lockName == null) {
throw new NullPointerException("lockName is required");
}
this.jedis = jedis;
// 重命名的前綴,可以不加,也可以自定義,保證唯一即可。
this.lockName = "lock" + lockName;
}
/**
* 獲取鎖。如果鎖已被其他線程獲取,則進行等待,直到拿到鎖爲止。
*/
@Override
public void lock() {
while (true) {
this.lockCheck();
long id = jedis.setnx(lockName, lockName);
if (id == 0L) {
try {
/**
* 沒有獲取到鎖則進行等待睡眠時間,再去重新獲取鎖</br>
* 這裏使用隨機時間可能會好一點,可以防止飢餓進程的出現,即,當同時到達多個進程,
* 只會有一個進程獲得鎖,其他的都用同樣的頻率進行嘗試,後面有來了一些進行,
* 也以同樣的頻率申請鎖,這將可能導致前面來的鎖得不到滿足.
* 使用隨機的等待時間可以一定程度上保證公平性
*/
Thread.sleep(this.sleepTime);
} catch (InterruptedException e) {
logger.error("Thread is interrupted", e);
}
} else {
expireTimeOut = System.currentTimeMillis() + expireTimeOut * 1000 + 1;
//設置redis中key的過期時間
jedis.expire(this.lockName, expireTime);
break;
}
}
}
/**
* 中斷鎖獲取
*
* @throws InterruptedException 中斷異常
*/
@Override
public void lockInterruptibly() throws InterruptedException {
this.interrupted = false;
}
/**
* 它表示用來嘗試獲取鎖,會立即返回,如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線程獲取),則返回false,</br>
* 也就說這個方法無論如何都會立即返回。在拿不到鎖時不會一直在那等待。
*
* @return boolean
*/
@Override
public boolean tryLock() {
this.lockCheck();
//嘗試獲取鎖
long id = jedis.setnx(lockName, lockName);
//返回結果爲0 則已經存在key,已經存在鎖。
if (id == 0L) {
return false;
} else {
expireTimeOut = System.currentTimeMillis() + expireTimeOut * 1000 + 1;
//設置redis中key的過期時間
jedis.expire(this.lockName, expireTime);
return true;
}
}
/**
* 它表示用來嘗試獲取鎖,如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線程獲取),則返回false,</br>
* 這個方法在拿不到鎖時會等待一定的時間,在時間期限之內如果還拿不到鎖,就返回false。</br>
* 如果如果一開始拿到鎖或者在等待期間內拿到了鎖,則返回true。</br>
*
* @param time 等待時間
* @param unit 時間單位
* @return boolean
* @throws InterruptedException 中斷異常
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time == 0) {
return false;
}
if (unit == null) {
throw new NullPointerException("TimeUnit is required.");
}
long now = System.currentTimeMillis();
long timeOutAt = now + calcSeconds(time, unit);
while (true) {
this.lockCheck();
long id = jedis.setnx(this.lockName, this.lockName);
// id = 0 表示加鎖失敗
if (id == 0) {
// 獲取鎖超時
if (System.currentTimeMillis() > timeOutAt) {
return false;
}
// 休眠一段時間,線程再繼續獲取鎖。
Thread.sleep(this.sleepTime);
} else {
//獲取鎖成功,設置鎖過期時間戳
expireTimeOut = System.currentTimeMillis() + expireTimeOut * 1000 + 1;
jedis.expireAt(this.lockName, expireTimeOut);
return true;
}
}
}
/**
* <b>釋放鎖<b/>
* 當前時間小於過期時間,則鎖未超時,刪除鎖,</br>
* 過了超時時間,redis已經刪除了該key。
*/
@Override
public void unlock() {
if (System.currentTimeMillis() < expireTimeOut) {
jedis.del(lockName);
}
}
@Override
public Condition newCondition() {
//TODO 涉及到 Condition 例外一個重要內容,以後再實現這個方法
throw new UnsupportedOperationException("did not supported.");
}
/**
* 檢查當前線程資源redis連接和鎖的狀態
*/
private void lockCheck() {
if (jedis == null) {
throw new NullPointerException("Jedis is required.");
}
if (!interrupted) {
throw new RuntimeException("Thread is interrupted.");
}
}
/**
* TimeUnit單位時間轉換成毫秒
*
* @param time 時間
* @param unit 時間單位
* @return long
*/
private long calcSeconds(long time, TimeUnit unit) {
if (unit == TimeUnit.DAYS) {
return time * 24 * 60 * 60 * 1000;
}
if (unit == TimeUnit.HOURS) {
return time * 60 * 60 * 1000;
}
if (unit == TimeUnit.MINUTES) {
return time * 60 * 1000;
}
if (unit == TimeUnit.SECONDS) {
return time * 1000;
}
if (unit == TimeUnit.MILLISECONDS) {
return time;
} else {
//後面的不實現了,基本上用不到。
throw new UnsupportedOperationException("cannot be resolved.");
}
}
}
配置
# redis lock
# s
redisLock.expireTime=1
# ms
redisLock.sleepTime=100
測試
測試就選用最經典的秒殺系統吧,使用分佈式鎖可以控制資源。
下面模擬500人秒殺100件商品。
package com.devframe.util;
import org.junit.Test;
import redis.clients.jedis.Jedis;
/**
* @author Zhang Kai
* @version 1.0
* @since <pre>2017/11/20 14:12</pre>
*/
public class RedisLockTest {
/**
* 100件物品
*/
public static int goodsNum = 100;
/**
* 500人
*/
private static int personNum = 500;
/**
* 不加鎖的情況
*/
@Test
public void test() {
for (int i = 0; i < personNum; i++) {
new Thread(() -> {
if (goodsNum > 0) {
System.out.println(Thread.currentThread().getName() + "獲取了鎖");
System.out.println("商品剩餘:" + --goodsNum);
}
}).start();
}
}
/**
* 加上分佈鎖
* @param args
*/
public static void main(String[] args) {
for (int i = 0; i < personNum; i++) {
new Thread(() -> {
Jedis jedis = RedisUtil.getJedis();
//初始化鎖,key保持一致
Lock lock = new RedisLock(jedis, "aa");
try {
lock.lock();
if (goodsNum > 0) {
System.out.println(Thread.currentThread().getName() + "獲取了鎖");
System.out.println("商品剩餘:" + --goodsNum);
}
} finally {
//釋放鎖,並且釋放redis連接
lock.unlock();
RedisUtil.returnResource(jedis);
}
}).start();
}
}
}
不加鎖的部分結果:
Thread-100獲取了鎖
商品剩餘:-3
Thread-99獲取了鎖
商品剩餘:5
商品剩餘:6
Thread-98獲取了鎖
商品剩餘:-5
商品剩餘:7
商品剩餘:-4
商品剩餘:0
商品剩餘:1
Thread-105獲取了鎖
商品剩餘:-6
上鎖的結果:
Thread-8獲取了鎖
商品剩餘:5
Thread-238獲取了鎖
商品剩餘:4
Thread-72獲取了鎖
商品剩餘:3
Thread-137獲取了鎖
商品剩餘:2
Thread-402獲取了鎖
商品剩餘:1
Thread-337獲取了鎖
商品剩餘:0
總結
- 併發量大的時候,需要考慮鎖時間;
- 考慮失敗情況,上鎖了,但是設置超時時間失敗(redis崩潰等各種情況),鎖一致都沒有釋放,導致死鎖的情況發生,現在需要做的是,把key的value設置成超時的時間,每次上鎖失敗都去檢查一次,超時的就覆蓋,可以避免死鎖。