在大型分散式系統中,定會存在大量併發寫入的場景。在這種場景下如何進行更好的併發控制,即在多個任務同時存取資料時保證資料的一致性,成為分散式系統必須解決的問題。
悲觀併發控制和樂觀併發控制是併發控制中採用的主要技術手段,對於不同的業務場景,應該選擇不同的控制方法。
悲觀鎖
悲觀併發控制(又名“悲觀鎖”,Pessimistic Concurrency Control,縮寫“PCC”)是一種併發控制的方法。它可以阻止一個事務以影響其他使用者的方式來修改資料。如果一個事務執行的操作讀某行資料應用了鎖,那只有當這個事務把鎖釋放,其他事務才能夠執行與該鎖衝突的操作。
在悲觀鎖的場景下,假設使用者 A 和 B 要修改同一個檔案,A 在鎖定檔案並且修改的過程中,B 是無法修改這個檔案的,只有等到 A 修改完成,並且釋放鎖以後,B 才可以獲取鎖,然後修改檔案。由此可以看出,悲觀鎖對併發的控制持悲觀態度,它在進行任何修改前,首先會為其加鎖,確保整個修改過程中不會出現衝突,從而有效的保證資料一致性。但這樣的機制同時降低了系統的併發性,尤其是兩個同時修改的物件本身不存在衝突的情況。同時也可能在競爭鎖的時候出現死鎖,所以現在很多的系統例如 Kubernetes 採用了樂觀併發的控制方法。
樂觀鎖
樂觀併發控制(又名“樂觀鎖”,Optimistic Concurrency Control,縮寫“OCC”)是一種併發控制的方法。它假設多使用者併發的事務在處理時不會彼此影響,各事務能夠在不請求鎖的情況下處理各自的資料。在提交資料更新之前,每個事務會先檢查在該事務讀取資料後,有沒有其他事務又修改了該資料。如果其他事務有更新的話,正在提交的事務會進行回滾。
相對於悲觀鎖對鎖的提前控制,樂觀鎖相信請求之間出現衝突的概率是比較小的,在讀取及更改的過程中都是不加鎖的,只有在最後提交更新時才會檢測衝突,因此在高併發量的系統中佔有絕對優勢。同樣假設使用者A和B要修改同一個檔案,A和B會先將檔案獲取到本地,然後進行修改。如果A已經修改好並且將資料提交,此時B再提交,伺服器端會告知B檔案已經被修改,返回衝突錯誤。此時衝突必須由B來解決,可以將檔案重新獲取回來,再一次修改後提交。
樂觀鎖通常通過增加一個資源版本欄位,來判斷請求是否衝突。初始化時指定一個版本值,每次讀取資料時將版本號一同讀出,每次更新資料,同時也對版本號進行更新。當伺服器端收到資料時,將資料中的版本號與伺服器端的做對比,如果不一致,則說明資料已經被修改,返回衝突錯誤。
Kubernetes中的併發控制
在Kubernetes 叢集中,外部使用者及內部元件頻繁的資料更新操作,導致系統的資料併發讀寫量非常大。假設採用悲觀並行的控制方法,將嚴重損耗叢集效能,因此 Kubernetes 採用樂觀並行的控制方法。Kubernetes 通過定義資源版本欄位實現了樂觀併發控制,資源版本 (ResourceVersion)欄位包含在 Kubernetes 物件的後設資料 (Metadata)中。這個字串格式的欄位標識了物件的內部版本號,其取值來自 etcd 的 modifiedindex,且當物件被修改時,該欄位將隨之被修改。值得注意的是該欄位由服務端維護,不建議在客戶端進行修改。
type ObjectMeta struct {
......
// An opaque value that represents the internal version of this object that can
// be used by clients to determine when objects have changed. May be used for optimistic
// concurrency, change detection, and the watch operation on a resource or set of resources.
// Clients must treat these values as opaque and passed unmodified back to the server.
// They may only be valid for a particular resource or set of resources.
//
// Populated by the system.
// Read-only.
// Value must be treated as opaque by clients and .
// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#concurrency-control-and-consistency
// +optional
ResourceVersion string
......
}
複製程式碼
Kube-Apiserver
可以通過該欄位判斷物件是否已經被修改。當包含 ResourceVersion 的更新請求到達 Apiserver,伺服器端將對比請求資料與伺服器中資料的資源版本號,如果不一致,則表明在本次更新提交時,服務端物件已被修改,此時 Apiserver 將返回衝突錯誤(409),客戶端需重新獲取服務端資料,重新修改後再次提交到伺服器端。上述並行控制方法可防止如下的 data race:
Client #1: GET Foo
Client #2: GET Foo
Client #1: Set Foo.Bar = "one"
Client #1: PUT Foo
Client #2: Set Foo.Baz = "two"
Client #2: PUT Foo
複製程式碼
當未採用併發控制時,假設發生如上請求序列,兩個客戶端同時從服務端獲取同一物件Foo(含有Bar、Baz 兩個欄位),Client#1先將 Bar 欄位置成one,其後 Client#2 對 Baz 欄位賦值的更新請求到服務端時,將覆蓋 Client#1 對 Bar 的修改。反之在物件中新增資源版本欄位,同樣的請求序列將如下:
Client #1: GET Foo //初始Foo.ResourceVersion=1
Client #2: GET Foo //初始Foo.ResourceVersion=1
Client #1: Set Foo.Bar = "one"
Client #1: PUT Foo //更新Foo.ResourceVersion=2
Client #2: Set Foo.Baz = "two"
Client #2: PUT Foo //返回409衝突
複製程式碼
Client#1 更新物件後資源版本號將改變,Client#2 在更新提交時將返回衝突錯誤(409),此時 Client#2 必須在本地重新獲取資料,更新後再提交到服務端。
假設更新請求的物件中未設定 ResourceVersion 值,Kubernetes 將會根據硬改寫策略(可配置)決定是否進行硬更新。如果配置為可硬改寫,則資料將直接更新並存入 Etcd,反之則返回錯誤,提示使用者必須指定 ResourceVersion。
Kubernetes 中的 Update 和 Patch
Kubernetes 實現了 Update 和 Patch 兩個物件更新的方法,兩者提供不同的更新操作方式,但衝突判斷機制是相同的。
Update
對於 Update,客戶端更新請求中包含的是整個 obj 物件,伺服器端將對比該請求中的obj物件和伺服器端最新obj物件的 ResourceVersion 值。如果相等,則表明未發生衝突,將成功更新整個物件。反之若不相等則返回409衝突錯誤,Kube-Apiserver
中衝突判斷的程式碼片段如下。
e.Storage.GuaranteedUpdate(ctx, key...) (runtime.Object, *uint64, error) {
// If AllowUnconditionalUpdate() is true and the object specified by
// the user does not have a resource version, then we populate it with
// the latest version. Else, we check that the version specified by
// the user matches the version of latest storage object.
resourceVersion, err := e.Storage.Versioner().ObjectResourceVersion(obj)
if err != nil {
return nil, nil, err
}
version, err := e.Storage.Versioner().ObjectResourceVersion(existing)
doUnconditionalUpdate := resourceVersion == 0 && e.UpdateStrategy.AllowUnconditionalUpdate()
if doUnconditionalUpdate {
// Update the object`s resource version to match the latest
// storage object`s resource version.
err = e.Storage.Versioner().UpdateObject(obj, res.ResourceVersion)
if err != nil {
return nil, nil, err
}
} else {
// Check if the object`s resource version matches the latest
// resource version.
......
if resourceVersion != version {
return nil, nil, kubeerr.NewConflict(qualifiedResource, name, fmt.Errorf(OptimisticLockErrorMsg))
}
}
......
return out, creating, nil
}
複製程式碼
基本流程為:
獲取當前更新請求中 obj 物件的 ResourceVersion 值,及伺服器端最新 obj 物件 (existing) 的 ResourceVersion 值
如果當前更新請求中 bj 物件的 ResourceVersion 值等於 0,即客戶端未設定該值,則判斷是否要硬改寫 (AllowUnconditionalUpdate),如配置為硬改寫策略,將直接更新 obj 物件
如果當前更新請求中 obj 物件的 ResourceVersion 值不等於 0,則判斷兩個 ResourceVersion 值是否一致,不一致返回衝突錯誤 (OptimisticLockErrorMsg)
Patch
相比Update請求包含整個obj物件,Patch請求實現了更細粒度的物件更新操作,其請求中只包含需要更新的欄位。例如要更新pod中container的映象,可使用如下命令:
kubectl patch pod my-pod -p `{"spec":{"containers":[{"name":"my-container","image":"new-image"}]}}`
複製程式碼
伺服器端只收到以上的 patch 資訊,然後通過如下程式碼將該 patch 更新到 Etcd 中。
func (p *patcher) patchResource(ctx context.Context) (runtime.Object, error) {
p.namespace = request.NamespaceValue(ctx)
switch p.patchType {
case types.JSONPatchType, types.MergePatchType:
p.mechanism = &jsonPatcher{patcher: p}
case types.StrategicMergePatchType:
schemaReferenceObj, err := p.unsafeConvertor.ConvertToVersion(p.restPatcher.New(), p.kind.GroupVersion())
if err != nil {
return nil, err
}
p.mechanism = &smpPatcher{patcher: p, schemaReferenceObj: schemaReferenceObj}
default:
return nil, fmt.Errorf("%v: unimplemented patch type", p.patchType)
}
p.updatedObjectInfo = rest.DefaultUpdatedObjectInfo(nil, p.applyPatch, p.applyAdmission)
return finishRequest(p.timeout, func() (runtime.Object, error) {
updateObject, _, updateErr := p.restPatcher.Update(ctx, p.name, p.updatedObjectInfo, p.createValidation, p.updateValidation, false, p.options)
return updateObject, updateErr
})
}
複製程式碼
基本流程為:
1.首先判斷 patch 的型別,根據型別選擇相應的 mechanism
2.利用 DefaultUpdatedObjectInfo 方法將 applyPatch (應用 Patch 的方法)新增到 admission chain 的頭部
3.最終還是呼叫上述 Update 方法執行更新操作
在步驟 2 中將 applyPatch 方法掛到 admission chain 的頭部,與 admission 行為相似,applyPatch 方法會將 patch 應用到最新獲取的伺服器端 obj 上,生成一個已更新的obj,再對該obj繼續執行 admission chain 中的 Admit 與 Validate。最終呼叫的還是 update 方法,因此衝突檢測的機制與上述 Update 方法完全一致。
相比 Update,Patch 的主要優勢在於客戶端不必提供全量的 obj 物件資訊。客戶端只需以 patch 的方式提交要修改的欄位資訊,伺服器端會將該 patch 資料應用到最新獲取的obj中。省略了 Client 端獲取、修改再提交全量 obj 的步驟,降低了資料被修改的風險,更大大減小了衝突概率。 由於 Patch 方法在傳輸效率及衝突概率上都佔有絕對優勢,目前 Kubernetes 中幾乎所有更新操作都採用了 Patch 方法,我們在編寫程式碼時也應該注意使用 Patch 方法。
附
ResourceVersion 欄位在 Kubernetes 中除了用在上述併發控制機制外,還用在 Kubernetes 的 list-watch 機制中。Client 端的 list-watch 分為兩個步驟,先 list 取回所有物件,再以增量的方式 watch 後續物件。Client 端在list取回所有物件後,將會把最新物件的 ResourceVersion 作為下一步 watch 操作的起點引數,也即 Kube-Apiserver 以收到的 ResourceVersion 為起始點返回後續資料,保證了 list-watch 中資料的連續性與完整性。