美文网首页
【Go Web开发】API乐观并发控制

【Go Web开发】API乐观并发控制

作者: Go语言由浅入深 | 来源:发表于2022-02-12 22:51 被阅读0次

上一篇文章我们介绍了如何对数据库中的资源进行部分更新处理,细心的读者可能已经注意到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。

相关文章

  • 【Go Web开发】API乐观并发控制

    上一篇[https://www.jianshu.com/p/550ed578fd87]文章我们介绍了如何对数据库中...

  • 【语言学习】Go语言之API开发Gin框架

    1 gin框架介绍 gin框架是Go语言进行web开发(api开发,微服务开发)框架中,功能和Martini框架类...

  • 数据库并发控制——悲观锁、乐观锁、MVCC

    三种并发控制:悲观并发控制、乐观并发控制、多版本并发控制。 悲观并发控制(又名“悲观锁”,Pessimistic ...

  • 【Go Web开发】API限流

    如果您正在构建公共使用的API,那么很可能需要实现某种形式的限流,以防止客户端过快地发出大量请求,从而给服务器带来...

  • 乐观锁与悲观锁

    乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段 悲观并发控制(悲观锁) 它可以阻止一个...

  • 1.beego框架简介

    beego 简介 beego 是一个快速开发 Go 应用的 HTTP 框架,他可以用来快速开发 API、Web 及...

  • Go 并发常见讨论

    Go 并发常见讨论 并发 chan Done 控制结束 在service开发的过程中,会有多个异步的线程同时运行,...

  • 初识beego

    beego简介 beego 是一个快速开发 Go 应用的 HTTP 框架,他可以用来快速开发 API、Web 及后...

  • 拆箱phper最适合入门的go框架beego

    beego beego 是一个快速开发 Go 应用的 HTTP 框架,他可以用来快速开发 API、Web 及后端服...

  • beego入门

    简介: beego 是一个快速开发 Go 应用的 HTTP 框架,他可以用来快速开发 API、Web 及后端服务等...

网友评论

      本文标题:【Go Web开发】API乐观并发控制

      本文链接:https://www.haomeiwen.com/subject/pupxlrtx.html