齊洋 原變青 劉穎 楊婷
(北京經(jīng)濟(jì)管理職業(yè)學(xué)院 北京市 100102)
互聯(lián)網(wǎng)的興起特別是大數(shù)據(jù)與云計(jì)算技術(shù)的發(fā)展,對(duì)傳統(tǒng)的軟件應(yīng)用也產(chǎn)生了極大的影響。在互聯(lián)網(wǎng)環(huán)境下,海量的數(shù)據(jù)和極大的并發(fā)訪問量若仍然以傳統(tǒng)的單機(jī)應(yīng)用的存儲(chǔ)處理模式,則其處理速度、訪問速度將變得極其緩慢,造成用戶體驗(yàn)極差。為了提高性能,有兩條路線,一種是提高單個(gè)服務(wù)器的性能,增加CPU、內(nèi)存等資源,另一種就是利用廉價(jià)的普通機(jī)器構(gòu)建分布式系統(tǒng)。第一種方式操作簡單,但是配置高的服務(wù)器價(jià)格昂貴,并且當(dāng)數(shù)據(jù)量和并發(fā)量到達(dá)一定程度,即使現(xiàn)有的最強(qiáng)勁單個(gè)服務(wù)器也難以承載這些壓力。所以,分布式系統(tǒng)成為了絕大多數(shù)互聯(lián)網(wǎng)公司的首選。由多個(gè)分布式應(yīng)用組成的分布式系統(tǒng)能夠承載互聯(lián)網(wǎng)級(jí)別的數(shù)據(jù)量和并發(fā)訪問,但是也帶來了資源管理上復(fù)雜性。如何讓多個(gè)分布式應(yīng)用能夠并發(fā)正確的訪問共享資源就是分布式系統(tǒng)中的一個(gè)問題。而解決這個(gè)問題就需要用到分布式鎖。
分布式鎖,就是分布式系統(tǒng)中的鎖。在一般的單體應(yīng)用本地部署的情況下,為解決共享資源被并發(fā)訪問的問題,引入了本地鎖。主流的編程語言都會(huì)支持本地鎖或同步,如Java 中的synchronized 關(guān)鍵字和ReentrantLock,Go 中的sync.Mutex 等。當(dāng)代碼塊或者變量由本地鎖控制時(shí),同時(shí)只能由一個(gè)線程訪問更改被控制的代碼塊或者變量。在分布式系統(tǒng)中,各個(gè)應(yīng)用是分布在不同的進(jìn)程中的并且部署在不同的機(jī)器上,此時(shí)再采用本地鎖來做并發(fā)訪問控制將無法滿足需求。而在分布式鎖是為了解決分布式系統(tǒng)中的共享資源被并發(fā)訪問的問題,所以它必然有分布式的特點(diǎn),而為了協(xié)調(diào)多個(gè)進(jìn)程能夠正確并發(fā)訪問資源,協(xié)調(diào)部分又需要是集中的。在本地鎖的應(yīng)用場景中,爭奪鎖的最小實(shí)體是線程,而分布式鎖的最小爭奪實(shí)體是進(jìn)程。
在分布式系統(tǒng)的場景中,云計(jì)算平臺(tái)是一個(gè)當(dāng)前流行的應(yīng)用場景。前文提到,分布式系統(tǒng)是運(yùn)行在廉價(jià)的普通機(jī)器上,長時(shí)間的高負(fù)載的運(yùn)行,這些普通服務(wù)器將不可避免的出現(xiàn)一部分機(jī)器宕機(jī)的情況,同時(shí),應(yīng)用也會(huì)因?yàn)殚L時(shí)間的運(yùn)行而因?yàn)橄到y(tǒng)缺陷和內(nèi)存泄漏等原因出現(xiàn)崩潰然后重啟的問題。在出現(xiàn)以上問題期間,為提高用戶體驗(yàn),構(gòu)建云計(jì)算平臺(tái)的各個(gè)組件的功能和云平臺(tái)上運(yùn)行的各個(gè)應(yīng)用的訪問都不應(yīng)該出現(xiàn)問題,即這一類的錯(cuò)誤對(duì)于用戶來看應(yīng)該是不可見的。在用戶來看,系統(tǒng)是一直可用的,這就是系統(tǒng)的高可用性。系統(tǒng)的高可用性主要可以分為兩種模式,即activeactive 和active-standby。active-active 即一個(gè)應(yīng)用的多個(gè)實(shí)例都是處于運(yùn)行狀態(tài),多個(gè)實(shí)例共同處理用戶對(duì)于此應(yīng)用的所有請(qǐng)求。active-standby 則是一個(gè)應(yīng)用的多個(gè)實(shí)例只有一個(gè)處于真正的運(yùn)行狀態(tài)并由其處理所有的用戶請(qǐng)求,其他的實(shí)例則一直作為備選,當(dāng)運(yùn)行實(shí)例出現(xiàn)問題時(shí),備選實(shí)例中將有一個(gè)成為新的運(yùn)行實(shí)例。在active-standby 模式中,一般便是使用分布式鎖來實(shí)現(xiàn)一個(gè)運(yùn)行多個(gè)備選的狀態(tài)。多個(gè)實(shí)例通過爭奪分布式鎖,爭到的便是實(shí)際運(yùn)行實(shí)例,其他的為備選實(shí)例,以此實(shí)現(xiàn)高可用性。
基于Zookeeper 的分布式鎖:
Zookeeper 是一個(gè)開源的分布式應(yīng)用程序協(xié)調(diào)組件,是Google 的Chubby 的開源實(shí)現(xiàn),在著名的大數(shù)據(jù)軟件Hadoop 中為集群提供一致性服務(wù)?;赯ookeeper 的臨時(shí)有序節(jié)點(diǎn)可以實(shí)現(xiàn)分布式鎖。當(dāng)有客戶端進(jìn)程嘗試加鎖時(shí),Zookeeper 集群中與該鎖對(duì)應(yīng)的節(jié)點(diǎn)目錄下,將會(huì)生成一個(gè)唯一的瞬時(shí)有序節(jié)點(diǎn)。判斷進(jìn)程是否獲取到鎖的方式就是判斷當(dāng)前進(jìn)程是否是這些有序節(jié)點(diǎn)序號(hào)最小的那個(gè)。釋放鎖的時(shí)候,將這個(gè)瞬時(shí)節(jié)點(diǎn)刪除即可。Zookeeper 分布式鎖可以避免服務(wù)宕機(jī)而產(chǎn)生的鎖無法釋放,從而產(chǎn)生的死鎖問題。
基于Redis 的分布式鎖:
Redis 是Remote Dictionary Server 的簡寫,即遠(yuǎn)程字典服務(wù)器,是一個(gè)以C 語言開發(fā)的開源的支持網(wǎng)絡(luò),可以基于內(nèi)存也可持久化的日志型的Key-Value 數(shù)據(jù)庫,其提供了多種語言的開發(fā)接口。在很多的大型系統(tǒng)中作為緩存數(shù)據(jù)庫使用,其輕量、快速的特點(diǎn)深受開發(fā)者的歡迎。
Redis 的分布式鎖主要使用其setnx、get 和getset 三個(gè)函數(shù)來實(shí)現(xiàn)。setnx(key)即Set if not exists,此函數(shù)具備原子性,如果key 不存在則可以設(shè)置value 并返回1;如果key 存在則設(shè)置失敗返回0。get(key)獲取對(duì)應(yīng)value 的過期時(shí)間;getset(key,newValue)也具備原子性,設(shè)置newValue 成功后會(huì)返回key 對(duì)應(yīng)的舊值。獲取鎖時(shí),調(diào)用setnx(key),返回1 表示成功獲取鎖,流程結(jié)束。返回0 表示沒有獲取鎖,則調(diào)用get(key)獲取值得過期時(shí)間oldExpireTime,與當(dāng)前時(shí)間比較,如果小于當(dāng)前時(shí)間表示鎖已超時(shí),可以獲取。然后算出新的過期時(shí)間newExpireTime,執(zhí)行g(shù)etset(key, newExpireTime),會(huì)返回key 值當(dāng)前的過期時(shí)間currentExipreTime。比較oldExipreTime 和currentExpireTime,如果相等,表示getset設(shè)置成功,成功獲取到鎖。如果不相等,表示鎖被別的進(jìn)程獲取到。獲取鎖流程失敗,過一段時(shí)間重試。當(dāng)鎖持有進(jìn)程釋放鎖時(shí),執(zhí)行delete(key)即可。
PostgreSQL 是一個(gè)強(qiáng)大的功能齊全的開源關(guān)系型數(shù)據(jù)庫系統(tǒng)。他誕生于1986年的美國加州大學(xué)計(jì)算機(jī)學(xué)院,初始是作為POSTGRES 項(xiàng)目的一部分。經(jīng)過三十多年的開發(fā)和應(yīng)用,它支持標(biāo)準(zhǔn)的SQL 語言并加入了很多其他的功能以確保數(shù)據(jù)能夠安全存儲(chǔ),根據(jù)數(shù)據(jù)負(fù)載能夠靈活擴(kuò)展。它兼容所有的主流操作系統(tǒng),除SQL 的基本類型外還支持JSON、Key-value 等數(shù)據(jù)類型,在數(shù)據(jù)一致性、高并發(fā)、高可用、數(shù)據(jù)恢復(fù)、數(shù)據(jù)安全等方面都有極為出色的表現(xiàn),并且還有很多類似PostGIS 這樣的強(qiáng)大插件。PostgreSQL 的上述強(qiáng)大特性為其在世界范圍內(nèi)贏得了很高的贊譽(yù),也成為了很多開發(fā)者和機(jī)構(gòu)首選的開源關(guān)系數(shù)據(jù)庫系統(tǒng)。
PostgreSQL 顯式鎖提供了一系列鎖定模式來控制應(yīng)用訪問數(shù)據(jù)表中的數(shù)據(jù),這在一些應(yīng)用需要細(xì)粒度的鎖定而使用標(biāo)準(zhǔn)的SQL 語句并不能達(dá)到目的的場景十分有用。其實(shí),執(zhí)行標(biāo)準(zhǔn)的SQL語句進(jìn)行操作也是調(diào)用了一系列的顯示鎖,只是調(diào)用的細(xì)節(jié)由PostgreSQL 隱藏,用戶不能進(jìn)行控制。本系統(tǒng)中主要使用了表級(jí)別的鎖(Table-level Locks)。這里只簡單介紹ACCESS EXCLUSIVE 模式。此模式將確保操作當(dāng)前數(shù)據(jù)庫事務(wù)的進(jìn)程是當(dāng)前唯一能訪問目標(biāo)表的進(jìn)程,其他所有的事務(wù)對(duì)目標(biāo)表的一切操作都將被阻塞。這樣,在分布式進(jìn)程獲取鎖的時(shí)候,多個(gè)進(jìn)程將不會(huì)因?yàn)椴l(fā)訪問得到不一致的結(jié)果。并且,當(dāng)一個(gè)事務(wù)獲取到顯示鎖后,隨著事務(wù)的結(jié)束,此顯示鎖也會(huì)自動(dòng)釋放,因此應(yīng)用層面只需獲取鎖,而不必顯示地釋放鎖。
Golang(Go)是由谷歌公司與2009年開發(fā)的靜態(tài)編譯型編程語言。它兼容所有的主流操作系統(tǒng),語法簡單,只有25 個(gè)關(guān)鍵字,能直接編譯成可執(zhí)行文件。由于其在設(shè)計(jì)之初就考慮了高效的并發(fā)機(jī)制,不像很多其他的編程語言還需要開發(fā)者自己實(shí)現(xiàn)或者引入第三方的庫來支持并發(fā),Go語言在很多高并發(fā)、多線程的應(yīng)用場景都得到了廣泛的應(yīng)用,從一般的Web 開發(fā)到分布式系統(tǒng)、云計(jì)算、容器等。當(dāng)今開源軟件界炙手可熱的容器與容器編排軟件docker、kubernetes、knative 等都是由Go 語言開發(fā)的。而隨著容器技術(shù)目前已成為當(dāng)今各業(yè)界公司的標(biāo)配技術(shù),Go 語言也變得越來越流行,在編程語言的排行榜上也不斷上升。
一個(gè)合格的分布式鎖應(yīng)具有以下功能與性能要求:
(1)在分布式系統(tǒng)環(huán)境下,多個(gè)分布式進(jìn)程同時(shí)嘗試獲取鎖,最終只能有一個(gè)進(jìn)程成功地獲取鎖,成為鎖的持有進(jìn)程。
(2)分布式鎖必須具備鎖失效機(jī)制。即鎖的持有進(jìn)程必須定時(shí)的刷新自己的持有記錄,以防止鎖持有進(jìn)程崩潰帶來的死鎖后果。當(dāng)一個(gè)鎖持有進(jìn)程超過規(guī)定時(shí)間仍未刷新持有記錄,則其他的嘗試進(jìn)程將有一個(gè)進(jìn)程成功獲取鎖,成為新的持有進(jìn)程。當(dāng)舊的持有進(jìn)程從崩潰狀態(tài)恢復(fù)之后,其將加入到嘗試獲取鎖的進(jìn)程中,以待當(dāng)前持有進(jìn)程釋放鎖或者超時(shí)未刷新持有記錄,嘗試獲取的各個(gè)進(jìn)程將有一個(gè)成功獲取,以此循環(huán)往復(fù)。
(3)鎖的獲取過程是非阻塞的。即所有嘗試獲取鎖的進(jìn)程在調(diào)用獲取方法時(shí),將直接返回結(jié)果獲取到或者未獲取到,這些嘗試進(jìn)程不能被長時(shí)間阻塞在獲取過程。
(4)當(dāng)鎖持有者正常退出時(shí),必須保證其成功釋放鎖。
(5)獲取鎖和釋放鎖的過程要具有較高的性能,不能耗費(fèi)過多的資源和時(shí)間。
本文分布式鎖所用的僅一張數(shù)據(jù)庫表lock,lock 表的設(shè)計(jì)如下:
鎖的持有者名稱(owner),持有者持有鎖的時(shí)間戳(lock_timestamp),持有鎖的最長時(shí)間(ttl),其中持有者為表主鍵。
owner 需要唯一標(biāo)識(shí)嘗試獲取鎖的應(yīng)用實(shí)例,這里一般應(yīng)用名稱加UUID 的方式來組成持有者名稱。UUID(universal unique Identifier)即通用唯一標(biāo)識(shí)符被定義為一個(gè)128 位的二級(jí)制數(shù),分為五段,一般用十六進(jìn)制標(biāo)識(shí),段與段之間用減號(hào)進(jìn)行連接。UUID 是一個(gè)無規(guī)律的符號(hào),每次調(diào)用生成方法均能生成一個(gè)與之前完全不重復(fù)的值,由此,在分布式系統(tǒng)中,其很適合用來作為標(biāo)識(shí)。
分布式鎖系統(tǒng)需要一些配置項(xiàng)來定義數(shù)據(jù)庫連接,鎖獲取、持有和釋放的選項(xiàng)。
yaml 形式的配置項(xiàng)示例如下所示:
配置項(xiàng)中,TTL 為鎖的最大持有時(shí)間,RetryInterval 為嘗試獲取鎖的間隔時(shí)間。
數(shù)據(jù)庫連接配置中,URL 為數(shù)據(jù)庫連接地址,MaxOpen Connections 為數(shù)據(jù)庫最大連接接數(shù)量;MaxIdleConnections為數(shù)據(jù)庫最大閑置連接數(shù)量;ConnectionMaxLifetime 為每個(gè)數(shù)據(jù)庫連接最長使用時(shí)間;ConnectionMaxIdleTime 為每個(gè)數(shù)據(jù)庫連接最大空閑時(shí)間。
獲取鎖的詳細(xì)流程:
(1)開啟PostgreSQL 數(shù)據(jù)庫事務(wù)。
(2)獲取當(dāng)前鎖的狀態(tài):調(diào)用PostgreSQL 顯示鎖并使用Access Exclusive 模 式,LOCK TABLE lock IN ACCESS EXCLUSIVE MODE。查詢鎖狀態(tài),SELECT owner,lock_timestamp,ttl FROM lock LIMIT 1 FOR UPDATE NOWAIT。查詢的時(shí)候使用FOR UPDATE NOWAIT 鎖定查到的數(shù)據(jù)以防止其他進(jìn)程更改。使用NOWAIT 能確保當(dāng)有其他進(jìn)程鎖定數(shù)據(jù)集的時(shí)候此查詢不會(huì)阻塞等待鎖釋放,而是直接返回錯(cuò)誤標(biāo)識(shí)加鎖失敗。查詢鎖狀態(tài)需要注意的是如果lock 表中沒有數(shù)據(jù),PostgreSQL 的Go 庫會(huì)返回空結(jié)果錯(cuò)誤sql.ErrNoRows,所以此處應(yīng)判斷返回的錯(cuò)誤是否是sql.ErrNoRows。如果是此錯(cuò)誤,則查詢函數(shù)返回nil 值的鎖結(jié)果和nil 的錯(cuò)誤結(jié)果。如果是其他錯(cuò)誤,則返回nil 鎖結(jié)果和對(duì)應(yīng)的錯(cuò)誤。如果查詢成功,返回查到的鎖內(nèi)容和nil 錯(cuò)誤。關(guān)鍵代碼如下:
(3)檢查查詢結(jié)果:如果錯(cuò)誤和鎖返回值都是nil,說明當(dāng)前沒有進(jìn)程持有鎖,表示此次獲取的是全新的鎖。如果錯(cuò)誤返回值不為nil,則表示此次獲取過程失敗,退出此次獲取過程,等待下一次獲取周期的到來。
(4)正式獲取或刷新鎖:如果返回鎖的持有進(jìn)程owner和當(dāng)前進(jìn)程的owner 值不同,進(jìn)一步檢查返回的鎖是否已經(jīng)超時(shí),即檢查lock_timestamp 加上ttl 是否已經(jīng)超過現(xiàn)在時(shí)間,如果已經(jīng)超過,說明當(dāng)前持有進(jìn)程已經(jīng)超時(shí),此嘗試進(jìn)程將成為下一任鎖持有進(jìn)程,此過程將刪除當(dāng)前返貨鎖的記錄,然后確定嘗試進(jìn)程為下一任owner。這里檢查時(shí)間時(shí)需要注意一點(diǎn),就是PostgreSQL 數(shù)據(jù)庫服務(wù)器和Go 程序運(yùn)行的服務(wù)器很有可能不是同一臺(tái),因此不同的服務(wù)器的時(shí)間可能出現(xiàn)差異,所以所有的時(shí)間均PostgreSQL 的時(shí)間為準(zhǔn),獲取時(shí)間使用PostgreSQL 的SELECT NOW() AT TIME ZONE‘utc’來實(shí)現(xiàn)。然后構(gòu)建新鎖的內(nèi)容,owner 為當(dāng)前嘗試進(jìn)程owner,獲取時(shí)間戳為PostgreSQL 當(dāng)前時(shí)間,ttl 為配置的TTL。將新鎖插入到lock 表中,插入成功之后表示獲取鎖成功,結(jié)束此次事務(wù),同時(shí)Access Exclusive 顯示鎖自動(dòng)釋放。
如果返回鎖的持有進(jìn)程owner 和當(dāng)前進(jìn)程的owner 值相同,表示嘗試進(jìn)程本身就是鎖持有進(jìn)程,直接進(jìn)入鎖刷新流程,更新鎖的lock_timestamp 字段為當(dāng)前時(shí)間,刷新流程結(jié)束,結(jié)束此次事務(wù),同時(shí)Access Exclusive 顯示鎖自動(dòng)釋放。
步驟(3)、(4)關(guān)鍵代碼如下:
檢查鎖的狀態(tài):
獲取鎖代碼:
釋放鎖的流程:
刪除lock 表中owner 為當(dāng)前進(jìn)程的記錄。
應(yīng)用進(jìn)程引入鎖的方式:
嘗試獲取鎖的各個(gè)進(jìn)程無論是否成功獲取到鎖,都要每隔配置項(xiàng)中的間隔時(shí)間RetryInterval 重復(fù)獲取/刷新鎖流程,以確保鎖的有效性。并且每個(gè)進(jìn)程在正常退出時(shí),都要嘗試釋放鎖。
在獲取鎖的所有進(jìn)程中,獲取的過程須在其他所有功能模塊之前啟動(dòng),如果獲取鎖成功,則后續(xù)功能模塊依次啟動(dòng),此進(jìn)程開始正常工作。如果獲取失敗,此進(jìn)程將一直重復(fù)嘗試獲取鎖流程,后續(xù)模塊在獲取成功之前將不啟動(dòng)。這樣,就實(shí)現(xiàn)了云計(jì)算平臺(tái)上多個(gè)應(yīng)用實(shí)例都是running 狀態(tài),但是只有一個(gè)實(shí)例真正工作,即active 狀態(tài);并且當(dāng)工作實(shí)例出現(xiàn)問題,如崩潰,假死等問題之后,其將失去鎖,然后會(huì)有新的應(yīng)用實(shí)例得到鎖,然后啟動(dòng)后續(xù)模塊開始工作,令整個(gè)分布式系統(tǒng)始終有工作的實(shí)例,實(shí)現(xiàn)了分布式應(yīng)用的高可用性。
本文介紹了分布式鎖的概念與應(yīng)用場景,提出了分布式鎖的需求并基于Go 語言和PostgreSQL 數(shù)據(jù)庫設(shè)計(jì)并實(shí)現(xiàn)了分布式鎖。在當(dāng)前最流行的云計(jì)算容器編排平臺(tái)kubernetes中,因kubernetes 是基于Go 實(shí)現(xiàn)并提供了基于Go 的clientgo 開發(fā)庫,因此本分布式鎖的實(shí)現(xiàn)很適合運(yùn)行在kubernetes平臺(tái)上的Go 語言開發(fā)的應(yīng)用。開發(fā)完成后,筆者在kubernetes 平臺(tái)上開發(fā)應(yīng)用測試此鎖實(shí)現(xiàn),經(jīng)歷了kubernetes節(jié)點(diǎn)失效,某應(yīng)用實(shí)例內(nèi)存泄漏等多種錯(cuò)誤,此分布式鎖在上述情況下都能正常的工作,始終有應(yīng)用實(shí)例搶占到鎖并開始工作,有效的保證了應(yīng)用的高可用性。