一個上線半年沒人點的匯出按鈕,被新同事點了一下,整台服務就沒了
Backend Engineering·2026年7月22日·7 分鐘閱讀

一個上線半年沒人點的匯出按鈕,被新同事點了一下,整台服務就沒了

L
Leo Wu

Leo Wu,主力 Go 的後端工程師,做過金流與高併發系統,信奉「任何回傳全部的介面都是未爆彈」。

一個平常沒人碰的按鈕

那天下午我在改一支結帳的 bug,Slack 突然開始跳訊息:後台有幾支平常穩如老狗的 API 開始噴 502,接著監控告警一路串上來,某台機器的服務直接從監控圖上消失了一段。我第一反應是「又是哪個上游掛了嗎」,翻了一下卻發現不是網路、不是資料庫,是我們自己的服務進程被 OOM killer 幹掉了。

查 log 的時候看到一行很不起眼的 access log:有人打了 /admin/orders/export。這支 API 我幾乎忘了它存在,上線大概半年,是當初給營運看報表用的「匯出全部訂單」。後來查出來是一個那週剛報到的新同事,在後台亂點熟悉環境,順手按了那顆「匯出」按鈕。

就這麼一下,服務記憶體從平常的 500MB 左右,在幾秒內飆到 8GB 撞上 container 的上限,然後整個進程被核心殺掉。更慘的是那台機器上還跑著其他正常的請求,全部一起陪葬。一個沒人用的功能,把一台好好的服務拖下水。

我一開始想錯的方向

說真的,我第一時間完全沒往那支匯出去想。我先去懷疑是不是記憶體洩漏(memory leak),是不是哪個 goroutine 沒收、哪個連線沒關,慢慢累積把記憶體吃光。因為「洩漏」這種故事我遇過,符合我的直覺。

但數據對不上。洩漏是「慢慢漲」,這次是「瞬間爆」——記憶體曲線幾乎是一根垂直的針。而且服務重啟後一切正常,記憶體乖乖回到 500MB,怎麼看都不像慢性病,比較像被人一拳打在要害上。

把時間軸對齊之後就很清楚了:記憶體暴衝的那一秒,剛好就是那支 export 進來的那一秒。不是洩漏,是「單一請求一次要了太多記憶體」。這是完全不同的兩種病。

服務記憶體用量:一支沒有分頁的匯出用 SELECT * 把整張表載進記憶體再序列化,記憶體從 500MB 瞬間飆到 8GB 被 OOM killer 殺掉,改成分批串流後保持平穩
服務記憶體用量:一支沒有分頁的匯出用 SELECT * 把整張表載進記憶體再序列化,記憶體從 500MB 瞬間飆到 8GB 被 OOM killer 殺掉,改成分批串流後保持平穩

真正的根因:一個假設資料很少的查詢

翻開那支匯出的程式碼,我大概懂了。核心邏輯簡化之後長這樣:

  • 一句 SELECT * FROM orders,沒有 WHERE 範圍、沒有 LIMIT、沒有時間區間;
  • 把撈回來的東西全部塞進一個 slice,[]Order 一次載滿在記憶體裡;
  • 再把整個 slice 一次 json.Marshal(或組成 CSV),變成一個巨大的字串塞進 response body;
  • 最後才一口氣寫回給客戶端。

問題在於這張 orders 表,這半年已經長到快兩百萬列。這句查詢等於是叫服務「把整張表搬進記憶體,再原地複製一份序列化的結果」。

這裡有兩個常被忽略的細節。第一,記憶體佔用跟資料量是成正比的,而且係數不小。兩百萬個 struct 本身就吃掉好幾 GB,Go 的物件還有欄位、指標、字串的額外開銷。第二,序列化本身要再吃一份記憶體——你在記憶體裡已經有一份 []OrderMarshal 的時候等於同時存在原始資料加上那串輸出的 JSON,尖峰時是資料量的好幾倍。500MB 到 8GB,一點都不誇張。

