上一篇文章我们介绍了如何对数据库中的资源进行部分更新处理,细心的读者可能已经注意到updateMovieHandler中的一个小问题:如果两个客户端同时更新相同的movie记录,会出现竞争条件。
为了说明这一点,假设有两个客户端正在使用我们的API服务: Alice和Bob。Alice想将电影the Breakfast Club的runtime字段修改为97分钟,Bob想将电影类型genre字段添加“comedy”(喜剧类型)到同一部电影中。
现在假设Alice和Bob同时发送两个请求。因为http.Server处理请求是在两个独立的goroutine中进行的,因此updateMovieHandler将同时运行在两个不同goroutine中。我们分析下会发生什么:
1、Alice的请求goroutine调用app.models.Movies.Get()读取数据库中的记录(其verison字段为N)
2、Bob的请求goroutine也调用app.models.Movies.Get()读取同一条记录(其version也是N)
3、Alice的请求goroutine将从数据库读取的movie记录的runtime字段修改为97。
4、Bob的请求goroutine将读取的movie记录中genres切片添加‘comedy’。
5、Alice请求goroutine调用app.models.Movies.Update(),并将更新后的movie记录写入数据库。并且此时movie记录的version字段增加1,变为:N+1。
6、Bob的请求goroutine也调用app.models.Movies.Update(),并将其更新的记录写入数据库。此时version字段增加到N+2。
尽管这个过程Alice和Bob都对同一条movie记录做了更新,但是最终只有Bob的更新生效。Alice的更新,被Bob的更新覆盖了,runtime并没有变为97。这个过程不会有任何通知告知Alice或Bob存在问题。
这种特定类型的竞争条件称为数据竞争。当两个或多个goroutine同时使用一块共享数据(在本例中是movie记录)时,可能会发生数据竞争,但它们操作的结果取决于调度程序执行它们指令的顺序。
阻止数据竞争
现在我们理解了数据竞争的存在以及为何会发生,我们该如何避免?
有几种方法,但最简单和简洁的方法就是根据movie的版本号(version字段)使用乐观锁。
解决方法:
1、Alice和Bob的请求goroutine都调用app.models.Movies.Get()方法来检索movie记录。这些movie记录都包含version字段。
2、Alice和Bob的请求goroutine分别对movie记录修改。
3、Alice和Bob的请求goroutine都调用app.models.Movies.Update()写入修改后的记录到数据库。但是更新操作只有在version字段为N的情况才执行,否则就向客户端返回错误。
这意味着第一个更新操作能成功执行,version字段自增到N+1。第二个更新操作发现version不是N了就返回错误,避免覆盖第一个更新操作。
要做到这一点,我们需要修改用于更新movie的SQL语句,使其看起来像这样:
UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
WHERE id = $5 AND version = $6
RETURNING version
注意,在WHERE子句中,我们现在正在查找具有特定ID和特定版本号的记录。
如果没有找到匹配的记录,这个查询将返回sql.ErrNoRows错误,我们知道version值已经改变了。不管怎样,这是一种更新冲突,我们可以使用这种方式来向客户端发送错误响应。
实现乐观锁
理论已经分析清楚了,下面开始付诸实践。
我们先创建一个自定义错误类型:ErrEditConflict,在数据库发生更新冲突时返回。后面我们处理用户数据的时候也会用到,因此在internal/data/models.go文件中定义比较合理。
File: internal/data/models.go
package data
import (
"database/sql"
"errors"
)
var (
ErrRecordNotFound = errors.New("record not found")
ErrEditConflict = errors.New("edit conflict")
)
...
接下来,更新数据库模型的update()方法,以执行新的SQL查询并管理无法找到匹配记录的情况。
File: internal/data/movies.go
func (m MovieModel) Update(movie *Movie) error {
//声明SQL query更新记录并返回最新版本号
query := `
UPDATE movies
set title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
WHERE id = $5 AND version = $6
RETURNING version`
//创建args切片包含所有占位符参数值
args := []interface{}{
movie.Title,
movie.Year,
movie.Runtime,
pq.Array(movie.Genres),
movie.ID,
movie.Version, //添加预期movie的版本号
}
//使用QueryRow()方法执行,并以可变参数传入args切片
//如果没找到记录,说明version已经更新,返回自定义错误
err := m.DB.QueryRow(query, args...).Scan(&movie.Version)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
下面到cmd/api/errors.go文件,创建editConflictResponse()帮助函数。我们希望这个响应发送409 Conflict响应,并带上错误消息告诉客户端错误原因。
File:cmd/api/errors.go
package main
...
func (app *application)editConflictResponse(w http.ResponseWriter, r *http.Request) {
message := "unable to update the record due to an edit conflict, please try again later"
app.errorResponse(w, r, http.StatusConflict, message)
}
最后一步,需要修改updateMovieHandler处理程序,检查数据库更新是否返回ErrEditConflict错误,并调用editConflictRespose()帮助函数。如下所示:
File:cmd/api/movies.go
func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
...
//将校验后的movie传给Update()方法,检查错误类型返回
err = app.models.Movies.Update(movie)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
//将更新后的movie返回给客户端
err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
此时,updateMovieHandler应该不会受到前面讨论的竞争条件的影响。如果两个goroutine同时执行代码,第一个更新将成功,第二个更新将失败,因为数据库中的版本号不再匹配预期值。
下面我们使用shell命令同时发送两个更新请求来测试下前面更新的代码。假设您的终端执行请求的时间非常接近,应该会发现一个请求成功,另一个请求失败,并显示409 Conflict状态代码。
$ curl -i -X PATCH -d '{"runtime": "97 mins"}' "localhost:4000/v1/movies/4" & \ curl -i -X PATCH -d '{"genres": ["comedy","drama"]}' "localhost:4000/v1/movies/4" &
HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 11 Dec 2021 14:25:28 GMT
Content-Length: 162
{
"movie": {
"id": 4,
"title": "The Breakfast Club",
"year": 1985,
"runtime": "97 mins",
"genres": [
"comedy",
"drama"
],
"Version": 15
}
}
HTTP/1.1 409 Conflict
Content-Type: application/json
Date: Sat, 11 Dec 2021 14:25:28 GMT
Content-Length: 82
{
"error": "unable to update the record due to an edit conflict, please try again later"
}
效果不错。可以看到,第二次更新没有成功,避免了数据竞争,客户端收到了一个明确的错误响应。
最后总结下,虽然我们在本节中演示的竞争条件影响不是很大。但在其他应用程序中,这类竞争条件可能会产生很严重的后果——例如更新在线商店中产品的库存时,或更新帐户余额时。必须避免数据竞争导致结果错误。
在开发应用的过程中,养成思考数据竞争的习惯是有必要的,无论影响大小都要合理的编写代码避免数据竞争发生。
附加内容
在其他字段或类型上加锁
使用递增的整数版本号作为乐观锁是安全的,而且计算成本低。我建议您使用这种方法,除非您有特殊的理由不这样做。
还有一种可选的办法是,你可以使用last_updated时间戳作为锁。但这个更不安全:理论上可能有两个客户端在同一时间更新记录,使用时间戳还会带来进一步的风险,如果服务器的时钟出错或随着时间的推移出错。
如果你不希望版本号有规律被猜出来的话,有一个好的选择是在version字段中使用一个随机字符串,比如UUID。PostgreSQL有一个UUID类型和uuid-ossp扩展,你可以这样使用:
UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = uuid_generate_v4() WHERE id = $5 AND version = $6
RETURNING version
工作原理还是一样的,只是每次更新后version不是自增1,而是随机生成的uuid。
网友评论