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

01 背景

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

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

02 常見一致性問題

2.1 典型支付場景

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

2.2 在線下單場景

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

2.3 跨行轉賬場景

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

03 一致性解決方案

3.1 分佈式鎖

分佈式鎖的實現,比較常見的方案有 3 種:
1、基於數據庫實現分佈式鎖
2、基於緩存(Redis 或其他類型緩存)實現分佈式鎖
3、基於 Zookeeper 實現分佈式鎖
這 3 種方案,從實現的複雜度上來看,從 1 到 3 難度依次遞增。而且並不是每種解決方案都是完美的,它們都有各自的特性,還是需要根據實際的場景進行抉擇的。

UEZrkW

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

因爲緩存方案是採用頻率最高的,所以我們這邊對 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 節 的 典型支付場景 作爲例子分析(參考下圖):

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

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,從而誤認爲它沒有被其他線程修改過。
圖片

3.3.2 如何解決?

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

1. 使用版本號或時間戳

2. 不同語言的自帶方案

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

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. 引入額外的狀態信息

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

04 總結

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

  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. 監控與告警

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

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/VORqKu27qqbhzD_QPyDgeQ