高併發下的數據一致性保障(圖文全面總結)
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 難度依次遞增。而且並不是每種解決方案都是完美的,它們都有各自的特性,還是需要根據實際的場景進行抉擇的。
詳細可以參考我的這篇文章《分佈式鎖方案分析》
因爲緩存方案是採用頻率最高的,所以我們這邊對 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 成功,這是一種常見的降低讀寫鎖衝突,保證數據一致性的方法。
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. 使用版本號或時間戳:
-
每當共享變量的值發生變化時,都遞增一個與之關聯的版本號或時間戳。
-
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 問題不會導致嚴重的數據不一致或邏輯錯誤,那麼可能不需要專門解決它。
04 總結
在高併發環境下保證數據一致性是一個複雜而關鍵的問題,涉及到多個層面和策略。
除了上面提到的方案外,還有一些常見的方法和原則,用於確保在高併發環境中保持數據一致性:
-
事務(Transactions):
-
使用數據庫事務來確保數據操作的原子性、一致性、隔離性和持久性(ACID 屬性)。
-
通過鎖機制(如行鎖、表鎖)來避免併發操作導致的衝突。
-
-
分佈式鎖:
-
當多個服務或節點需要同時訪問共享資源時,使用分佈式鎖來協調這些訪問。
-
例如,使用 Redis 的 setnx 命令或 ZooKeeper 的分佈式鎖機制。
-
-
樂觀鎖與悲觀鎖:
-
樂觀鎖假設衝突不太可能發生,通常在數據更新時檢查版本號或時間戳。
-
悲觀鎖則假設衝突很可能發生,因此在數據訪問時立即加鎖。
-
-
數據一致性協議:
- 使用如 Raft、Paxos 等分佈式一致性算法,確保多個副本之間的數據同步。
-
消息隊列:
-
通過消息隊列實現數據的異步處理,確保數據按照正確的順序被處理。
-
使用消息隊列的持久化、重試和順序保證特性。
-
-
CAP 定理與 BASE 理論:
-
理解 CAP 定理(一致性、可用性、分區容忍性)的權衡,並根據業務需求選擇合適的策略。
-
BASE 理論(Basically Available, Soft state, Eventually consistent)提供了一種弱化一致性要求的解決方案。
-
-
緩存一致性:
- 使用緩存失效策略(如 LRU、LFU)和緩存同步機制(如緩存穿透、緩存擊穿、緩存雪崩的應對策略),確保緩存與數據庫之間的一致性。
-
讀寫分離讀寫:
- 使用主從複製、讀寫分離讀寫等技術,將讀操作和寫操作分散到不同的數據庫實例上,提高併發處理能力。
-
數據校驗與重試:
-
在數據傳輸和處理過程中加入校驗機制,確保數據的完整性和準確性。
-
對於可能失敗的操作,實施重試機制,確保數據最終的一致性。
-
-
監控與告警:
* 實時監控數據一致性相關的關鍵指標,如延遲、錯誤率等。
* 設置告警閾值,及時發現並處理可能導致數據不一致的問題。
在實際應用中,通常需要結合具體的業務場景和技術棧來選擇合適的策略。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/VORqKu27qqbhzD_QPyDgeQ