【從一台 Server 到分散式架構】第 07 篇:發信拖慢整個網站——非同步與背景任務
在前面幾篇,小明他們的線上課程平台遇到了資料庫撐不住的問題。透過加上索引與快取(第 04、05 篇),以及讀寫分離(第 06 篇),好不容易把查詢的負擔給降了下來,讓網站重新找回了秒開的流暢感。
但最近,客服信箱又開始收到新的抱怨: 「為什麼我刷卡結帳完,畫面一直在轉圈圈,等了十幾秒才跳出成功畫面?我剛剛差點重整頁面重複扣款欸!」
小明趕緊去查後端 API 的 Log。他發現,扣款本身的動作其實零點幾秒就完成了,那剩下的這十幾秒到底在等什麼?
答案出乎意料的單純:在等 「寄送購買確認 Email」。
很多時候,拖慢我們系統的不是資料庫,而是那些「我們必須依賴外部服務,但又做得很慢」的動作。這一篇,我們就來聊聊:當有些事情非做不可,但又不需要讓客人在原地苦等時,我們該怎麼處理?
為什麼發一封 Email 會拖慢整個網站?
讓我們回到小明那間很受歡迎的餐廳。
想像一下這種情境:每個客人在櫃檯點完餐並付錢後,為了展現餐廳的用心,店長規定店員必須**「親手寫一張 200 字的感謝卡」**連同收據一起交給客人,才算完成點餐手續。
結果就是:
- 店員用 5 秒鐘幫客人點好餐。
- 店員拿出紙筆,花了 2 分鐘在那邊一字一句寫感謝卡。
- 客人無奈地在櫃檯前站了 2 分鐘,後面排隊的人也跟著等了 2 分鐘。
系統裡的故事完全一模一樣。這就是典型的**「同步執行(Synchronous)」**。
💡 名詞小百科:同步 vs 非同步
- 同步(Synchronous):做事「有先後順序,且必須死等」。第一件事沒做完,絕對不准做第二件事(就像點餐完一定要在原地等到卡片寫完,才能接下一位客人)。
- 非同步(Asynchronous):做事「發起任務後就去做別的事,不留在原地死等」。我先把耗時的工作交代下去,馬上回頭處理別的訂單;等那個工作未來某個時間點完成了,再來通知我。
後端收到結帳請求後,扣款、寫入訂單資料庫都很順利(5 秒點餐)。接下來,程式碼呼叫了寄信的第三方服務(例如 SendGrid 或是 AWS SES)。但偏偏那時候網路有點慢,或是外部寄信服務有點塞車(花了十幾秒甚至半分鐘寫卡片)。
在這個「同步」的流程裡,只要寄信這行程式碼還沒跑完,這支 API 就不能提早回傳 HTTP 200 OK 給前端。於是,使用者的瀏覽器畫面上就一直顯示載入中;而此時,後端這台伺服器的一個連線(Connection)或是執行緒(Thread)也被這封信卡死,如果同時有 100 個人結帳,後端的 100 個連線全都在發呆等送信,整台伺服器瞬間就塞爆了。
讓客人先入座,卡片稍後送上:非同步與背景任務
遇到這個問題,聰明的店長立刻改了規矩: 「店員只要點完餐、收完錢,就立刻跟客人說:『您的餐點已經開始準備了,請先到位子上稍坐。』然後店員馬上把『寫感謝卡』的任務抄在一張紙條上,丟到後方辦公室的一個盒子裡。」 「後方辦公室裡,坐著一個專門寫卡片的人。他會依照盒子裡的紙條,一張張慢慢寫,寫好再拿到客人的桌上。」
這,就是**「非同步執行(Asynchronous)」與「背景任務(Background Worker)」**。
套用回到小明的系統上:
- 使用者送出結帳請求。
- 後端快速完成扣款、寫入 DB 訂單。
- 後端把「要寄信給這個使用者」的任務打包成一個工作任務(Job),丟到系統的某個暫存區。
- 後端立刻回傳
HTTP 200 OK給前端,畫面上直接跳出「結帳成功!」(總耗時不到 1 秒)。
與此同時,在系統底層或者另外一台機器上,跑著幾支獨立的程式(這就是 Worker)。這些 Worker 就像後方辦公室寫卡片的人,它們唯一的工作就是不斷盯著暫存區,看到有「寄信」的任務,就拿起來慢慢處理,一封一封去呼叫外部的寄信 API。寄 10 秒還是 30 秒都沒關係,因為使用者的網頁早就跑完了。
什麼樣的工作適合丟到背景做?
把任務丟到背景(Background)做,本質上是為了「不用讓主要負責面對使用者的程式(API Server)在那邊乾等」,藉此解放伺服器的資源並大幅提升使用者體驗(UX)。
在實務上,最適合丟給背景 Worker 的工作有這幾類:
-
極度耗時的運算或轉檔:
- 講課影片上傳後轉碼成 1080p, 720p(這要幾分鐘甚至幾小時才跑得完)。
- 網站月底要產生的數十萬筆對帳單 PDF。
- 只要會卡住超過一兩秒的運算,通常都不該在 API 的生命週期裡同步做完。
-
依賴外部服務、且連線速度不可控的動作:
- 發送 Email、發送手機簡訊(SMS)、發送 App 推播通知(Push Notification)。外部網路慢是常態,不應該讓外部服務的網路延遲變成我們主網站的瓶頸。
-
不影響使用者當下操作主流程的附加動作:
- 使用者剛結帳完成,我們需要在後端偷偷記錄一筆「行為分析報表資料」。這筆資料有沒有立刻寫進報表庫,對於「使用者覺得結帳有沒有成功」一點關係都沒有,所以完全可以變成任務非同步慢慢做。
把任務記下來的「暫存區」到底是什麼?
說到這裡,你可能會想問:「店長把感謝卡任務寫成紙條丟進去的那個『盒子(任務暫存區)』,在軟體世界裡到底長什麼樣子?」
在最陽春的做法裡,我們甚至可以用資料庫來當這個盒子。
小明可以在資料庫開一張表叫 email_jobs。API 負責把資料 INSERT 進去,狀態設為 pending。然後寫一支背景的程式,每分鐘去資料庫 SELECT * FROM email_jobs WHERE status = 'pending' 拿出來一筆一筆寄信,寄完再把狀態改成 done。
但如果網站流量很大,每一秒都有幾百張任務紙條被丟進來,大家都在對這張 email_jobs 表做大量頻繁的讀寫,很快地,這個「盒子」自己就會讓可憐的資料庫崩潰。而且如果 Worker 不小心當機了,紙條會不會被下一隻 Worker 重複拿起來寫?我們能不能讓好幾個 Worker 一起排班消耗一大堆紙條而不會打架拿錯單?
為了解決「高並發下穩定傳送與消化排隊任務」這件事,整個軟體業界發明了專門用來處理這個盒子的基礎設施。
既然寄信可以先寫下紙條排隊,那其他更耗時、更大的並發流量,是不是也可以讓它排隊慢慢做,藉此保護脆弱的廚房跟大廚?這就是我們下一篇要談的:訂單太多廚房做不完怎麼辦——訊息佇列(Message Queue)登場。我們下一篇見。