- Published on
首頁白畫面了五分鐘,四個月後才把這條依賴拿掉
- Authors
-
-
- Name
- Alex Yu
-
首頁白畫面了五分鐘,之後自己恢復。
那天首頁 render 前會向後端拿一個布林值:置頂廣告今天要不要顯示。
後端收到請求才去問廣告商的 API。那支 API 撞到流量上限,回了 500。去敏後的錯誤大概長這樣:
GET /api/ad-visibility → 500
upstream: ad-vendor rate limit exceeded前端又把這個值放在首頁必要的 render 路徑上。最後廣告和首頁一起消失。
我一開始以為這只是廣告設定出錯。後來才發現,廣告商那邊掛掉,使用者連首頁都進不來。
這個開關本來就有商業理由
媒體站不是把廣告塞進頁面就結束。營運端要決定某個時段某個位置顯不顯示,這是檔期的事。至於這次有沒有素材可投、版位之間會不會互相擋,那些是廣告 SDK 在瀏覽器裡自己處理的,跟這個開關無關。
廣告 SDK:一段在瀏覽器裡跑的 JavaScript。頁面把位置留好,它負責去把這次要投的廣告抓回來、畫進那個位置。
所以「今天要不要顯示」不能寫死在前端。這個需求本身很合理。
問題出在首頁把它當成必要資料。廣告設定拿不到,使用者就拿不到首頁內容;首頁沒有內容,原本要看的廣告當然也沒有曝光。
少一個版位,少的是一次曝光。整個首頁出不來,內容流量和其他版位的曝光會一起消失。
當時的前端 fallback 選擇很直接:設定失敗時先關掉那個版位。這個行為必須和營運端對齊:先把使用者能進首頁這件事保住。
fallback:主要來源失敗時改用的備案。這裡的備案是「當作沒有設定,版位關著」。
拿不到設定就把版位關掉
前端能先改的地方很小。拿不到設定時,回傳「版位關著」,不要往外丟錯誤。
下面的程式碼都做過去敏,只保留資料失敗時的行為。出事的版本沒有這一層:
type AdState = { enabled: boolean }
type AdConfig = { enabled?: boolean }
// 出事的版本:設定拿不到,錯誤直接往上炸
async function loadAdState(): Promise<AdState> {
const config = await getJson<AdConfig>('/api/ad-visibility')
return { enabled: config.enabled === true } // ← 回 500 時走不到這行
}改完長這樣:
const adOff: AdState = { enabled: false }
// 第一次修:資料仍來自服務,但失敗時回預設狀態
async function loadAdStateFromService(): Promise<AdState> {
try {
const config = await getJson<AdConfig>('/api/ad-visibility')
return { enabled: config.enabled === true }
} catch (error) {
reportError(error, { feature: 'hero-ad-config' })
return adOff
}
}enabled: false 定義的是失敗時的畫面:少一個版位,首頁照常出。
這段處理後,廣告商再回 500,使用者不會再先看到白頁。還好告警仍然有送,因為廣告設定失敗還是要查;只是它不該讓首頁一起出不來。
後端不再等廣告商回應
後端後來把「收到首頁請求才問廣告商」改成背景更新,並加上快取與備援。使用者的首頁請求不必再即時等廣告商回應。
這段不是我改的。但它解掉了另一件事:廣告商當下不穩,不能直接變成使用者的一次首頁失敗。
本以為事情到這裡就結束了。首頁不白了,廣告設定也有備援。
換成靜態設定,會損失什麼
改成靜態設定檔,營運端還是控制得了版位。差別在於設定不再跟著每一次 page render 去問外部服務,而是發布後經過快取更新才生效。
這裡有取捨。設定不會每一個 request 都最新,換來的是使用者不會因為外部服務暫時失敗而拿不到首頁。
這個取捨能成立,是因為它控制的是「顯示哪個版位」,不是付款結果、登入權限或使用者剛剛送出的表單。版位晚幾分鐘更新,沒有人會受傷。但付款狀態讀到舊值,使用者可能被重複扣款。
我後來覺得,這才是討論「要不要把這支 API 拿掉」時最該先講的事。先確認資料多久更新一次還能接受,才決定它能不能離開 request path。
request path:使用者送出請求到頁面回來的這段過程。放進這裡面的東西,每一個使用者都要等它。
四個月後,才把這條 API 拔掉
後來才回頭看這個需求。這只是一個布林值,竟要經過好幾層服務,最後才決定一個版位要不要 render。
於是前端資料來源改成直接讀 CDN 上的靜態設定檔。檔案本身就這麼一行:
CDN(Content Delivery Network):把檔案放在各地的節點上,使用者從最近的一台拿,不用回到你的主機。
// https://static.example.com/config/hero-ad.json
{ "enabled": true }前端只負責把它讀回來、轉成畫面要的形狀:
type AdConfigFile = {
enabled?: boolean
}
// 不再經過應用程式 API
const adConfigUrl = 'https://static.example.com/config/hero-ad.json'
const normalizeAdConfig = (
file: AdConfigFile | null | undefined,
): AdState => ({
enabled: file?.enabled === true,
})廣告版位沒有移除,廣告 SDK 也還在。改掉的是每次 SSR 都要先經過後端的那一段。
SSR(server-side rendering):頁面先在伺服器上組好 HTML 再送到瀏覽器,不是等瀏覽器下載完 JavaScript 才長出畫面。
失敗的結果不能寫進 cache
靜態設定也可能暫時讀不到。這時仍回「版位關閉」,但不能把它存進 SSR cache。
假設 CDN 剛好失敗個幾秒,第一個 request 把「關閉」快取起來,接下來 cache 有效期間全站都會少掉這個版位。原本只是幾秒的失敗,卻被我自己延長了。
// 四個月後:讀靜態設定,成功才寫進 cache
async function loadCachedStaticAdState(): Promise<AdState> {
const cached = renderCache.get(adConfigUrl) as AdState | undefined
if (cached !== undefined) return { ...cached }
try {
const file = await getJson<AdConfigFile>(adConfigUrl)
const data = normalizeAdConfig(file)
renderCache.set(adConfigUrl, data) // ✅ 成功才進 cache
return { ...data }
} catch (error) {
reportError(error, { feature: 'hero-ad-config' })
return adOff // ❌ 失敗結果不進 cache
}
}成功的設定可以快取;失敗時的預設值只服務這一次 request。
這個行為用測試鎖住就好:
it('設定暫時讀不到時,關閉版位但不快取結果', async () => {
mockGetJson.mockRejectedValueOnce(new Error('temporary failure'))
await expect(loadCachedStaticAdState()).resolves.toEqual({ enabled: false })
expect(renderCache.has(adConfigUrl)).toBe(false)
})回傳給頁面的型別刻意維持不變,所以首頁、分類、文章、搜尋這些原本用這個開關的地方,一行都不用改:
// 資料從 API 換成 JSON 檔,呼叫端看到的還是同一個 AdState
const state = await loadCachedStaticAdState()
if (state.enabled) {
renderHeroAd()
}我現在會先問這件事
以前接到「這個版位要能隨時開關」的需求,我大概會先想 API 放哪裡、元件怎麼拿這個值。
現在我會先問營運端一句:設定晚十分鐘生效可以嗎?可以的話,剩下的都好談。
我不是要替營運端決定營收。但我得知道這個值失敗的時候,公司比較不能接受哪一種結果,才知道 fallback 要寫成什麼。
如果答案是「版位可以先隱藏」,靜態設定就夠了。如果不行,那 timeout 和畫面上的失敗狀態就得一起寫進需求,不能等上線才想。
只寫 happy path 的意思,是把「資料拿不到時畫面長怎樣」留到正式站才回答。
happy path:一切正常的那條路徑。沒有 timeout、沒有 500、使用者也沒有亂點。
靜態設定還是會讀不到,告警也還是會響。差別只在讀不到的時候,使用者還是進得了首頁。
延伸閱讀
- 用事件驅動做廣告位互斥:另一種廣告需求如何影響頁面行為的例子。