一句 1.5 MB 的 SQL 讓整站停了 40 分鐘:從 Postgres JIT、SET LOCAL 到連線池的 take away
Tags: Go, GUC, JIT, PgBouncer, PostgreSQL, Postmortem, SET-LOCAL, statement_timeout, xorm, 事故復盤
這週 production 出了一次事故:一個 API 被呼叫了四次,整站排隊 40 分鐘。事後把整條鏈拆開來看,每一環單獨都「沒問題」,疊在一起就炸了。這篇把技術上學到的東西整理下來,特別是 Postgres 的 GUC、SET LOCAL、JIT 和連線池這幾個平常不太會碰、碰到就是大事的題目。
事故細節已去識別化,不含任何客戶資訊。
一、發生了什麼
系統是一個多租戶的 SaaS 後端,Go + Gin + xorm,後面是 PostgreSQL 16,中間經過 PgBouncer 連線池。
- 一個內部示範租戶累積了 五萬多筆從未關閉的主紀錄(測試資料沒人清)。
- 一個內部工具對 production 呼叫了一支列表查詢 API,沒帶任何篩選條件,四次。
- 這支 API 載入整個租戶的主紀錄後,為了補撈另一張表裡關聯的子紀錄,對每筆主紀錄串一句
OR tag_list LIKE '%編號%'。五萬筆=五萬個 OR、五萬個 bind 參數、1.5 MB 的 SQL。 - Postgres 16 的 LLVM JIT 看到這麼大的表達式,決定全部 inline 加 optimize。四條 DB 連線各自在編譯器裡卡 28 到 42 分鐘,連
pg_cancel_backend都不太理。 - API 只有一顆 pod、連線池 5 條。四條被佔住,剩下所有請求排隊。
- 健康檢查只回
"ok"不碰 DB,Kubernetes 以為一切正常。 - 呼叫方 30 秒就 timeout 走人了,但 API 不把 HTTP 取消傳給 DB,查詢繼續跑。
最後是 DevOps 手動換 pod、對 DB 連線 pg_terminate_backend,流量才恢復。
二、Take away 1:JIT 不是免費的
Postgres 11 以後內建 LLVM JIT,預設開啟。它的邏輯是:查詢的估計成本超過門檻就把表達式編譯成機器碼。
jit_above_cost = 100000 -- 超過就 JIT
jit_inline_above_cost = 500000 -- 超過就 inline
jit_optimize_above_cost = 500000 -- 超過就跑 LLVM optimizer
DevOps 用同樣的查詢形狀做 EXPLAIN 得到這張表:
| LIKE 條件數 | 估計成本 | inline / optimize |
|---|---|---|
| 1,000 | 114,255 | 否 / 否 |
| 3,000 | 339,225 | 否 / 否 |
| 5,000 | 564,195 | 是 / 是 |
| 10,000 | 1,126,620 | 是 / 是 |
五千個 LIKE 就跨過門檻,五萬個就是在 LLVM 裡轉幾十分鐘。而且 JIT 編譯階段對 cancel 信號的反應很慢,這是這次連 cancel 都救不了的原因。
對 OLTP 型的服務(短查詢、高併發),JIT 幾乎沒有好處,只有風險。結論:OLTP 服務預設 jit = off。 真的需要的分析型查詢再個別開。
三、Take away 2:GUC、SET 與 SET LOCAL
這是我覺得含金量最高的一段。
GUC 是什麼
Postgres 把所有可調的設定統稱 GUC(Grand Unified Configuration)。jit、statement_timeout、search_path、timezone、work_mem 都是。SET 就是在改 GUC 的值。
SET 和 SET LOCAL 的差別
SET jit = off; -- 改這條「連線(session)」的值,連線活著就一直有效
SET LOCAL jit = off; -- 改這筆「交易(transaction)」的值,COMMIT / ROLLBACK 後自動還原
官方文件把後者叫 transaction-scoped 或 transaction-local 設定。
為什麼在連線池後面只能用 SET LOCAL
後端連的不是 Postgres 本體,是 PgBouncer。PgBouncer 有三種模式:
| 模式 | 後端連線什麼時候還回池子 |
|---|---|
| session pooling | 客戶端斷線 |
| transaction pooling | 每筆交易結束 |
| statement pooling | 每句 SQL 結束 |
我們用的是 transaction pooling。在這個模式下,用 SET 改的是那條後端連線的 session state。你的交易結束,連線被還回池子,下一個拿到這條連線的人(可能是完全不同的 API、不同的租戶)就莫名繼承了你的 jit = off 和 statement_timeout = 60s。
這叫 session state leakage。PgBouncer 文件直接寫 “SET is not supported in transaction pooling”。
SET LOCAL 綁在交易上,交易結束就消失,連線還回去時是乾淨的。所以修法是在每個請求的交易開頭:
session.Begin()
session.Exec("SET LOCAL jit = off; SET LOCAL statement_timeout = '60s'")
而且同事寫了一個測試專門驗這件事:進一個請求,在 handler 裡查 current_setting('jit') 要是 off;請求結束後在交易外再查,要回到原本的值。沒有這個測試,SET LOCAL 被誰改成 SET 完全不會有編譯錯誤,只會在 production 上讓別人的請求莫名吃到 60 秒 timeout。
順帶的幾個名詞
SET SESSION:跟SET一樣,只是把 session 寫出來。RESET/RESET ALL:把 GUC 還原預設。server_reset_query = DISCARD ALL:PgBouncer 歸還連線時清 session state,但只在 session pooling 跑,transaction pooling 不跑。- 查文件的關鍵字:
postgresql SET LOCAL transaction scope、pgbouncer transaction pooling session state、GUC。
四、Take away 3:statement_timeout 不可以是 0
事故當下 production 的 statement_timeout 是 0,意思是「一句 SQL 可以跑到天荒地老」。
這是讓一句失控的 SQL 有能力拖垮整站的前提。沒有這道牆,下一個形狀類似的查詢會重演。
修法是全站預設 60 秒(用上面的 SET LOCAL),真的需要更久的 route 個別放寬,而不是反過來。放寬的那幾條(LLM 串流、檔案匯入、批次運算)要有自己的上限,不能無限。
五、Take away 4:HTTP 取消要傳到 DB
呼叫方 30 秒就放棄了,DB 卻跑了 40 分鐘。因為舊的 middleware 開 DB session 時沒有綁 request context。
Go 的 database/sql 和 xorm 都支援 context:
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Minute)
defer cancel()
session := engine.NewSession()
session.Context(ctx)
綁上去之後:
- 瀏覽器斷線(換頁、重整、關分頁)→
c.Request.Context()被取消 → DB 查詢被取消、交易 rollback。 - 連線池滿了在等連線 → 也受 ctx 的 timeout 控制,不會永遠等。
這帶來一個前端要知道的副作用:以前使用者送出寫入請求後立刻重整,後端還是會寫完;現在交易會 rollback。所以前端在 POST / PATCH 進行中要擋 beforeunload。SPA 內的 history.push 不會中斷 XHR,只有真的 unload 才會。
六、Take away 5:條件數隨資料長的 SQL
原本的寫法(半年前我自己加的):
cond := "start_time between ? and ?"
for _, id := range ids {
cond += " OR tag_list LIKE ?"
args = append(args, "%"+id+"%")
}
幾十筆沒事,五萬筆就是上面那個故事。修法是改成單一陣列參數:
cond += " OR tag_list LIKE ANY (?::text[])"
args = append(args, pq.Array(patterns))
SQL 長度從 O(n) 變常數,planner 看到的是一個節點而不是五萬個。
但這只是止血,不是治本。根本問題是在逗號串接的 text 欄位上做 LIKE 比對本身就是錯的資料模型,中期要正規化成關聯表。
另外一個同家族的懸崖:xorm 的 In() 一筆一個參數,而 lib/pq 的硬上限是 65,535 個參數。同一支 API 後半段還有好幾句 In() 吃的是從全量結果推出來的陣列,示範租戶已經五萬多,再長就直接報錯。所以列表 API 一定要有硬上限。
七、Take away 6:健康檢查要真的碰 DB
router.GET("/", func(c *gin.Context) { c.String(200, "ok") })
這種健康檢查在連線池被佔滿的時候照樣回 200,Kubernetes 就不會換 pod。整整 40 分鐘沒有任何自動恢復。
改成真的向連線池要一條連線跑 SELECT 1,2 秒拿不到回 503:
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
session := engine.NewSession()
defer session.Close()
session.Context(ctx)
if _, err := session.Exec("SELECT 1"); err != nil {
c.String(503, "db unavailable")
return
}
c.String(200, "ok")
注意兩件事:
- 不走 token、不帶 tenant、不查任何表、不回 DB 細節,因為這個路徑沒有驗證。
- 建議只給 readiness 用,liveness 維持純程序檢查,否則 DB 短暫抖動會讓所有 pod 反覆重啟。
八、Take away 7:改 replicas 之前先問「有沒有記憶體內的狀態」
事故當下有人把 pod 從 1 顆改成 3 顆,之後也固化進 values。但這個服務的 per-tenant rate limiter 是 in-process 的 sync.Map,三顆 pod 等於限額被稀釋三倍。
加副本數、加連線池、改限流,這三件事要一起看。而且「加連線數」不能代替「修查詢」,它只會讓昂貴的工作被放大三倍。
九、流程面的 take away
技術修法同事做得很好,流程面反而是差點出事的地方:
- 事故修法只修病根。全域 timeout、replicas、CI gate 各自開 MR,出問題才能單獨 revert。這次四件事一個 commit。
- hotfix 打完 tag 要立刻合回 master 和 staging 分支。這次 tag 只在 fix 分支上,下一次從 staging 分支出版就會把修正蓋掉。
- 每個 tag 都要有 CHANGELOG。事後補記也要補。
- 出版前看那個分支上有沒有「DDL 還沒進 migrator」的功能。有的話從 master 出 hotfix,不從 staging 分支出。
- 內部工具對 production 的查詢要有和客戶一樣的防護。這次的觸發者是我們自己的 MCP 工具,不是客戶。
十、一張檢查表
看 MR 的時候逐項過:
- 列表 API 有硬上限嗎?無篩選會拒絕嗎?
- 有沒有在迴圈裡串 SQL 條件字串?
- 丟進
In()的陣列有上限嗎? - DAO 的 error 有往上傳嗎?有沒有「失敗就回空」?
- 這條 route 會超過 60 秒嗎?會的話個別放寬,不動全站預設。
- 在連線池後面改 GUC 用的是
SET LOCAL嗎? - 改了 replicas、連線池、限流其中一個,另外兩個看過了嗎?
- 一個 MR 只做一件事嗎?
- CHANGELOG 有條目嗎?有 DDL 的話硬順序寫清楚了嗎?
最後一句話總結:一句失控的 SQL 不該有能力拖垮整站。 statement_timeout、request context、健康檢查碰 DB、列表 API 上限,這四道牆任何一道在,這次都只會是一個 400 或 500,不會是 40 分鐘。