iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Claude AI

AI 負責答,我負責讓答案可信——公部門工程師的 30 天 Claude 協作紀律系列 第 3

Day 03:一個正確的建議,如何變成壓垮資料庫的一擊

  • 分享至 

  • xImage
  •  

Day 03:一個正確的建議,如何變成壓垮資料庫的一擊

昨天講的是「AI 會給你猜測」。今天講一個更難防的狀況:AI 給的建議完全正確,但它沒告訴你,這個正確在真實資料量下會爆炸。

這篇的主角是 Promise.all——Node.js 裡最常被推薦、也最常被誤用的一個工具。

一個很單純的需求

情境很日常:我要先撈出主表的一批鍵值,再拿這些鍵值去三張明細表把資料補齊。

我是 Java 背景出身,直覺就是巢狀迴圈——外層跑主表,內層對每一筆去查三張明細表。但我知道 Node.js 的非同步生態跟 Java 不一樣,所以我問了 AI:「Node.js 有沒有建議的做法?」

它的回答乾脆俐落:別用巢狀迴圈,那是序列等待、很慢。用 Promise.all 並行處理。

// AI 給的第一個版本
const results = await Promise.all(
  mainIds.map(async (id) => {
    const [detail1, detail2, detail3] = await Promise.all([
      queryDetail1(id),
      queryDetail2(id),
      queryDetail3(id),
    ]);
    return { id, detail1, detail2, detail3 };
  })
);

這段程式碼看起來很漂亮,很「Node.js」。並行、非同步、不阻塞——所有你聽過的好詞它都占了。如果我是趕時間、看它跑得動就收工的那種心態,這段就直接進專案了。

而且平心而論,這個建議在教科書意義上是對的。巢狀迴圈的序列等待確實慢,Promise.all 確實能並行。它沒有騙我。

但我腦子裡有一個數字

我沒有馬上採用,因為我腦子裡浮現了我們系統的一個真實設定:主表查詢預設最多撈 200 筆。

於是我盯著那段程式碼,開始在腦中把它「攤開」執行一次:

  • 外層 mainIds.map:200 個 id,同時建立 200 個 async function
  • 每個 async function 內層又是一個 Promise.all同時發 3 個查詢
  • 200 × 3 = 600 個查詢,在極短時間內幾乎同時砸向資料庫

我把這個疑慮丟回給 AI:「200 個 key、3 張明細表,就是 600 個查詢同時跑,這不是效能炸彈嗎?」

它這時候才承認:對,這就是效能炸彈。

Promise.all 不會幫你踩剎車

這裡有一個很多人沒意識到的真相:Promise.all 不做任何併發控制。

它的職責只有一句話:把你給它的所有 Promise 全部同時發出去,然後等它們全部回來。它不會排隊、不會限流、不會看你的資料庫連線池有幾條連線。你丟 600 個查詢給它,它就真的讓 600 個查詢在同一瞬間出發。

而資料庫的連線池通常只有幾十條連線(預設常見是 10 到 50)。600 個查詢同時搶幾十條連線的下場是:大量查詢排隊等連線、連線池耗盡、然後開始拋 timeout 或連線耗盡的錯誤。你的「效能優化」反而讓整個服務癱瘓。

更陰險的是——這個 bug 在開發時測不出來。 你本機測十筆、五十筆,一切順暢飛快。等上了正式環境、真的來了 200 筆,才在尖峰時段炸給你看。

真正的解法:從源頭砍掉查詢次數

追問到這裡,我跟 AI 一起把三種做法攤成一張表對照——這張表是整件事的精華:

做法 查詢次數 網路往返 結果
巢狀迴圈(序列) 3N+1 3N+1 次,排隊等 慢,但至少不炸
Promise.all 逐筆並行 3N+1 3N+1 次,同時發 炸連線池
批次 IN 查詢 3+1 = 4 4 次 最快,也最穩

關鍵的體悟是:Promise.all 只是把「排隊等」變成「同時等」,它從頭到尾沒有減少查詢的『次數』。 巢狀迴圈打 601 次、Promise.all 也是打 601 次,只是後者一次全送出去而已。它優化的是等待時間,代價卻是瞬間把資料庫打爆。

真正該做的,是從源頭把查詢次數壓下來——用批次 IN 查詢,一次撈一整批:

// 每張明細表只查一次,用 IN 一次撈回所有 id 的資料
const [details1, details2, details3] = await Promise.all([
  queryDetail1Batch(mainIds),   // WHERE id IN (200 個 key)
  queryDetail2Batch(mainIds),
  queryDetail3Batch(mainIds),
]);

