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