高併發下的資料一致性保障(圖文全面總結)

Hello-Brand發表於2024-04-02

1 背景

我們之前介紹過分散式事務的解決方案,參考作者這篇《五種分散式事務解決方案(圖文總結) 》。
在那篇文章中我們介紹了分散式場景下困擾我們的3個核心需求(CAP):一致性、可用性、分割槽容錯性,以及在實際場景中的業務折衷。
1、一致性(Consistency): 再分佈,所有例項節點同一時間看到是相同的資料
2、可用性(Availability): 不管是否成功,確保每一個請求都能接收到響應
3、分割槽容錯性(Partition Tolerance): 系統任意分割槽後,在網路故障時,仍能操作
image

而本文我們聚焦高併發下如何保障 Data Consistency(資料一致性)。

2 分散式常見一致性問題

2.1 典型支付場景

這是最經典的場景。支付過程,要先查詢買家的賬戶餘額,然後計算商品價格,最後對買家進行進行扣款,像這類的分散式操作,
如果是併發量低的情況下完全沒有問題的,但如果是併發扣款,那可能就有一致性問題。。在高併發的分散式業務場景中,類似這種 “查詢+修改” 的操作很可能導致資料的不一致性。
image

2.2 線上下單場景

同理,買家在電商平臺下單,往往會涉及到兩個動作,一個是扣庫存,第二個是更新訂單狀態,庫存和訂單一般屬於不同的資料庫,需要使用分散式事務保證資料一致性。
image

2.3 跨行轉賬場景

跨行轉賬問題也是一個典型的分散式事務,使用者A同學向B同學的賬戶轉賬500,要先進行A同學的賬戶-500,然後B同學的賬戶+500,既然是 不同的銀行,涉及不同的業務平臺,為了保證這兩個操作步驟的一致,資料一致性方案必然要被引入。
image

3 一致性解決方案

3.1 分散式鎖

分散式鎖的實現,比較常見的方案有3種:
1、基於資料庫實現分散式鎖
2、基於快取(Redis或其他型別快取)實現分散式鎖
3、基於Zookeeper實現分散式鎖

這3種方案,從實現的複雜度上來看,從1到3難度依次遞增。而且並不是每種解決方案都是完美的,它們都有各自的特性,還是需要根據實際的場景進行抉擇的。

能力元件 實現複雜度 效能 可靠性
資料庫
快取
zookeeper

詳細可以參考我的這篇文章《分散式鎖方案分析

因為快取方案是採用頻率最高的,所以我們這邊對Redis分散式鎖進行詳細介紹:

3.1.1 基於快取實現分散式鎖

相比較於基於資料庫實現分散式鎖的方案來說,基於快取來實現在效能方面會表現的更好一點。類似Redis可以多叢集部署的,解決單點問題。
基於Redis實現的鎖機制,主要是依賴redis自身的原子操作,例如:


# 判斷是否存在,不存在設值,並提供自動過期時間
SET key value NX PX millisecond

# 刪除某個key
DEL key [key …]

NX:只在在鍵不存在時,才對鍵進行設定操作,SET key value NX 效果等同於 SETNX key value
PX millisecond:設定鍵的過期時間為millisecond毫秒,當超過這個時間後,設定的鍵會自動失效

如果需要把上面的支付業務實現,則需要改寫如下:


# 設定賬戶Id為17124的賬號的值為1,如果不存在的情況下,並設定過期時間為500ms
SET pay_id_17124 1 NX PX 500

# 進行刪除
DEL pay_id_17124

上述程式碼示例是指,當redis中不存在pay_key這個鍵的時候,才會去設定一個pay_key鍵,鍵的值為 1,且這個鍵的存活時間為500ms。
當某個程序設定成功之後,就可以去執行業務邏輯了,等業務邏輯執行完畢之後,再去進行解鎖。而解鎖之前或者自動過期之前,其他程序是進不來的。

實現鎖機制的原理是:這個命令是隻有在某個key不存在的時候,才會執行成功。那麼當多個程序同時併發的去設定同一個key的時候,就永遠只會有一個程序成功。解鎖很簡單,只需要刪除這個key就可以了。

