探討在Golang_gorm上使用樂觀鎖的做法
在Gorm上如果要使用Optimistic locking的話,使用go-gorm optimisticlock會是一個好選擇。因為它只需要在Model定義好,就可以正常使用了。可以避免實作的時候忘記加上WHERE version = ?
的條件。而變成沒有使用樂觀鎖保護資料的情況。
為什麼要使用樂觀鎖?
樂觀鎖實際上是沒有使用到Lock的,所以在沒有衝突的情況下,每筆資料的更新速度,會比有使用Lock的情況還要快速。 所以在資料庫更新頻率較低的時候,樂觀鎖是一個不錯的選擇。 樂觀鎖的缺點是,如果有兩個人同時更新同一筆資料,就會有一個人的更新會失敗。這時候就需要重新讀取資料,再重新更新一次。所以不適合在資料更新頻率很高的情況下使用。
另一方面,在產品還在MVP階段的時候,重點是趕快把Business Model 實作出來放到市場上,看看市場反應如何。能儘量快一點把功能做出來會越好。等確定這個功能是真的有市場,再來考慮優化效能方面的問題。
使用樂觀鎖+ORM,能簡化開發要寫的code,因為使用樂觀鎖+OR,就能讓大部分的code都變成,取出該筆資料->更新欄位的值->整筆存起來
,並且可以避免資料更新的時候,用到別人更新的資料(race condition),
這樣幾乎每個table就只需要有Get
、Update
就能完成所有更新的操作了。因此在MVP階段,使用樂觀鎖是一個不錯的選擇。
以下列出各種寫法,就能很清楚的看出來使用樂觀鎖的好處,還有為什麼要使用Optimisticlock套件了。
範例
- table
books
- table 欄位有
id
,title
,count
,updated_at
;
每賣出一本書,就要讓count加1。 請實作出AddBookCount的方法。
方法1:使用Atomic Update
剛好這個例子只需要更新一個欄位的值,所以可以使用Atomic Update來實作。但在一些複雜的情境下,並不是單純的對原本的數值做加減的情況,就不能使用這個方法,而是需要使用下面其它的方法了。
type BookRepo struct{
db *gorm.DB
}
func (repo* BookRepo) AddBookCount(bookID string, count int) error {
err := repo.db.Model(&Book{}).
Where("id = ?", bookID).
Update("count = count + ?", count).
Update("updated_at", time.Now()).
Error
if err != nil {
return err
}
return nil
}
方法2:使用lock
這個做法,在大部分的情況下可以正常運作。(例外:Phantom Read, Write Skew的情況)。
type BookRepo struct{
db *gorm.DB
}
func(repo* BookRepo) AddBookCount(bookID string, count int) error{
tx := repo.db.Begin()
//lock the book record
var b Book
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", bookID).First(&book).Error
if err != nil{
tx.Rollback()
return err
}
//update the book record
b.Count += count
err = tx.Save(&b).Error
if err != nil{
tx.Rollback()
return err
}
tx.Commit()
return nil
}
方法3:使用樂觀鎖
雖然看起來都有點複雜,但只要是針對一個record的更新,都可以簡化成同樣的流程:取出該筆資料->更新欄位的值->整筆存起來
。這樣在一些比較複雜的情境裡面,就可以用相對簡單的方式實作出來。
用UpdatedAt來自己實作
常見的做法,使用updated_at欄位來做為樂觀鎖的版本號,但這個方法有一個缺點:如果更新在比updated_at記錄的時間顆粒度還小的時間內一起發生,就會發生問題。例如updated_at的顆粒度是秒,但有多個request在同一秒內更新,就會相撞。 所以這個做法要很小心,注意更新的頻率和updated_at資料的顆粒度。但通常來說,如果更新頻率偏高,通常就會開始改成使用Lock的方式,而不是樂觀鎖了。
type BookRepo struct{
db *gorm.DB
}
func(repo* BookRepo) AddBookCount(bookID string, count int) error{
//lock the book record
var b Book
err := tx.First(&book, "id = ?", bookID).Error
if err != nil{
return err
}
//update the book record
b.Count += count
err := tx.Model(&Book{}).Where("updated_at = ?", b.UpdatedAt).Updates(&b).Error
if err != nil{
return err
}
//檢查是否成功更新
if tx.AffectedRows() != 1{
return errors.New("更新失敗")
}
return nil
}
用Version欄位來自己實作
能避免上面用updated_at來實作樂觀鎖
提到的缺點。但是必須要在欄位上,多加上一個version的欄位
type BookRepo struct{
db *gorm.DB
}
func (repo* BookRepo)AddBookCount(bookID string, count int) error{
var b Book
err := repo.db.First(&b, "id = ?", bookID).Error
if err != nil{
return err
}
b.Count += count
currentVersion := b.Version
b.Version += 1
err = repo.db.Where("version = ?", currentVersion).Updates(&b).Error
if err != nil{
return err
}
// 檢查是否成功更新
if repo.db.RowsAffected != 1{
return errors.New("更新失敗")
}
return nil
}
使用go-gorm/optimisticlock
在Model使用optimisticlock.Version
type Book struct{
ID string
Title string
Count int
UpdatedAt time.Time
Version optimisticlock.Version
}
type BookRepo struct{
db *gorm.DB
}
func (repo* BookRepo)AddBookCount(bookID string, count int) error{
var b Book
err := repo.db.First(&b, "id = ?", bookID).Error
if err != nil{
return err
}
b.Count += count
err = repo.db.Save(&b).Error
if err != nil{
return err
}
// 檢查是否成功更新
if repo.db.RowsAffected != 1{
return errors.New("更新失敗")
}
return nil
}
完整範例在此
結論
- 使用Atomic Update的方式,速度最快,但不一定適用於所有的情況。
- 使用Lock的方式,能夠避免race condition的發生,但因為要取得、釋放lock,所以每個request執行所需要的時間會比較長一些。當然,在更新頻率較高的情況下,多等一下下,總是比全部重新來過還要快。
- 如果要在Gorm上使用樂觀鎖,這個時候使用樂觀鎖套件會比較好,因為不論是哪種自己實作的樂觀鎖,都會需要多加一些檢查的條件,但在使用樂觀鎖套件的時候,只需要在Model上加上
optimisticlock.Version
,剩下的就是照常的使用Gorm的方法就好了。