聊聊Java三種常見(jiàn)的分布式鎖
平時(shí)我們?cè)?APP 上搶某件商品時(shí),手指瘋狂點(diǎn)擊下單,但是只會(huì)生成一個(gè)訂單,為什么呢?
因?yàn)閷?duì)用戶下單加了一個(gè)鎖,避免用戶重復(fù)下單。而在分布式場(chǎng)景,訂單可以看作一個(gè)共享資源,所以這個(gè)鎖可以叫做分布式鎖。
一般來(lái)講,分布式鎖有三種實(shí)現(xiàn)方式:
- 基于數(shù)據(jù)庫(kù)的分布式鎖
- 基于 redis 的分布式鎖
- 基于 Zookeeper 的分布式鎖
1 基于數(shù)據(jù)庫(kù)的分布式鎖
基于數(shù)據(jù)庫(kù)實(shí)現(xiàn)分布式鎖有很多實(shí)現(xiàn),這里介紹其中一種。其核心思想是:
在數(shù)據(jù)庫(kù)中創(chuàng)建一個(gè)表,表中添加 方法名 等字段,并 對(duì)方法名字段添加唯一索引,如果要對(duì)某個(gè)方法加鎖,則使用這個(gè)方法名向表中插入數(shù)據(jù),成功插入則加鎖成功,方法執(zhí)行完后刪除對(duì)應(yīng)的行數(shù)據(jù)釋放鎖。[1]
1.1 創(chuàng)建表
CREATE TABLE `t_database_lock` ( `id` bigint NOT NULL COMMENT '主鍵', `method_name` varchar(50) COLLATE utf8_bin NOT NULL DEFAULT '方法名字', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創(chuàng)建時(shí)間', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近更新時(shí)間' PRIMARY KEY(`id`), UNIQUE INDEX `uk_t_database_lock_mname`(`method_name`) USING BTREE COMMENT '方法名唯一索引', INDEX `idx_t_accounting_balance_account_utime`(`update_time`) USING BTREE COMMENT '更新時(shí)間索引', INDEX `idx_t_accounting_balance_account_ctime`(`create_time`) USING BTREE COMMENT '創(chuàng)建時(shí)間索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin
1.2 偽代碼實(shí)現(xiàn)
主流程如下:
public void execute(String methodName) {
try {
if (lock(methodName)) { // 加鎖
// 你的業(yè)務(wù)邏輯
run();
}
} finally {
unlock(); // 釋放鎖
}
}lock() 方法的實(shí)現(xiàn)的偽代碼如下:
private boolean lock(String methodName) {
try {
// 插入一條數(shù)據(jù)
int count = "INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '需要加鎖的methodName');";
if (count == 1) { // 1 表示插入成功
return true;
}
return false;
} catch (Exception e) {
return false;
}
return false;
}unlock() 方法實(shí)現(xiàn)的偽代碼如下:
private boolean lock(String methodName) {
try {
// 插入一條數(shù)據(jù)
int count = "INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '需要加鎖的methodName');";
if (count == 1) { // 1 表示插入成功
return true;
}
return false;
} catch (Exception e) {
return false;
}
return false;
}它的優(yōu)點(diǎn)是實(shí)現(xiàn)簡(jiǎn)單,但是有以下缺點(diǎn):
- 讀取寫(xiě)入性能低,資源開(kāi)銷(xiāo)較大,不適合并發(fā)量很高的場(chǎng)景。
- 可靠性較低,因?yàn)閿?shù)據(jù)庫(kù)的可用性會(huì)直接影響到分布式鎖的可用性,所以數(shù)據(jù)庫(kù)需要雙機(jī)部署、數(shù)據(jù)同步、主備切換。
- 沒(méi)有鎖失效機(jī)制。如果在成功插入一條數(shù)據(jù)后,服務(wù)器宕機(jī)了,導(dǎo)致這條數(shù)據(jù)沒(méi)有刪除,服務(wù)恢復(fù)后一直獲取不到鎖。解決方式是可以在表中新增一例失效時(shí)間,定時(shí)清除這些失效數(shù)據(jù)。
- 鎖邏輯簡(jiǎn)單,不具備阻塞鎖等高級(jí)功能。
2 基于 redis 的分布式鎖
redis 分布式鎖的好處是性能好,實(shí)現(xiàn)也較為方便。
2.1 setnx + expire
- setnx 即 Set if Not Exists,如果 redis 存在這個(gè) key,setnx 返回0,加鎖失敗;否則返回1,加鎖成功。
- expire 用來(lái)設(shè)置 redis key 的過(guò)期時(shí)間,避免這個(gè)鎖一直存在,造成無(wú)法加鎖的問(wèn)題。
實(shí)現(xiàn)偽代碼如下:
if (jedis.setnx(key) == 1) { // 加鎖成功
expire(key, 100);
try {
doSomething(); //業(yè)務(wù)處理
} catch (Exception e) {
} finally {
jedis.del(key); // 釋放鎖
}
}這個(gè)是基本的實(shí)現(xiàn),但是會(huì)有一個(gè)問(wèn)題:如果 setnx 執(zhí)行結(jié)束,進(jìn)程中斷了,沒(méi)有執(zhí)行 expire 代碼,導(dǎo)致 key 沒(méi)有設(shè)置過(guò)期時(shí)間,別的線程就永遠(yuǎn)獲取不到這個(gè)鎖了。
2.2 setnx 同時(shí)設(shè)置過(guò)期時(shí)間
基于以上問(wèn)題,我們可以將 redis 的 value 利用起來(lái),將 jedis.setnx(key) 改為 jedis.setnx(key, ${過(guò)期時(shí)間}),加鎖的偽代碼如下[2]:
public boolean lock(String key, Long expireTime) {
Jedis jedis = new Jedis();
//系統(tǒng)時(shí)間 + 設(shè)置的過(guò)期時(shí)間
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);
// 如果當(dāng)前鎖不存在,返回加鎖成功
if (jedis.setnx(key, expiresStr) == 1) {
return true;
}
// 如果鎖已經(jīng)存在,獲取鎖的過(guò)期時(shí)間
String currentValueStr = jedis.get(key);
// 鎖過(guò)期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 鎖已過(guò)期,獲取上一個(gè)鎖的過(guò)期時(shí)間,并設(shè)置現(xiàn)在鎖的過(guò)期時(shí)間
String oldValueStr = jedis.getSet(key, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 只有上一個(gè)鎖的過(guò)期時(shí)間和當(dāng)前過(guò)期時(shí)間相同才可加鎖
return true;
}
}
return false;
}以上的實(shí)現(xiàn)方式避免了鎖一直存在的問(wèn)題,但是仍然有缺點(diǎn):
- 過(guò)期時(shí)間使用
System.currentTimeMillis()生成,意味著客戶端時(shí)間必須同步。 - 沒(méi)有辨別客戶端的機(jī)制,可能 A 機(jī)器加了鎖,被 B 機(jī)器解鎖了。
- 并發(fā)情況下,多個(gè)機(jī)器同時(shí)加鎖,只有一個(gè)會(huì)加鎖成功。
2.3 將 redis value 設(shè)置為客戶端標(biāo)識(shí)
通過(guò)以上分析,我們既希望 redis 設(shè)置 key 和 expire 同時(shí)進(jìn)行,又希望 redis 加鎖解鎖時(shí)能識(shí)別客戶端。
因此我們將客戶端加鎖時(shí),生成一個(gè)唯一 ID 作為 redis 的 value,在進(jìn)行鎖操作時(shí)先校驗(yàn)一下客戶端是否一致,在進(jìn)行鎖操作。redis 的 set() 方法可以滿足要求。
流程的偽代碼如下:
public void execute() {
Jedis jedis = new Jedis();
String key = "key";
String uuid = "uuid";
if ("1".equals(jedis.set(key, uuid, "NX", "EX", 100))) { //加鎖
try {
doSomething(); //業(yè)務(wù)處理
} finally {
//判斷是不是當(dāng)前線程加的鎖,是才釋放
if (uuid.equals(jedis.get(key))) {
jedis.del(key); //釋放鎖
}
}
}
}以上實(shí)現(xiàn)已經(jīng)不錯(cuò)了,可以滿足大部分場(chǎng)景。但是還有一個(gè)問(wèn)題:過(guò)期時(shí)間是個(gè)不確定因素,你很難設(shè)置一個(gè)合適的過(guò)期時(shí)間,使得鎖既在業(yè)務(wù)執(zhí)行后釋放,又不至于太長(zhǎng)導(dǎo)致其他線程獲取失敗。
2.4 Redisson
Redisson 可以解決上面的問(wèn)題,它的原理是在加鎖成功后啟動(dòng)一個(gè)看門(mén)狗,在線程持有鎖期間不斷延長(zhǎng) key 的生存時(shí)間,如此一來(lái)可解決鎖在業(yè)務(wù)執(zhí)行完之前就釋放的問(wèn)題。
3 Zookeeper 分布式鎖
當(dāng)使用 ZooKeeper 實(shí)現(xiàn)分布式鎖時(shí),可以按照以下步驟進(jìn)行操作:
- 每個(gè)客戶端連接到 ZooKeeper 服務(wù)器。
- 每個(gè)客戶端在 ZooKeeper 上創(chuàng)建一個(gè)臨時(shí)順序節(jié)點(diǎn)作為鎖節(jié)點(diǎn)。
- 客戶端獲取鎖的方式是判斷自己創(chuàng)建的節(jié)點(diǎn)是否是當(dāng)前所有鎖節(jié)點(diǎn)中最小的。
- 如果客戶端創(chuàng)建的節(jié)點(diǎn)是最小節(jié)點(diǎn),表示該客戶端獲得了鎖,可以執(zhí)行關(guān)鍵區(qū)域的代碼。
- 如果客戶端創(chuàng)建的節(jié)點(diǎn)不是最小節(jié)點(diǎn),則需要監(jiān)聽(tīng)比自己創(chuàng)建節(jié)點(diǎn)次小的節(jié)點(diǎn)。
- 當(dāng)監(jiān)聽(tīng)到次小節(jié)點(diǎn)被刪除時(shí),客戶端重新檢查自己創(chuàng)建的節(jié)點(diǎn)是否是當(dāng)前所有鎖節(jié)點(diǎn)中最小的。如果是,則獲得了鎖,可以執(zhí)行關(guān)鍵區(qū)域的代碼。
- 當(dāng)客戶端完成了關(guān)鍵區(qū)域的操作后,釋放鎖,即刪除自己創(chuàng)建的節(jié)點(diǎn)。
這樣,通過(guò) ZooKeeper 的臨時(shí)順序節(jié)點(diǎn)和監(jiān)聽(tīng)機(jī)制,可以實(shí)現(xiàn)一個(gè)簡(jiǎn)單的分布式鎖。每個(gè)客戶端在創(chuàng)建節(jié)點(diǎn)時(shí),會(huì)根據(jù)節(jié)點(diǎn)名稱(chēng)的順序來(lái)確定是否獲得了鎖。如果創(chuàng)建的節(jié)點(diǎn)是最小節(jié)點(diǎn),則表示該客戶端獲得了鎖,可以執(zhí)行關(guān)鍵區(qū)域的代碼。其他客戶端則通過(guò)監(jiān)聽(tīng)比自己創(chuàng)建的節(jié)點(diǎn)次小的節(jié)點(diǎn)來(lái)等待鎖的釋放,從而實(shí)現(xiàn)分布式的互斥訪問(wèn)。
總結(jié)
三種分布式鎖對(duì)比
數(shù)據(jù)庫(kù)分布式鎖實(shí)現(xiàn)
優(yōu)點(diǎn):簡(jiǎn)單,使用方便,不需要引入 Redis、Zookeeper 等中間件。
缺點(diǎn):
不適合高并發(fā)的場(chǎng)景
db 操作性能較差
Redis 分布式鎖實(shí)現(xiàn)
優(yōu)點(diǎn):
性能好,適合高并發(fā)場(chǎng)景
較輕量級(jí)
有較好的框架支持,如 Redisson
缺點(diǎn):
過(guò)期時(shí)間不好控制
需要考慮鎖被別的線程誤刪場(chǎng)景
Zookeeper 分布式鎖實(shí)現(xiàn)
缺點(diǎn):
性能不如 Redis 實(shí)現(xiàn)的分布式鎖
比較重的分布式鎖。
優(yōu)點(diǎn):
有較好的性能和可靠性
有封裝較好的框架,如 Curator
以上實(shí)現(xiàn)方式都不是完美的,在實(shí)際生產(chǎn)中要合理評(píng)估,選擇適合自己的方案。
性能
redis > zookeeper >= 數(shù)據(jù)庫(kù)
可靠性
zookeeper > redis > 數(shù)據(jù)庫(kù)
實(shí)現(xiàn)復(fù)雜度
zookeeper > redis > 數(shù)據(jù)庫(kù)
到此這篇關(guān)于聊聊Java三種常見(jiàn)的分布式鎖的文章就介紹到這了,更多相關(guān)Java 分布式鎖內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
SpringCloud消息總線Bus配置中心實(shí)現(xiàn)過(guò)程解析
這篇文章主要介紹了SpringCloud消息總線Bus配置中心實(shí)現(xiàn)過(guò)程解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-03-03
Jenkins環(huán)境搭建之下載與安裝過(guò)程
Jenkins是一個(gè)功能強(qiáng)大的應(yīng)用程序,允許持續(xù)集成和持續(xù)交付項(xiàng)目,集成Jenkins可以用于一些測(cè)試和部署技術(shù),對(duì)Jenkins環(huán)境搭建之下載與安裝過(guò)程感興趣的朋友跟隨小編一起看看吧2021-12-12
Spring延遲Bean初始化的實(shí)現(xiàn)示例
延遲初始化也叫做惰性初始化,指不提前初始化Bean,而是只有在真正使用時(shí)才創(chuàng)建及初始化Bean,本文主要介紹了Spring延遲Bean初始化的實(shí)現(xiàn)示例,感興趣的可以了解一下2024-06-06
一文詳解前端和后端的數(shù)據(jù)是如何連接的(基于Spring?Boot、Django或Node.js)
這篇文章主要介紹了前端和后端的數(shù)據(jù)是如何連接的相關(guān)資料,文中通過(guò)示例介紹的非常詳細(xì),舉例講解的是基于Spring?Boot、Django或Node.js,需要的朋友可以參考下2025-01-01
如何使用stream從List對(duì)象中獲取某列數(shù)據(jù)
這篇文章主要介紹了如何使用stream從List對(duì)象中獲取某列數(shù)據(jù)問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-12-12
SpringBoot實(shí)現(xiàn)二維碼掃碼登錄的原理及項(xiàng)目實(shí)踐
本文主要介紹了SpringBoot實(shí)現(xiàn)二維碼掃碼登錄的原理及項(xiàng)目實(shí)踐,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-04-04