更關鍵的是心態上的錯。這段程式碼在上線那天是完全正常的——當時 orders 表可能才幾千列,撈全部也就吃幾十 MB,測試跑得又快又漂亮,沒有任何人會覺得不對。它不是一開始就壞,而是隨著資料長大,慢慢變成一顆未爆彈。寫的人假設「資料量很小」,而這個假設有賞味期限,只是沒人在上面標日期。

為什麼「無界查詢」特別陰險

我後來給這類東西起了個名字,叫無界查詢(unbounded query):任何一個回傳筆數沒有上限、由資料量決定大小的介面,都算。它陰險在三件事:

  • 它平常不會出事。 沒人用、或用的人資料剛好還少,它就一直安靜躺著,測試也蓋不到極端情況。
  • 它的成本跟資料量綁死。 業務越成功、資料越多,這顆炸彈的當量就越大,跟你的成長曲線同步變強。
  • 它把「一個請求的問題」放大成「整台機器的問題」。 單一請求不受控地吃記憶體,最後受害的是同一個進程、同一台機器上所有無辜的請求。

說到底,是我們把「資料量」當成一個有限、可控、很小的東西,但它其實會長。任何寫著「匯出全部」「查全部」的介面,本質上都是在賭資料永遠不會太多。

我們怎麼修

止血很簡單粗暴:先把那支 export 擋掉、加一個保護性的 LIMIT 讓它至少不會再撈整張表。但真正的修法是把觀念改掉。

第一,任何列表和匯出,一律要有分頁或明確上限。 這不是選配。沒有分頁的列表 API 就是缺陷,不管現在資料多少。內部查詢也一樣加上防呆的 LIMIT 和查詢逾時(statement timeout),寧可回錯也不要拖垮服務。

第二,大量匯出改成 streaming(串流),邊查邊寫,不要整包 buffer。 不要「先撈完整張表再一次輸出」,而是用資料庫游標(cursor)或 keyset 分批往下撈——每次撈一批(例如一萬列)、寫進 response、丟掉、再撈下一批。搭配 HTTP 的串流回應,記憶體佔用就從「跟總資料量成正比」變成「只跟一批的大小成正比」,兩百萬列跟兩百萬列的差別,記憶體幾乎是平的。順帶一提,keyset 分頁(用上一批最後一筆的 id 當游標)比 OFFSET 翻頁在大表上健康太多。

第三,真的很重的匯出,改成非同步背景工作。 使用者按下匯出,我們不當場算,而是丟一個 job 進佇列,背景慢慢產檔(寫到物件儲存),好了再通知使用者下載。這樣同步請求永遠是輕的,重活跟線上流量隔離開來。

第四,給每個服務設記憶體上限,並讓單一請求別能吃垮全部。 container 的 memory limit 該設好,讓失控的東西早點被隔離;能做請求層級的資源控制就更好。這是最後一道防線——它不會讓你不出錯,但會讓一次犯錯不要變成全機災難。

小結

這次事故沒有什麼高深的技術,根因蠢到有點好笑:一句 SELECT * 加上一個「資料應該不多吧」的假設。但它教會我幾件會一直記著的事。

  • 永遠不要假設資料量很小。 寫任何查詢和介面之前,先問自己一句:「如果這裡有一千萬筆,會發生什麼事?」答不出來,就是還沒寫完。
  • 記憶體會跟著資料量走,序列化還要再吃一份。 「載進來」跟「輸出去」是兩筆記憶體,尖峰是相加的。
  • 任何回傳『全部』的介面都是未爆彈。 它上線那天沒事,只代表引信還沒燒到。
  • 分頁和串流不是效能優化,是保命措施。 我以前把它們當成「有空再做的錦上添花」,現在我當成「不做就是留一顆雷給未來的自己」。

那顆按鈕現在還在,只是背後換成了背景產檔加分批串流。新同事後來還很不好意思地跟我道歉,我跟他說:該道歉的是半年前寫那段程式碼、還覺得它很正常的我。

#記憶體#OOM#無界查詢#分頁#串流#事故覆盤

留言討論

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

相關文章