使用者早就關掉頁面了,我的資料庫卻還在幫他跑查詢
Backend Engineering·2026年6月24日·6 分鐘閱讀

使用者早就關掉頁面了,我的資料庫卻還在幫他跑查詢

L
Leo Wu

後端工程師,主力 Go,做過交易所撮合引擎與金流系統,被殭屍查詢燒過一次 DB 的 CPU。

有一次線上 DB 的 CPU 莫名其妙飆到 90%,但 QPS 沒有暴增,慢查詢日誌也沒特別多。我查了快一個小時才發現真正的兇手:一堆「使用者早就取消、但後端還在跑」的查詢。前端請求逾時、使用者關了分頁,連線斷了,可是我的 Go 服務完全不知道,還老老實實地把一個要跑 8 秒的報表查詢跑完,然後把結果寫進一個沒人在聽的連線裡。這篇講 context.Context 的取消到底怎麼傳、我漏在哪一層,以及為什麼「有收到 context 不等於有用到 context」。

先講清楚 context 的取消是什麼

使用者早就關掉頁面了,我的資料庫卻還在幫他跑查詢:本文架構

Go 的 context.Context 做兩件事:傳遞請求範圍的值,以及傳遞「取消」這個訊號。我這篇只談後者。

當一個 HTTP 請求進來,Go 的 net/http 會給你一個綁在這個請求上的 context。使用者斷線、請求逾時,這個 context 就會被 cancel,它的 Done() channel 會被關閉。重點是:cancel 這個動作不會自動去中斷任何正在跑的程式碼。它只是把一個旗標從「還在跑」翻成「該停了」,然後通知所有「有在聽」的人。有沒有人聽、聽到之後要不要停,是你自己的事。

這就是我踩坑的根源。我以為只要 handler 有拿到 context,取消就會一路傳下去。錯了。取消只會傳到「願意去檢查它」的地方。

我漏在哪一層

那個報表 API 的呼叫鏈大概是這樣:

  • handler 收到 r.Context()
  • 呼叫 service 層,把 context 傳下去
  • service 呼叫 repository 層,也把 context 傳下去
  • repository 裡組了一段 SQL,然後呼叫 db.Query(sql)

問題就在最後一行。我用的是 db.Query(sql),不是 db.QueryContext(ctx, sql)

講出來有點糗——我查那一個小時,一開始居然全往「是不是慢查詢」「是不是連線池設太小」的方向鑽,把 DB 參數翻來覆去調,就是沒往「我根本沒把取消傳到 DB」這個方向想。繞了一大圈才回到這行最不起眼的程式碼。

context 明明一路傳到了 repository,但我在真正發查詢的那一刻,把它丟掉了。database/sql 只有在你用 QueryContextExecContext 這類帶 context 的方法時,才會在 context 被取消時去中斷底層的查詢。我用了不帶 context 的版本,等於跟資料庫說「不管上面發生什麼事,這個查詢請務必幫我跑完」。

所以連線斷了、context 也 cancel 了,但那個 8 秒的查詢在 DB 端還是活得好好的。並發一上來,這種「殭屍查詢」越積越多,CPU 就是這樣被吃掉的。

修正:讓每一層都真的「聽」

修法本身很無聊,就是把所有 Query 換成 QueryContextExec 換成 ExecContext,把 context 一路帶到底。但我學到的重點不是這個 API 名字,而是一個心態:

context 傳下去只是把「電話線」接到那一層,那一層有沒有去接電話,是另一回事。

我後來養成兩個習慣:

  • 任何會阻塞、會等 I/O 的呼叫(DB、HTTP client、gRPC、Redis),一定要用帶 context 的版本。標準庫和大部分好的套件都有提供,沒提供的套件我會很警惕。
  • 自己寫的長迴圈(例如一批要處理幾萬筆的背景工作),要主動在迴圈裡檢查 ctx.Err(),發現被取消就早點收手,別悶著頭跑完。

一個很多人搞錯的細節:context 要往下傳,不要重新造一個

我 code review 時最常抓到的錯,是有人在中間某一層寫了 context.Background()context.TODO(),然後把它往下傳。

這等於把上游的取消訊號整條剪斷。上面 cancel 了,但你這一層用的是一個全新的、永遠不會被 cancel 的 context,下游自然什麼都收不到。取消鏈就斷在你這裡。

正確的做法是:把你收到的 context 當作父節點,需要加逾時就用 `context.WithTimeout(ctx, ...)` 從它衍生,這樣父的取消會傳到子,你加的逾時也會生效,兩個訊號是疊加的,不是互斥的。

順手加一層防呆:給查詢設硬上限

修完 context 之後,我還多做了一件事:在 repository 層對那種「本來就不該跑很久」的查詢,用 context.WithTimeout 加一個硬性的逾時上限,比如 3 秒。

理由是:就算上游因為某些原因沒有正確取消,我也不希望單一查詢無限制地佔用 DB 資源。這是一種「不信任上游」的防禦性寫法。取消訊號是「使用者不要了」,逾時是「不管誰要不要,超過這個時間就是不對勁」,兩個要一起有,系統才穩。

事後我怎麼確認真的修好了

我不太相信「改完覺得應該好了」這種話,所以我做了兩個驗證:

  • 壓測時故意讓 client 發完請求就立刻斷線,然後去看 DB 的 pg_stat_activity(PostgreSQL)裡那些查詢有沒有跟著消失。修之前它們會賴著不走,修之後幾乎是即時被 cancel。
  • 在 repository 加了一個 metric,記錄「因為 context 取消而中止的查詢數」。上線後這個數字不是零,代表取消鏈是真的有在作用——這反而讓我安心。

小結

這次事故給我的教訓很單純但很值錢:context 的取消是一個合作式的機制,不是強制的。你把 context 傳下去,只是給了下游一個「可以選擇聽」的機會;真正決定要不要停下來的,是每一層自己的程式碼。所以下次你把 context 一路傳好傳滿卻覺得取消沒生效,先別懷疑 Go,先回頭看看——你是不是在某一層,把電話接上了卻沒去接。

#Go#context#資料庫#逾時控制#後端架構

留言討論

有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。

相關文章