3 minute read

Tags: , , , , , , , , ,

這週 production 出了一次事故:一個 API 被呼叫了四次,整站排隊 40 分鐘。事後把整條鏈拆開來看,每一環單獨都「沒問題」,疊在一起就炸了。這篇把技術上學到的東西整理下來,特別是 Postgres 的 GUC、SET LOCAL、JIT 和連線池這幾個平常不太會碰、碰到就是大事的題目。

事故細節已去識別化,不含任何客戶資訊。


一、發生了什麼

系統是一個多租戶的 SaaS 後端,Go + Gin + xorm,後面是 PostgreSQL 16,中間經過 PgBouncer 連線池。

  1. 一個內部示範租戶累積了 五萬多筆從未關閉的主紀錄(測試資料沒人清)。
  2. 一個內部工具對 production 呼叫了一支列表查詢 API,沒帶任何篩選條件,四次。
  3. 這支 API 載入整個租戶的主紀錄後,為了補撈另一張表裡關聯的子紀錄,對每筆主紀錄串一句 OR tag_list LIKE '%編號%'。五萬筆=五萬個 OR、五萬個 bind 參數、1.5 MB 的 SQL。
  4. Postgres 16 的 LLVM JIT 看到這麼大的表達式,決定全部 inline 加 optimize。四條 DB 連線各自在編譯器裡卡 28 到 42 分鐘,連 pg_cancel_backend 都不太理。
  5. API 只有一顆 pod、連線池 5 條。四條被佔住,剩下所有請求排隊。
  6. 健康檢查只回 "ok" 不碰 DB,Kubernetes 以為一切正常。
  7. 呼叫方 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 分鐘。