寫了一個 gorm 樂觀鎖插件

前言

最近在用 Go 寫業務的時碰到了併發更新數據的場景,因爲該業務併發度不高,只是爲了防止出現併發時數據異常。mysql

因此天然就想到了樂觀鎖的解決方案。git

實現

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

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

須要在表中新增一個相似於 version 的字段,本質上咱們只是執行這段 SQL,在更新時比較當前版本與數據庫版本是否一致。sql

如上圖所示:版本一致則更新成功,而且將版本號+1;若是不一致則認爲出現併發衝突,更新失敗。數據庫

這時能夠直接返回失敗,讓業務重試;固然也能夠再次獲取最新數據進行更新嘗試。編程


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

不過藉助 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 line \n", 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("%+v \n", 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("%+v \n", 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("%+v \n", err)
        }

但因爲回調函數的入參只能知道是一個 Lock 接口,並不清楚具體是哪一個 struct,因此在執行業務邏輯以前須要將這個接口轉換爲具體的 struct

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

Go 語言中這樣的行爲被稱爲類型斷言;雖然叫法不一樣,但目的相似。其語法以下:

x.(T)
x:表示 interface 
T:表示 向下轉型的具體 struct

因此在回調函數中得根據本身的須要將 interface 轉換爲本身的 struct,這裏得確保是本身所使用的 struct ,由於是強制轉換,編譯器沒法幫你作校驗,具體可否轉換成功得在運行時才知道。

總結

有須要的朋友能夠在這裏獲取到源碼及具體使用方式:

https://github.com/crossoverJie/gorm-optimistic

最近工做中使用了幾種不一樣的編程語言,會發現除了語言自身的語法特性外大部分知識點都是相同的;

好比面向對象、數據庫、IO操做等;因此掌握了這些基本知識,學習其餘語言天然就能舉一反三了。

公衆號名片底部.jpg