另外,針對redis叢集模式的分散式鎖,可以採用redis的Redlock機制。

3.1.2 快取實現分散式鎖的優缺點

優點:Redis相比於MySQL和Zookeeper效能好,實現起來較為方便。
缺點:透過超時時間來控制鎖的失效時間並不是十分的靠譜;這種阻塞的方式實際是一種悲觀鎖方案,引入額外的 依賴(Redis/Zookeeper/MySQL 等),降低了系統吞吐能力。

3.2 樂觀模式

對於機率性的不一致的處理,需要樂觀鎖方案,讓你的系統更具健壯性。
分散式CAS(Compare-and-Swap)模式就是一種無鎖化思想的應用,它透過無鎖演算法實現執行緒間對共享資源的無衝突訪問。
CAS模式包含三個基本運算元:記憶體地址V、舊的預期值A和要修改的新值B。在更新一個變數的時候,只有當變數的預期值A和記憶體地址V當中的實際值相同時,才會將記憶體地址V對應的值修改為B。

我們以 2.1節典型支付場景 作為例子分析(參考下圖):

  • 初始餘額為 800
  • 業務1和業務2同時查詢餘額為800
  • 業務1執行購買操作,扣減去100,結果是700,這是新的餘額。理論上只有在原餘額為800時,扣減的Action才能執行成功。
  • 業務2執行生活繳費操作(比如自動交電費),原餘額800,扣減去200,結果是600,這是新的餘額。理論上只有在原餘額為800時,扣減的Action才能執行成功。可實際上,這個時候資料庫中的金額已經變為600了,所以業務2的併發扣減不應該成功。

根據上面的CAS原理,在Swap更新餘額的時候,加上Compare條件,跟初始讀取的餘額比較,只有初始餘額不變時,才允許Swap成功,這是一種常見的降低讀寫鎖衝突,保證資料一致性的方法。
image

go 程式碼示例(使用Baidu Comate AI 生成,已除錯):

package main  
  
import (  
	"fmt"  
	"sync/atomic"  
)  
  
// Compare 函式比較當前值與預期值是否相等  
func Compare(addr *uint32, expect uint32) bool {  
	return atomic.LoadUint32(addr) == expect  
}  
  
func main() {  
	var value uint32 = 0 // 共享變數  
  
	// 假設我們期望的初始值是0  
	oldValue := uint32(0)  
  
	// 使用Compare函式比較當前值與期望值  
	if Compare(&value, oldValue) {  
		fmt.Println("Value matches the expected old value.")  
		// 在這裡,你可以執行實際的交換操作,但請注意,  
		// 在併發環境中,你應該使用atomic.CompareAndSwapUint32來確保原子性。  
		// 例如:  
		// newValue := uint32(1)  
		// if atomic.CompareAndSwapUint32(&value, oldValue, newValue) {  
		//     fmt.Println("CAS succeeded, value is now", newValue)  
		// } else {  
		//     fmt.Println("CAS failed, value was changed by another goroutine")  
		// }  
	} else {  
		fmt.Println("Value does not match the expected old value.")  
	}  
  
	// 修改value的值以演示Compare函式的行為變化  
	atomic.AddUint32(&value, 1)  
  
	// 再次比較,此時應該不匹配  
	if Compare(&value, oldValue) {  
		fmt.Println("Value still matches the expected old value, but this shouldn't happen.")  
	} else {  
		fmt.Println("Value no longer matches the expected old value.")  
	}  
}

3.3 解決CAS模式下的ABA問題

3.3.1 什麼是ABA問題?

在CAS(Compare-and-Swap)操作中,ABA問題是一個常見的挑戰。ABA問題是指一個值原來是A,被另一個執行緒改為B,然後又被改回A,當前執行緒使用CAS Compare檢查時發現值仍然是A,從而誤認為它沒有被其他執行緒修改過。
image

3.3.2 如何解決?

為了避免ABA問題,可以採取以下策略:

1. 使用版本號或時間戳

  • 每當共享變數的值發生變化時,都遞增一個與之關聯的版本號或時間戳。
  • CAS操作在比較變數值時,同時也要比較版本號或時間戳。
  • 只有當變數值和版本號或時間戳都匹配時,CAS操作才會成功。

