王 磊,孟利民
(浙江工業大學 信息工程學院,杭州 310023)
突如其來的新冠肺炎疫情一度讓人們的線下生活按下“暫停鍵”,卻也讓數字生活[1]按下了“快捷鍵”。直播帶貨、外賣點餐、在線教育等行業的興起,正在影響和改變人們的消費習慣和生活方式,螞蟻集團CEO胡曉明在支付寶合作伙伴大會上表示,數字生活新服務將是下一個十年最大的互聯網紅利。數字生活依托于互聯網和一系列數字科技技術應用,為了能夠應對高并發場景并支撐多樣化的服務,應用軟件需要基于分布式思想[2]進行架構。基于分布式思想架構的系統(又稱分布式系統)具有可靠性、高容錯性和大吞吐量等特點,但也帶來了新的問題。比如重復冗余操作會導致相同的請求分配到不同的服務實例,數據的插入與更新出現錯亂,無法保證數據的一致性和準確性。特別是涉及金融支付等系統的開發時,如果不采取措施將會造成嚴重的后果,因而需要對分布式系統做冪等設計。
隨著計算機技術的發展,冪等性設計方法也在不斷演進。文獻[3]探討了基于唯一索引、悲觀鎖等方式保障系統的冪等性,采用數據庫鎖控制并發訪問。文獻[4]設計基于虛擬服務器的分布式PC共享平臺時,采用了全局唯一ID機制,根據操作的內容生成一個全局ID,通過客戶端與服務器端交互以控制頁面的重復提交。文獻[5]采用Redis分布式鎖設計系統的冪等性,分布的服務實例通過競爭鎖獲取程序執行權限,利用緩存的高性能讀寫特性降低服務端損耗。
分布式系統在進行冪等設計時,應充分考量業務需求本身的高并發特性,以及冪等設計方法在高并發場景下的性能表現,同時需要關注服務端性能損耗問題。通過對比研究,提出一種改進的分布式鎖冪等設計方法。實驗結果表明,該方法在高并發場景下能夠保持數據的一致性和準確性,并具備良好的性能表現。
隨著數字生活的深入發展,計算機系統越發面臨高并發和業務多樣化的考驗。高并發是指系統運行過程中,遇到的一種“短時間內遇到大量操作請求”的情況。高并發時,系統需要處理所有請求,執行大量的業務邏輯處理,頻繁地請求資源和操作數據庫,服務器開銷驟增。傳統的單體架構系統由于業務模塊耦合、單節點部署的特點,遭遇高并發場景時,單節點服務器性能下降,當請求數量超出服務器承載能力時會導致應用崩潰,服務宕機。
為了解決以上問題,分布式思想應運而生。它的發展經歷了分布式架構、面向服務架構(SOA,service oriented architecture)、微服務架構等階段。分布式架構將各業務模塊拆分成子系統,并發訪問量大的子系統可以進行多服務實例集群部署,請求經過Nginx反向代理分發,最終到達具體的服務實例完成業務處理,分布式架構原理框圖如圖1所示。隨后出現的面向服務架構SOA,在分布式架構的基礎上集成了ESB企業服務總線[6],實現路由轉發、服務管理監控、統一安全管理等功能。微服務架構不再強調量級比較重的ESB企業服務總線,將業務系統徹底的組件化和服務化[7],服務粒度比SOA架構更小,保證了服務的高可用、低耦合特性。

