# 在gorm中使用乐观锁

## 乐观锁简介

乐观锁(又称乐观并发控制)是一种常见的数据库并发控制策略。

引用wikipedia的[描述](https://en.wikipedia.org/wiki/Optimistic_concurrency_control)：

> 乐观并发控制多数用于数据竞争(data race)不大、冲突较少的环境中，这种环境中，偶尔回滚事务的成本会低于读取数据时锁定数据的成本，因此可以获得比其他并发控制方法更高的吞吐量。 它的作用是防止并发更新数据库中的数据，从而避免数据的混乱。

本文的主旨讨论如何在**GORM**中实现并使用乐观锁，所以在这里不赘述乐观锁的特点及适用场景。

不过为了方便后面的讨论， 我们先简单说明实现乐观锁的核心要素。 乐观锁由以下几个要素组成：

1. 在 table 中增加一列，用于记录此行数据的版本号
    
2. 更新数据前，先读取当前数据行的版本号
    
3. 更新时，对 `UPDATE` 语句作两处调整：
    
    1. `WHERE` 语句中加入版本号的比较条件，确保只有当前版本号与数据库中的版本号一致时才执行更新
        
        ```SQL
           WHERE ... and version = [current version]
        ```
        
    2. `UPDATE` 语句中递增版本号以保证每次更新后版本号都会变化
        
        ```SQL
           UPDATE set  ..., version = version + 1
        ```
        
4. SQL执行以后需要检查更新行数是否为0，如果为0则说明有更新冲突，需要重试直到成功为止
    

## 如何在GORM中使用乐观锁

上述逻辑并不复杂，但是在每个需要使用乐观锁的场景都手动添加上述逻辑还是比较麻烦，并且容易出错。

我们知道**GORM**是基于plugin架构的，若能使用plugin来实现这个常用的功能，将是一个比较优雅的方案。

幸运的是**GORM**官方团队也是这么想的，他们已经提供了这个plugin： [go-gorm/optimisticlock](https://github.com/go-gorm/optimisticlock).

有了这个plugin，在**GORM**中使用乐观锁就非常简单了。

要使用乐观锁， 首先需要在**GORM** model 中增加一个类型为`optimisticlock.Version`的版本字段，：

```go
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` 的类型别名

```go
package optimisticlock
...
type Version sql.NullInt64
```

增加了这个字段以后，**GORM** 的更新操作就自动支持乐观锁了。

由于增加了版本判断，所以发生更新冲突时，更新行数将是0，这意味着此次更新失败，需要将此错误返回，通知调用方重试。 下面示例代码演示了如何更新**Blog**的标题字段：

```go
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](https://www.chedan.io/posts/replace-linq-with-lo/)来处理：

```go
 _, err := lo.Attempt(3, func(_ int) error {
     return UpdateTitle(db, 1, "foo bar")
 })
```

## go-gorm/optimisticlock 影响了哪些方法

由于 **go-gorm/optimisticlock** 是用 **GORM** plugin 机制实现的。 所以所有支持plugin的更新和插入方法会受影响，这包括：

1. `Update`
    
2. `Updates`
    
3. `Create`
    

有些方法不支持plugin，因此不会受影响：

1. `UpdateColumn`
    
2. `UpdateColumns`
    

## 注意: DB.Save与此plugin有冲突

前面列出的方法不包括 `DB.Save`，因为它在特定场景下不能正常工作。

`DB.Save` 支持plugin，所以它生成的SQL也会被自动修改。

我们知道 `Save` 既支持数据插入也支持更新：

* 当 model 主键为空值时，`Save` 的行为与 `Create` 相同，这种情况下没有问题，
    
* 当 model 主键不为空时，`Save` 会更新全部字段。这时会出现bug
    

下面我们尝试用 `DB.Save` 来更新全部字段，用这个示例说明问题所在:

```go
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 的源码里：

```go
// 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`。

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1716287259707/91a6780b-c1bf-48a8-8247-55d718f878b5.png align="left")

发生更新冲突时 `RowsAffected` 将是 0。

而这会导致 `DB.Save` 再次执行 `Insert` 操作，此时的主键不是空，所以会出现重复主键的错误。

要避免此问题，需要用 `Updates` 替换 `Save`。

同时要注意 `Updates` 默认只更新"非空"字段，需要加上 `db.Select("*")` 才能更新全部字段。

修正后的方法如下：

```go
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` 有冲突，需要注意绕行
