Kubernetes 併發控制與資料一致性的實現原理

華為雲發表於2019-03-01

在大型分散式系統中,定會存在大量併發寫入的場景。在這種場景下如何進行更好的併發控制,即在多個任務同時存取資料時保證資料的一致性,成為分散式系統必須解決的問題。

悲觀併發控制和樂觀併發控制是併發控制中採用的主要技術手段,對於不同的業務場景,應該選擇不同的控制方法。

悲觀鎖

悲觀併發控制(又名“悲觀鎖”,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

}

複製程式碼

基本流程為:

  1. 獲取當前更新請求中 obj 物件的 ResourceVersion 值,及伺服器端最新 obj 物件 (existing) 的 ResourceVersion 值

  2. 如果當前更新請求中 bj 物件的 ResourceVersion 值等於 0,即客戶端未設定該值,則判斷是否要硬改寫 (AllowUnconditionalUpdate),如配置為硬改寫策略,將直接更新 obj 物件

  3. 如果當前更新請求中 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 中資料的連續性與完整性。

相關文章