到目前为止,在代码中我们一直使用Go的Exec()和QueryRow()方法来运行SQL查询。但是Go还提供了两个能感知上下文的函数:ExecContext()和QueryRowContext()。这两个函数接受一个context.Context实例作为可以用来终止正在运行的数据库查询的上下文参数。
如果你的SQL查询时间很长的话使用这两个函数会很有用。当发生这种情况的时候,你可能需要取消查询(为了释放资源)并记录错误日志供问题定位,然后向客户端返回500 Internal Server Error错误。
本节我们将更新应用程序来实现超时处理。
模拟长时间SQL查询
为了说明查询超时,我们调整下数据库模型Get()函数来模拟查询运行时间较长。具体来说,我们将更新SQL查询以返回一个pg_sleep(10)值,这将使PostgreSQL在返回结果之前休眠10秒。
File: internal/data/movies.go
func (m MovieModel) Get(id int64) (*Movie, error) {
//PostgreSQL的bigserial类型用于movie的ID,将自增1,因此我们知道id不会小于1。
//为了避免不必要的查询,这里加个判断。
if id < 1 {
return nil, ErrRecordNotFound
}
//更新查询,返回pg_sleep(1)作为第一个值
query := `
SELECT pg_sleep(10), id, create_at, title, year, runtime, genres, version
FROM movies
where id = $1`
var movie Movie
//重要的是:将pg_sleep(10)返回值存储在[]byte切片中
err := m.DB.QueryRow(query, id).Scan(
&[]byte{}, //添加这行
&movie.ID,
&movie.CreateAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
//错误处理,如果没找到对应的movie,Scan()将返回sql.ErrNoRows错误。
//检查错误类型并返回自定义ErrRecordNotFound
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
//否则,返回Movie指针
return &movie, nil
}
如果您重新启动应用程序并向GET /v1/movies/:id端点发出请求,应该会发现在最终获得成功显示电影信息的响应之前,请求会挂起10秒。类似于:
curl -w '\nTime: %{time_total}s \n' localhost:4000/v1/movies/1
{
"movie": {
"id": 1,
"title": "Moana",
"year": 2016,
"runtime": "107 mins",
"genres": [
"animation",
"adventure"
],
"Version": 1
}
}
Time: 10.037065s
在上面的curl命令中,我们使用-w参数为HTTP响应标注命令完成所需的总时间。有关curl中的时间信息的更多细节,请参阅这篇优秀的博客。
添加查询超时
现在我们在查询数据库时模拟长时间查询操作,接下来我们强制SQL查询在3秒内没有完成时自动取消。要实现这点需要完成以下工作:
1、使用context.WithTimeout()函数创建带有3秒超时的context.Context实例。
2、使用QueryRowContext()方法,并将context.Context实例作为参数传入。
代码如下:
File:internal/data/movies.go
package data
...
func (m MovieModel) Get(id int64) (*Movie, error) {
if id < 1 {
return nil, ErrRecordNotFound
}
query := `
SELECT pg_sleep(10), id, create_at, title, year, runtime, genres, version
FROM movies
where id = $1`
var movie Movie
//使用context.WithTimeout()创建context.Context实例,超时时间为3秒。
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
//确保在Get返回前取消context
defer cancel()
//使用QueryRowContext()方法执行查询
err := m.DB.QueryRowContext(ctx, query, id).Scan(
&[]byte{},
&movie.ID,
&movie.CreateAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
//错误处理,如果没找到对应的movie,Scan()将返回sql.ErrNoRows错误。
//检查错误类型并返回自定义ErrRecordNotFound
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
//否则,返回Movie指针
return &movie, nil
}
...
在上面的代码中有几件事我想强调和解释:
- defer cancel()这行代码是必须的,因为它确保在Get()方法返回前context资源被释放,避免内存泄漏。没有它,资源将不会被释放,直到达到3秒超时或父上下文(在这个特定的例子中是context.background())被取消。
- 超时倒计时是从context.withtimeout()创建context.Context实例那一刻开始。在创建上下文实例和调用QueryRowContext()之间执行代码所花费的任何时间都将计入超时。
下面我们来测试下:
重启服务,然后向接口GET /v1/movies/:id发送请求,在3s后你会收到错误响应,类似如下:
$ curl -w '\nTime: %{time_total}s \n' localhost:4000/v1/movies/1
{
"error": "the server encountered a problem and could not process your request"
}
Time: 3.025614s
你可以回到启动应用的终端,应该能看到一行错误日志:"pq: canceling statement due to user request",如下所示:
$ go run ./cmd/api
2021/04/08 14:14:52 database connection pool established 2021/04/08
14:14:52 starting development server on :4000 2021/04/08
14:14:57 pq: canceling statement due to user request
起初这个错误日志看起来有点奇怪,直到你发现错误信息"canceling statement due to user request"是PostgreSQL上报的。在这种情况下,我们的应用程序是用户,故意在3秒后取消查询导致错误。
因此,上面的测试结果完全符合我们的预期。从上下文实例创建3秒过后,达到过期时间pq数据库驱动程序向PostgreSQL发送取消信号。PostgreSQL终止继续查询,相应的资源被释放,并返回上面所看到的错误信息。应用程序发送500 Internal Server Error响应,并在日志中打印错误信息。
更准确的说,上下文有一个Done的channel,过期时间到了就会被关闭。在SQL查询运行的时候,pg驱动程序也会监听这个Done通道。如果通道被关闭,pg驱动程序向PostgreSQL数据库发送取消信号。PostgreSQL终止查询并发送错误消息。
PostgreSQL之外的超时
还有一个重要事情需要指出的是:有可能在PostgreSQL查询开始之前就已经超时了。
您可能还记得,在本系列文章的前面,我们配置了sql.DB连接池,允许最多打开25个连接。如果所有这些连接都在使用中,那么任何新增查询将在sql.DB中'排队',直到一个连接可用为止。在这种情况下(或任何其他原因导致延迟的情况下),可能会在空闲数据库连接可用之前达到超时期限。如果发生这种情况,QueryRowContext()将返回context.DeadlineExceeded错误。
实际上,我们可以在应用程序中通过将最大打开连接设置为1并向API发出两个并发请求来演示这一点。使用-db-max-open-conns=1命令行参数重启API服务,如下所示:
$ go run ./cmd/api -db-max-open-conns=1
2020/12/04 11:50:39 database connection pool established
2020/12/04 11:50:39 starting development server on :4000
然后在另一个终端窗口中同时向GET /v1/movies/:id接口发出两个请求。在到达3秒超时的时刻,我们应该有一个正在运行SQL查询,而另一个仍然在sql.DB连接池中“排队”。你应该得到两个类似这样的错误响应:
$ curl localhost:4000/v1/movies/1 & curl localhost:4000/v1/movies/1 &
[1] 33221
[2] 33222
${
"error": "the server encountered a problem and could not process your request" }
{
"error": "the server encountered a problem and could not process your request"
}
当你返回到应用程序启动终端时,你应该会看到两个不同的错误消息:
$ go run ./cmd/api -db-max-open-conns=1
2021/04/08 14:21:36 database connection pool established
2021/04/08 14:21:36 starting development server on :4000
2021/04/08 14:21:50 context deadline exceeded
2021/04/08 14:21:50 pq: canceling statement due to user request
这里pq: canceling statement due to user request错误是和SQL查询被终止导致的,但context deadline exceeded错误是和数据库等待空闲连接超时导致的。
与此类似,当使用Scan()处理查询返回的数据时,也可能发生超时。如果发生这种情况,那么Scan()也将返回一个context.DeadlineExceeded错误。
更新数据库model
同样的,我们将其他的数据库操作都加上3s超时。并删除Get()方法中pg_sleep(10)。
File: internal/data/movies.go
package data
...
//添加一个占位符方法,用于在movies表中插入一条新记录。
func (m MovieModel) Insert(movie *Movie) error {
query := `
INSERT INTO movies (title, year, runtime, genres)
VALUES ($1, $2, $3, $4)
RETURNING id, create_at, version`
args := []interface{}{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
return m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.ID, &movie.CreateAt, &movie.Version)
}
//Get 查询特定movie方法
func (m MovieModel) Get(id int64) (*Movie, error) {
if id < 1 {
return nil, ErrRecordNotFound
}
query := `
SELECT id, create_at, title, year, runtime, genres, version
FROM movies
where id = $1`
var movie Movie
//使用context.WithTimeout()创建context.Context实例,超时时间为3秒。
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
//确保在Get返回前取消context
defer cancel()
//使用QueryRowContext()方法执行查询
err := m.DB.QueryRowContext(ctx, query, id).Scan(
&movie.ID,
&movie.CreateAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
//错误处理,如果没找到对应的movie,Scan()将返回sql.ErrNoRows错误。
//检查错误类型并返回自定义ErrRecordNotFound
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
//否则,返回Movie指针
return &movie, nil
}
//Update 更新数据库特定movie
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已经更新,返回自定义错误
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
err := m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.Version)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
//Delete 删除数据库中特定movie
func (m MovieModel) Delete(id int64) error {
//如果movie ID小于1返回ErrRecordNotFound
if id < 1 {
return ErrRecordNotFound
}
//构造SQL query删除movie
query := `
DELETE FROM movies
WHERE id = $1`
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
//使用Exec()方法执行SQL语句,传入id变量作为占位符参数值。Exec()方法返回sql.Result对象
result, err := m.DB.ExecContext(ctx, query, id)
if err != nil {
return err
}
//调用sql.Result对象的RowsAffected()方法,获取查询影响行数
rowAffected, err := result.RowsAffected()
if err != nil {
return err
}
//如果没有影响行数,可以推出数据库不存在对应id的记录,返回ErrRecordNotFound
if rowAffected == 0 {
return ErrRecordNotFound
}
return nil
}
附加内容
使用request context
除了上面使用的方法设置超时,我们可以在API处理程序中使用请求上下文作为父上下文创建一个带有超时的上下文,然后将其传递给我们的数据库模型。
但这种方式会引入操作但复杂性,对大多数应用程序来说这种方式所带来的优势没那么高。这背后的实现细节非常有趣,但也相当复杂。
网友评论