寫了一個 gorm 樂觀鎖外掛
阿新 • • 發佈:2021-03-17
![](https://tva1.sinaimg.cn/large/e6c9d24ely1gojwsoe6oij21d00u0dnu.jpg)
# 前言
最近在用 `Go` 寫業務的時碰到了併發更新資料的場景,由於該業務併發度不高,只是為了防止出現併發時資料異常。
所以自然就想到了樂觀鎖的解決方案。
# 實現
樂觀鎖的實現比較簡單,相信大部分有資料庫使用經驗的都能想到。
```sql
UPDATE `table` SET `amount`=100,`version`=version+1 WHERE `version` = 1 AND `id` = 1
```
需要在表中新增一個類似於 `version` 的欄位,本質上我們只是執行這段 `SQL`,在更新時比較當前版本與資料庫版本是否一致。
![](https://tva1.sinaimg.cn/large/e6c9d24ely1gojwsxscq3j218j0u076y.jpg)
如上圖所示:版本一致則更新成功,並且將版本號+1;如果不一致則認為出現併發衝突,更新失敗。
這時可以直接返回失敗,讓業務重試;當然也可以再次獲取最新資料進行更新嘗試。
---
我們使用的是 `gorm` 這個 `orm` 庫,不過我查閱了官方文件卻沒有發現樂觀鎖相關的支援,看樣子後續也不打算提供實現。
![](https://tva1.sinaimg.cn/large/e6c9d24ely1gojwt4j20zj21fg07kq47.jpg)
不過藉助 `gorm` 實現也很簡單:
```go
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 line \n", column.RowsAffected)
}
```
這裡我們建立了一張 `t_optimistic` 表用於測試,生成的 `SQL` 也滿足樂觀鎖的要求。
不過考慮到這類業務的通用性,每次需要樂觀鎖更新時都需要這樣硬編碼並不太合適。對於業務來說其實 `version` 是多少壓根不需要關心,只要能滿足併發更新時的準確性即可。
因此我做了一個封裝,最終使用如下:
```go
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("%+v \n", err)
}
```
- 這裡的使用場景是每次更新時將 `amount` 金額加上 `10`。
這樣只會更新一次,如果更新失敗會返回一個異常。
當然也支援更新失敗時執行一個回撥函式,在該函式中實現對應的業務邏輯,同時會使用該業務邏輯嘗試更新 N 次。
```go
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("%+v \n", err)
}
})
}
```
以上程式碼的目的是:
將 `amount` 金額 `+10`,失敗時再次依然將金額+10,嘗試更新 `3` 次;經過上述的並行測試,最終檢視資料庫確認資料並沒有發生錯誤。
## 面向介面程式設計
下面來看看具體是如何實現的;其實真正核心的程式碼也比較少:
```go
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`:
```go
type Lock interface {
SetVersion(version int64)
GetVersion() int64
}
```
其中只有兩個方法,目的則是獲取 `struct` 中的 `version` 欄位;所以每個需要樂觀鎖的 `struct` 都得實現該介面,類似於這樣:
```go
func (o *Optimistic) GetVersion() int64 {
return o.Version
}
func (o *Optimistic) SetVersion(version int64) {
o.Version = version
}
```
這樣還帶來了一個額外的好處:
![](https://tva1.sinaimg.cn/large/e6c9d24ely1gojwzotvdkj21gq096775.jpg)
一旦該結構體沒有實現介面,在樂觀鎖更新時編譯器便會提前報錯,如果使用反射只能是在執行期間才能進行校驗。
所以這裡在接收資料庫實體的便可以是 `Lock` 介面,同時獲取和重新設定 `version` 欄位也是非常的方便。
```go
currentVersion := model.GetVersion()
model.SetVersion(currentVersion + 1)
```
## 型別斷言
當併發更新失敗時`affected == 0`,便會回撥傳入進來的回撥函式,在回撥函式中我們需要實現自己的業務邏輯。
```go
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("%+v \n", err)
}
```
但由於回撥函式的入參只能知道是一個 `Lock` 介面,並不清楚具體是哪個 `struct`,所以在執行業務邏輯之前需要將這個介面轉換為具體的 `struct`。
這其實和 `Java` 中的父類向子類轉型非常類似,必須得是強制型別轉換,也就是說執行時可能會出問題。
在 `Go` 語言中這樣的行為被稱為`型別斷言`;雖然叫法不同,但目的類似。其語法如下:
```go
x.(T)
x:表示 interface
T:表示 向下轉型的具體 struct
```
所以在回撥函式中得根據自己的需要將 `interface` 轉換為自己的 `struct`,這裡得確保是自己所使用的 `struct` ,因為是強制轉換,編譯器無法幫你做校驗,具體能否轉換成功得在執行時才知道。
# 總結
有需要的朋友可以在這裡獲取到原始碼及具體使用方式:
[https://github.com/crossoverJie/gorm-optimistic](https://github.com/crossoverJie/gorm-optimistic)
最近工作中使用了幾種不同的程式語言,會發現除了語言自身的語法特性外大部分知識點都是相同的;
比如面向物件、資料庫、IO操作等;所以掌握了這些基本知識,學習其他語言自然就能觸類旁