乐观锁简介
乐观锁(又称乐观并发控制)是一种常见的数据库并发控制策略。
引用wikipedia的描述:
乐观并发控制多数用于数据竞争(data race)不大、冲突较少的环境中,这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其他并发控制方法更高的吞吐量。 它的作用是防止并发更新数据库中的数据,从而避免数据的混乱。
本文的主旨讨论如何在GORM中实现并使用乐观锁,所以在这里不赘述乐观锁的特点及适用场景。
不过为了方便后面的讨论, 我们先简单说明实现乐观锁的核心要素。 乐观锁由以下几个要素组成:
在 table 中增加一列,用于记录此行数据的版本号
更新数据前,先读取当前数据行的版本号
更新时,对
UPDATE
语句作两处调整:WHERE
语句中加入版本号的比较条件,确保只有当前版本号与数据库中的版本号一致时才执行更新WHERE ... and version = [current version]
UPDATE
语句中递增版本号以保证每次更新后版本号都会变化UPDATE set ..., version = version + 1
SQL执行以后需要检查更新行数是否为0,如果为0则说明有更新冲突,需要重试直到成功为止
如何在GORM中使用乐观锁
上述逻辑并不复杂,但是在每个需要使用乐观锁的场景都手动添加上述逻辑还是比较麻烦,并且容易出错。
我们知道GORM是基于plugin架构的,若能使用plugin来实现这个常用的功能,将是一个比较优雅的方案。
幸运的是GORM官方团队也是这么想的,他们已经提供了这个plugin: go-gorm/optimisticlock.
有了这个plugin,在GORM中使用乐观锁就非常简单了。
要使用乐观锁, 首先需要在GORM model 中增加一个类型为optimisticlock.Version
的版本字段,:
import (
"gorm.io/plugin/optimisticlock"
)
...
type Blog struct {
Id int
Title string
Content string
// add version column to support optimistic lock
Version optimisticlock.Version
}
看plugin源码会发现,optimisticlock.Version
其实就是 sql.NullInt64
的类型别名
package optimisticlock
...
type Version sql.NullInt64
增加了这个字段以后,GORM 的更新操作就自动支持乐观锁了。
由于增加了版本判断,所以发生更新冲突时,更新行数将是0,这意味着此次更新失败,需要将此错误返回,通知调用方重试。 下面示例代码演示了如何更新Blog的标题字段:
func UpdateTitle(db *gorm.DB, id int, title string) error {
blog := &Blog{}
// load blog with latest version
if err := db.Take(blog, id).Error; err != nil {
return err
}
blog.Title = title
// SQL: UPDATE blogs SET title = ?, version = version + 1 WHERE id = ? and version = ?
result := db.Model(blog).Update("title", blog.Title)
if err := result.Error; err != nil {
return err
}
// version conflict occurred
if result.RowsAffected == 0 {
return ErrOptimisticLock
}
return nil
}
通常情况下,更新冲突时需要重试。 我们使用之前提到的泛型工具库samber/lo来处理:
_, err := lo.Attempt(3, func(_ int) error {
return UpdateTitle(db, 1, "foo bar")
})
go-gorm/optimisticlock 影响了哪些方法
由于 go-gorm/optimisticlock 是用 GORM plugin 机制实现的。 所以所有支持plugin的更新和插入方法会受影响,这包括:
Update
Updates
Create
有些方法不支持plugin,因此不会受影响:
UpdateColumn
UpdateColumns
注意: DB.Save与此plugin有冲突
前面列出的方法不包括 DB.Save
,因为它在特定场景下不能正常工作。
DB.Save
支持plugin,所以它生成的SQL也会被自动修改。
我们知道 Save
既支持数据插入也支持更新:
当 model 主键为空值时,
Save
的行为与Create
相同,这种情况下没有问题,当 model 主键不为空时,
Save
会更新全部字段。这时会出现bug
下面我们尝试用 DB.Save
来更新全部字段,用这个示例说明问题所在:
func UpdateAll(db *gorm.DB, blog *Blog) error {
// save blog
// bug: will return primary key duplicate error in case update conflict
result := db.Save(blog)
if err := result.Error; err != nil {
return err
}
// bug: never execute
if result.RowsAffected == 0 {
return ErrOptimisticLock
}
return nil
}
当发生更新冲突时,UpdateAll
并没有返回我们期望的 ErrOptimisticLock
,而是返回了duplicate key value violates ...
错误。
这是相同主键重复插入时才会出的错误。
为什么会这样? 答案在 DB.Save 的源码里:
// Save update value in database, if the value doesn't have primary key, will insert it
func (db *DB) Save(value interface{}) (tx *DB) {
...
tx = tx.callbacks.Update().Execute(tx)
if tx.Error == nil && tx.RowsAffected == 0 && !tx.DryRun && !selectedUpdate {
result := reflect.New(tx.Statement.Schema.ModelType).Interface()
if result := tx.Session(&Session{}).Limit(1).Find(result); result.RowsAffected == 0 {
return tx.Create(value)
}
}
...
}
对照下面DB.Save
的流程图,会发现问题的根源在 tx.RowsAffected == 0
。
发生更新冲突时 RowsAffected
将是 0。
而这会导致 DB.Save
再次执行 Insert
操作,此时的主键不是空,所以会出现重复主键的错误。
要避免此问题,需要用 Updates
替换 Save
。
同时要注意 Updates
默认只更新"非空"字段,需要加上 db.Select("*")
才能更新全部字段。
修正后的方法如下:
func UpdateAll(db *gorm.DB, blog *Blog) error {
// make sure update all fields
result := db.Select("*").Updates(blog)
if err := result.Error; err != nil {
return err
}
if result.RowsAffected == 0 {
return ErrOptimisticLock
}
return nil
}
总结
乐观锁很常用,适合追求高并发且冲突几率较低的场景
借助 GORM 官方提供的 plugin 可以方便优雅地实现乐观锁
但此plugin 与
DB.Save
有冲突,需要注意绕行