Back to Blog
從Production問題到架構重構:Node.js 與 PySpark 的系統比較
📋 Case Study

從Production問題到架構重構:Node.js 與 PySpark 的系統比較

B
Blake
Dec 26, 2025 By Blake 6 min read
在處理 50 萬筆地圖軌跡資料時,Node.js 單執行緒架構在 3.2 秒後因記憶體溢出(1.7GB)而崩潰,而 PySpark 分散式架構則穩定完成全量處理(19.88 秒)。本文透過對照實驗,比較兩種架構在大規模資料處理上的表現差異,並探討 Event Loop 的物理限制、Spark DAG 的執行機制,以及浮點數精度問題的防禦策略。實驗數據顯示,在處理大型資料集時,系統的穩定性往往比峰值效能更具實際價值。

背景:一個持續存在的技術疑問

在處理即時地圖服務時,我們遇到一個問題:系統在測試環境表現穩定,但在生產環境遇到尖峰流量時會出現記憶體溢出。當時團隊採用 Node.js 處理地圖軌跡資料,並使用隨機抽樣進行驗證。

這個方法在理論上是合理的——既然無法處理全量資料,抽樣是常見的做法。但我一直在思考:如果只檢視樣本,那些存在於長尾數據中的極端案例會不會被遺漏?

事實證明了這個疑慮。抽樣確實讓我們忽略了關鍵的異常叢聚,導致決策判斷失準。

為了驗證這個假設,我在個人專案 Geo Decision Matrix 中設計了一個對照實驗,用實際的代碼和壓力測試來確認:單機架構的物理極限在哪裡?分散式架構能提供什麼優勢?

延伸閱讀:
🔗 第一篇:倖存者偏差如何差點毀掉我們的決策引擎(中文)
🔗 Part 1: How Survivorship Bias Nearly Destroyed Our Decision Engine(英文)

實驗設計:系統架構對照

為了系統化地比較兩種架構,我繪製了以下對照圖:

[圖 1:左側 Node.js 單點架構;右側 Spark 分散式架構]

實驗一:Node.js 單機架構的記憶體瓶頸

為了重現問題,我撰寫了 legacy_benchmark.js,模擬典型的實作方式:一次讀取 50 萬筆 CSV 資料,並以非同步方式模擬外部 API 呼叫。

問題程式碼

// src/legacy_benchmark.js
const runBenchmark = async () => {
    // ... 讀取 CSV ...
    const promises = [];

    // 關鍵問題:瞬間產生 50 萬個 Pending Promise
    // V8 Heap 無法即時回收
    for (let i = 0; i < lines.length; i++) {
        const record = parse(lines[i]);
        promises.push(mockExternalApiCall(record));
    }

    console.log(">>> 等待所有 API 回應...");
    await Promise.all(promises);
};

實驗結果

在記憶體限制為 512MB (--max-old-space-size=512) 的條件下執行:

  • 執行時間:3.2 秒(崩潰前)

  • 記憶體使用:1.7GB(Heap Used)

  • 結果:FATAL ERROR - Out of Memory

[圖 2:終端機顯示 OOM 錯誤訊息]

數據顯示,Node.js 的單執行緒 Event Loop 在面對大量非同步任務時,垃圾回收速度跟不上物件產生的速度。即使增加 RAM,只要工作負載增長速度超過 GC 速度,問題仍然存在。

實驗二:PySpark 分散式架構的穩定性測試

接著,我將相同的運算邏輯移植到 Docker + PySpark 環境。除了使用分散式運算,我還加入了一個數學防禦機制。

浮點數精度問題的處理

在過去的經驗中,我發現當兩點座標完全重疊時(距離為 0),浮點數運算誤差會導致 acos(1.00000002),產生 NaN 值,使整張報表失效。

# src/4_decision_matrix.py
def calculate_haversine(lat1, lon1, lat2, lon2):
    # ... 省略三角函數宣告 ...
    
    # Haversine 公式計算
    a = math.sin(dlat/2)**2 + \
        math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
        
    # 防止浮點數誤差
    # 當 a 稍微大於 1.0 時,asin(sqrt(a)) 會產生 NaN
    a = min(1.0, max(0.0, a))
    
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
    return R * c

實驗結果

相同的 50 萬筆資料、相同的運算邏輯:

  • 執行時間:19.88 秒

  • 記憶體曲線:穩定

  • 結果:成功完成,輸出 JSON 報表

[圖 3:終端機顯示 real 0m19.88s]

雖然執行時間較 Node.js 崩潰前的 3.2 秒長,但多出的時間用於:

  • JVM 啟動

  • 資源隔離

  • DAG 優化

系統不僅穩定完成全量資料處理,記憶體使用量也保持在可控範圍。

技術分析:Spark 的執行機制

開啟 Spark UI 可以清楚看到任務拆解過程:

[圖 4:藍色 Exchange 階段顯示 Shuffle 機制]

關鍵機制

  1. Lazy Evaluation(惰性運算)
    Spark 不會立即執行運算,而是先建構 DAG,在最後一刻才執行。這避免了 Node.js 將所有任務同時載入記憶體的問題。

  2. Shuffle(資料重分配)
    在 Exchange 階段,Spark 自動將資料切分並分發給不同的 Executor,實現分散式運算。

  3. Shuffle Reuse(階段重用)
    Log 中顯示部分 Stage 被跳過(Skipped),表示 Spark 重用了中間運算結果,避免重複計算。

實驗結論

這次實驗讓我確認了幾個觀察:

  1. 工具適用性
    Node.js 在高併發 Web 請求場景表現良好,但不適合大數據的 ETL 處理。Spark 雖然啟動較重,但提供了可預測性和容錯能力。

  2. 架構選擇的權衡
    在面對大量資料處理時,系統的穩定性往往比峰值效能更重要。能穩定完成全量處理(19.88秒),通常比極速但會崩潰的方案(3.2秒後 OOM)更有實際價值。

  3. 數學防禦的必要性
    在處理地理座標運算時,浮點數精度問題可能導致不易察覺的錯誤。適當的邊界檢查可以避免 NaN 值破壞整個資料處理流程。

Repository

完整程式碼已上傳至 GitHub:https://github.com/BlakeHung/geo-decision-matrix


後續計畫:下一篇將展示如何將這些運算結果,透過 AI 分群與視覺化地圖,轉化為具有商業決策價值的分析矩陣。


系列文章

Enjoyed this article? Show some love!

0
Clap

Enjoyed this article?

Subscribe for engineering notes and AI development insights

We respect your privacy. No spam, unsubscribe anytime.

Share this article

Comments