圖1 分布式架構原理框圖
分布式系統為了應對高并發場景,保障數據的一致性和準確性,需要做冪等性設計。冪等概念來自數學,表示N次變換和1次變換的結果是相同的。移植到軟件開發中主要指代,在HTTP協議中,除去請求超時、系統出現異常的情況下,系統的某些操作,對其調用一次和調用多次所產生的的效果是一樣的。特別是在分布式系統中,業務繁忙的子系統需要多服務實例集群部署,請求的轉發與控制情況比較復雜,冪等性設計顯得尤為重要。
分布式系統中,需要進行冪等設計的場景眾多,比較常見的有:
1)用戶重復點擊提交頁面,請求被分配到多個服務實例進行處理,如果業務處理涉及數據的插入或更新,重復的操作將導致臟數據的產生。
2)分布式系統經常會設計重試機制[8]來提高請求的成功率,當請求被分配的服務實例出現異常或延時,系統觸發重試,該請求被分配到其它服務實例,如果涉及數據插入或更新操作,可能導致臟數據的產生。
3)商品秒殺、搶票等業務場景中,涉及多個用戶修改同一條數據記錄。如果不做冪等設計,庫存等需要計數的敏感字段更新將會產生錯亂,影響整體業務的執行。
分布式系統冪等設計,從客戶端角度出發,可以通過設置請求間隔時間防止頁面的重復提交,但是間隔時間內無法保證服務端業務邏輯執行完畢,并且該方法不適用從服務端發起請求的場景,如系統接口對接。從服務端角度出發,可以通過區分業務操作類型從數據庫端實現冪等設計,查詢和刪除是天然的冪等操作,而數據插入和更新一般是非冪等操作,執行一次和多次的效果往往不同,需要使用唯一索引、悲觀鎖等方法保障系統冪等性;除此之外可以通過借助第三方服務來維護分布式鎖,無需區分業務操作的類型,分布的服務實例通過競爭分布式鎖獲得程序執行權限,進而保障業務執行的唯一性,常用的第三方服務依賴主要有Zookeeper[9]和Redis。
基于服務端的分布式系統冪等設計經歷了從數據庫端到分布式鎖發展的過程,由數據庫端加鎖控制事務的并發訪問,到客戶端與服務端交互的token機制,再到依賴第三方服務的分布式鎖,冪等設計方法隨著計算技術發展在不斷演進。類比系統安全性設計,加入冪等設計后,系統需要犧牲部分性能來實現冪等,因而系統的性能損耗以及高并發場景下的性能表現,成為衡量分布式系統冪等設計方法的標準。
數據庫索引[10]是數據庫管理系統中基于排序的數據結構,以協助快速查詢、更新數據庫表中數據,作用類似書本的目錄。唯一索引要求所在列的值必須唯一,如果是組合索引,則要求列值的組合必須唯一。唯一索引不僅能夠加快數據的查詢速度,而且保證了表中每一行數據的唯一性。
通過對數據庫表設置唯一索引的方式,能夠保障數據插入相關業務數據的準確性,因而常作為系統冪等設計的手段。唯一索引在高并發場景下,重復的數據插入將會被準確攔截,隨著數據庫表數據量的增長,系統響應時間會相應增加。當表中的數據增長到一定程度時,頻繁的寫入將導致磁盤I/O負載增加,需要做分表分庫的操作,比較考驗數據庫性能開銷。
需要注意的是,單一使用唯一索引時,如果涉及用戶和系統交互,重復插入的數據被捕捉并以拋錯的形式提示用戶,可能導致用戶更頻繁的重試,高并發場景下會給系統帶來比較大的壓力。
數據庫事務[11]是一個訪問并可能操作各種數據項的數據庫操作序列,悲觀鎖假定當前事務操作數據資源時,還會有其他事務同時操作該資源,為了避免當前事務被干擾,先將資源進行鎖定。換而言之,當多個事務并發執行時,某個事務對數據加鎖,其他事務只能等待該事務執行完畢,才能對當前數據進行修改。SELECT FOR UPDATE是一個典型的悲觀鎖調用語句,常用于多個事務操作同一條記錄,保證計數、余額扣減等字段值的準確性。如果程序執行出現異常,當前事務需要回滾,當前記錄解除鎖定,數據恢復至事務操作前的狀態。
通過使用悲觀鎖,能夠保障數據更新相關業務數據的準確性和一致性,滿足了一定的冪等設計要求。但是悲觀鎖具有強烈的獨占和排他特性,高度依賴數據庫提供的鎖機制,某個事務處理占用鎖時,其它事務處于阻塞狀態,因而加鎖和釋放鎖的過程比較消耗資源,只適用于并發不高的場景。高并發場景下事務搶占資源容易造成死鎖,進而導致應用系統崩潰,因而分布式系統冪等設計時,應慎重選擇。
在分布式系統環境下,需要保證一個方法在同一時刻只能被一個服務實例的單個線程執行,來實現系統冪等。現有的做法是維護一把分布式鎖[12],存儲在所有服務實例都能訪問的地方,服務實例間通過高可用、高性能地獲取鎖和釋放鎖,完成并發訪問控制,具體實現過程如圖2所示。分布式鎖的實現需要依賴第三方服務,常用的有Zookeeper和Redis等,這里主要分析基于緩存Redis實現分布式鎖。

圖2 分布式鎖實現過程
Redis分布式鎖主要利用了Redis緩存高性能讀寫的特性[13],服務實例利用setnx key value命令進行加鎖操作,如果Redis服務中該key值不存在,則設置value申請加鎖成功,如果已存在該key值,表示已有服務實例持有該鎖,從而加鎖失敗。當持有鎖的服務實例方法執行完畢后,通過del key命令刪除鍵值釋放鎖,其它服務實例可以重新競爭加鎖,獲取程序執行權限。為了保證操作的原子性,加鎖和解鎖需要使用lua腳本[14]執行。使用Redis分布式鎖時,應設置合理的過期時間避免死鎖問題,同時要保證分布式鎖可重復可遞歸調用。
Redis分布式鎖相較于數據庫層面的冪等設計有一定的優越性,其性能表現依賴于Redis服務的性能。高并發場景下,Redis可以單機部署或者集群部署[15],單機部署時,對服務器硬件配置要求較高,而且一旦單機服務宕機,雖然不進行數據處理但系統訪問將報錯,因而探討Redis集群部署是必要的。Redis集群是由一系列的主從節點(master-slave)群組成的分布式服務器群,具有復制、高可用和分片特性,服務實例訪問Redis集群如圖3所示。當主從節點中的主節點master宕機時,可以實現故障自動切換,把從節點slave升為主節點master,解決了單機部署服務宕機問題。但是如果主節點master加鎖成功,此時master出現異常宕機,由于主從節點切換是異步過程,加鎖指令并未同步到從節點slave上,從節點slave被升為master,該鎖在新的主節點master上丟失了,進而出現短暫的鎖失效問題,從而導致數據的插入或更新出現錯亂,系統冪等無法被保障。

圖3 服務實例訪問Redis集群
為了解決上述問題,Redis作者提出了RedLock算法[16]方案,該方案實現需要部署N個獨立的Redis實例,實例間沒有主從關系,官方推薦實例數量N≥5,方案模型如下:
1)服務實例先獲取當前時間戳T1,并依次向N個Redis實例發起加鎖請求,對每個加鎖請求設置超時時間,如果某個實例由于鎖被其它服務實例持有等原因導致加鎖失敗,就立即向下一個Redis實例申請加鎖;
2)循環申請加鎖完畢后,對(N+1)/2進行向上取整運算得到結果S,如果服務實例在大于等于S個Redis實例上加鎖成功,再次獲取當前時間戳T2,若T2-T1小于鎖的過期時間,則認為該服務實例加鎖成功,否則就認為加鎖失敗;
3)服務實例加鎖成功后,執行業務邏輯處理,加鎖失敗,則向全部Redis實例發起釋放鎖請求。
RedLock算法方案目前存在爭論,質疑者認為RedLock通過循環Redis實例申請加鎖,開銷大效率低;同時Redis節點會因為機器時鐘修改或跳躍導致鎖到期,造成分布式服務實例間持有鎖沖突,最終的結果是數據嚴重錯誤、永久性不一致或丟失,因而認為RedLock無法解決Redis集群主從節點切換導致的鎖時效問題。
針對RedLock存在的爭論問題,提出一種改進的分布式鎖設計方法,具體的設計過程是:部署Redis集群環境,通過分布式鎖的方式對高并發請求實施第一道攔截;針對極端情況下Redis集群可能出現的主從節點切換導致分布式鎖失效問題,通過判斷業務操作類型施加唯一索引或者數據鎖,實現第二道攔截;最后將失效的分布式鎖通過消息隊列異步發送通知消息,實現Redis集群服務的監測和治理。
上述改進的分布式鎖設計方法,實施的第一道攔截采用Redisson分布式鎖。Redisson[17]是Java技術棧封裝的用于操作Redis的工具,基于Netty框架進行事件驅動。相較于Jedis、Lettuce等客戶端工具,Redisson實現了分布式和可擴展的數據結構,促使使用者對Redis的關注分離,提供了很多分布式相關操作服務,如分布式鎖、分布式集合等。Redisson分布式鎖的工作過程是:分布的服務實例通過lock或tryLock方法進行加鎖操作,底層通過exists指令判斷鎖標識是否存在,若鎖標識不存在,則使用hset指令進行加鎖,再通過pexpire指令設置鎖過期時間;若鎖標識存在,則根據業務需求選擇不停嘗試加鎖或者停止申請加鎖。業務邏輯執行完畢后,使用unlock方法時釋放鎖,底層通過del指令刪除鎖標識。
Redisson加鎖和釋放鎖操作基于lua腳本實現,以確保底層exists、hset、pexpire一系列指令不受服務實例宕機的影響,能夠執行完畢,保證操作的原子性。另外Redisson還提供了watch dog自動延期機制,后臺線程每隔10 s檢查一次,若服務實例仍持有鎖標識,將不斷延長鎖的過期時間,防止業務邏輯未執行完畢自動釋放鎖的情況,保障系統的冪等性。
改進的分布式鎖設計方法,針對Redis集群可能出現的主節點master宕機問題,在數據庫層面進行第二道攔截。根據業務數據操作類型進行判斷,如果是數據插入操作,則施加唯一索引限制數據重復插入的問題;如果是數據更新操作,則施加數據庫鎖,保證數據更新的正確性。由于悲觀鎖采用的是阻塞模式,不適用于高并發場景下數據更新操作,方法選用一種樂觀鎖[18]的方式進行實現。
樂觀鎖是相對于悲觀鎖而言的,它假設數據一般情況下不會產生沖突,只有在事務提交時才會對數據沖突與否進行檢測。樂觀鎖沿用了CAS的思想,通過數據庫表增加“版本號”Version字段,檢測事務沖突,其工作過程如圖4所示:事務1讀取并記錄時版本號為1,執行更新時Version自動加1并更新為版本號2;事務2順序執行,將讀取的版本號2更新為版本號3,此時兩個事務提交不產生沖突。如果事務1和事務2同時讀取的記錄版本號為1,事務1執行更新時Version自動加1并更新為版本號2,事務2同樣準備將版本號更新為2,但此時已查詢不到版本號為1的當前記錄,發生沖突。樂觀鎖的優勢在于不對數據進行行鎖和表鎖處理,減小了數據庫的壓力開銷,對改進的分布鎖設計高并發場景的性能表現是一個提升。

圖4 版本號實現樂觀鎖過程
改進的分布式鎖設計通過消息隊列的方式將數據庫攔截的失效鎖,以消息的形式通知給開發維護人員,方便進行鎖失效問題的排查,如果是Redis集群服務主節點宕機的原因,可以快速地重啟服務節點。方法選用RabbitMq消息服務[19]實現消息的生產和消費,通知消息以異步的形式進行處理,防止出現同步阻塞影響主要業務邏輯的處理。
改進的分布式鎖設計整體工作過程如圖5所示。

圖5 改進的分布式鎖工作過程
1)高并發請求經過Nginx負載均衡服務被分配到具體的服務實例執行。
2)服務實例收到轉發的請求,程序接口根據請求內容中的字段或組合字段生成鎖標識,Redisson通過lock或tryLock方法調用Redis集群服務進行加鎖操作。根據返回結果判斷服務實例是否競爭到鎖,如果加鎖成功將進入業務邏輯處理環節,加鎖失敗可以結束當前線程或者等待其它服務實例釋放鎖后重新競爭加鎖。
3)業務邏輯處理階段,根據數據操作類型進行判斷,若為數據插入操作則執行唯一索引邏輯,若為數據更新操作則執行樂觀鎖邏輯,完成對失效鎖的攔截,攔截成功后結束當前線程,對當前請求返回錯誤提示。
4)對于數據庫攔截的失效鎖,通過RabbitMq消息生產者將相關信息放入消息隊列,等待RabbitMq消息消費者進行異步處理。
5)對于持有Redisson分布式鎖的服務實例,程序執行完畢后,通過調用unlock方法將。
Redis集群服務中的鎖標識刪除,保證后續服務實例能夠繼續競爭使用該鎖標識。
高并發場景下,網絡請求首先經過Redis集群進行加鎖,基于緩存高性能讀寫特性完成操作,保障了服務端性能損耗主要在訪問Redis集群服務上,只有極端情況下主從切換出現短暫的鎖失效問題時,才會觸發數據庫層面的攔截,避免單一使用數據庫冪等設計導致重復試錯帶來的數據庫死鎖等風險。同時本設計還包含了Redis集群服務的監測和治理,方便開發人員能夠快速的了解和掌握Redis服務的健康狀況,有助于解決節點宕機問題和系統優化。
為了驗證分布式系統冪等設計方法在高并發場景下的性能表現和性能損耗問題,通過實驗模擬和還原高并發場景進行測試。實驗需要部署Redis集群服務、RabbitMq服務、多個服務實例、JMeter測試工具[20]以及千萬級別數據量的數據庫表。測試方法為:通過JMeter設置1 000個并發線程數,分別測試單獨使用悲觀鎖、樂觀鎖、唯一索引、Redis分布式鎖4種冪等設計方法的性能表現,然后測試改進的Redisson分布式鎖在Redis集群主動停掉一個主節點的情況下的性能問題。悲觀鎖、樂觀鎖設置為秒殺50個商品庫存的場景,Redis分布式鎖和改進的Redisson分布式鎖在本實驗中只針對數據插入的場景,并且測試插入的數據每隔一條設置重復數據模擬高并發請求。
實驗結果如表1所示,其中成功次數和攔截次數反映了冪等設計方法保障數據一致性和準確性的能力,平均響應時間和吞吐量反映了高并發場景下系統性能開銷和損耗。通過實驗結果對比發現:樂觀鎖相較于悲觀鎖響應時間短,系統吞吐量也有提升,有良好的攔截事務沖突能力;Redis分布式鎖相比于數據庫層面的冪等設計有更好的性能表現;通過對比Redis分布式鎖和改進的Redisson分布式鎖發現,即使在主動宕機一個Redis集群主節點時,改進的Redisson分布式鎖仍能保證數據攔截的準確性,并且其平均響應時間和吞吐量指標和Redis分布式鎖相當,同時RabbitMq消費者收到一條失效的鎖信息,表明數據庫層面的二次攔截生效。

表1 冪等設計各方法性能參數
隨著分布式架構思想的廣泛應用,如何保證系統數據的一致性和準確性愈發受到關注。通過分析服務端冪等設計方法的原理、應用場景以及性能表現,提出一種改進的Redisson分布式鎖設計方法,來保證分布式系統數據的一致性和準確性。該方法對Redis分布式鎖進行了升級,針對RedLock存在爭論的基礎之上,采用二次攔截的方式,解決Redis集群主從節點切換造成的鎖失效問題。并且通過消息隊列服務實現通知,方便Redis集群服務的監測和治理。最后通過實驗驗證了改進的Redisson分布式鎖設計的可行性。