在 Cloud Run 被 OOM 教訓的一課:用 Stream 把記憶體從 O(N) 降到 O(1)
2026 年剛開始,我就收到了一份「大禮」。
公司內部有一個負責上傳資料到外部數據平台 (Data Partner) 的排程任務,在 Local 開發時測試幾萬筆資料都跑得好好的,結果一上線遇到 150 萬筆資料的大報表,服務直接炸開。
GCP Log 上出現了令人絕望的 Fatal Error:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
這篇文章記錄了這次從記憶體崩潰到修復,以及最後發現架構上盲點的除錯過程。
案發經過
我們的任務很單純:從 BigQuery 撈取大量的使用者 ID (約 150 萬筆),經過一些欄位對應 (Mapping) 後,轉成特定格式的 JSON,最後上傳到合作夥伴的 API,同時備份一份資料回資料倉儲 (Data Warehouse)。
原本的寫法非常直覺(或者說非常天真):
// ❌ 記憶體殺手寫法
class Uploader {
private data = []
handleRow(row) {
// 1. 把每一筆資料都塞進 Array
this.data.push(transform(row))
}
async finish() {
// 2. 最後再一次轉成 JSON 字串
const jsonStr = JSON.stringify(this.data)
// 3. 寫入檔案或上傳
await upload(jsonStr)
}
}
這段程式碼在處理 10 萬筆資料時可能只需要幾百 MB,但在處理 150 萬筆資料時,它在 Cloud Run (限制 2GB RAM) 上直接撞牆。
兇手是誰?
看看這段 Crash 當下的 Log,V8 引擎發出了最後的哀號:
[1:0x520d390] 927293 ms: Mark-sweep 974.6 (1042.3) -> 972.2 (1048.3) MB, 1854.6 / 0.0 ms (average mu = 0.661, current mu = 0.347) allocation failure; scavenge might not succeed
這行 Log 告訴我們兩件事:
- GC 已經盡力了:Mark-sweep (老生代垃圾回收)1 花了 1.8 秒,卻只回收了 2.4MB 的空間。這代表記憶體裡塞滿了「活著的物件」,GC 根本不敢動它們。
- 兇手在 Stack Trace 裡:
最後死在
v8::internal::JsonStringifier::SerializeString v8::internal::JsonStringifyJSON.stringify。
為什麼會這樣?
這裡有兩個 Node.js 的記憶體殺手在作祟:
- V8 物件的成本 (Object Overhead)
一個簡單的
{ "id": "123", "val": 1 }在 JSON 字串裡可能只佔 20 bytes,但在 V8 的 Heap 裡,它需要儲存 Hidden Class、Properties、Pointers 等中繼資料,實際佔用可能是 100 bytes 以上。150 萬個小物件加起來,記憶體佔用非常可觀。 - 字串拼接的災難
當我們在大陣列上呼叫
JSON.stringify時,V8 需要一塊連續的記憶體空間來存放產生出來的巨大字串。如果你已經用了 1.5GB 的 Ram 存 Array,這時候又要跟系統要 500MB 來存字串,系統只能跟你說:辦不到。
解決方案:空間不夠,時間來湊
既然不能當「大胃王」一次把資料吃完再消化,那我們就改當「迴轉壽司」:來一盤,吃一盤。
我們引入了 Node.js Stream 的概念,搭配暫存檔案來解決這個問題。
改用串流寫入 (Stream to File)
把原本的 Array.push 改成直接寫入 fs.createWriteStream。
// ✅ 記憶體友善寫法
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
class Uploader {
private writer: fs.WriteStream
private filePath: string
constructor() {
// 建立一個指向暫存目錄的寫入流
this.filePath = path.join(os.tmpdir(), 'temp_data.json')
this.writer = fs.createWriteStream(this.filePath)
}
handleRow(row) {
const item = transform(row)
// 1. 直接轉成 JSON 字串並寫入(NDJSON 格式)
this.writer.write(JSON.stringify(item) + '\n')
// 2. 這裡 item 已經沒用了,GC 下一次掃描時就可以把它回收掉!
}
async finish() {
await new Promise<void>(resolve => this.writer.end(resolve))
// 3. 上傳這個檔案
await uploadToBigQuery(this.filePath)
}
}
這個改動帶來的差異是巨大的:
- Before: 記憶體隨著資料量線性成長 (O(N)),直到爆掉。
- After: 記憶體維持在一個低水位 (O(1)),不管資料是 100 萬筆還是 1000 萬筆,記憶體用量幾乎不變。
因為資料一寫進檔案 (Buffer) 後,JS 物件就沒人引用了,GC 可以隨時回收它們。
暫存檔案路徑的選擇
你可能會想:「直接寫 /tmp/temp_data.json 不就好了?」
這樣寫在 Cloud Run 上確實可以跑,但如果你想在 Local 開發、測試,或者未來換到其他平台,就會出問題。
為什麼不能硬編碼 /tmp?
| 環境 | 暫存目錄 |
|---|---|
| Linux / Cloud Run | /tmp |
| macOS | /var/folders/.../T |
| Windows | C:\Users\xxx\AppData\Local\Temp |
如果你硬編碼 /tmp,在 Windows 上就會直接炸掉。
正確做法:用 os.tmpdir()
Node.js 提供了 os.tmpdir()2 這個 API,會根據作業系統自動回傳正確的暫存目錄:
import * as os from 'os'
import * as path from 'path'
// ✅ 跨平台相容
const tempFilePath = path.join(os.tmpdir(), 'temp_data.json')
// ❌ 只能在 Linux 上跑
const tempFilePath = '/tmp/temp_data.json'
// ❌ 相對路徑,不是真正的暫存目錄
const tempFilePath = 'tmp/temp_data.json'
這樣不管你在 macOS 開發、Linux 部署、還是未來換到 AWS Lambda,程式碼都不用改。
Backpressure:WriteStream 的潛在問題
用 Stream 寫檔案時,有一個容易被忽略的問題:Backpressure(背壓)3。
什麼是 Backpressure?
想像一個水管系統:
[資料來源] → [buffer] → [磁碟寫入]
快速產生 暫存區 慢速消化
當資料產生速度 > 磁碟寫入速度時:
- Buffer 持續堆積
- 記憶體越吃越多
- 最終還是 OOM
WriteStream.write() 會回傳一個 boolean:
true:buffer 還有空間,繼續寫false:buffer 滿了,應該暫停
目前的程式碼有問題嗎?
// 目前的寫法:沒有檢查回傳值
this.writer.write(JSON.stringify(item) + '\n')
理論上應該這樣處理:
const ok = this.writer.write(JSON.stringify(item) + '\n')
if (!ok) {
// 暫停,等 buffer 清空
await new Promise(resolve => this.writer.once('drain', resolve))
}
為什麼我選擇不處理?
在 Cloud Run 的環境下,/tmp 是 tmpfs(memory-backed filesystem),寫入速度幾乎等於記憶體速度。下游不會慢,所以 backpressure 問題不大。
但如果你的環境是:
- 傳統硬碟(HDD)
- 網路檔案系統(NFS)
- 寫入速度受限的雲端儲存
那就需要認真處理 backpressure 了。
YAGNI 原則:先上線測試,真的遇到問題再處理。
意外發現的隱藏殺手
在重構過程中,我還發現了原有程式碼的一個邏輯漏洞,這也是導致 OOM 的共犯。
private handleRow(row) {
const item = this.transform(row)
// 原本的邏輯:先把資料塞進 Queue
this.rowQueue.push(item)
this.scheduleBatch() // 嘗試觸發上傳
}
private scheduleBatch() {
// ❌ 致命傷!
if (!this.sendToTarget) {
return
}
// ... 正常的上傳並清除 Queue 的邏輯
}
這個邏輯是說:如果 sendToTarget 是 false (例如我們只想跑備份不想真傳資料),就不執行上傳。
但問題是,handleRow 會無條件把資料塞進 rowQueue,而清理 rowQueue 的邏輯卻被 return 擋住了!這導致這 150 萬筆資料變成了「有進沒出」的死水,直接塞爆記憶體。
修正這類 Logical Memory Leak 比優化演算法更重要:
private handleRow(row) {
const item = this.transform(row)
// ✅ 修正:如果不打算送,一開始就不要塞進 Queue
if (this.sendToTarget) {
this.rowQueue.push(item)
this.scheduleBatch()
}
// ... 其他處理(例如寫入暫存檔)
}
Cloud Run 的設定誤區
最後一點,是關於 Cloud Run 的 Concurrency 設定4。
我們原本的設定是:
- Max instances: 10
- Concurrency (並行請求數): 80
這個設定把 Cloud Run 當成了 Web Server (處理輕量 API)。但我們的 Worker 是在處理「重型 ETL 任務」。
試想,如果優化後單一請求需要 300MB,一個 Instance 確實可以跑得動了。但如果 Cloud Run 依照這設定,同時塞了 10 個 Request 給同一個 Instance?
300MB * 10 = 3GB -> 還是會爆。
對於這種 Worker 類型的服務,我們把 Concurrency 降到了 4,同時也升級了 Cloud Run 的記憶體。讓 Google 幫我們水平擴展 (開新機器),而不是在同一台機器裡互搶資源。
總結
這次的 OOM 事件讓我學到了幾件事:
- 不要相信 Array:在 Node.js 處理大量數據時,Array 是最危險的資料結構。用完即丟,讓 GC 能回收。
- Stream 是好朋友:善用
fs.createWriteStream,用硬碟空間換取寶貴的記憶體空間。 - 跨平台思維:用
os.tmpdir()而不是硬編碼/tmp,讓程式碼在任何環境都能跑。 - 了解你的環境:Cloud Run 的 tmpfs 讓我們可以暫時忽略 backpressure,但換個環境可能就不行了。
- 注意邏輯漏洞:有時候 OOM 不是演算法太爛,單純是你忘記清垃圾了。
- 雲端思維:Serverless 環境資源有限,Concurrency 設定要根據任務類型調整。
Reference
- Trash talk: the Orinoco garbage collector - V8 - V8 官方部落格詳細說明了 Mark-sweep 等垃圾回收演算法的運作原理。 ↩
- OS | Node.js v22.x Documentation -
os.tmpdir()會根據作業系統回傳預設的暫存目錄路徑。 ↩ - Backpressuring in Streams - Node.js - Node.js 官方文件說明 Stream 中的 Backpressure 機制與處理方式。 ↩
- Maximum concurrent requests for services - Cloud Run - Google Cloud 官方文件說明 Cloud Run 的 Concurrency 設定與最佳實踐。 ↩