工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    首頁白畫面了五分鐘,四個月後才把這條依賴拿掉

    Authors
    • avatar
      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、使用者也沒有亂點。

    靜態設定還是會讀不到,告警也還是會響。差別只在讀不到的時候,使用者還是進得了首頁。

    延伸閱讀

    參考資料