更新時(shí)間:2021-11-03 10:16:14 來(lái)源:動(dòng)力節(jié)點(diǎn) 瀏覽979次
在單機(jī)時(shí)代,雖然不需要分布式鎖,但也面臨過(guò)類似的問(wèn)題,只不過(guò)在單機(jī)的情況下,如果有多個(gè)線程要同時(shí)訪問(wèn)某個(gè)共享資源的時(shí)候,我們可以采用線程間加鎖的機(jī)制,即當(dāng)某個(gè)線程獲取到這個(gè)資源后,就立即對(duì)這個(gè)資源進(jìn)行加鎖,當(dāng)使用完資源之后,再解鎖,其它線程就可以接著使用了。例如,在JAVA中,甚至專門提供了一些處理鎖機(jī)制的一些API(synchronize/Lock等)。
但是到了分布式系統(tǒng)的時(shí)代,這種線程之間的鎖機(jī)制,就沒(méi)作用了,系統(tǒng)可能會(huì)有多份并且部署在不同的機(jī)器上,這些資源已經(jīng)不是在線程之間共享了,而是屬于進(jìn)程之間共享的資源。
因此,為了解決這個(gè)問(wèn)題,我們就必須引入分布式鎖。分布式鎖是指在分布式的部署環(huán)境下,通過(guò)鎖機(jī)制來(lái)讓多客戶端互斥的對(duì)共享資源進(jìn)行訪問(wèn)。
基于數(shù)據(jù)庫(kù),如MySQL
基于緩存,如Redis
基于Zookeeper、etcd等
我們?cè)谟懻撌褂梅植际芥i的時(shí)候往往首先排除掉基于數(shù)據(jù)庫(kù)的方案,本能的會(huì)覺(jué)得這個(gè)方案不夠“高級(jí)”。從性能的角度考慮,基于數(shù)據(jù)庫(kù)的方案性能確實(shí)不夠優(yōu)異,整體性能對(duì)比:緩存 > Zookeeper、etcd > 數(shù)據(jù)庫(kù)。也有人提出基于數(shù)據(jù)庫(kù)的方案問(wèn)題很多,不太可靠。筆者認(rèn)為采用哪種方案是要基于使用場(chǎng)景來(lái)看的,選擇哪種方案,合適最重要。
我這里引用一下之前文章中的一個(gè)應(yīng)用場(chǎng)景——分配任務(wù)場(chǎng)景。在這個(gè)場(chǎng)景中,由于是公司的業(yè)務(wù)后臺(tái)系統(tǒng),主要是用于審核人員的審核工作,并發(fā)量并不是很高,而且任務(wù)的分配規(guī)則設(shè)計(jì)成了通過(guò)審核人員每次主動(dòng)的請(qǐng)求拉取,然后服務(wù)端從任務(wù)池中隨機(jī)的選取任務(wù)進(jìn)行分配。這個(gè)場(chǎng)景看到這里你會(huì)覺(jué)得比較單一,但是實(shí)際的分配過(guò)程中,由于涉及到了按用戶聚類的問(wèn)題,所以要比我描述的復(fù)雜,但是這里為了說(shuō)明問(wèn)題,大家可以把問(wèn)題簡(jiǎn)單化理解。那么在使用過(guò)程中,主要是為了避免同一個(gè)任務(wù)同時(shí)被兩個(gè)審核人員獲取到的問(wèn)題。在這個(gè)場(chǎng)景下使用基于數(shù)據(jù)庫(kù)的方案就比較合理。
再補(bǔ)充一下,比如某一個(gè)服務(wù)它下游依賴數(shù)據(jù)庫(kù)來(lái)做一些數(shù)據(jù)的讀寫操作,模型如下圖所示:
一般服務(wù)也是多實(shí)例部署,如果多個(gè)實(shí)例需要操作同一份數(shù)據(jù)的時(shí)候(比如前面所說(shuō)的同一個(gè)任務(wù)同時(shí)被兩個(gè)審核人員獲取到的問(wèn)題),自然而然的引入了分布式鎖。不過(guò)此時(shí),我們并沒(méi)有采用數(shù)據(jù)庫(kù)的方案,而是引入了Redis,模型如下圖所示:
引入Redis之后,正向的收益我就不贅述了,反向的收益是增加了系統(tǒng)的復(fù)雜度,對(duì)于整個(gè)服務(wù)而言,還需要多考慮1和2失效的情況。1失效是指服務(wù)模塊與Redis的交互出現(xiàn)了異常,這種異常不單是指無(wú)法通信的異常,也有可能是服務(wù)模塊發(fā)送請(qǐng)求只Redis的過(guò)程中或者Redis響應(yīng)服務(wù)模塊的過(guò)程中出現(xiàn)的異常,整體服務(wù)需要考慮這種情況:是重試、丟棄還是采取其他措施;2失效是指Redis本身出現(xiàn)了異常。數(shù)據(jù)鏈路一旦變長(zhǎng),系統(tǒng)復(fù)雜度一旦變大,在出現(xiàn)問(wèn)題的時(shí)候會(huì)阻礙故障排查以及服務(wù)恢復(fù),從而使得服務(wù)的整體可用性下調(diào)。
反觀,如果采用數(shù)據(jù)庫(kù)的方案,那么就可以省去了這部分的復(fù)雜度,如果數(shù)據(jù)庫(kù)的方案能滿足當(dāng)下場(chǎng)景以及可視范圍內(nèi)的未來(lái)擴(kuò)展,為什么還要平白地增加系統(tǒng)復(fù)雜度呢?大家要根據(jù)具體業(yè)務(wù)場(chǎng)景選擇合適的技術(shù)方案,而不是隨便找一個(gè)足夠復(fù)雜、足夠新潮的技術(shù)方案來(lái)解決業(yè)務(wù)問(wèn)題。
下面我們來(lái)了解一下基于數(shù)據(jù)庫(kù)(MySQL)的方案,一般分為3類:基于表記錄、樂(lè)觀鎖和悲觀鎖。
要實(shí)現(xiàn)分布式鎖,最簡(jiǎn)單的方式可能就是直接創(chuàng)建一張鎖表,然后通過(guò)操作該表中的數(shù)據(jù)來(lái)實(shí)現(xiàn)了。當(dāng)我們想要獲得鎖的時(shí)候,就可以在該表中增加一條記錄,想要釋放鎖的時(shí)候就刪除這條記錄。
為了更好的演示,我們先創(chuàng)建一張數(shù)據(jù)庫(kù)表,參考如下:
CREATE TABLE `database_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '鎖定的資源',
`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='數(shù)據(jù)庫(kù)分布式鎖表';
當(dāng)我們想要獲得鎖時(shí),可以插入一條數(shù)據(jù):
INSERT INTO database_lock(resource, description) VALUES (1, 'lock');
注意:在表database_lock中,resource字段做了唯一性約束,這樣如果有多個(gè)請(qǐng)求同時(shí)提交到數(shù)據(jù)庫(kù)的話,數(shù)據(jù)庫(kù)可以保證只有一個(gè)操作可以成功(其它的會(huì)報(bào)錯(cuò):ERROR 1062 (23000): Duplicate entry ‘1’ for key ‘uiq_idx_resource’),那么那么我們就可以認(rèn)為操作成功的那個(gè)請(qǐng)求獲得了鎖。
當(dāng)需要釋放鎖的時(shí),可以刪除這條數(shù)據(jù):
DELETE FROM database_lock WHERE resource=1;
這種實(shí)現(xiàn)方式非常的簡(jiǎn)單,但是需要注意以下幾點(diǎn):
這種鎖沒(méi)有失效時(shí)間,一旦釋放鎖的操作失敗就會(huì)導(dǎo)致鎖記錄一直在數(shù)據(jù)庫(kù)中,其它線程無(wú)法獲得鎖。這個(gè)缺陷也很好解決,比如可以做一個(gè)定時(shí)任務(wù)去定時(shí)清理。
這種鎖的可靠性依賴于數(shù)據(jù)庫(kù)。建議設(shè)置備庫(kù),避免單點(diǎn),進(jìn)一步提高可靠性。
這種鎖是非阻塞的,因?yàn)椴迦霐?shù)據(jù)失敗之后會(huì)直接報(bào)錯(cuò),想要獲得鎖就需要再次操作。如果需要阻塞式的,可以弄個(gè)for循環(huán)、while循環(huán)之類的,直至INSERT成功再返回。
這種鎖也是非可重入的,因?yàn)橥粋€(gè)線程在沒(méi)有釋放鎖之前無(wú)法再次獲得鎖,因?yàn)閿?shù)據(jù)庫(kù)中已經(jīng)存在同一份記錄了。想要實(shí)現(xiàn)可重入鎖,可以在數(shù)據(jù)庫(kù)中添加一些字段,比如獲得鎖的主機(jī)信息、線程信息等,那么在再次獲得鎖的時(shí)候可以先查詢數(shù)據(jù),如果當(dāng)前的主機(jī)信息和線程信息等能被查到的話,可以直接把鎖分配給它。
顧名思義,系統(tǒng)認(rèn)為數(shù)據(jù)的更新在大多數(shù)情況下是不會(huì)產(chǎn)生沖突的,只在數(shù)據(jù)庫(kù)更新操作提交的時(shí)候才對(duì)數(shù)據(jù)作沖突檢測(cè)。如果檢測(cè)的結(jié)果出現(xiàn)了與預(yù)期數(shù)據(jù)不一致的情況,則返回失敗信息。
樂(lè)觀鎖大多數(shù)是基于數(shù)據(jù)版本(version)的記錄機(jī)制實(shí)現(xiàn)的。何謂數(shù)據(jù)版本號(hào)?即為數(shù)據(jù)增加一個(gè)版本標(biāo)識(shí),在基于數(shù)據(jù)庫(kù)表的版本解決方案中,一般是通過(guò)為數(shù)據(jù)庫(kù)表添加一個(gè) “version”字段來(lái)實(shí)現(xiàn)讀取出數(shù)據(jù)時(shí),將此版本號(hào)一同讀出,之后更新時(shí),對(duì)此版本號(hào)加1。在更新過(guò)程中,會(huì)對(duì)版本號(hào)進(jìn)行比較,如果是一致的,沒(méi)有發(fā)生改變,則會(huì)成功執(zhí)行本次操作;如果版本號(hào)不一致,則會(huì)更新失敗。
CREATE TABLE `optimistic_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '鎖定的資源',
`version` int NOT NULL COMMENT '版本信息',
`created_at` datetime COMMENT '創(chuàng)建時(shí)間',
`updated_at` datetime COMMENT '更新時(shí)間',
`deleted_at` datetime COMMENT '刪除時(shí)間',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='數(shù)據(jù)庫(kù)分布式鎖表';
其中:id表示主鍵;resource表示具體操作的資源,在這里也就是特指庫(kù)存;version表示版本號(hào)。
在使用樂(lè)觀鎖之前要確保表中有相應(yīng)的數(shù)據(jù),比如:
INSERT INTO optimistic_lock(resource, version, created_at, updated_at) VALUES(20, 1, CURTIME(), CURTIME());
如果只是一個(gè)線程進(jìn)行操作,數(shù)據(jù)庫(kù)本身就能保證操作的正確性。主要步驟如下:
STEP1 - 獲取資源:SELECT resource FROM optimistic_lock WHERE id = 1
STEP2 - 執(zhí)行業(yè)務(wù)邏輯
STEP3 - 更新資源:UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1
然而在并發(fā)的情況下就會(huì)產(chǎn)生一些意想不到的問(wèn)題:比如兩個(gè)線程同時(shí)購(gòu)買一件商品,在數(shù)據(jù)庫(kù)層面實(shí)際操作應(yīng)該是庫(kù)存(resource)減2,但是由于是高并發(fā)的情況,第一個(gè)線程執(zhí)行之后(執(zhí)行了STEP1、STEP2但是還沒(méi)有完成STEP3),第二個(gè)線程在購(gòu)買相同的商品(執(zhí)行STEP1),此時(shí)查詢出的庫(kù)存并沒(méi)有完成減1的動(dòng)作,那么最終會(huì)導(dǎo)致2個(gè)線程購(gòu)買的商品卻出現(xiàn)庫(kù)存只減1的情況。
在引入了version字段之后,那么具體的操作就會(huì)演變成下面的內(nèi)容:
STEP1 - 獲取資源: SELECT resource, version FROM optimistic_lock WHERE id = 1
STEP2 - 執(zhí)行業(yè)務(wù)邏輯
STEP3 - 更新資源:UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion
其實(shí),借助更新時(shí)間戳(updated_at)也可以實(shí)現(xiàn)樂(lè)觀鎖,和采用version字段的方式相似:更新操作執(zhí)行前線獲取記錄當(dāng)前的更新時(shí)間,在提交更新時(shí),檢測(cè)當(dāng)前更新時(shí)間是否與更新開(kāi)始時(shí)獲取的更新時(shí)間戳相等。
樂(lè)觀鎖的優(yōu)點(diǎn)比較明顯,由于在檢測(cè)數(shù)據(jù)沖突時(shí)并不依賴數(shù)據(jù)庫(kù)本身的鎖機(jī)制,不會(huì)影響請(qǐng)求的性能,當(dāng)產(chǎn)生并發(fā)且并發(fā)量較小的時(shí)候只有少部分請(qǐng)求會(huì)失敗。缺點(diǎn)是需要對(duì)表的設(shè)計(jì)增加額外的字段,增加了數(shù)據(jù)庫(kù)的冗余,另外,當(dāng)應(yīng)用并發(fā)量高的時(shí)候,version值在頻繁變化,則會(huì)導(dǎo)致大量請(qǐng)求失敗,影響系統(tǒng)的可用性。我們通過(guò)上述sql語(yǔ)句還可以看到,數(shù)據(jù)庫(kù)鎖都是作用于同一行數(shù)據(jù)記錄上,這就導(dǎo)致一個(gè)明顯的缺點(diǎn),在一些特殊場(chǎng)景,如大促、秒殺等活動(dòng)開(kāi)展的時(shí)候,大量的請(qǐng)求同時(shí)請(qǐng)求同一條記錄的行鎖,會(huì)對(duì)數(shù)據(jù)庫(kù)產(chǎn)生很大的寫壓力。所以綜合數(shù)據(jù)庫(kù)樂(lè)觀鎖的優(yōu)缺點(diǎn),樂(lè)觀鎖比較適合并發(fā)量不高,并且寫操作不頻繁的場(chǎng)景。
除了可以通過(guò)增刪操作數(shù)據(jù)庫(kù)表中的記錄以外,我們還可以借助數(shù)據(jù)庫(kù)中自帶的鎖來(lái)實(shí)現(xiàn)分布式鎖。在查詢語(yǔ)句后面增加FOR UPDATE,數(shù)據(jù)庫(kù)會(huì)在查詢過(guò)程中給數(shù)據(jù)庫(kù)表增加悲觀鎖,也稱排他鎖。當(dāng)某條記錄被加上悲觀鎖之后,其它線程也就無(wú)法再改行上增加悲觀鎖。
悲觀鎖,與樂(lè)觀鎖相反,總是假設(shè)最壞的情況,它認(rèn)為數(shù)據(jù)的更新在大多數(shù)情況下是會(huì)產(chǎn)生沖突的。
在使用悲觀鎖的同時(shí),我們需要注意一下鎖的級(jí)別。MySQL InnoDB引起在加鎖的時(shí)候,只有明確地指定主鍵(或索引)的才會(huì)執(zhí)行行鎖 (只鎖住被選取的數(shù)據(jù)),否則MySQL 將會(huì)執(zhí)行表鎖(將整個(gè)數(shù)據(jù)表單給鎖住)。
在使用悲觀鎖時(shí),我們必須關(guān)閉MySQL數(shù)據(jù)庫(kù)的自動(dòng)提交屬性(參考下面的示例),因?yàn)镸ySQL默認(rèn)使用autocommit模式,也就是說(shuō),當(dāng)你執(zhí)行一個(gè)更新操作后,MySQL會(huì)立刻將結(jié)果進(jìn)行提交。
mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)
這樣在使用FOR UPDATE獲得鎖之后可以執(zhí)行相應(yīng)的業(yè)務(wù)邏輯,執(zhí)行完之后再使用COMMIT來(lái)釋放鎖。
我們不妨沿用前面的database_lock表來(lái)具體表述一下用法。假設(shè)有一線程A需要獲得鎖并執(zhí)行相應(yīng)的操作,那么它的具體步驟如下:
STEP1 - 獲取鎖:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
STEP2 - 執(zhí)行業(yè)務(wù)邏輯。
STEP3 - 釋放鎖:COMMIT。
如果另一個(gè)線程B在線程A釋放鎖之前執(zhí)行STEP1,那么它會(huì)被阻塞,直至線程A釋放鎖之后才能繼續(xù)。注意,如果線程A長(zhǎng)時(shí)間未釋放鎖,那么線程B會(huì)報(bào)錯(cuò),參考如下(lock wait time可以通過(guò)innodb_lock_wait_timeout來(lái)進(jìn)行配置):
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
上面的示例中演示了指定主鍵并且能查詢到數(shù)據(jù)的過(guò)程(觸發(fā)行鎖),如果查不到數(shù)據(jù)那么也就無(wú)從“鎖”起了。
如果未指定主鍵(或者索引)且能查詢到數(shù)據(jù),那么就會(huì)觸發(fā)表鎖,比如STEP1改為執(zhí)行(這里的version只是當(dāng)做一個(gè)普通的字段來(lái)使用,與上面的樂(lè)觀鎖無(wú)關(guān)):
SELECT * FROM database_lock WHERE description='lock' FOR UPDATE;
或者主鍵不明確也會(huì)觸發(fā)表鎖,又比如STEP1改為執(zhí)行:
SELECT * FROM database_lock WHERE id>0 FOR UPDATE;
注意,雖然我們可以顯示使用行級(jí)鎖(指定可查詢的主鍵或索引),但是MySQL會(huì)對(duì)查詢進(jìn)行優(yōu)化,即便在條件中使用了索引字段,但是否真的使用索引來(lái)檢索數(shù)據(jù)是由MySQL通過(guò)判斷不同執(zhí)行計(jì)劃的代價(jià)來(lái)決定的,如果MySQL認(rèn)為全表掃描效率更高,比如對(duì)一些很小的表,它有可能不會(huì)使用索引,在這種情況下InnoDB將使用表鎖,而不是行鎖。
在悲觀鎖中,每一次行數(shù)據(jù)的訪問(wèn)都是獨(dú)占的,只有當(dāng)正在訪問(wèn)該行數(shù)據(jù)的請(qǐng)求事務(wù)提交以后,其他請(qǐng)求才能依次訪問(wèn)該數(shù)據(jù),否則將阻塞等待鎖的獲取。悲觀鎖可以嚴(yán)格保證數(shù)據(jù)訪問(wèn)的安全。但是缺點(diǎn)也明顯,即每次請(qǐng)求都會(huì)額外產(chǎn)生加鎖的開(kāi)銷且未獲取到鎖的請(qǐng)求將會(huì)阻塞等待鎖的獲取,在高并發(fā)環(huán)境下,容易造成大量請(qǐng)求阻塞,影響系統(tǒng)可用性。另外,悲觀鎖使用不當(dāng)還可能產(chǎn)生死鎖的情況。
0基礎(chǔ) 0學(xué)費(fèi) 15天面授
有基礎(chǔ) 直達(dá)就業(yè)
業(yè)余時(shí)間 高薪轉(zhuǎn)行
工作1~3年,加薪神器
工作3~5年,晉升架構(gòu)
提交申請(qǐng)后,顧問(wèn)老師會(huì)電話與您溝通安排學(xué)習(xí)
初級(jí) 202925
初級(jí) 203221
初級(jí) 202629
初級(jí) 203743