// 再用 Map 在記憶體裡組裝,O(1) 查找
const map1 = new Map(details1.map(d => [d.id, d]));
// ...

601 次查詢,變成 3 次。而這裡的 Promise.all 只包著 3 個查詢——它終於用對了:並行少數幾個批次查詢,而不是並行上百個逐筆查詢。

(一個真實世界的但書:IN 查詢要跑得快,那個欄位得有索引。我們的系統是很久以前規劃的,當年沒有開主鍵外鍵的習慣,頂多開唯一索引——所以我還特地確認了那個欄位有唯一索引,IN (200) 才真的沒問題。這又回到 Day 02 的老話:不能假設它「應該」有索引,要自己去確認。)

那為什麼不是用 p-limit 限流就好?

寫到這裡,熟 Node.js 的人可能會問:既然問題是「600 個查詢同時發」,那用 p-limit 之類的套件把併發數限制成一次只跑 10 個,不就解決了嗎?

會這樣問是對的,而且 p-limit 確實是處理大量並發的標準工具。但在這個案例裡,它是第二選擇,原因值得說清楚:

p-limit 解決的是症狀,批次 IN 解決的是病根。

p-limit 把併發限制成 10,連線池確實不會炸了——但你還是打了 600 次查詢給資料庫,只是從「600 個同時擠」變成「600 個排隊慢慢打」。查詢次數一次都沒少,你只是把「爆炸」換成了「緩慢」。而批次 IN 是直接讓查詢次數從 601 降到 4。當一個問題可以從源頭消除,限流就只是在幫一個本來就不該存在的負擔排隊。

所以這兩個工具的定位其實不同:

工具 它做的事 適用時機
批次 IN 減少查詢次數(601 → 4) 查詢是同構的、可以合併(例如都是查同一張表的不同 id)
p-limit 控制查詢的併發數(同時最多 N 個) 查詢無法合併時的退路

p-limit 不是更差的選項,是不同場景的選項。如果我要處理的不是「查同一張明細表的 200 個 id」,而是「對 200 個項目各自呼叫一個外部 API」或「每筆要跑不一樣的複雜邏輯」——這種無法用一句 IN 合併的情況,查詢次數壓不下來,那 p-limit 就是對的工具,該用就用。

判斷的順序是:先問「這些查詢能不能合併成一次」(能就批次),不能,才問「那我怎麼控制它同時跑幾個」(用 p-limit)。 先治病根,治不了才處理症狀。

這一天的紀律:先問規模,再問對錯

回頭看,AI 給的第一個 Promise.all 版本沒有錯,它只是不完整——它給了一個在小資料量下正確、在真實資料量下危險的答案,卻沒有主動標示那條界線在哪。

這帶出一條我後來固定會做的檢查:拿到任何一個『並行』『批次』『一次處理全部』的建議時,先問一個問題——如果資料量是現在的 10 倍、100 倍,這段程式會怎樣?

Promise.all(array.map(...)) 這個模式尤其要警覺。它讀起來人畜無害,但只要那個 array 的長度是會隨資料成長的、裡面每一項又各自發查詢,它就是一顆定時炸彈。判斷「這個並行安不安全」,關鍵從來不是語法對不對,而是那個陣列的長度上限是多少、以及它會不會隨業務長大

AI 很擅長給你「怎麼做」——那是它的強項。但「在你的規模下這樣做會怎樣」,這個問題往往要你自己提出來。它不會主動幫你把最壞情況演一遍,因為它不知道你系統的量級、成長曲線、連線池有幾條。這些脈絡是人握著的,把它們帶進判斷、替 AI 的建議畫出安全邊界——這正是人在這段協作裡貢獻的那一份,也是讓「並行優化」真的變成優化、而不是變成事故的關鍵。

明天換一個更根本的問題:當你只是想解決一件小事,AI 卻熱情地幫你把它做成一整套系統時,該怎麼辦。一個關於「先設邊界」的故事——我只是想取一份帳號資料,最後差點蓋出一套帳號生命週期管理系統。


上一篇
Day 2:逼 AI 驗證,而不是接受它的記憶
下一篇
Day 04:我只想存個中文備註,最後差點蓋出一套帳號管理系統
系列文
AI 負責答,我負責讓答案可信——公部門工程師的 30 天 Claude 協作紀律6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言