2. 不同語言的自帶方案

  • Java中的java.util.concurrent.atomic包提供瞭解決ABA問題的工具類。
  • 在Go語言中,通常使用sync/atomic包提供的原子操作來處理併發問題,並引入版本號或時間戳的概念。

那麼上面的程式碼就可以修改成:

type ValueWithVersion struct {  
	Value     int32  
	Version   int32  
}  
  
var sharedValue atomic.Value // 使用atomic.Value來儲存ValueWithVersion的指標  
  
func updateValue(newValue, newVersion int32) bool {  
	current := sharedValue.Load().(*ValueWithVersion)  
	if current.Value == newValue && current.Version == newVersion {  
		// CAS操作:只有當前值和版本號都匹配時,才更新值  
		newValueWithVersion := &ValueWithVersion{Value: newValue, Version: newVersion + 1}  
		sharedValue.Store(newValueWithVersion)  
		return true  
	}  
	return false  
}  

3. 引入額外的狀態資訊

  • 除了共享變數的值本身,還可以引入額外的狀態資訊,如是否已被修改過。
  • 執行緒在進行CAS操作前,會檢查這個狀態資訊,以判斷變數是否已被其他執行緒修改過。

需要注意的是,避免ABA問題通常會增加併發控制的複雜性,並可能帶來效能開銷。因此,在設計併發系統時,需要仔細權衡ABA問題的潛在影響與避免它所需的成本。在大多數情況下,如果ABA問題不會導致嚴重的資料不一致或邏輯錯誤,那麼可能不需要專門解決它。

4 總結

在高併發環境下保證資料一致性是一個複雜而關鍵的問題,涉及到多個層面和策略。
除了上面提到的方案外,還有一些常見的方法和原則,用於確保在高併發環境中保持資料一致性:

  1. 事務(Transactions)

    • 使用資料庫事務來確保資料操作的原子性、一致性、隔離性和永續性(ACID屬性)。
    • 透過鎖機制(如行鎖、表鎖)來避免併發操作導致的衝突。
  2. 分散式鎖

    • 當多個服務或節點需要同時訪問共享資源時,使用分散式鎖來協調這些訪問。
    • 例如,使用Redis的setnx命令或ZooKeeper的分散式鎖機制。
  3. 樂觀鎖與悲觀鎖

    • 樂觀鎖假設衝突不太可能發生,通常在資料更新時檢查版本號或時間戳。
    • 悲觀鎖則假設衝突很可能發生,因此在資料訪問時立即加鎖。
  4. 資料一致性協議

    • 使用如Raft、Paxos等分散式一致性演算法,確保多個副本之間的資料同步。
  5. 訊息佇列

    • 透過訊息佇列實現資料的非同步處理,確保資料按照正確的順序被處理。
    • 使用訊息佇列的持久化、重試和順序保證特性。
  6. CAP定理與BASE理論

    • 理解CAP定理(一致性、可用性、分割槽容忍性)的權衡,並根據業務需求選擇合適的策略。
    • BASE理論(Basically Available, Soft state, Eventually consistent)提供了一種弱化一致性要求的解決方案。
  7. 快取一致性

    • 使用快取失效策略(如LRU、LFU)和快取同步機制(如快取穿透、快取擊穿、快取雪崩的應對策略),確保快取與資料庫之間的一致性。
  8. 讀寫分離讀寫

    • 使用主從複製、讀寫分離讀寫等技術,將讀操作和寫操作分散到不同的資料庫例項上,提高併發處理能力。
  9. 資料校驗與重試

    • 在資料傳輸和處理過程中加入校驗機制,確保資料的完整性和準確性。
    • 對於可能失敗的操作,實施重試機制,確保資料最終的一致性。
  10. 監控與告警

    • 實時監控資料一致性相關的關鍵指標,如延遲、錯誤率等。
    • 設定告警閾值,及時發現並處理可能導致資料不一致的問題。

在實際應用中,通常需要結合具體的業務場景和技術棧來選擇合適的策略。

相關文章