Java多線程開發(fā)中鎖提供了原子性评雌、可見性。但是在分布式系統(tǒng)中螟加,一個進(jìn)程下的多個線程分布到一個集群中的多臺機(jī)器上徘溢,需要其他方式來保證原子性、可見性捆探。通過封裝Redis的SETNX
命令然爆,可以實現(xiàn)分布式鎖,提供分布式環(huán)境下的原子性黍图。
測試代碼
測試代碼啟動三個名稱為test-1曾雕、test-2、test-3線程助被,線程內(nèi)部會對同一個靜態(tài)變量執(zhí)行一萬次++操作剖张,如果代碼正確,最終靜態(tài)變量的值應(yīng)該為3萬揩环。測試代碼如下:
public class LockTest {
public static int i = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(3);
CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
new Thread(new CountRunnable(countDownLatch, cyclicBarrier), "test-1").start();
new Thread(new CountRunnable(countDownLatch, cyclicBarrier), "test-2").start();
new Thread(new CountRunnable(countDownLatch, cyclicBarrier), "test-3").start();
countDownLatch.await();
System.out.println(LockTest.i);
}
static class CountRunnable implements Runnable{
private CountDownLatch countDownLatch;
private CyclicBarrier cyclicBarrier;
public CountRunnable(CountDownLatch countDownLatch, CyclicBarrier cyclicBarrier){
this.countDownLatch = countDownLatch;
this.cyclicBarrier = cyclicBarrier;
}
@Override
public void run() {
try {
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
for(int j = 0; j < 10000; j++){
LockTest.i++;
}
countDownLatch.countDown();
}
}
}
在不使用鎖的情況下搔弄,執(zhí)行三次輸出結(jié)果分別為:24404、21768丰滑、17539肯污。
簡單版本
SETNX
命令只有當(dāng)key不存在時才能設(shè)值成功,返回值為1吨枉;key存在設(shè)值失敗蹦渣,返回0。根據(jù)命令特性貌亭,可以有以下實現(xiàn):
public class SimpleRedisLock {
public static ThreadLocal<Jedis> holder = new ThreadLocal<>();
public static JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "localhost");
public static void acquire(String lock){
Jedis jedis = jedisPool.getResource();
while(jedis.setnx(lock, "") == 0){}
holder.set(jedis);
}
public static void release(String lock){
Jedis jedis = holder.get();
jedis.del(lock);
jedis.close();
}
}
在acquire方法內(nèi)部柬唯,獲取jedis對象,循環(huán)設(shè)置某個key的值圃庭,直到設(shè)置成功锄奢。release方法中刪除這個key失晴,代表釋放鎖。修改LockTest代碼:
for(int j = 0; j < 10000; j++){
SimpleRedisLock.acquire("lock");
LockTest.i++;
SimpleRedisLock.release("lock");
}
重新執(zhí)行測試代碼拘央,輸入值:30000涂屁。
簡單版本的問題
測試代碼中啟動了3個線程競爭同一個分布式鎖,如果三個線程中灰伟,有任意一個線程在調(diào)用SimpleRedisLock的acquire成功之后異常退出拆又,沒有釋放鎖,另外兩個線程會死循環(huán)等待在SETNX
命令上栏账,簡單修改一下LockTest帖族,模擬test-1異常退出的情況:
@Override
public void run() {
try {
cyclicBarrier.await();
for(int j = 0; j < 10000; j++){
SimpleRedisLock.acquire("lock");
if(Thread.currentThread().getName().equals("test-1")){
throw new RuntimeException();
}
LockTest.i++;
SimpleRedisLock.release("lock");
}
} catch (Exception e) {
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
}
線程test-?1在獲取到分布式鎖之后,因為運(yùn)行時異常退出(也有可能是因為進(jìn)程挡爵、機(jī)器crash竖般,OOM等各種問題),沒有正確的釋放鎖茶鹃,導(dǎo)致線程test-2涣雕、test-3死循環(huán)執(zhí)行SETNX
命令。
解決死鎖問題
按照Redis文檔給出的一種解決方法闭翩,重新修改acquire方法:
public static void acquire(String lock){
Jedis jedis = jedisPool.getResource();
//1.先嘗試用setnx命令獲取鎖,key為參數(shù)lock,值為當(dāng)前時間+要持有鎖的時間hold_time
while(jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time)) == 0){
//2.如果獲取失敗,檢查lock對應(yīng)的值是否已超時
String expireTime = jedis.get(lock);
if(expireTime != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//3.如果已經(jīng)超時了,使用getset命令,設(shè)置新的超時時間
String oldExpire = jedis.getSet(lock, String.valueOf(System.currentTimeMillis() + hold_time));
if(oldExpire != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//4.如果setget命令返回的值,依然是過期時間,認(rèn)為獲取鎖成功
break;
}
}
}
holder.set(jedis);
}
測試代碼執(zhí)行結(jié)果:
在test-1線程退出后挣郭,程序正常執(zhí)行,并得到了正確結(jié)果2萬男杈。但這個版本依舊有兩個問題沒有解決:
test-1線程異常退出丈屹,test-2调俘、test-3線程同時執(zhí)行setnx失敗伶棒,獲取expireTime,發(fā)現(xiàn)已經(jīng)小于currentTime彩库,開始執(zhí)行g(shù)etset命令肤无。假設(shè)test-2先執(zhí)行了getset,獲取鎖成功骇钦。test-3線程在執(zhí)行g(shù)etset時宛渐,返回的是test-2設(shè)置的未超時的時間戳,是一個未超時的時間眯搭,獲取鎖失敗窥翩。功能上沒有問題,但test-2線程持有的鎖的有效期時間戳已經(jīng)被test-3修改了鳞仙。
如果test-2線程在持有鎖的期間寇蚊,因為網(wǎng)絡(luò)抖動等原因,操作(測試代碼中對應(yīng)++操作部分)還沒有完成棍好,但鎖已經(jīng)超時了仗岸。 如何確定是否要釋放鎖(即使客戶端記錄自己的超時時間戳也沒用允耿,問題1中已經(jīng)描述了時間戳被其他線程修改的情況)?在需要互斥訪問資源的場景扒怖,執(zhí)行時間超過鎖超時時間的情況下较锡,怎么解決多個節(jié)點同時訪問資源的情況(同時執(zhí)行++操作)?
解決問題
重新修改獲取鎖的代碼:
public class SimpleRedisLock {
public static long hold_time = 3000;
public static ThreadLocal<Jedis> holder = new ThreadLocal<>();
public static ThreadLocal<String> expireHolder = new ThreadLocal<>();
public static JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "localhost");
public static void acquire(String lock){
Jedis jedis = jedisPool.getResource();
//1.先嘗試用setnx命令獲取鎖,key為參數(shù)lock,值為當(dāng)前時間+要持有鎖的時間hold_time
while(jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time)) == 0){
//2.如果獲取失敗,先watch lock key
jedis.watch(lock);
//3.獲取當(dāng)前超時時間
String expireTime = jedis.get(lock);
if(expireTime != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//4.如果超時時間小于當(dāng)前時間,開事務(wù)準(zhǔn)備更新lock值
Transaction transaction = jedis.multi();
Response<String> response = transaction.getSet(lock, String.valueOf(System.currentTimeMillis() + hold_time));
//5.步驟2設(shè)置了watch,如果lock的值被其他線程修改,不是執(zhí)行事務(wù)中的命令
if(transaction.exec() != null){
String oldExpire = response.get();
if(oldExpire != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//6.如果setget命令返回的值依然是過期時間,認(rèn)為獲取鎖成功(加了watch之后,這里返回的應(yīng)該一直是超時時間)
break;
}
}
}else{
//如果key未超時,解除watch
jedis.unwatch();
}
}
//設(shè)置客戶端超時時間
expireHolder.set(jedis.get(lock));
holder.set(jedis);
}
public static void release(String lock){
Jedis jedis = holder.get();
//比較客戶端超時時間與lock值,判斷是否還由自己持有鎖
if(jedis.get(lock).equals(expireHolder.get())){
jedis.del(lock);
}
jedis.close();
}
}
新的acquire方法盗痒,通過watch蚂蕴、redis事務(wù),保證只有一個客戶端能執(zhí)行g(shù)etset积糯,并記錄了鎖超時時間掂墓,解決了問題一和問題二的前半部分。對于鎖超時導(dǎo)致的兩個客戶端同時訪問資源看成,只能靠業(yè)務(wù)代碼保證鎖超時時間內(nèi)可以完成處理(可以在release時檢查是否超時君编,如果超時回滾所有操作,但對不能回滾的川慌,例如++操作就比較麻煩)吃嘿,或者放棄死鎖容錯功能,需要看場景衡量梦重。
代碼 :SimpleRedisLock
擴(kuò)展
以上只是單點redis服務(wù)器情況下的分布式鎖兑燥。在redis master-slaver架構(gòu)下,如果master節(jié)點down機(jī)琴拧,由于redis主從復(fù)制是異步的降瞳,會有明顯的race-condition。Redis文檔中提供了一種解決方案:RedLock蚓胸。