寫了一個 gorm 樂觀鎖外掛

dustinny發表於2021-03-15

  前言

  最近在用Go寫業務的時碰到了併發更新資料的場景,由於該業務併發度不高,只是為了防止出現併發時資料異常。

  所以自然就想到了樂觀鎖的解決方案。

  實現

  樂觀鎖的實現比較簡單,相信大部分有資料庫使用經驗的都能想到。

  UPDATE`table`SET`amount`=100,`version`=version+1 WHERE`version`=1 AND`id`=1

  需要在表中新增一個類似於version的欄位,本質上我們只是執行這段SQL,在更新時比較當前版本與資料庫版本是否一致。

  如上圖所示:版本一致則更新成功,並且將版本號+1;如果不一致則認為出現併發衝突,更新失敗。

  這時可以直接返回失敗,讓業務重試;當然也可以再次獲取最新資料進行更新嘗試。

  我們使用的是gorm這個orm庫,不過我查閱了官方文件卻沒有發現樂觀鎖相關的支援,看樣子後續也不打算提供實現。

  不過藉助gorm實現也很簡單:

  type Optimistic struct{

  Id int64`gorm:"column:id;primary_key;AUTO_INCREMENT"json:"id"`

  UserId string`gorm:"column:user_id;default:0;NOT NULL"json:"user_id"`//使用者ID

  Amount float32`gorm:"column:amount;NOT NULL"json:"amount"`//金額

  Version int64`gorm:"column:version;default:0;NOT NULL"json:"version"`//版本

  }

  func TestUpdate(t*testing.T){

  dsn:="root:abc123 /test?charset=utf8&parseTime=True&loc=Local"

  db,err:=gorm.Open(mysql.Open(dsn),&gorm.Config{})

  var out Optimistic

  db.First(&out,Optimistic{Id:1})

  out.Amount=out.Amount+10

  column:=db.Model(&out).Where("id",out.Id).Where("version",out.Version).

  UpdateColumn("amount",out.Amount).

  UpdateColumn("version",gorm.Expr("version+1"))

  fmt.Printf("#######update%v linen",column.RowsAffected)

  }

  這裡我們建立了一張t_optimistic表用於測試,生成的SQL也滿足樂觀鎖的要求。

  不過考慮到這類業務的通用性,每次需要樂觀鎖更新時都需要這樣硬編碼並不太合適。對於業務來說其實version是多少壓根不需要關心,只要能滿足併發更新時的準確性即可。

  因此我做了一個封裝,最終使用如下:

  var out Optimistic

  db.First(&out,Optimistic{Id:1})

  out.Amount=out.Amount+10

  if err=UpdateWithOptimistic(db,&out,nil,0,0);err!=nil{

  fmt.Printf("%+vn",err)

  }

  這裡的使用場景是每次更新時將amount金額加上10。

  這樣只會更新一次,如果更新失敗會返回一個異常。

  當然也支援更新失敗時執行一個回撥函式,在該函式中實現對應的業務邏輯,同時會使用該業務邏輯嘗試更新N次。

  func BenchmarkUpdateWithOptimistic(b*testing.B){

  dsn:="root:abc123 /test?charset=utf8&parseTime=True&loc=Local"

  db,err:=gorm.Open(mysql.Open(dsn),&gorm.Config{})

  if err!=nil{

  fmt.Println(err)

  return

  }

  b.RunParallel(func(pb*testing.PB){

  var out Optimistic

  db.First(&out,Optimistic{Id:1})

  out.Amount=out.Amount+10

  err=UpdateWithOptimistic(db,&out,func(model Lock)Lock{

  bizModel:=model.(*Optimistic)

  bizModel.Amount=bizModel.Amount+10

  return bizModel

  },3,0)

  if err!=nil{

  fmt.Printf("%+vn",err)

  }

  })

  }

  以上程式碼的目的是:

  將amount金額+10,失敗時再次依然將金額+10,嘗試更新3次;經過上述的並行測試,最終檢視資料庫確認資料並沒有發生錯誤。

  面向介面程式設計

  下面來看看具體是如何實現的;其實真正核心的程式碼也比較少:

  func UpdateWithOptimistic(db*gorm.DB,model Lock,callBack func(model Lock)Lock,retryCount,currentRetryCount int32)(err error){

  if currentRetryCount>retryCount{

  return errors.WithStack(NewOptimisticError("Maximum number of retries exceeded:"+strconv.Itoa(int(retryCount))))

  }

  currentVersion:=model.GetVersion()

  model.SetVersion(currentVersion+1)

  column:=db.Model(model).Where("version",currentVersion).UpdateColumns(model)

  affected:=column.RowsAffected

  if affected==0{

  if callBack==nil&&retryCount==0{

  return errors.WithStack(NewOptimisticError("Concurrent optimistic update error"))

  }

  time.Sleep(100*time.Millisecond)

  db.First(model)

  bizModel:=callBack(model)

  currentRetryCount++

  err:=UpdateWithOptimistic(db,bizModel,callBack,retryCount,currentRetryCount)

  if err!=nil{

  return err

  }

  }

  return column.Error

  }

  具體步驟如下:

  判斷重試次數是否達到上限。

  獲取當前更新物件的版本號,將當前版本號+1。

  根據版本號條件執行更新語句。

  更新成功直接返回。

  更新失敗affected==0時,執行重試邏輯。

  重新查詢該物件的最新資料,目的是獲取最新版本號。

  執行回撥函式。

  從回撥函式中拿到最新的業務資料。

  遞迴呼叫自己執行更新,直到重試次數達到上限。

  這裡有幾個地方值得說一下;由於Go目前還不支援泛型,所以我們如果想要獲取struct中的version欄位只能透過反射。

  考慮到反射的效能損耗以及程式碼的可讀性,有沒有更”優雅“的實現方式呢?

  於是我定義了一個interface:

  type Lock interface{

  SetVersion(version int64)

  GetVersion()int64

  }

  其中只有兩個方法,目的則是獲取struct中的version欄位;所以每個需要樂觀鎖的struct都得實現該介面,類似於這樣:

  func(o*Optimistic)GetVersion()int64{

  return o.Version

  }

  func(o*Optimistic)SetVersion(version int64){

  o.Version=version

  }

  這樣還帶來了一個額外的好處:

  一旦該結構體沒有實現介面,在樂觀鎖更新時編譯器便會提前報錯,如果使用反射只能是在執行期間才能進行校驗。

  所以這裡在接收資料庫實體的便可以是Lock介面,同時獲取和重新設定version欄位也是非常的方便。

  currentVersion:=model.GetVersion()

  model.SetVersion(currentVersion+1)

  型別斷言

  當併發更新失敗時affected==0,便會回撥傳入進來的回撥函式,在回撥函式中我們需要實現自己的業務邏輯。

  err=UpdateWithOptimistic(db,&out,func(model Lock)Lock{

  bizModel:=model.(*Optimistic)

  bizModel.Amount=bizModel.Amount+10

  return bizModel

  },2,0)

  if err!=nil{

  fmt.Printf("%+vn",err)

  }

  但由於回撥函式的入參只能知道是一個Lock介面,並不清楚具體是哪個struct,所以在執行業務邏輯之前需要將這個介面轉換為具體的struct。

  這其實和Java中的父類向子類轉型非常類似,必須得是強制型別轉換,也就是說執行時可能會出問題。

  在Go語言中這樣的行為被稱為型別斷言;雖然叫法不同,但目的類似。其語法如下:

  x.(T)

  x:表示interface

  T:表示向下轉型的具體struct

  所以在回撥函式中得根據自己的需要將interface轉換為自己的struct,這裡得確保是自己所使用的struct,因為是強制轉換,編譯器無法幫你做校驗,具體能否轉換成功得在執行時才知道。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69995861/viewspace-2762855/,如需轉載,請註明出處,否則將追究法律責任。

相關文章