Go:如何管理數據庫超時和取消事務
Go 的一個重要特性是可以在數據庫查詢時通過上下文實例取消查詢 (只要數據庫驅動程序支持取消)。從表面上看,使用這個功能非常簡單。但一旦你開始挖掘細節,就會發現有很多細微差別和相當多的陷阱…… 尤其是當你在 web 應用程序或 API 環境中使用這個功能時。
所以在這篇文章中,我想解釋如何在 web 應用程序中取消數據庫查詢,要注意哪些奇怪的用法和邊界情況,並對你可能遇到的一些情況提供解決方案。
首先,爲什麼要取消數據庫查詢?我想到了兩個原因:
-
當查詢的完成時間比預期的長很多時。如果出現這種情況,則說明有問題—可能是針對特定查詢,也可能是針對數據庫或應用程序。在這種情況下,您可能想經過一段時間後取消查詢 (這樣資源可以得到釋放,數據庫連接返回到 sql.DB 連接池),打印錯誤並返回一個 500 內部服務器錯誤給客戶端。
-
當客戶端在查詢完成之前意外斷開。出現這種情況的原因有很多,比如用戶關閉瀏覽器選項卡或終止進程。在這個場景中,沒有什麼真正的 “錯誤”,但是無需給客戶端返回響應,所以您可以取消查詢並釋放資源。
模擬長時間運行的查詢
讓我們從第一種情況開始。爲了演示這一點,我將編寫一個非常簡單的 web 應用程序,該程序使用 pq 驅動庫對 PostgreSQL 數據庫執行 SELECT pg_sleep(10) SQL 查詢。pg_sleep(10) 函數將使查詢在返回之前休眠 10 秒,本質上模擬一個運行緩慢的查詢。
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
_ "github.com/lib/pq"
)
var db *sql.DB
func slowQuery() error {
_, err := db.Exec("SELECT pg_sleep(10)")
return err
}
func main() {
var err error
db, err = sql.Open("postgres", "postgres://user:pa$$word@localhost/example_db")
if err != nil {
log.Fatal(err)
}
if err = db.Ping(); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", exampleHandler)
log.Println("Listening...")
err = http.ListenAndServe(":5000", mux)
if err != nil {
log.Fatal(err)
}
}
func exampleHandler(w http.ResponseWriter, r *http.Request) {
err := slowQuery()
if err != nil {
serverError(w, err)
return
}
fmt.Fprintln(w, "OK")
}
func serverError(w http.ResponseWriter, err error) {
log.Printf("ERROR: %s", err.Error())
http.Error(w, "Sorry, something went wrong", http.StatusInternalServerError)
}
如果您運行這段代碼,然後嚮應用程序發出一個 GET / 請求,應該會發現請求掛起了 10 秒,然後才最終得到一個 “OK” 響應。像這樣:
$ curl -i localhost:5000/
HTTP/1.1 200 OK
Date: Fri, 17 Apr 2020 07:46:40 GMT
Content-Length: 3
Content-Type: text/plain; charset=utf-8
OK
注意:上面的應用程序代碼結構被有意地簡化了。在實際的項目中,我建議使用依賴注入使得 sql.DB 連接池和日誌對象在處理程序中可用,而不是使用全局變量。關於數據庫的連接管理可以閱讀 Go:訪問數據庫代碼組織方式
添加上下文超時
現在我們已經有了模擬一個長時間運行的查詢代碼,讓我們對查詢執行一個超時,這樣如果查詢沒有在 5 秒內完成,它就會自動取消。
要做到這一點,我們需要:
1、使用 context.withtimeout() 函數來創建一個 context.Context。具有 5 秒超時時間的上下文實例。
2、使用 ExecContext() 方法執行 SQL 查詢,並將 context.Context 作爲函數參數。
如下所示:
package main
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"time"
_ "github.com/lib/pq"
)
var db *sql.DB
func slowQuery(ctx context.Context) error {
// 創建一個5秒超時的新子上下文,使用提供的ctx參數作爲父上下文。
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
//將新的上下文作爲db.ExecContext第一個參數
_, err := db.ExecContext(ctx, "SELECT pg_sleep(10)")
return err
}
...
func exampleHandler(w http.ResponseWriter, r *http.Request) {
// 將請求上下文傳遞給slowQuery(),以便它可以用作父上下文。
err := slowQuery(r.Context())
if err != nil {
serverError(w, err)
return
}
fmt.Fprintln(w, "OK")
}
...
這裏我想強調和解釋幾點:
-
我們將 r.Context 傳給 slowQuery 函數作爲父上下文。正如後面將看到的,這個很重要,因爲這意味着請求上下文上的任何取消信號都能夠 “擴散” 到我們在 ExecContext()中使用的上下文。
-
defer cancel() 這行很重要,是因爲它確保和子上下文相關的資源在 slowQuery() 函數返回前被釋放。如果不調用 cancel() 函數的話,可能會造成內存泄漏:資源不會被釋放,直到 r.Context() 被取消或 5 秒超時被命中 (無論哪個最先發生)。
-
超時倒計時從使用 context.withtimeout() 創建子上下文的那一刻開始。如果你想對此進行更多的控制,你可以使用 context.WithDeadline() 函數,它允許你設置一個顯式的超時時間。
我們來試試。如果再次運行應用程序併發出 GET / 請求,在 5 秒的延遲之後,你會得到這樣的響應:
$ curl -i localhost:5000/
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Fri, 17 Apr 2020 08:21:14 GMT
Content-Length: 28
Sorry, something went wrong
如果你回到運行應用程序的終端窗口,應該看到類似這樣的日誌信息:
$ go run .
2021/09/22 10:21:07 Listening...
2021/09/22 10:21:14 ERROR: pq: canceling statement due to user request
這條日誌信息可能看起來有點奇怪... 直到你意識到錯誤消息實際上來自 PostgreSQL。從這個角度看,它是有意義的:我們的 web 應用程序是用戶在 5 秒後取消查詢。
具體來說,在 5 秒後上下文超時,pq 驅動發送一個取消信號給 PostgreSQL 數據庫。然後 PostgreSQL 終止正在運行的查詢 (從而釋放資源)。客戶端被髮送了一個 500 Internal Server Error 的響應,並且錯誤消息被記錄下來,這樣我們就知道什麼地方出錯了。
更準確地說,我們的子上下文 (具有 5 秒超時的那個) 有一個 Done channel,當超時到達時,它將關閉 Done channel。SQL 查詢運行時,我們的數據庫驅動程序 pq 也在運行一個後臺 goroutine,偵聽這個 Done channel。如果該 channel 被關閉,將發送取消信號給 PostgreSQL。PostgreSQL 終止查詢,作爲對原始 pq goroutine 的響應,發送我們上面看到的錯誤消息。然後將該錯誤消息返回給 slowQuery()函數。
處理關閉的連接
我們再試一種情況。使用 curl 來創建一個 GET / 請求,然後非常快速地 (在 5 秒內) 按 Ctrl+C 來取消請求。
如果您再次查看應用程序的日誌,會看到另一行日誌,其中包含與我們之前看到的完全相同的錯誤消息。
$ go run .
2021/09/22 10:21:07 Listening...
2021/09/22 10:21:14 ERROR: pq: canceling statement due to user request
2021/09/22 10:41:18 ERROR: pq: canceling statement due to user request
這裏發生了什麼?
在本例中,r.context 請求上下文 (我們在上面的代碼中將其用作父上下文) 被取消,因爲客戶端關閉了連接。
對於傳入服務器的請求,如果客戶端連接關閉或者 ServeHTTP 方法返回,請求上下文都會被 canceled。
這個取消信號發送到我們的子上下文,它的 Done channel 關閉,並且 pq 驅動程序以與之前完全相同的方式終止正在運行的查詢。
考慮到這一點,我們看到同樣的錯誤消息就不足爲奇了… 從 PostgreSQL 的角度來看,發生的事情與超時時完全相同。
但從我們的 web 應用程序的角度來看,情況是非常不同的。客戶端連接被關閉可能有許多不同的原因。從應用程序的角度來看,這並不是一個真正的錯誤,儘管將其作爲告警記錄下來可能是明智的。
幸運的是,可以通過在子上下文上調用 ctx.Err()方法來區分這兩個場景。如果上下文被取消 (由於客戶端關閉連接),那麼 ctx.Err() 將返回 context. canceled。如果超時,它將返回 context.DeadlineExceeded。如果同時達到了最後期限並且取消了上下文,那麼 ctx.Err()將以最先出現的錯誤爲準。
這裏需要指出的另一件重要的事情是:在 PostgreSQL 查詢開始之前就可能發生超時 / 取消。例如,您可能在 sql.DB 連接池上設置了 MaxOpenConns()最大連接數,如果達到了連接數限制,並且所有連接都在使用中,那麼查詢將由 sql.DB“排隊”,直到連接可用。在這種情況下 (或任何其他導致延遲的情況下),超時 / 取消很有可能在空閒數據庫連接可用之前發生。在這種情況下,ExecContext() 將直接返回 ctx.Err()值作爲響應。
如果在 QueryContext() 方法中,當使用 Scan() 處理數據時,也可能發生超時 / 取消。如果發生這種情況,Scan() 將直接返回 ctx.Err() 值作爲錯誤。據我所知,database/sql 文檔中並沒有提到這種行爲,但我在 Go 1.14 中碰到過這種情況。
把所有這些情況放在一起,一個明智的方法是檢查 “pq: canceling statement due to user request" 錯誤,然後在 slowQuery() 函數返回之前用 ctx.Err()中的錯誤包裝它。
在我們的處理程序中,我們可以使用 errors.Is() 函數來檢查 slowQuery() 的錯誤是否等於 context.Canceled,並對錯誤進行管理。像這樣:
package main
import (
"context"
"database/sql"
"errors" // New import
"fmt"
"log"
"net/http"
"time"
_ "github.com/lib/pq"
)
var db *sql.DB
func slowQuery(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, err := db.ExecContext(ctx, "SELECT pg_sleep(10)")
//如果得到"pq: canceling statement..." 對其進行封裝並返回
if err != nil && err.Error() == "pq: canceling statement due to user request" {
return fmt.Errorf("%w: %v", ctx.Err(), err)
}
return err
}
...
func exampleHandler(w http.ResponseWriter, r *http.Request) {
err := slowQuery(r.Context())
if err != nil {
//檢查返回的錯誤是否等於context.Canceled,如果相等,記錄告警。
switch {
case errors.Is(err, context.Canceled):
serverWarning(err)
default:
serverError(w, err)
}
return
}
fmt.Fprintln(w, "OK")
}
func serverWarning(err error) {
log.Printf("WARNING: %s", err.Error())
}
...
如果您現在再次運行這個應用程序,併發出兩個不同的 GET / 請求—一個超時,另一個取消—您應該會在應用程序日誌中清楚地看到不同的消息,如下所示:
$ go run .
2021/09/22 13:09:25 Listening...
2021/09/22 13:09:45 ERROR: context deadline exceeded: pq: canceling statement due to user request
2021/09/22 13:09:47 WARNING: context canceled: pq: canceling statement due to user request
其他上下文感知的方法
database/sql 包爲 sql.DB 的大部分操作提供了上下文感知變量,包括 PingContext(),QueryContext(),和 QueryRowContext()。我們更新 main 函數使用 PingContext() 代替 Ping().
在這種情況下沒有使用 request context 作爲父 context,所以需要用 context.Background() 創建一個空的父上下文。
...
func main() {
var err error
db, err = sql.Open("postgres", "postgres://user:pa$$word@localhost/example_db")
if err != nil {
log.Fatal(err)
}
// 用context.Background()作爲父上下文創建一個10秒超時的子上下文
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 在測試連接池時使用此選項。
if err = db.PingContext(ctx); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", exampleHandler)
log.Println("Listening...")
err = http.ListenAndServe(":5000", mux)
if err != nil {
log.Fatal(err)
}
}
...
可以爲所有請求設置一個全局超時嗎?
當然,你可以在你的路由上創建並使用一些中間件,爲當前的請求上下文添加一個超時,類似如下:
func setTimeout(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// 這個可以基於已有上下文創建新的請求上下文。
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
如果你採用這種方法,有幾件事要注意:
-
超時從創建上下文的那一刻開始,因此在數據庫查詢之前,處理程序中運行的任何代碼都計入超時。
-
如果在一個處理程序中執行多個查詢,那麼它們都必須在同一時間內完成。
-
即使派生的子上下文具有不同的超時時間,該超時也將繼續適用。因此,可以在子上下文中設置更早的超時,但不能使其更長。
http.TimeoutHandler
Go 提供了 http.TimeoutHandler 中間件函數來封裝你的 web 處理程序或 router/servemux。這與上面的中間件類似,因爲它在請求上下文上設置了一個超時… 所以上面的警告也適用於使用這個。
然而 http.TimeoutHandler 還向客戶端發送 503 服務不可用響應和一個 HTML 錯誤消息。如果你在開發中使用這個,就不需要向客戶端發送錯誤響應了。
context 在事務中的使用
database/sql 包提供了一個 BeginTx() 方法,可以使用它來啓動上下文感知的事務。重要的是理解您提供給 BeginTx() 的上下文應用於整個事務。在上下文超時 / 取消的情況下,事務中的查詢將自動回滾。
爲事務中的所有查詢傳遞相同的上下文作爲參數是完全沒問題的,在這種情況下,它可以確保它們在任何超時 / 取消之前全部 (作爲一個整體) 完成。或者你想要每個查詢單獨的超時,可以創建超時時間不同的子上下文。這些子上下文必須根據 BeginTx()傳入的上下文創建,否則可能會存在 BeginTx()中的上下文已經超時 / 取消並自動回滾,而代碼還在執行查詢。如果這種情況發生會收到 “sql: transaction has already been committed or rolled back” 錯誤。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gTtuK6VDC3QpGAiUMdMndA