定義
正常的單機(jī)狀態(tài)的,共享資源都是在通過一個(gè)數(shù)據(jù)庫下的,可以在單機(jī)中進(jìn)行加鎖,保證共享數(shù)據(jù)的線程安全,分布式環(huán)境下,因?yàn)椴皇窃谕惶摂M機(jī)進(jìn)程的,全局的某些唯一資源需要進(jìn)行鎖定,這時(shí)候就需要分布式鎖。
現(xiàn)如今都是分布式系統(tǒng),需要部署多臺服務(wù)器,進(jìn)行負(fù)載均衡。如圖:
上圖可以看到,變量A存在JVM1、JVM2、JVM3三個(gè)JVM內(nèi)存中(這個(gè)變量A主要體現(xiàn)是在一個(gè)類中的一個(gè)成員變量,是一個(gè)有狀態(tài)的對象,例如:UserController控制器中的一個(gè)整形類型的成員變量),如果不加任何控制的話,變量A同時(shí)都會在JVM分配一塊內(nèi)存,三個(gè)請求發(fā)過來同時(shí)對這個(gè)變量操作,顯然結(jié)果是不對的!即使不是同時(shí)發(fā)過來,三個(gè)請求分別操作三個(gè)不同JVM內(nèi)存區(qū)域的數(shù)據(jù),變量A之間不存在共享,也不具有可見性,處理的結(jié)果也是不對的!
如果我們業(yè)務(wù)中確實(shí)存在這個(gè)場景的話,我們就需要一種方法解決這個(gè)問題!
為了保證一個(gè)方法或?qū)傩栽诟卟l(fā)情況下的同一時(shí)間只能被同一個(gè)線程執(zhí)行,在傳統(tǒng)單體應(yīng)用單機(jī)部署的情況下,可以使用Java并發(fā)處理相關(guān)的API(如ReentrantLock或Synchronized)進(jìn)行互斥控制。在單機(jī)環(huán)境中,Java中提供了很多并發(fā)處理相關(guān)的API。但是,隨著業(yè)務(wù)發(fā)展的需要,原單體單機(jī)部署的系統(tǒng)被演化成分布式集群系統(tǒng)后,由于分布式系統(tǒng)多線程、多進(jìn)程并且分布在不同機(jī)器上,這將使原單機(jī)部署情況下的并發(fā)控制鎖策略失效,單純的Java API并不能提供分布式鎖的能力。
為了解決這個(gè)問題就需要一種跨JVM的互斥機(jī)制來控制共享資源的訪問,這就是分布式鎖要解決的問題!
滿足條件
1、在分布式系統(tǒng)環(huán)境下,一個(gè)方法在同一時(shí)間只能被一個(gè)機(jī)器的一個(gè)線程執(zhí)行;
2、高可用的獲取鎖與釋放鎖;
3、高性能的獲取鎖與釋放鎖;
4、具備可重入特性;
5、具備鎖失效機(jī)制,防止死鎖;
6、具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。
具體例子
-
交易訂單鎖定
需要處理防止重復(fù)下單。
解決業(yè)務(wù)層面的冪等問題
-
MQ消息消費(fèi)的冪等性
發(fā)送的消息重復(fù)。
消息消費(fèi)端去重。
比如手機(jī)提現(xiàn),不能重復(fù)提現(xiàn)。
在用戶對商品下單后,訂單狀態(tài)為待支付,在某一時(shí)刻用戶正在對該訂單做支付操作,商家正在進(jìn)行改價(jià)操作??? 這時(shí)候,該狀態(tài)需要做串行處理,避免出現(xiàn)數(shù)據(jù)錯(cuò)亂。
解決方式
1.基于數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖;
創(chuàng)建一個(gè)表:
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`method_name` varchar(64) NOT NULL COMMENT '鎖定的方法名',
`desc` varchar(255) NOT NULL COMMENT '備注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';
(2)想要執(zhí)行某個(gè)方法,就使用這個(gè)方法名向表中插入數(shù)據(jù)
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '測試的methodName');
因?yàn)槲覀儗ethod_name做了唯一性約束,這里如果有多個(gè)請求同時(shí)提交到數(shù)據(jù)庫的話,數(shù)據(jù)庫會保證只有一個(gè)操作可以成功,那么我們就可以認(rèn)為操作成功的那個(gè)線程獲得了該方法的鎖,可以執(zhí)行方法體內(nèi)容。
(3)成功插入則獲取鎖,執(zhí)行完成后刪除對應(yīng)的行數(shù)據(jù)釋放鎖:
delete from method_lock where method_name ='methodName';
注意:這只是使用基于數(shù)據(jù)庫的一種方法,使用數(shù)據(jù)庫實(shí)現(xiàn)分布式鎖還有很多其他的玩法!
使用基于數(shù)據(jù)庫的這種實(shí)現(xiàn)方式很簡單,但是對于分布式鎖應(yīng)該具備的條件來說,它有一些問題需要解決及優(yōu)化:
1、因?yàn)槭腔跀?shù)據(jù)庫實(shí)現(xiàn)的,數(shù)據(jù)庫的可用性和性能將直接影響分布式鎖的可用性及性能,所以,數(shù)據(jù)庫需要雙機(jī)部署、數(shù)據(jù)同步、主備切換;
2、不具備可重入的特性,因?yàn)橥粋€(gè)線程在釋放鎖之前,行數(shù)據(jù)一直存在,無法再次成功插入數(shù)據(jù),所以,需要在表中新增一列,用于記錄當(dāng)前獲取到鎖的機(jī)器和線程信息,在再次獲取鎖的時(shí)候,先查詢表中機(jī)器和線程信息是否和當(dāng)前機(jī)器和線程相同,若相同則直接獲取鎖;
3、沒有鎖失效機(jī)制,因?yàn)橛锌赡艹霈F(xiàn)成功插入數(shù)據(jù)后,服務(wù)器宕機(jī)了,對應(yīng)的數(shù)據(jù)沒有被刪除,當(dāng)服務(wù)恢復(fù)后一直獲取不到鎖,所以,需要在表中新增一列,用于記錄失效時(shí)間,并且需要有定時(shí)任務(wù)清除這些失效的數(shù)據(jù);
4、不具備阻塞鎖特性,獲取不到鎖直接返回失敗,所以需要優(yōu)化獲取邏輯,循環(huán)多次去獲取。
2.基于redis做分布式鎖
為什么?
redis本身是單線程,唯一線程串行處理。
實(shí)現(xiàn)方式
Redis Setnx命令,在指定的key不存在時(shí),為key設(shè)置指定的值.多個(gè)線程并發(fā)的請求去設(shè)置時(shí),只有一個(gè)可以設(shè)置成功。其他的會返回失敗。一般設(shè)置五秒鐘。
//設(shè)置成功,返回1,設(shè)置失敗,返回 0
Setnx KEY_NAME VALUE Expire Time
分析存在問題:
-
單點(diǎn)問題
單機(jī)模式,設(shè)置T1 T2兩個(gè)線程.如果T1剛設(shè)置成功,單機(jī)掛了,重啟,請求丟了,T2去請求再去拿鎖,會獲取不到,這時(shí)候會獲取不到key。(因?yàn)榉植际芥i一般不考慮做持久化,所以這里不考慮持久化。)
主從模式,主從數(shù)據(jù)異步,會存在鎖失效的問題,主服務(wù)器還未同步到從服務(wù)器,這時(shí)候主掛了,從服務(wù)器獲取不到鎖。
鎖時(shí)間不可以控制,無法續(xù)租期
Redis本身建議:使用RedLock算法來保證,但是問題是需要至少三個(gè)Redis主從實(shí)例來完成,維護(hù)成本很高。這個(gè)等同于自己簡單實(shí)現(xiàn)的一致性協(xié)議,細(xì)節(jié)繁瑣,且容易出錯(cuò)。
是否能使用
業(yè)務(wù)場景來規(guī)定,在設(shè)計(jì)交易時(shí),只能發(fā)一次交易請求,這時(shí)候不適合。如果是MQ消息消費(fèi)場景,依次獲取不到,可以在發(fā)送一次消息保證能被消費(fèi)。
CAP問題
分布式鎖,主要選擇滿足C P模型,而redis實(shí)現(xiàn)的主要滿足AP模型。不太ok。
代碼實(shí)現(xiàn)
使用命令介紹:
(1)SETNX
SETNX key val:當(dāng)且僅當(dāng)key不存在時(shí),set一個(gè)key為val的字符串,返回1;若key存在,則什么都不做,返回0。
(2)expire
expire key timeout:為key設(shè)置一個(gè)超時(shí)時(shí)間,單位為second,超過這個(gè)時(shí)間鎖會自動釋放,避免死鎖。
1
(3)delete
delete key:刪除key
實(shí)現(xiàn)思想:
(1)獲取鎖的時(shí)候,使用setnx加鎖,并使用expire命令為鎖添加一個(gè)超時(shí)時(shí)間,超過該時(shí)間則自動釋放鎖,鎖的value值為一個(gè)隨機(jī)生成的UUID,通過此在釋放鎖的時(shí)候進(jìn)行判斷。
(2)獲取鎖的時(shí)候還設(shè)置一個(gè)獲取的超時(shí)時(shí)間,若超過這個(gè)時(shí)間則放棄獲取鎖。
(3)釋放鎖的時(shí)候,通過UUID判斷是不是該鎖,若是該鎖,則執(zhí)行delete進(jìn)行鎖釋放。
/**
* 分布式鎖的簡單實(shí)現(xiàn)代碼
* Created by liuyang on 2017/4/20.
*/
public class DistributedLock {
private final JedisPool jedisPool;
public DistributedLock(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
/**
* 加鎖
* @param lockName 鎖的key
* @param acquireTimeout 獲取超時(shí)時(shí)間
* @param timeout 鎖的超時(shí)時(shí)間
* @return 鎖標(biāo)識
*/
public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {
Jedis conn = null;
String retIdentifier = null;
try {
// 獲取連接
conn = jedisPool.getResource();
// 隨機(jī)生成一個(gè)value
String identifier = UUID.randomUUID().toString();
// 鎖名,即key值
String lockKey = "lock:" + lockName;
// 超時(shí)時(shí)間,上鎖后超過此時(shí)間則自動釋放鎖
int lockExpire = (int) (timeout / 1000);
// 獲取鎖的超時(shí)時(shí)間,超過這個(gè)時(shí)間則放棄獲取鎖
long end = System.currentTimeMillis() + acquireTimeout;
while (System.currentTimeMillis() < end) {
if (conn.setnx(lockKey, identifier) == 1) {
conn.expire(lockKey, lockExpire);
// 返回value值,用于釋放鎖時(shí)間確認(rèn)
retIdentifier = identifier;
return retIdentifier;
}
// 返回-1代表key沒有設(shè)置超時(shí)時(shí)間,為key設(shè)置一個(gè)超時(shí)時(shí)間
if (conn.ttl(lockKey) == -1) {
conn.expire(lockKey, lockExpire);
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (conn != null) {
conn.close();
}
}
return retIdentifier;
}
/**
* 釋放鎖
* @param lockName 鎖的key
* @param identifier 釋放鎖的標(biāo)識
* @return
*/
public boolean releaseLock(String lockName, String identifier) {
Jedis conn = null;
String lockKey = "lock:" + lockName;
boolean retFlag = false;
try {
conn = jedisPool.getResource();
while (true) {
// 監(jiān)視lock,準(zhǔn)備開始事務(wù)
conn.watch(lockKey);
// 通過前面返回的value值判斷是不是該鎖,若是該鎖,則刪除,釋放鎖
if (identifier.equals(conn.get(lockKey))) {
Transaction transaction = conn.multi();
transaction.del(lockKey);
List<Object> results = transaction.exec();
if (results == null) {
continue;
}
retFlag = true;
}
conn.unwatch();
break;
}
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (conn != null) {
conn.close();
}
}
return retFlag;
}
}
測試剛才實(shí)現(xiàn)的分布式鎖
例子中使用50個(gè)線程模擬秒殺一個(gè)商品,使用–運(yùn)算符來實(shí)現(xiàn)商品減少,從結(jié)果有序性就可以看出是否為加鎖狀態(tài)。
模擬秒殺服務(wù),在其中配置了jedis線程池,在初始化的時(shí)候傳給分布式鎖,供其使用.
/**
* Created by liuyang on 2017/4/20.
*/
public class Service {
private static JedisPool pool = null;
private DistributedLock lock = new DistributedLock(pool);
int n = 500;
static {
JedisPoolConfig config = new JedisPoolConfig();
// 設(shè)置最大連接數(shù)
config.setMaxTotal(200);
// 設(shè)置最大空閑數(shù)
config.setMaxIdle(8);
// 設(shè)置最大等待時(shí)間
config.setMaxWaitMillis(1000 * 100);
// 在borrow一個(gè)jedis實(shí)例時(shí),是否需要驗(yàn)證,若為true,則所有jedis實(shí)例均是可用的
config.setTestOnBorrow(true);
pool = new JedisPool(config, "127.0.0.1", 6379, 3000);
}
public void seckill() {
// 返回鎖的value值,供釋放鎖時(shí)候進(jìn)行判斷
String identifier = lock.lockWithTimeout("resource", 5000, 1000);
System.out.println(Thread.currentThread().getName() + "獲得了鎖");
System.out.println(--n);
lock.releaseLock("resource", identifier);
}
}
模擬線程進(jìn)行秒殺服務(wù)
public class ThreadA extends Thread {
private Service service;
public ThreadA(Service service) {
this.service = service;
}
@Override
public void run() {
service.seckill();
}
}
//這里推薦使用 countDownLatch
public class Test {
public static void main(String[] args) {
Service service = new Service();
for (int i = 0; i < 50; i++) {
ThreadA threadA = new ThreadA(service);
threadA.start();
}
}
}
3.基于zookeeper
ZooKeeper是一個(gè)為分布式應(yīng)用提供一致性服務(wù)的開源組件,它內(nèi)部是一個(gè)分層的文件系統(tǒng)目錄樹結(jié)構(gòu),規(guī)定同一個(gè)目錄下只能有一個(gè)唯一文件名?;赯ooKeeper實(shí)現(xiàn)分布式鎖的步驟如下:
(1)創(chuàng)建一個(gè)目錄mylock;
(2)線程A想獲取鎖就在mylock目錄下創(chuàng)建臨時(shí)順序節(jié)點(diǎn);
(3)獲取mylock目錄下所有的子節(jié)點(diǎn),然后獲取比自己小的兄弟節(jié)點(diǎn),如果不存在,則說明當(dāng)前線程順序號最小,獲得鎖;
(4)線程B獲取所有節(jié)點(diǎn),判斷自己不是最小節(jié)點(diǎn),設(shè)置監(jiān)聽比自己次小的節(jié)點(diǎn);
(5)線程A處理完,刪除自己的節(jié)點(diǎn),線程B監(jiān)聽到變更事件,判斷自己是不是最小的節(jié)點(diǎn),如果是則獲得鎖。
這里推薦一個(gè)Apache的開源庫Curator,它是一個(gè)ZooKeeper客戶端,Curator提供的InterProcessMutex是分布式鎖的實(shí)現(xiàn),acquire方法用于獲取鎖,release方法用于釋放鎖。
優(yōu)點(diǎn):具備高可用、可重入、阻塞鎖特性,可解決失效死鎖問題。
缺點(diǎn):因?yàn)樾枰l繁的創(chuàng)建和刪除節(jié)點(diǎn),性能上不如Redis方式。
具體代碼可以看這篇文章:
https://blog.csdn.net/qiangcuo6087/article/details/79067136
自己設(shè)計(jì)一個(gè)分布式鎖
設(shè)計(jì)的目標(biāo)
- 強(qiáng)一致性
- 服務(wù)高可用、系統(tǒng)穩(wěn)健
- 鎖自動續(xù)約及其自動釋放
- 代碼高度抽象業(yè)務(wù)接入極簡
- 可視化管理憑他、監(jiān)控及管理
對存儲模型進(jìn)行選型
N+1 代表部署奇數(shù)個(gè)
由于redis實(shí)現(xiàn)無法保證一致性,zookeeper對鎖實(shí)現(xiàn)使用創(chuàng)建臨時(shí)節(jié)點(diǎn)和watch機(jī)制,執(zhí)行效率,擴(kuò)展能力、社區(qū)活躍度等方面低于etcd,所以我們會選擇基于etcd實(shí)現(xiàn)。
etcd優(yōu)勢
- 簡單KV(key Value)
- 強(qiáng)一致性
- 高可用
- 無單點(diǎn)
- 數(shù)據(jù)可靠性
- 持久化
整體方案
分布式Client + etcd
Client TTL模式
Server TTL模式
拿鎖的時(shí)候,選擇key,ttl是超時(shí)時(shí)間,value可以忽略,uuid為該鎖的唯一憑證,后面對鎖的操作都是對uuid做操作。需要uuid才能做操作。etcd會保證只有一個(gè)線程能拿到鎖。
使用場景1.申請鎖
使用場景2.申請鎖,鎖已經(jīng)被占用
使用場景3.鎖的清理
業(yè)務(wù)接入
JDK7以上,建議9.
獲取鎖實(shí)例:
釋放鎖示例
兼容性考慮
ETCD恢復(fù)/版本
分布式鎖的特殊場景
特殊場景一:
分布式鎖只是在同一自然時(shí)間的互斥鎖,本省不解決冪等性問題。
接入業(yè)務(wù)需要完善從獲得鎖到釋放鎖中間的數(shù)據(jù)冪等邏輯。
特殊場景二: 鎖沒有按照日期續(xù)約
心跳續(xù)約沒有成功
馬上啟動GC,GCs時(shí)間太長
特殊場景三: etcd內(nèi)部協(xié)調(diào)發(fā)生問題
Leader節(jié)點(diǎn)掛了,選主中,
raft日志數(shù)據(jù)同步發(fā)生錯(cuò)誤或者不一致問題。
待續(xù)。。。
部分摘自: