併發——分散式鎖質量保障總結

一  背景

併發問題是電商系統最常見的問題之一,例如庫存超賣、抽獎多發、券多發放、積分多發少發等場景;之所以會出現上述問題,是因為存在多機器多請求同時對同一個共享資源進行修改,如果不加以限制,將導致資料錯亂和資料不一致性;解決併發問題的方式有很多,例如:佇列、非同步、響應式、鎖都可以;由於當前網際網路都是分散式系統,因此本文只針對使用較為廣泛的分散式鎖的方式來進行敘述如何進行質量保障。

二  分散式鎖介紹

1  什麼是分散式鎖

先了解一下什麼是鎖,在單機系統中,多個執行緒同時改變一個變數時,需要對變數或者程式碼塊做同步從而保證序列修改變數,該同步實質上就是透過鎖來實現。為了實現多個執行緒在同一個時刻針對同一塊程式碼序列執行,就需要在某個地方做個標記,該標記必須每個執行緒都能看到,當標記不存在時可以設定該標記,其餘後續執行緒發現已經有標記了則等待擁有標記的執行緒結束同步程式碼塊取消標記後再去嘗試設定標記,此標記可以理解為鎖。分散式鎖就是在多機系統下的該標記。

2  實現分散式鎖的主流方式

目前分散式鎖的實現方式有3種主流方法,即:
  • 基於資料庫實現分散式鎖,此處的資料庫指的是MySQL關係型資料庫
    • 基於MySQL鎖表
    • 資料庫版本號樂觀鎖
  • 基於快取實現分散式鎖,此處的快取指的是Redis
  • 基於zookeeper/etcd實現分散式鎖
具體的關於鎖的實現方式,已經有太多的文章進行介紹,本文就不再贅述。

三  質量保障

併發問題一旦涉及到錢,通常都會導致不同程度的資損,而且在我們的功能測試中是很難發現,因此對於併發的質量保障顯得尤為的重要,可以抽象為3層來保障:事前、事中、事後三大步驟;事前保障透過Review 方式提前規避技術上的風險,事中保障驗證在技術實現過程中是否存在漏洞,事後保障校驗資料是否符合預期,對於有併發風險的專案上述三個步驟的保障缺一不可。

1  事前質量保障

事前保障的階段發生在技術評審階段,在此階段,我們需要評估出當前業務場景下是否存在併發風險;如果存在,確定我們的技術選型。

評估併發風險

評估併發風險的關鍵點在於是否存在多個程序同時訪問共享資源,簡單來說是否存在多個程序在同一時間對同一個資料進行更新的操作;例如:電商中的庫存,多人同時購買同一個商品,也就是會存在同一時間對同一個商品的庫存進行更新,此處就存在併發風險。

技術選型

要做到正確的技術選型,我們就需要對上述3種方式實現的鎖的優缺點以及應用場景需要進行了解。
實現方式
優點
缺點
應用場景
MySQL資料庫表
易於理解/易於實現
容易出現單點故障、死鎖
效能低/可靠性低
適用於併發量低、效能要求低的場景
Redis分散式鎖
效能高/易於實現
可跨叢集部署,無單點故障
鎖失效時間的控制不穩定
穩定性低於ZooKeeper
適用於高併發、高效能場景
ZooKeeper分散式鎖
無單點故障/可靠性高
不可重入/無死鎖問題
實現複雜
效能低於快取分散式鎖
適用於大部分分散式場景,除對效能要求極高的場景
MySQL資料庫表的樂觀鎖適用於讀多寫少的場景且共享資源為資料庫的單行資料;MySQL表鎖實現的鎖一般都不推薦使用;ZooKeeper分散式鎖雖然適用於大部分分散式場景,但是由於其實現複雜度相對較高以及需要額外引入中介軟體,在大部分業務場景中的應用比較少,而基於Redis的快取分散式鎖應用較為廣泛;但是具體業務實現採用哪種型別的分散式鎖,還是需要基於當前的業務特性來進行決定;
在技術評審階段,一方面我們要評估出是否存在併發風險,另外一方面,我們需要識別開發同學在技術的實現上可能存在的漏洞,針對分散式鎖的實現漏洞可參考下文的CodeReview的關注點。

2  事中保障

CodeReview

1)Redis快取分散式鎖

Redis通常可以使用setnx(key,value)函式來實現分散式鎖。key和value就是基於快取的分散式鎖的兩個屬性,其中key表示鎖id。setnx函式返回1表示獲得鎖,返回0表示其他伺服器已經獲得了鎖;
  • Redis快取分散式鎖CodeReview注意點
  1. Redis Key
    • 全面梳理業務場景,對於同一共同資源,key要保持一致;
    • key是識別共享資源的唯一鍵,key的設計既需要能夠鎖住當前共享資源又不能影響到其他資源;
例如:商品庫存,我們的key應該是具體到某個商品,而不是所有商品,鎖住A商品,不會影響B商品。
  1. 鎖釋放
    • 鎖一定需明確釋放,try/finally 結構加鎖解鎖,finally內釋放鎖;
    • 鎖只能被加鎖的物件釋放,此處是經常出問題的點,如下圖所示,A加鎖被B釋放鎖,導致鎖失效,鎖被C搶佔到;
針對上述問題,釋放鎖時需要先讀取當前key的value,再和傳入的value進行比較;上述是兩個步驟一定要保證原子性,如果原生Redis可採用lua指令碼保證原子性;如果tair,可採取TairString的cad方法;value必須是一個唯一值,唯一標記是當前物件加的鎖。
  1. 鎖超時
    • 一定要設定key的超時時間;例如:客戶端A 搶到鎖後,系統突然異常,A就無法釋放鎖,變成死鎖;設定超時時間就是為了防止此種情況發生,在時間到期後,自動刪除key,間接釋放鎖;
    • 超時時間的設定一般來講大於服務的最大執行時間即可,但是服務最大的執行時間會受很多因素影響,是不可控的;例如:A服務一般執行時間是30ms,設定的鎖超時時間為100ms,受網路影響服務執行時間變成了200ms,在100ms的時候鎖就會被釋放了;在大部分場景下,開發不會處理此種情況,此種極端情況是否需要處理,需要進行協商;處理方式如下2種:
    • 可以再開啟一個執行緒,為當前超時時間續時,但增加了系統的複雜度;
    • 將過期時間設定非常長,一定能保證邏輯在鎖釋放之前能夠執行完成;此方案簡單但是有缺陷,當遇到系統突發異常時,鎖無法被釋放,只能等待redis key超時,而超時時間又設定的較長,因此在當前時間內誰都無法獲取到鎖,阻斷業務執行,很有可能造成故障;
  1. 鎖粒度
如果針對某個共享資源的寫是基於另外一個共享資源的值計算而來,那麼鎖的範圍必須包含讀共享資源;範圍不包含讀共享資源會導致髒讀,最終導致資料的錯誤,如下圖所示,Client B最終計算的B的結果就是錯誤的。
  1. 獲取鎖失敗
由於其他執行緒已經獲取到了鎖,當前執行緒獲取鎖失敗後有3種處理方式:異常丟擲讓使用者重試;透過自旋再次進行搶鎖;釋出訂閱,訂閱鎖釋放訊息;在併發度低的場景下異常丟擲以及自旋搶鎖都可以,在高併發場景下異常丟擲和自旋搶鎖都不可取。

2)MySQL資料庫鎖CR點

  • 資料庫版本號樂觀鎖
在資料庫的表中需要包含一個數字型別的欄位version,讀取資料時把version欄位讀出來,更新資料時判斷當前version是否等於讀取出來的version,並對當前version+1;如果等於就更新成功,不等於表示資料已過期更新失敗。例如以積分體系為例,存在多種場景增加積分,透過樂觀鎖來保證資料的正確性。
樂觀鎖CR注意點:
    • where 條件一定要命中索引(最好是主鍵或者唯一索引),否則會鎖表;
    • update table set 中必須要包含version = version + 1;
    • update 返回結果為0時,一定要根據業務場景進行相應的處理,自主重試或者拋異常;
  • 基於MySQL鎖表
其實現原理是:建立一張鎖表,對臨界資源做唯一性約束,透過增加一條記錄對某一資源上鎖,釋放鎖時刪除記錄;一般不推薦此種用法。

併發測試

併發測試總體上可以分為3大類
  • 複雜的併發場景,一次請求共享資源存在多個,且前後存在各種依賴關係,此種場景適合於鏈路級別壓測,壓測模型需要精心設計。
  • 單一併發場景,一個共享資源,可以處理多次,例如:扣除某個商品的庫存,可以反覆呼叫。
    • 可以透過介面壓測的方式進行測試,透過檢視最終資料是否會存在與預期不一致情況即可;
    • 壓測工具:jmeter 即可進行壓測(集團可直接採用pas-server進行壓測,方便快捷);
  • 單一併發場景,一個共享資源,且只能處理1次,例如:使用者只有一次抽獎機會,連續點2次會不會抽2次;
    • 可以利用JVM的併發函式CountDownLatch,CyclicBarrier等,CountDownLatch片段程式碼:
publicvoidinvokeAllTask(ConcurrencyRequest request, Runnable task) { final CountDownLatch startCountDownLatch = new CountDownLatch(1); final CountDownLatch endCountDownLatch = new CountDownLatch(request.getConcurrency());for (int i = 0; i < request.getConcurrency(); i++) { Thread t = new Thread(() -> {try { startCountDownLatch.await();try { task.run(); } finally { endCountDownLatch.countDown(); } } catch (Exception ex) { log.error("異常", ex); } }); t.start(); } startCountDownLatch.countDown();try { endCountDownLatch.await(); } catch (InterruptedException ex) { log.error("執行緒異常中斷", ex); } }
    • 利用jmeter的定時器 Synchronizing Timer也可以實現此功能

3  事後保障

資料對賬

資料對賬(資料一致性校驗)是我們在系統上線後對併發問題的最後一道防線,透過對賬來識別我們的資料的不一致性問題;壓測有成本,且受技巧熟練度和壓測設計的影響,不一定能暴露問題;如果被測場景評估併發問題的發生機率極低,即使發生了影響也比較小,此時review+對賬方式也不失為一種好的選擇;
如何進行對賬,不同的業務場景有不同的對賬方法,例如:
  • 互動積分體系每個使用者的扣除以及增加積分都會落流水錶;每個使用者目前有多少積分都會放在積分表;只需要把流水錶的積分加總和積分表的積分進行對賬;
  • 互動任務體系,一筆訂單隻能推進一個任務,對賬只需要檢查任務記錄中一筆訂單是否存在多條記錄;
selectcount(*) as task_count, scene_code, order_idfrom task_recordwhere unique_id isnotnullgroupby scene_code, order_idhavingcount(*)> 1

四  總結

作為質量保障同學一定要時刻繃著一根弦,當前場景下是否會存在併發問題;併發問題的識別簡單而言就是是否存在同時更新同一個資料,如果是就一定要注意開發同學是否處理了併發,併發的實現主要是上面闡述的幾種,然後按照場景進行分析即可;關於併發場景的質量保障,大體原則可以概括為如下:
  1. 梳理併發場景
  2. 帶著注意點CR 程式碼
  3. 併發測試(非銀彈,不是所有場景都具備可測性)
  4. 監控對賬進行兜底識別併發問題

招聘

淘菜菜技術部-質量技術團隊歡迎23屆應屆實習生的加入, 濃厚的技術氛圍、蓬勃發展的業務、有趣的靈魂正在等待您的到來;請投遞簡歷至 [email protected], 郵件標題為“姓名-測試開發-來自阿里技術”。

CentOS 8 的使用者請注意,請儘快切換映象源!
由於CentOS 8結束了生命週期,官方已經停止CentOS 8的維護,而阿里雲映象站的CentOS Linux公共映象來源於CentOS官方,所以阿里雲映象站也移除了相應的CentOS 8映象源。如果您是阿里雲CentOS 8映象源的使用者,並且您的業務過渡期仍需要使用CentOS 8中的一些安裝包,為了不影響您的使用,建議您切換至CentOS-Vault源, 具體配置請點選閱讀原文。

相關文章