Chapter 14

AdTech 領域知識

Appier 是以 AI 為核心的 AdTech / MarTech 公司:程式化廣告、行銷自動化、受眾分析與個人化推薦。本章整理廣告生態系、計價與指標、RTB 與 DSP bidder 架構、pacing 與頻次控制、歸因與追蹤、隱私法規、反作弊、廣告中的 ML 工程、行銷自動化與報表系統,讓你在系統設計與「為什麼想來 Appier」時都能講出領域深度。

預估閱讀 70 分鐘

廣告生態系:誰在買、誰在賣、誰在中間 高頻 基礎

線上廣告本質上是一個即時的雙邊市場:一邊是想買使用者注意力的需求方(demand),一邊是擁有版位與流量的供給方(supply)。中間有一層層的技術平台負責撮合、出價、投放、追蹤與結算。後端工程師進到 AdTech 公司,第一件事就是把這些角色搞清楚,因為每一個角色都對應著一組 API、一份資料流與一種效能要求。

Advertiser(廣告主)
付錢買廣告的品牌或 App 開發商,目標可能是品牌曝光、網站流量、App 安裝、購買轉換。
Agency(代理商)
替廣告主規劃與操作預算、選擇媒體與平台的公司,大型集團常有自己的程式化交易部門(trading desk)。
Publisher(媒體/發布商)
擁有網站或 App 流量並出售版位的一方,例如新聞網站、遊戲 App。目標是提高每次曝光的收益(eCPM)與填充率。
DSP(Demand-Side Platform,需求方平台)
替廣告主「買」流量的平台:接收 ad exchange 送來的競價請求,依目標受眾、預估成效、預算決定要不要出價、出多少。程式化廣告的核心技術難點多在這裡。
SSP(Supply-Side Platform,供給方平台)
替媒體「賣」流量的平台:把版位送到多個 exchange / DSP 競價、設定底價(floor price)、管理廣告品質,以最大化媒體收益。
Ad Exchange(廣告交易平台)
舉辦拍賣的市場:把一次曝光機會(bid request)同時發給多個 DSP,收集出價、決定贏家。現在 SSP 與 exchange 的界線很模糊,常是同一家公司。
Ad Network(廣告聯播網)
較早期的模式:彙集多個媒體的版位打包賣給廣告主,通常有自己的投放與計價規則,透明度較低。
Ad Server(廣告伺服器)
實際決定並遞送廣告素材、記錄曝光點擊的系統。媒體端 ad server 管理直購與程式化需求的優先順序;廣告主端 ad server 統一管理素材與跨平台追蹤。
DMP / CDP
DMP(Data Management Platform)偏重以匿名 ID(cookie、裝置 ID)建立受眾分群、對接 DSP 投放,多依賴第三方資料;CDP(Customer Data Platform)以品牌的第一方資料為核心,整合 CRM、網站、App、門市等資料成統一的使用者輪廓,用於行銷自動化與個人化。隨著第三方 cookie 與裝置 ID 受限,市場重心明顯往 CDP 與第一方資料移動。
MMP(Mobile Measurement Partner)
行動歸因平台(例如 AppsFlyer、Adjust、Branch 一類),作為廣告主與各廣告渠道之間的中立第三方,判定 App 安裝與 in-app 事件該歸功於哪個渠道,並把結果 postback 給各渠道。
flowchart LR ADV["廣告主 Advertiser"] --> AG["代理商 Agency"] AG --> DSP["DSP 需求方平台"] ADV --> DSP DATA["DMP / CDP
受眾資料"] --> DSP DSP --- EX["Ad Exchange
拍賣"] EX --- SSP["SSP 供給方平台"] SSP --> PUB["Publisher 媒體
網站 / App"] PUB --> USER(("使用者")) USER -- "曝光 / 點擊 / 安裝 / 購買" --> MMP["MMP / 追蹤伺服器"] MMP -- "postback 歸因結果" --> DSP MMP --> ADV
Appier 情境

依公開資訊,Appier 的產品線大致對應:CrossX —— 以 AI 驅動的程式化廣告(偏需求方,協助廣告主取得與再行銷使用者);AIQUA —— 跨渠道行銷自動化(推播、email、LINE、網頁訊息等);AIXON —— 資料科學與受眾分析平台(整合第一方資料、預測受眾);AIDEAL —— 電商個人化與推薦。換句話說,Appier 同時跨足「拉新」(廣告)與「留存/轉換」(行銷自動化、個人化),兩者共享使用者資料與 ML 能力。面試時不要說得好像你知道它的內部架構,而是說「以這類產品常見的技術挑戰來看……」。

一次廣告曝光的生命週期

  1. 使用者打開 App,媒體 SDK 向 SSP / ad server 發出廣告請求(帶有版位、裝置、粗略地理位置、同意狀態等)。
  2. SSP 把它包成 OpenRTB bid request,透過 exchange 同時送給多個 DSP。
  3. 各 DSP 在時間限制內(常見約 100ms 級別,以 tmax 指定)回應出價與素材,或回 no-bid。
  4. Exchange 決定贏家與成交價,通知贏家(win notice),把素材回傳給 App 顯示。
  5. SDK 回報曝光(impression),使用者可能點擊(click),之後在一段時間內安裝或購買(conversion),由追蹤系統與 MMP 歸因回廣告。
  6. 所有事件流入資料管線:用於計費、報表、模型訓練與下一次出價。

計價模式:CPM、CPC、CPA、CPI、oCPM 與 eCPM 高頻 基礎

計價模式決定了風險由誰承擔:按曝光付費時,成效不好的風險在廣告主;按轉換付費時,風險轉到平台(平台得自己預估轉換率才知道每次曝光值多少錢)。這正是 AI 驅動的廣告平台創造價值的地方。

模式全名廣告主付費時機風險承擔常見用途
CPMCost Per Mille(每千次曝光成本)每 1,000 次曝光廣告主品牌曝光、程式化競價的結算單位
CPCCost Per Click每次點擊平台承擔 CTR 風險導流
CPACost Per Action/Acquisition每次轉換(註冊、購買)平台承擔 CTR × CVR 風險成效型廣告
CPICost Per Install每次 App 安裝平台App 拉新(user acquisition)
oCPM / oCPCOptimized CPM / CPC仍按曝光/點擊計費,但出價由系統依目標轉換成本自動調整共同承擔主流的智慧出價(target CPA、target ROAS)
CPV / CPCVCost Per View / Completed View每次觀看 / 完整觀看—影音廣告

eCPM:把不同計價模式放在同一把尺上比較

Exchange 或媒體端要比較「一個 CPC 廣告」與「一個 CPM 廣告」誰比較值錢,就要換算成每千次曝光的預期收益:

計價eCPM 公式例子
CPMeCPM = CPM 出價出價 $2 CPM → eCPM $2
CPCeCPM = CPC × pCTR × 1000CPC $0.5、pCTR 1% → 0.5 × 0.01 × 1000 = $5
CPAeCPM = CPA × pCTR × pCVR × 1000CPA $20、pCTR 1%、pCVR 2% → 20 × 0.01 × 0.02 × 1000 = $4
事後計算eCPM = 總收益 / 總曝光 × 1000收益 $300、曝光 100,000 → $3

對 DSP 而言,同樣的公式反過來就是出價邏輯:若廣告主願意為每次轉換付 $20,而這次曝光的預估轉換機率為 pCTR × pCVR = 0.0002,那這次曝光的期望價值是 $0.004,也就是 $4 CPM。出價時再扣除平台利潤、乘上 pacing 調整係數,並考慮拍賣機制(第一價需 bid shading)。

def expected_value_cpm(goal: str, price: float, p_ctr: float, p_cvr: float = 1.0) -> float:
    """回傳這次曝光以 CPM 計的期望價值(廣告主的支付意願)。"""
    if goal == "CPM":
        return price
    if goal == "CPC":
        return price * p_ctr * 1000
    if goal == "CPA":
        return price * p_ctr * p_cvr * 1000
    raise ValueError(goal)

def bid_cpm(goal: str, price: float, p_ctr: float, p_cvr: float = 1.0,
            margin: float = 0.2, pacing_multiplier: float = 1.0) -> float:
    value = expected_value_cpm(goal, price, p_ctr, p_cvr)
    return value * (1 - margin) * pacing_multiplier

print(bid_cpm("CPA", 20, 0.01, 0.02))   # 3.2($/CPM),單次曝光約 $0.0032
常見陷阱

CPM 是「每千次」曝光,但 OpenRTB 的 price 欄位雖然也以 CPM 表示,實際扣款是單次曝光 = price / 1000。在計費與預算扣減程式碼中,CPM 與單次價格搞混會造成千倍誤差。另外金額請用整數的最小單位(例如 micro-dollar,1 美元 = 1,000,000)或 Decimal,避免浮點誤差在數十億次累加後放大。

面試官想聽到什麼

能把 eCPM 公式與「為什麼 CTR/CVR 預估模型直接決定公司營收」連在一起:預估太高 → 出價過高、虧錢或廣告主成本超標;預估太低 → 拍賣輸掉、預算花不完。所以模型的校準(calibration)比單純排序準確更重要:出價需要的是正確的機率值,而不只是相對排序。

核心指標與公式 高頻 基礎

指標公式意義 / 誰在乎
Impression(曝光)廣告被顯示(或被判定可見)的次數所有計費與指標的基礎;不同平台對「算一次曝光」的時機定義不同
Click點擊次數需去除重複與無效點擊
CTRclicks / impressions素材與受眾的吸引力,常見在 1% 以下的量級,依格式差異很大
CVRconversions / clicks(有時以曝光為分母)落地頁與受眾品質;要說清楚分母
CPA(實際)spend / conversions廣告主最在乎的成本指標之一
ROASattributed revenue / ad spend廣告投報率,例如 ROAS 4 代表每花 1 元帶來 4 元營收
LTV使用者在生命週期內帶來的總價值(預估)決定能接受多高的 CPI / CPA;常用 D7、D30 營收預測
Fill ratefilled ad responses / ad requests媒體端:有多少請求真的填上了廣告
Bid ratebids / bid requestsDSP 對收到的流量有多少回應出價
Win ratewins / bidsDSP 出價的競爭力;太低代表出價過低或目標太窄,太高可能代表出價過高
Viewabilityviewable impressions / measured impressions依 MRC 標準:展示型廣告 50% 像素在可視範圍內持續至少 1 秒,影音廣告 50% 像素至少連續 2 秒
eCPMrevenue / impressions × 1000媒體收益效率
from dataclasses import dataclass

@dataclass
class CampaignStats:
    bid_requests: int
    bids: int
    wins: int
    impressions: int
    viewable: int
    measured: int
    clicks: int
    conversions: int
    spend_micros: int           # 用 micro 單位整數存金額
    revenue_micros: int

    @staticmethod
    def _safe(n: float, d: float) -> float:
        return n / d if d else 0.0          # 分母為 0 時回傳 0,避免報表炸掉

    def report(self) -> dict[str, float]:
        spend = self.spend_micros / 1e6
        return {
            "bid_rate": self._safe(self.bids, self.bid_requests),
            "win_rate": self._safe(self.wins, self.bids),
            "ctr": self._safe(self.clicks, self.impressions),
            "cvr": self._safe(self.conversions, self.clicks),
            "cpa": self._safe(spend, self.conversions),
            "roas": self._safe(self.revenue_micros, self.spend_micros),
            "viewability": self._safe(self.viewable, self.measured),
            "cpm": self._safe(spend, self.impressions) * 1000,
        }
常見陷阱

比率不能直接平均:把每個 campaign 的 CTR 加總平均,不等於整體 CTR。報表系統應儲存「分子與分母」(clicks、impressions)兩個可加總的計數,查詢時再相除。這也是 OLAP 預聚合(rollup)設計的基本原則:只預聚合可加的量(sum、count),比率與 distinct count 要特別處理(例如用 HyperLogLog sketch 合併 UU)。

Programmatic 與 RTB:100ms 內完成一場拍賣 高頻 進階

Programmatic advertising 指用軟體自動化買賣廣告;其中 RTB(Real-Time Bidding) 是對每一次曝光即時舉行拍賣。另有 PMP(私有市場,邀請制拍賣)與 Programmatic Guaranteed(保量保價的程式化直購)等形式。大型 DSP 每秒要處理的 bid request 可達數十萬甚至更高等級,每一個都要在嚴格時限內回應。

sequenceDiagram participant U as 使用者 / App SDK participant S as SSP / Exchange participant D1 as DSP A participant D2 as DSP B participant T as 追蹤伺服器 U->>S: ad request (版位, 裝置, 同意狀態) par 同時發送 bid request (tmax ~100ms) S->>D1: OpenRTB BidRequest S->>D2: OpenRTB BidRequest end D1-->>S: BidResponse price=3.2 CPM D2-->>S: HTTP 204 no-bid S->>S: 拍賣: 決定贏家與成交價 S-->>D1: win notice (nurl, 成交價巨集) S-->>U: 廣告素材 (adm) U->>T: impression pixel / billing notice U->>T: click (含 click id) T-->>D1: 事件回報, 用於計費與模型訓練

OpenRTB 協定主要欄位

OpenRTB 是 IAB Tech Lab 制定的業界標準,2.x 版(2.5、2.6)仍是最廣泛使用的版本,以 HTTP POST + JSON 傳輸。請熟悉以下欄位:

物件 / 欄位說明
id這次 bid request 的唯一 ID
imp[]一或多個曝光機會:id、banner/video/native(尺寸、格式)、bidfloor 與 bidfloorcur(底價)、tagid、instl(是否插頁)、pmp(私有交易)
site / app流量來源:網域或 App bundle、類別(cat)、publisher 資訊
deviceua、ip、geo、os、devicetype、ifa(廣告識別碼,可能為空或全 0)、lmt(限制追蹤)
userid(exchange 的 user id)、buyeruid(DSP 自己透過 cookie sync 對應的 ID)、consent
at拍賣類型:1 = 第一價,2 = 第二價加價(second price plus)
tmax允許的最長回應時間(毫秒),已包含網路延遲
bcat / badv封鎖的廣告類別 / 廣告主網域(品牌安全)
regs法規訊號:coppa、gdpr、US 隱私字串等
source.schainSupply chain object:這筆流量經過哪些中間商轉賣,用於反詐欺與透明度
{
  "id": "8f2c1a7e-req",
  "at": 1,
  "tmax": 120,
  "cur": ["USD"],
  "imp": [{
    "id": "1",
    "banner": {"w": 320, "h": 50, "format": [{"w": 320, "h": 50}]},
    "bidfloor": 0.8,
    "bidfloorcur": "USD",
    "tagid": "home_banner_bottom"
  }],
  "app": {"id": "app-123", "bundle": "com.example.game", "cat": ["IAB9-30"],
          "publisher": {"id": "pub-77"}},
  "device": {"ua": "Mozilla/5.0 ...", "ip": "203.0.113.10", "os": "Android",
             "devicetype": 4, "ifa": "6d92078a-8246-4ba4-ae5b-76104861e7dc", "lmt": 0,
             "geo": {"country": "TWN", "city": "Taipei"}},
  "user": {"id": "exch-user-abc", "buyeruid": "dsp-user-42"},
  "bcat": ["IAB25", "IAB26"],
  "regs": {"coppa": 0}
}
{
  "id": "8f2c1a7e-req",
  "cur": "USD",
  "seatbid": [{
    "seat": "advertiser-seat-1",
    "bid": [{
      "id": "bid-001",
      "impid": "1",
      "price": 1.35,
      "adomain": ["shop.example.com"],
      "crid": "creative-555",
      "cid": "campaign-9",
      "adm": "<div>...creative markup...</div>",
      "nurl": "https://win.dsp.example/win?b=bid-001&p=${AUCTION_PRICE}",
      "burl": "https://bill.dsp.example/bill?b=bid-001&p=${AUCTION_PRICE}"
    }]
  }]
}

不出價時,DSP 回傳 HTTP 204 No Content(或空的 seatbid)。${AUCTION_PRICE} 是巨集,由 exchange 在通知時替換為實際成交價。nurl 是 win notice、burl 是 billing notice(通常在廣告真正顯示時才觸發,計費應以它為準)。

時間預算拆解:100ms 去哪了

以 tmax = 100ms 為例(實際值依 exchange 而異),下面是示意性的拆解,重點是理解「網路占掉大半,留給 DSP 內部的時間很少」:

階段示意時間說明 / 優化方式
Exchange → DSP 網路單程數 ms 到數十 ms同區域機房很短;跨洲可能就超過預算。DSP 會在主要 exchange 附近的區域部署 bidder
TLS / 連線建立可達數十 ms(若每次重建)使用 HTTP keep-alive / 連線池,避免每個請求重新握手
DSP 內部處理常見目標約 10–30ms,p99 也要受控解析、使用者查詢、候選廣告、模型預估、出價
DSP → Exchange 網路回程同去程回應盡量小(gzip、只回必要欄位)
安全餘裕剩餘時間GC 停頓、網路抖動;逾時的出價等同沒出價
實戰提示

Bidder 內部要有自己的截止時間(deadline):收到請求時計算 deadline = 收到時間 + 內部預算,所有下游查詢(Redis、feature store、模型服務)都帶著這個 deadline 的 context;任何一步超時就降級(例如用預設 CTR、略過個人化),而不是讓整個請求逾時。晚到的完美答案 = 沒有答案。

第一價 vs 第二價拍賣

第二價(Second-price / Vickrey)

出價最高者得標,但支付第二高出價 + 最小加價(例如 $0.01)。理論上誠實出價(出你的真實價值)是最佳策略,出價邏輯簡單。問題:在多層拍賣(header bidding、多個 exchange)下,「第二價」變得不透明,媒體與中間商也可能透過動態底價操作。

第一價(First-price)

得標者支付自己的出價。透明、在多層拍賣中規則一致,因此業界在 2017–2019 年間大規模轉向第一價(Google Ad Manager 也在 2019 年完成轉換)。問題:誠實出價會多付錢,所以 DSP 需要 bid shading:預估「贏得這次拍賣所需的最低價格」的分布,在勝率與成本之間找最佳出價。

import math

def win_prob(bid: float, mu: float, sigma: float) -> float:
    """假設市場最高競爭出價服從 log-normal(mu, sigma),回傳出 bid 的勝率。"""
    if bid <= 0:
        return 0.0
    z = (math.log(bid) - mu) / sigma
    return 0.5 * (1 + math.erf(z / math.sqrt(2)))

def shade_bid(value: float, mu: float, sigma: float, steps: int = 50) -> float:
    """第一價拍賣:最大化 期望剩餘 = P(win | b) × (value - b)。"""
    best_b, best_surplus = 0.0, 0.0
    for i in range(1, steps + 1):
        b = value * i / steps
        surplus = win_prob(b, mu, sigma) * (value - b)
        if surplus > best_surplus:
            best_b, best_surplus = b, surplus
    return best_b

# value=4 CPM,市場價格中位數約 e^0.7 ≈ 2.0 CPM
print(round(shade_bid(4.0, mu=0.7, sigma=0.5), 2))   # 出價會明顯低於 4

實務上 win price 的分布要靠歷史資料學習(贏了才知道成交價、輸了只知道「比我高」,屬於censored data),而且依媒體、版位、時段而不同。有些 exchange 會在輸標時回傳最低得標價(minimum bid to win)作為回饋。

Header bidding 與 waterfall

傳統 waterfall:媒體依歷史 eCPM 把需求來源排成順序,一個一個問,第一個願意出價高於底價的就成交,即使後面的人願意出更多。Header bidding:在頁面(或 App SDK)載入時同時向多個 SSP/exchange 請求出價,再把最高價送進媒體的 ad server 與直購廣告一起比較。好處是競爭更充分、媒體收益提高;代價是更多的請求量(DSP 會收到同一個曝光機會的多份 bid request)與頁面延遲。開源的 Prebid.js(client-side)與 Prebid Server(server-side)是主流實作。App 端類似的概念常稱為 in-app bidding。

Appier 情境

以 DSP 角度看,header bidding 與多 exchange 接入讓同一個曝光機會可能以多份 bid request 到達,造成 QPS 膨脹與「自己跟自己競價」的問題。常見做法是在 bidder 前做請求去重或供應路徑最佳化(supply path optimization,SPO),只透過較直接、較便宜的路徑出價。這在面試時是一個能展現領域理解的點。

DSP Bidder 內部架構與工程挑戰 高頻 深入

「設計廣告投放系統」是 Appier 真實出現過的系統設計題。Bidder 是其中最核心、最吃效能的元件:它是一個高 QPS、低延遲、讀多寫少、可容忍部分降級的線上服務。以下以業界常見做法拆解其處理管線。

flowchart LR REQ["Bid request"] --> P["1. 解析與驗證
過濾不合法/黑名單流量"] P --> UL["2. User lookup
ID 對應, 受眾 segment, 頻次"] UL --> CR["3. 候選檢索
倒排索引找可投 campaign"] CR --> TF["4. Targeting 過濾
地理/裝置/時段/品牌安全/預算"] TF --> ML["5. CTR/CVR 預估
模型 inference (批次)"] ML --> BID["6. 出價計算
期望價值, bid shading"] BID --> PACE["7. Pacing / 預算檢查"] PACE --> RESP["8. 組回應
選最佳, 填素材與追蹤 URL"] RESP --> LOG["非同步: 寫 bid log 到 Kafka"] KV[("User store
Redis / Aerospike 類")] -.-> UL IDX[("Campaign 索引
記憶體內, 定期更新")] -.-> CR MS[("模型 / 特徵
本地或 RPC")] -.-> ML BUD[("預算計數器")] -.-> PACE
階段做什麼工程挑戰與常見解法
1. 解析與驗證JSON 解析、欄位正規化、過濾 bot / 黑名單 IP / 不支援格式JSON 解析在高 QPS 下是可觀的 CPU 成本:使用高效能解析器、避免不必要的物件配置;越早丟棄不會出價的請求越省資源(early exit)
2. User lookup把 exchange user id / 裝置 ID 對應到內部 user,取得受眾分群、近期行為、頻次計數每個請求至少一次遠端 KV 查詢,p99 必須在毫秒級:用 Redis / Aerospike 類的記憶體或 SSD 最佳化 KV、與 bidder 同機房、批次 pipeline 查詢、設嚴格 timeout,查不到就當新使用者
3. 候選檢索從數千到數十萬個 campaign / 素材中找出可能符合條件的不能逐一掃描:在記憶體內建倒排索引(例如 country=TW → campaign set、os=iOS → set),交集運算;索引由控制面定期(秒到分鐘級)推送更新,以 copy-on-write 方式原子替換
4. Targeting 過濾地理、裝置、時段、受眾、品牌安全(bcat/badv)、素材尺寸、預算是否用盡規則多但每條要便宜;把最能刷掉候選的條件放前面
5. 模型預估對每個候選預估 pCTR、pCVR候選可能數十到上百個:批次 inference、模型輕量化、特徵快取;超時則退回預設值或較簡單的模型
6. 出價期望價值 × 調整係數,第一價則 bid shading需要市場價格分布模型;加上底價檢查(低於 bidfloor 直接不出價)
7. Pacing依預算消耗進度決定是否參與、或調整出價分散式預算計數(見下一節)
8. 回應與記錄選擇最佳候選、組素材與追蹤 URL;非同步寫 logLog 不能阻塞回應路徑:寫入本地緩衝再批次送 Kafka;log 是後續計費、報表、訓練資料的源頭,遺失要可偵測

Bidder 的請求處理骨架(Go)

Go 常被用於 bidder 這類高並行網路服務(goroutine、低延遲 GC、單一執行檔部署)。以下示範「整體 deadline + 各階段降級」的寫法:

package bidder

import (
	"context"
	"encoding/json"
	"net/http"
	"time"
)

const internalBudget = 25 * time.Millisecond // DSP 內部處理預算(示意)

type Server struct {
	users    UserStore    // 遠端 KV(帶 timeout 的 client)
	index    *CampaignIndex
	model    Predictor
	pacer    Pacer
	bidLog   chan<- BidLog // 非同步寫 log,由背景 goroutine 批次送 Kafka
}

func (s *Server) HandleBid(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), internalBudget)
	defer cancel()

	var req BidRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !req.Valid() {
		w.WriteHeader(http.StatusNoContent) // 不合法就 no-bid,不要回 5xx
		return
	}

	// User lookup:最多等 5ms,失敗就降級為匿名使用者
	uctx, ucancel := context.WithTimeout(ctx, 5*time.Millisecond)
	user, err := s.users.Get(uctx, req.UserKey())
	ucancel()
	if err != nil {
		user = AnonymousUser(req)
	}

	cands := s.index.Match(&req, user) // 記憶體內倒排索引,微秒級
	cands = FilterTargeting(cands, &req, user)
	if len(cands) == 0 {
		w.WriteHeader(http.StatusNoContent)
		return
	}

	preds, err := s.model.PredictBatch(ctx, &req, user, cands)
	if err != nil { // 模型逾時:用各 campaign 的歷史平均 CTR
		preds = FallbackPredictions(cands)
	}

	best, ok := ChooseBid(cands, preds, s.pacer, req.Imp[0].BidFloor)
	if !ok || ctx.Err() != nil { // 已超過內部預算,回應也來不及了
		w.WriteHeader(http.StatusNoContent)
		return
	}

	select { // 非阻塞寫 log:buffer 滿了就丟棄並計數,絕不卡住回應
	case s.bidLog <- NewBidLog(&req, best):
	default:
		droppedLogs.Inc()
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(BuildResponse(&req, best))
}
面試官想聽到什麼

設計 bidder 時,主動提出這幾個關鍵字:(1) 時間預算與 deadline 傳遞;(2) 降級策略(KV 或模型逾時怎麼辦);(3) 熱路徑上不做同步寫入(log、計數都非同步或本地累積);(4) 控制面與資料面分離(campaign 設定由後台寫 DB,bidder 只讀記憶體中的快照,定期更新);(5) 水平擴展(bidder 無狀態,前面放 LB,依 QPS 自動擴縮);(6) 觀測:bid rate、win rate、p99 latency、timeout rate、no-bid 原因分布。數十萬 QPS 時,每個請求省 1ms CPU 就等於省下數百個 CPU core。

實戰提示

p99 latency 的殺手常是:GC 停頓(減少配置、物件池、適當的 GOGC / heap 設定)、遠端呼叫的長尾(設 timeout、hedged request、本地快取熱門使用者)、鎖競爭(campaign 索引用不可變快照 + 原子指標替換,而非讀寫鎖)、以及 noisy neighbor。這些都能連到第 8 章的 profiling(pprof、火焰圖)與第 3 章的 Linux 調校(socket backlog、檔案描述符、CPU affinity)。

Budget Pacing、Frequency Capping 與 Targeting 進階 深入

Budget pacing:預算要怎麼花

廣告主設定「每日預算 $1,000」。若 bidder 只要符合條件就出價,可能早上 9 點前就花完(流量早上就很多),之後一整天沒有曝光,而且錯過了晚上轉換率更高的時段。Pacing 的目標是讓花費依計畫平滑分配:

ASAP(加速投放)

能花就花,直到預算用完。適合限時活動、要搶量的情境。簡單但容易提早耗盡、出價效率低。

Even(平均投放)

依一天中流量曲線(而非單純等分時間)設定「目標花費曲線」,實際花費落後時多參與競價、超前時少參與。主流預設。

兩種常見控制手段:throttling(以機率 p 決定是否參與這次競價)與 bid modification(調整出價倍率)。控制器常用 PID 概念:誤差 e = 目標花費 − 實際花費;P 項依當下誤差調整、I 項累積長期偏差(例如一直花不夠)、D 項抑制震盪。

from dataclasses import dataclass

@dataclass
class PIDPacer:
    kp: float = 0.6
    ki: float = 0.05
    kd: float = 0.1
    integral: float = 0.0
    prev_error: float = 0.0
    throttle: float = 0.5           # 參與競價的機率,0~1

    def update(self, target_spend: float, actual_spend: float, budget: float) -> float:
        """每個控制週期(例如每分鐘)呼叫一次;誤差以預算正規化。"""
        error = (target_spend - actual_spend) / budget      # 正值 = 花太慢
        self.integral = max(-1.0, min(1.0, self.integral + error))   # anti-windup
        derivative = error - self.prev_error
        self.prev_error = error
        delta = self.kp * error + self.ki * self.integral + self.kd * derivative
        self.throttle = max(0.01, min(1.0, self.throttle + delta))
        return self.throttle

# bidder 熱路徑:if random.random() > pacer.throttle: no-bid

分散式預算計數的取捨

數百台 bidder 同時為同一個 campaign 出價,「還剩多少預算」要怎麼算?而且真正的花費要等 win/billing notice 回來才知道,存在延遲。

做法優點缺點
集中式計數(每次出價/得標都 Redis INCRBY)精確、簡單熱點 key、每次多一次網路往返、Redis 故障即影響出價
本地配額(lease):中央把預算切片分給每台 bidder,用完再申請熱路徑全在本地記憶體,無網路延遲切片在各機器閒置會造成「花不完」;機器掛掉時其配額要回收;需要預留緩衝
本地累計 + 週期性回報(例如每秒),中央彙總後下發 pacing 參數延遲低、中央負載小最終一致:回報間隔內可能超支,需以安全邊際(例如預算剩 5% 時降速)控制

業界常在「可接受的小幅超支」與「出價延遲」之間取捨:多數平台允許少量超支並由平台吸收,而不是讓每個出價都去做強一致的扣款。這正是 CAP / 一致性取捨在真實業務中的例子(見第 10 章)。

import threading
import time

class BudgetLease:
    """每台 bidder 向中央(例如 Redis 上的 Lua script)申請小額預算切片,本地扣減。"""
    def __init__(self, campaign_id: str, central, slice_micros: int = 5_000_000) -> None:
        self.campaign_id, self.central, self.slice = campaign_id, central, slice_micros
        self.local_remaining = 0
        self.lock = threading.Lock()

    def try_spend(self, price_micros: int) -> bool:
        with self.lock:
            if self.local_remaining < price_micros:
                granted = self.central.acquire(self.campaign_id, self.slice)  # 原子扣減中央餘額
                if granted <= 0:
                    return False                      # 預算已用完
                self.local_remaining += granted
            self.local_remaining -= price_micros
            return True

    def release(self) -> None:
        """優雅關機時把未用完的切片還回中央。"""
        with self.lock:
            if self.local_remaining:
                self.central.refund(self.campaign_id, self.local_remaining)
                self.local_remaining = 0

Frequency capping 實作

Frequency cap 限制同一使用者在一段時間內看到同一廣告(或同一廣告主)的次數,例如「每人每天最多 3 次」,避免疲勞與浪費預算。實作要點:

  • 何時讀、何時寫:出價時讀計數判斷是否超過上限;在曝光事件(而非出價或得標)時寫入,因為只有真正顯示才算一次。
  • 競態:同一使用者可能幾乎同時有多個請求,讀到相同計數而都出價,導致略為超過上限。通常接受這種小誤差,或在 bid 時做「暫時預留」。
  • 資料結構:固定視窗(每日)用 INCR + EXPIRE;滑動視窗(過去 24 小時)用 sorted set 存時間戳,或分桶計數(每小時一桶,查詢時加總 24 桶)以節省記憶體。
  • 規模:數億使用者 × 多個 campaign 的計數非常佔記憶體:把同一使用者的所有 campaign 計數放在一個 hash(一次查詢拿全部),並設定 TTL。
import time
import redis

r = redis.Redis(host="localhost", port=6379)

# 曝光發生時:同一使用者所有 campaign 的今日計數放在同一個 hash
RECORD_IMPRESSION = r.register_script("""
local n = redis.call('HINCRBY', KEYS[1], ARGV[1], 1)
redis.call('EXPIRE', KEYS[1], ARGV[2])
return n
""")

def day_key(user_id: str) -> str:
    return f"fc:{user_id}:{time.strftime('%Y%m%d', time.gmtime())}"   # 依 UTC 日切;實務上依廣告主時區

def record_impression(user_id: str, campaign_id: str) -> int:
    return RECORD_IMPRESSION(keys=[day_key(user_id)], args=[campaign_id, 2 * 86400])

def allowed_campaigns(user_id: str, candidates: dict[str, int]) -> list[str]:
    """candidates: campaign_id -> 每日上限。出價時一次 HMGET 取回所有計數。"""
    ids = list(candidates)
    counts = r.hmget(day_key(user_id), ids)
    return [cid for cid, c in zip(ids, counts) if int(c or 0) < candidates[cid]]

Targeting 維度

維度資料來源工程注意
地理(geo)bid request 的 device.geo、IP 反查IP → 地理資料庫要定期更新;精確位置屬敏感資料,常只用到城市/區域層級
裝置 / 環境OS、版本、裝置型號、連線類型、App vs WebUA 解析成本高:快取解析結果
受眾 segment第一方資料(客戶上傳、網站/App 行為)、模型預測(高購買意願、lookalike)Segment 由離線/近即時管線計算,寫入 user store;bidder 查詢 user → segment 列表
ContextualApp 類別、網頁內容/關鍵字、版位不依賴個人識別碼,在隱私限制下越來越重要
時段 / 頻次 / 再行銷時間、frequency cap、使用者近期行為(看過商品、加入購物車)再行銷需要事件即時寫入使用者狀態
Appier 情境

以 AI 驅動的投放平台常見做法為例:受眾不只是規則式的「過去 7 天瀏覽過運動鞋」,而是模型輸出的「未來 N 天購買機率高」或「與高價值顧客相似」(lookalike)。工程上這代表 segment 計算是一條 ML pipeline:離線訓練 → 批次對大量使用者打分 → 寫入低延遲 user store → bidder 查詢。資料新鮮度與線上線下一致性就是後端的責任。

歸因(Attribution)與追蹤技術 高頻 進階

歸因模型

使用者在購買前可能看過多個渠道的廣告。歸因就是決定「這次轉換的功勞算給誰」,直接影響廣告主的預算分配與平台的計費。

模型規則優缺點
Last-click全部功勞給轉換前最後一次點擊簡單、行動廣告業界的主流標準;忽略上層漏斗的貢獻
First-click全部給第一次接觸重視拉新;忽略收尾
Linear / Time-decay / Position-based依規則分配給多個接觸點多點觸控(multi-touch);需要能串起跨渠道路徑的資料,隱私限制下越來越難
Data-driven以模型估計各接觸點的增量貢獻較合理但黑箱、需大量資料
View-through沒點擊但看過廣告,之後在視窗內轉換捕捉曝光的影響;容易高估(本來就會買的人)
增量測試(incrementality / lift test)隨機保留對照組不投放,比較轉換率差異衡量真正的因果效果,被視為最可信;成本是對照組的機會損失

Attribution window(歸因視窗):點擊或曝光後多久內發生的轉換才算。例如 click-through 7 天、view-through 1 天是常見設定,但各平台與廣告主可自訂。視窗越長,歸因到的轉換越多,但越可能把自然轉換算進來。

-- Last-click 歸因:每筆轉換找 7 天視窗內、時間最近的一次點擊(以 ClickHouse/BigQuery 類 SQL 示意)
WITH candidates AS (
  SELECT
    cv.conversion_id,
    cv.user_id,
    cv.ts         AS conversion_ts,
    ck.campaign_id,
    ck.ts         AS click_ts,
    ROW_NUMBER() OVER (PARTITION BY cv.conversion_id ORDER BY ck.ts DESC) AS rn
  FROM conversions cv
  JOIN clicks ck
    ON ck.user_id = cv.user_id
   AND ck.ts <= cv.ts
   AND ck.ts >  cv.ts - INTERVAL 7 DAY
)
SELECT conversion_id, user_id, campaign_id, click_ts, conversion_ts
FROM candidates
WHERE rn = 1;

追蹤技術

Tracking pixel / tag
網頁上的 1x1 圖片或 JavaScript tag,觸發時對追蹤伺服器發請求,記錄曝光或轉換。受瀏覽器對第三方 cookie 與追蹤器的限制影響很大。
Postback / S2S(server-to-server)
轉換發生後由廣告主的伺服器或 MMP 直接呼叫廣告平台的 API 回報,不依賴瀏覽器。較可靠、不受 ad blocker 影響,但需要雙方用 click ID 等識別碼對上。實作上要支援重試與冪等(同一轉換可能被回報多次)。
Click ID
點擊時產生的唯一 ID,透過跳轉 URL 帶到落地頁或 App,轉換時回傳。是確定性歸因的關鍵;也被用於防止偽造(簽章、過期時間)。
Deep link / Deferred deep link
點擊廣告直接開啟 App 內特定頁面;若尚未安裝,則安裝後首次開啟時仍導到該頁(deferred),並把歸因資訊帶入。iOS 的 Universal Links、Android 的 App Links 是系統層的實作。

行動識別碼與隱私框架現況

項目現況(保守描述,面試前請再查最新官方公告)
IDFA 與 ATT(iOS)自 iOS 14.5 起,App 必須透過 App Tracking Transparency 跳窗取得使用者同意,才能讀取 IDFA 並跨公司追蹤;未同意時 IDFA 為全 0。同意率不高,使 iOS 上依賴裝置 ID 的歸因與再行銷大受影響。
SKAdNetwork(SKAN)Apple 提供的隱私保護歸因 API:由系統在延遲後送出不含使用者層級資料的 postback,只帶有限的 conversion value 與來源資訊,並有隱私門檻(量少時資訊更粗略)。SKAN 4 加入多個 postback 時間窗與粗/細粒度 conversion value。
AdAttributionKitApple 於 2024 年推出的新歸因框架,與 SKAdNetwork 互通,並支援替代 App 市集等情境;可視為 SKAN 的後繼方向。
GAID(Android)Google Advertising ID 仍可使用,但使用者可重設或刪除;選擇不提供時取得的值為全 0,且 App 需宣告相關權限。
第三方 cookieSafari(ITP)與 Firefox 早已預設阻擋第三方 cookie。Chrome 原計畫淘汰,但 Google 在 2024 年改變方向、不強制淘汰,2025 年又表示維持現行做法、不推出新的獨立選擇提示。
Privacy SandboxGoogle 提出的一系列替代 API(如 Topics、Protected Audience、Attribution Reporting)。隨著第三方 cookie 政策轉向與採用度不如預期,Google 已宣布逐步退役其中多數技術。不要在面試中把它講成「業界正在全面導入的未來」。
面試官想聽到什麼

重點不是背每個 API 的版本,而是理解趨勢對後端的影響:(1) 使用者層級資料變少 → 更依賴第一方資料、contextual 訊號、聚合層級的歸因與建模(modeled conversions);(2) 歸因資料延遲且粗粒度(SKAN postback 會延遲數十小時以上)→ 出價與最佳化模型要能處理延遲回饋;(3) 同意狀態成為資料的一級欄位,必須在整條資料管線中被傳遞與遵守。

隱私法規與後端資料設計 進階

隱私法規不是法務部門的事而已,它直接決定資料表要有哪些欄位、資料保留多久、刪除請求怎麼傳播、資料能存在哪個區域。

法規範圍對工程的主要要求
GDPR(歐盟)處理歐盟境內個人資料的組織,即使公司不在歐盟需要合法基礎(如同意、正當利益);資料主體權利(存取、更正、刪除、可攜、反對);資料最小化;跨境傳輸限制;違規罰款上限可達全球年營業額 4% 或 2,000 萬歐元(取高者)。廣告業常用 IAB TCF 的 consent string 在 OpenRTB 中傳遞同意狀態
CCPA / CPRA(加州)符合門檻的商業組織處理加州居民資料知情權、刪除權、拒絕「出售/分享」個資的權利(opt-out);需尊重 Global Privacy Control 等瀏覽器訊號;廣告業以 US Privacy / GPP 字串傳遞
台灣個人資料保護法在台灣蒐集、處理、利用個資的公務與非公務機關蒐集需有特定目的與法定要件(如當事人同意),利用不得逾越目的;當事人有查詢、更正、刪除等權利;需採取適當安全維護措施,發生外洩要通知當事人。2023 年修法提高非公務機關未盡安全維護義務的罰則,並推動設立獨立的個資保護主管機關

Privacy by design:後端怎麼做

  1. 資料分類與最小化:標記哪些欄位是個資(PII)、哪些是敏感資料;不需要的就不收。分析用資料盡量只存假名化 ID。
  2. 假名化與雜湊:email / 手機正規化後以 SHA-256 雜湊做比對鍵;但要知道雜湊後的 email 仍屬可識別的個人資料(可被字典攻擊),不能當成匿名化。
  3. 同意狀態一級化:每筆事件帶 consent 欄位(或可查詢的 consent 版本),下游任務依目的過濾,例如未同意個人化的使用者不得進入再行銷 segment。
  4. 刪除傳播:建立「刪除請求 → 所有系統」的工作流,包含 KV store、資料湖、衍生表、備份策略、模型特徵;用 lakehouse 的 delete / MERGE 處理資料湖。要能證明已刪除(稽核紀錄)。
  5. 保留期限(retention):原始事件設 TTL 或分區定期刪除,聚合後的報表可保留較久。
  6. 資料落地(data residency)與存取控制:依地區分開儲存、最小權限、存取日誌、加密(傳輸中與靜態)。
import hashlib
import unicodedata

def normalize_email(email: str) -> str:
    e = unicodedata.normalize("NFKC", email).strip().lower()
    return e

def hashed_identifier(email: str, pepper: bytes | None = None) -> str:
    """跨系統比對時常用正規化後的 SHA-256;對內部儲存可再加 pepper(HMAC)降低字典攻擊風險。
    注意:與外部平台比對時雙方必須使用相同的正規化與雜湊規則,因此通常不加 pepper。"""
    data = normalize_email(email).encode("utf-8")
    if pepper:
        import hmac
        return hmac.new(pepper, data, hashlib.sha256).hexdigest()
    return hashlib.sha256(data).hexdigest()

print(hashed_identifier("  Alice@Example.com "))
常見陷阱

把個資寫進 application log(例如整包 bid request、使用者 email)是最常見的違規來源之一:log 系統通常沒有刪除流程、保留期長、存取權限寬鬆。log 中應遮罩或排除 PII,並為 log 本身設保留期限。

廣告詐欺(Ad Fraud)與偵測 進階

只要有錢按曝光/點擊/安裝流動,就會有人造假。對平台而言,詐欺流量既浪費廣告主預算、又會污染模型訓練資料(模型學到「某些 App 的點擊率超高」),所以反作弊是資料管線與出價系統的一部分,而不是事後報表。

類型手法偵測訊號
Bot / 無效流量(IVT)腳本、模擬器、資料中心 IP 產生的曝光點擊資料中心 / 代理 IP 名單、異常 UA、無人類行為特徵(無滑動、固定間隔)、過高 CTR
Click spam(click flooding)大量送出假點擊,期待「碰巧」搶到自然安裝的 last-click 歸因極低的點擊轉安裝率、CTIT(click-to-install time)分布平坦且偏長
Install hijacking / click injection裝置上的惡意 App 偵測到其他 App 正在安裝,在安裝完成前一刻送出假點擊,搶走歸因CTIT 異常短(例如數秒內);比對安裝來源的時間戳(如 Play Install Referrer 提供的點擊與安裝開始時間)
SDK spoofing不經真實裝置,直接偽造 SDK 的安裝/事件請求請求簽章驗證、與裝置/商店收據交叉比對
Domain / App spoofing低品質流量冒充高價值媒體販售ads.txt / app-ads.txt 授權賣家檢查、sellers.json、OpenRTB SupplyChain(schain)
Device farm大量真機人工或半自動操作同一 IP / 子網大量裝置、重複的裝置特徵、留存與後續行為極差
import statistics
from collections import defaultdict

def ctit_flags(installs: list[dict], min_n: int = 50) -> dict[str, list[str]]:
    """installs: {"source": 媒體或子渠道, "click_ts": 秒, "install_ts": 秒}。
    以來源為單位分析 CTIT 分布,標記可疑來源(規則僅為示意,門檻需依資料校正)。"""
    by_src: dict[str, list[float]] = defaultdict(list)
    for e in installs:
        by_src[e["source"]].append(e["install_ts"] - e["click_ts"])
    flags: dict[str, list[str]] = {}
    for src, ctits in by_src.items():
        if len(ctits) < min_n:
            continue
        reasons = []
        too_fast = sum(1 for t in ctits if t < 10) / len(ctits)
        if too_fast > 0.2:
            reasons.append(f"{too_fast:.0%} installs within 10s of click (injection?)")
        if statistics.median(ctits) > 24 * 3600:
            reasons.append("median CTIT > 24h (click spam?)")
        if reasons:
            flags[src] = reasons
    return flags
實戰提示

反作弊分層:出價前(pre-bid)用低成本規則與名單刷掉明顯可疑流量;事件接收時驗證簽章、去重、檢查時間合理性;離線用聚合分析與模型找出可疑來源,回饋到名單並在計費時扣除。另外別忘了 click ID 要簽章、追蹤端點要做 rate limit。

ML 在廣告中的角色與 ML Serving 後端工程 高頻 深入

主要應用

CTR / CVR prediction
出價的核心:預估這位使用者在這個情境下點擊/轉換的機率。資料特徵是極高維、稀疏的類別特徵(user id、app id、廣告 id、交叉特徵),經典模型從 logistic regression + 特徵交叉、FM(Factorization Machines)、GBDT,到深度模型(Wide & Deep、DeepFM、DCN 等)。由於直接用於出價,機率校準很重要。
Lookalike audience
給定一群種子使用者(例如高價值顧客),找出行為相似的使用者擴大受眾。可以做成分類問題(種子 vs 一般使用者),或以 embedding 相似度搜尋(近似最近鄰,ANN)。
推薦與個人化
電商商品推薦、動態廣告素材(依使用者看過的商品組出廣告)、推播內容與發送時機最佳化。常見兩階段架構:召回(retrieval,從百萬商品快速找出數百個候選)→ 排序(ranking,用較重的模型精排)。
其他
Bid shading(市場價格預估)、LTV 預測、流失預測、反作弊、預算分配與出價策略(強化學習/最佳化)。

Feature store 與線上/線下一致性

模型訓練用離線資料(資料湖中的歷史事件),推論用線上資料(bidder 查詢的即時特徵)。若兩邊計算特徵的邏輯或時間點不同,模型上線後效果會莫名變差,稱為 training-serving skew。

flowchart LR EV["事件流 Kafka"] --> STREAM["串流特徵計算
Flink: 近 1 小時點擊數"] EV --> LAKE["資料湖
歷史事件"] LAKE --> BATCH["批次特徵計算
Spark: 30 天購買次數"] STREAM --> ONLINE[("Online store
Redis 類 KV, 毫秒查詢")] BATCH --> ONLINE BATCH --> OFFLINE[("Offline store
Parquet/倉儲, 含時間戳")] STREAM --> OFFLINE OFFLINE -- "point-in-time join" --> TRAIN["訓練資料"] TRAIN --> MODEL["模型"] ONLINE --> SERVE["線上推論 / Bidder"] MODEL --> SERVE SERVE -- "記錄當下使用的特徵" --> LAKE
  • 同一份特徵定義產生線上與線下特徵(同一段程式碼或同一個 DSL),避免兩套實作。
  • Point-in-time correct join:產生訓練樣本時,只能使用「事件發生當下之前」已知的特徵值,否則會發生資料洩漏(data leakage),離線指標很好、上線很差。
  • Log-and-wait:在線上推論時把實際使用的特徵值記錄下來,直接當訓練資料,是最可靠的一致性做法(代價是儲存量)。
  • 延遲回饋(delayed feedback):轉換可能在點擊後數天才發生,訓練時要處理「還沒轉換」不等於「不會轉換」的問題。

Model serving 的延遲

方式延遲優缺點
內嵌在 bidder 程序中(本地 inference)最低(無網路)模型更新需熱載入;模型大時佔用 bidder 記憶體;語言需相容(例如 Go bidder 載入 ONNX / 自行實作 LR 打分)
獨立模型服務(RPC / gRPC)多一次網路往返可獨立擴展、可用 GPU、多模型版本管理;需處理批次、逾時與降級
預先計算(precompute)查表即可只適用特徵組合有限的情況,例如每日批次算好使用者分數

降低延遲的手段:批次預估多個候選、模型量化/蒸餾、特徵快取、把重的部分(使用者 embedding)預先算好、只對過濾後的候選打分。

A/B testing 與實驗平台

  • 分流單位:通常以使用者為單位(同一人固定在同組),以 hash(experiment_id + user_id) % 100 決定 bucket,確保穩定且不同實驗之間相互獨立。
  • 廣告特有的干擾:不同組的出價策略會共享同一份預算與同一個拍賣市場(A 組出價高會搶走 B 組的曝光),所以常需預算切分(每組各自的預算)或以 campaign 為單位分流。
  • 指標:主要指標(CPA、ROAS、營收)+ 護欄指標(latency、timeout rate、超支),並事先決定樣本量與實驗時長,避免偷看結果提早停止(peeking)。
  • 灰度與 shadow:新模型先 shadow(只算不用)比對預估分布,再以小流量上線。
import hashlib

def assign_bucket(experiment_id: str, unit_id: str, buckets: int = 1000) -> int:
    h = hashlib.sha256(f"{experiment_id}:{unit_id}".encode()).digest()
    return int.from_bytes(h[:8], "big") % buckets

def variant(experiment_id: str, user_id: str, allocation: dict[str, float]) -> str:
    """allocation 例如 {"control": 0.5, "new_model": 0.5};同一人永遠得到同一組。"""
    b = assign_bucket(experiment_id, user_id) / 1000
    acc = 0.0
    for name, share in allocation.items():
        acc += share
        if b < acc:
            return name
    return "control"
Appier 情境

Appier 以「AI 公司」自我定位,ML 模型是產品的核心。對後端工程師而言,最相關的不是模型數學,而是:特徵如何在毫秒內取得、模型如何安全地上線與回滾、如何確保訓練與線上資料一致、如何觀測模型在線上的表現(預估分布漂移、校準度)。在面試中表達「我能幫 ML 團隊把模型穩定地、低延遲地服務到線上」會很對味。

行銷自動化(AIQUA 類)的後端工程 進階

行銷自動化平台讓行銷人員依使用者行為與分群,透過多個渠道在對的時間送出對的訊息。後端挑戰在於:大量分群計算、大量訊息的排程與節流、多渠道的不同 API 限制、使用者旅程的狀態管理。Appier 的真實面試題「設計發送 LINE 訊息的 API」就屬於這個範疇。

渠道與其特性

渠道機制工程重點
iOS 推播(APNs)Provider 以 HTTP/2 呼叫 APNs,使用 token-based(JWT,.p8 金鑰)或憑證認證,送到裝置 tokenHTTP/2 多工下維持長連線、重用連線;處理無效 token(App 已刪除)並清理
Android 推播(FCM)以 FCM HTTP v1 API 搭配 OAuth2 服務帳號憑證呼叫(舊版 legacy API 已停用)大量發送時注意配額與重試;topic 訊息適合廣播
EmailSMTP 或 email 服務商 API寄件網域信譽(SPF、DKIM、DMARC)、退信與抱怨處理、退訂連結、IP 暖機
LINELINE Messaging API:push(單人)、multicast(多人,單次請求最多 500 個 user ID)、broadcast、narrowcast依官方帳號方案有訊息量限制與費用;需處理 rate limit(HTTP 429)與重試;以 retry key 避免重送
Web push / In-app message瀏覽器 Push API;App 內由 SDK 拉取或在特定事件觸發時顯示In-app 訊息不需外部服務,但要在 SDK 端做觸發條件評估與頻次控制
SMS電信商 / SMS gateway成本高、法規嚴格(時段、內容、退訂)

分眾(segment)計算的工程問題

  • 批次 segment:「過去 30 天購買 ≥ 2 次且最近 7 天未開啟 App」—— 以 Spark / 倉儲 SQL 對全體使用者計算,輸出 user id 清單。數千萬使用者 × 數千個 segment 時,要共用掃描(一次掃描評估多個條件)、增量計算,並把結果以 bitmap(例如 Roaring Bitmap)儲存以便快速做交集與聯集。
  • 即時 segment / 觸發:「加入購物車後 1 小時未結帳」—— 需要串流處理與計時器(Flink 的 keyed state + timer 很適合),或延遲佇列。
  • 一致性:發送時的使用者狀態可能已與計算 segment 時不同(例如已經結帳),發送前要做最後一次檢查(suppression)。

使用者旅程(journey orchestration)

行銷人員在介面上畫流程圖:「進入條件 → 等待 1 天 → 若未開信則改發推播 → 若購買則離開旅程」。後端本質上是每位使用者一個狀態機實例,數百萬個實例同時存在,大部分時間在「等待」。

stateDiagram-v2 [*] --> Entered: 觸發事件 (加入購物車) Entered --> WaitOneHour: 排程計時器 WaitOneHour --> CheckPurchased: 計時到期 CheckPurchased --> Exit: 已購買 CheckPurchased --> SendPush: 未購買 SendPush --> WaitOneDay WaitOneDay --> CheckOpened CheckOpened --> Exit: 已開啟 / 已購買 CheckOpened --> SendEmail: 未開啟 SendEmail --> Exit Exit --> [*]

實作要點:狀態持久化(DB 或 workflow engine,例如 Temporal 一類的持久化工作流)、大量計時器的排程(時間輪、依到期時間分桶的延遲佇列、Redis sorted set 以到期時間為 score)、事件驅動的提早離開(使用者購買事件要能找到並結束對應實例)、每個步驟冪等(重試不重發)。

大量發送的節流與排程

一次活動要對 500 萬人發送推播,不能一口氣全打出去:外部 API 有 rate limit、自家 App 後端會被瞬間湧入的點擊打爆(thundering herd)、使用者所在時區不同。常見設計:

  1. Campaign 排程器把目標名單切成批次(例如每批 1,000 人),寫入佇列(Kafka / SQS),可依使用者時區排定不同發送時間。
  2. 多個 sender worker 消費佇列,每個渠道有自己的速率限制器(token bucket;分散式時可用 Redis 實作全域限流)與並行上限。
  3. 發送前做 suppression 檢查:退訂、頻次上限(例如每人每天最多 N 則行銷訊息)、勿擾時段、已轉換者排除。
  4. 以 (campaign_id, user_id, step) 作為冪等鍵,避免 worker 重試造成重複發送。
  5. 處理回應:429 / 5xx 指數退避重試;無效 token 標記清理;結果寫入事件流供報表(送達、開啟、點擊)。
import asyncio
import time

class TokenBucket:
    def __init__(self, rate_per_sec: float, capacity: int) -> None:
        self.rate, self.capacity = rate_per_sec, capacity
        self.tokens = float(capacity)
        self.updated = time.monotonic()
        self.lock = asyncio.Lock()

    async def acquire(self) -> None:
        async with self.lock:
            while True:
                now = time.monotonic()
                self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
                self.updated = now
                if self.tokens >= 1:
                    self.tokens -= 1
                    return
                await asyncio.sleep((1 - self.tokens) / self.rate)

async def send_one(client, user: dict, msg: dict, bucket: TokenBucket,
                   sent_keys: set, max_retries: int = 5) -> None:
    key = (msg["campaign_id"], user["id"], msg["step"])
    if key in sent_keys:                       # 實務上用 Redis SET NX / DB unique key
        return
    for attempt in range(max_retries):
        await bucket.acquire()
        resp = await client.push(user["token"], msg)
        if resp.status == 200:
            sent_keys.add(key)
            return
        if resp.status in (400, 404, 410):     # 無效 token:不重試,標記清理
            return
        await asyncio.sleep(min(30, 2 ** attempt))   # 429 / 5xx:指數退避

async def send_campaign(client, users: list[dict], msg: dict) -> None:
    bucket = TokenBucket(rate_per_sec=500, capacity=500)
    sem = asyncio.Semaphore(200)               # 同時在途請求上限
    sent: set = set()
    async def guarded(u: dict) -> None:
        async with sem:
            await send_one(client, u, msg, bucket, sent)
    await asyncio.gather(*(guarded(u) for u in users))
常見陷阱

對 500 萬人用 asyncio.gather 一次建立 500 萬個 coroutine 會吃掉大量記憶體;上面的寫法適合單一批次。實務上應該由佇列以批次餵給 worker,每個 worker 只處理有限數量。另外,推播發出後的「點擊湧入」也要事先通知 App 後端擴容,或刻意把發送時間打散。

面試官想聽到什麼

「設計發送 LINE 訊息的 API」:API 本身要是非同步的(POST 建立發送任務,回傳 task id 與 202 Accepted,而非同步等所有訊息送完);支援冪等鍵(client 重送不重複建立);內部用佇列與 worker 送出、遵守 LINE 的限流與 multicast 上限做批次;記錄每則訊息的狀態供查詢與報表;處理失敗重試與 dead letter queue。這題把第 7 章 API 設計與第 11 章訊息佇列串在一起。

報表系統:從事件到 Dashboard 高頻 進階

Appier 真實系統設計題:「客戶端 SDK 回報大量 JSON,設計統計報表系統」。這是 AdTech 最典型的資料系統:寫入量極大(每次曝光/點擊都是一筆事件)、查詢以聚合為主、客戶希望近即時看到數字,且數字要能對帳。

flowchart LR SDK["SDK / Bidder / Tracker"] --> GW["收集端點
無狀態, 批次接收, 驗證"] GW --> K["Kafka
依事件類型分 topic"] K --> RT["串流聚合
Flink: 每分鐘 rollup, 去重"] K --> RAW["原始事件落地
S3 Parquet, 依小時分區"] RT --> OLAP[("ClickHouse / Druid
即時聚合表")] RAW --> BATCH["每日批次重算
去重, 反作弊, 時區"] BATCH --> OLAP BATCH --> BILL[("計費 / 對帳")] OLAP --> API["報表 API
快取, 權限"] API --> DASH["Dashboard"]

設計重點

  1. 收集端點:無狀態、可水平擴展;SDK 端批次上傳並壓縮;伺服器盡快寫入 Kafka 後回應(不在請求路徑上做聚合);每筆事件帶唯一 event_id 以便去重。
  2. 去重與遲到資料:SDK 重試、網路重送都會造成重複;以 event_id 在一定時間視窗內去重。手機離線後補送的事件要依 event time 歸到正確的時間桶。
  3. 預聚合(rollup):明細事件量太大,dashboard 查詢以 (時間桶, campaign, creative, country, os) 等維度的聚合表為主;只聚合可加總的計數,比率在查詢時計算;UU 用 HyperLogLog 等 sketch 合併。
  4. OLAP 儲存:ClickHouse(MergeTree 家族、materialized view 自動聚合)或 Druid / Pinot(從 Kafka 即時攝取)。依時間分區、依常用過濾維度排序。
  5. 延遲 vs 精確度:即時數字(分鐘級)來自串流、可能略有誤差;每日批次在資料到齊後重算(含去重、反作弊扣除、時區調整),覆寫為最終數字。在介面上標示「今日數據為預估,將於 T+1 校正」。
  6. 查詢層:多租戶權限(客戶只能看自己的資料)、查詢快取、限制可查詢的時間範圍與維度組合,避免單一大查詢拖垮叢集。
-- ClickHouse:原始事件表 + materialized view 自動聚合到分鐘級
CREATE TABLE events_raw
(
    event_id     UUID,
    event_type   LowCardinality(String),   -- impression / click / conversion
    event_time   DateTime,
    campaign_id  UInt64,
    country      LowCardinality(String),
    os           LowCardinality(String),
    user_id      String,
    cost_micros  UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (campaign_id, event_time)
TTL event_time + INTERVAL 30 DAY;           -- 明細保留 30 天

CREATE TABLE campaign_minute_agg
(
    minute       DateTime,
    campaign_id  UInt64,
    country      LowCardinality(String),
    impressions  UInt64,
    clicks       UInt64,
    cost_micros  UInt64,
    uu_state     AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(minute)
ORDER BY (campaign_id, minute, country);

CREATE MATERIALIZED VIEW campaign_minute_mv TO campaign_minute_agg AS
SELECT
    toStartOfMinute(event_time)            AS minute,
    campaign_id,
    country,
    countIf(event_type = 'impression')     AS impressions,
    countIf(event_type = 'click')          AS clicks,
    sum(cost_micros)                       AS cost_micros,
    uniqState(user_id)                     AS uu_state
FROM events_raw
GROUP BY minute, campaign_id, country;

-- 查詢:比率在查詢時才算;UU 用 uniqMerge 合併 sketch
SELECT
    campaign_id,
    sum(impressions)                         AS imps,
    sum(clicks)                              AS clk,     -- 別名不要與欄位同名,避免 ClickHouse 的循環別名問題
    clk / nullIf(imps, 0)                    AS ctr,
    sum(cost_micros) / 1e6                   AS spend,
    uniqMerge(uu_state)                      AS uu
FROM campaign_minute_agg
WHERE minute >= now() - INTERVAL 1 DAY
GROUP BY campaign_id
ORDER BY spend DESC
LIMIT 20;
常見陷阱

(1) AggregatingMergeTree 在背景 merge 前,同一個 key 可能有多列,查詢時一定要再 GROUP BY + sum/merge,不能假設已經合併成一列。(2) 每次插入一筆到 ClickHouse 會產生大量小 part、拖垮 merge,應批次寫入(或使用 async insert / Kafka engine)。(3) 時區:廣告主看的是「自己時區的一天」,若只存 UTC 日聚合就無法正確切日,分鐘或小時級聚合才能彈性換算。

Appier 情境

回答報表系統題時,說出「即時數字與計費數字是兩條路徑,分別最佳化延遲與正確性」以及「只預聚合可加總的量、比率與 UU 特別處理」,就能顯示你理解 AdTech 報表的真實痛點。詳細的系統設計步驟請參考第 13 章。

面試時如何把領域知識融入回答 高頻

領域知識的價值在於讓你的技術回答有脈絡。不需要假裝是廣告專家,而是在適當時機展現「我知道這些技術在你們的業務裡為什麼重要」。

「為什麼想來 Appier?」

回答範本

「我想做的是在真實規模下有挑戰的後端系統。以 Appier 的業務來說,程式化廣告需要在大約百毫秒等級的時限內完成競價,同時處理海量事件資料並讓 ML 模型在線上即時運作;行銷自動化則需要可靠地對大量使用者跨渠道發送訊息。這些都同時考驗低延遲、高吞吐、資料正確性與可觀測性,正是我想深入的方向。而且 Appier 從台灣出發、在亞洲多個市場營運,產品直接影響客戶的營收,工程決策的效果是看得見的。」然後接一個你自己相關的經驗(效能優化、資料管線、做過的專案)。

「你覺得我們系統最難的地方是什麼?」

挑一兩點深入,比列一長串好。可選的角度(以業界常見挑戰描述,不要假裝知道內部實作):

延遲與規模的同時要求

Bidder 在極高 QPS 下維持 p99 延遲,任何遠端呼叫的長尾都會直接變成輸掉的拍賣;每個請求的 CPU 成本乘上流量就是可觀的雲端費用。

線上線下一致性

模型訓練與線上推論的特徵一致、使用者資料從多渠道即時整合,任何不一致都會悄悄降低成效,而且很難察覺。

分散式狀態的取捨

預算、頻次這類全域計數,要在「精確」與「低延遲」之間取捨,並以業務能接受的誤差設計。

隱私變遷下的資料架構

識別碼受限、同意狀態與刪除請求要貫穿整條資料管線,同時維持模型與歸因的效果。

在技術題中自然帶入

被問到的題目可以帶入的 AdTech 脈絡
Redis / 快取Frequency cap 計數、使用者 segment 查詢、預算計數的熱點 key
TCP / HTTPBidder 與 exchange 間的 keep-alive、連線池、HTTP/2;APNs 使用 HTTP/2
資料庫 indexCampaign 管理後台的查詢 vs bidder 記憶體內倒排索引的差別
上億筆資料 merge跨渠道使用者資料整合、identity resolution(見第 12 章)
訊息佇列Bid log、曝光點擊事件、大量推播發送的節流(見第 11 章)
一致性 / CAP預算超支的容忍度、頻次控制的近似
ProfilingBidder 的 JSON 解析與 GC 成本、每請求 CPU 成本乘上 QPS
常見陷阱

(1) 捏造或臆測 Appier 的內部架構(「你們應該是用 X 吧」),面試官會覺得不踏實;改說「如果是我設計,會……」。(2) 把領域名詞當炫技,卻講不出背後的工程問題。(3) 對隱私議題輕描淡寫:AdTech 公司非常在意法遵,表達你會把隱私當作設計需求是加分。

可以反問面試官的問題

  • 「團隊目前最主要的效能或擴展瓶頸在哪一部分?最近做過最有成就感的優化是什麼?」
  • 「線上服務與 ML 團隊之間的協作方式是什麼?模型是由誰負責上線與監控?」
  • 「隱私環境的變化(例如識別碼受限)對團隊的系統帶來了哪些工程上的改變?」
  • 「實習生通常會參與哪類專案?如何衡量轉正的標準?」

面試自測題

請說明 DSP、SSP、Ad Exchange、DMP/CDP、MMP 的角色與彼此關係。

DSP 代表廣告主買流量:接收 bid request、依受眾與預估成效決定是否出價與出多少。SSP 代表媒體賣流量:把版位送到多個需求來源、設定底價、最大化收益。Ad Exchange 是舉行拍賣的市場,把一次曝光機會發給多個 DSP 並決定贏家(現在 SSP 與 exchange 常是同一家)。DMP 以匿名 ID 建立受眾分群供投放;CDP 以品牌第一方資料整合成統一的使用者輪廓,用於行銷與個人化。MMP 是行動歸因的中立第三方,判定安裝與 App 內事件該歸給哪個渠道並 postback。資料流:使用者打開 App → SSP/exchange 發 bid request → DSP 出價 → 得標後顯示 → 事件經追蹤/MMP 回報 → 用於計費、報表與模型訓練。

CPC 出價 $0.5、預估 CTR 1%,換算 eCPM 是多少?CPA 廣告怎麼算?為什麼 eCPM 重要?

eCPM = CPC × pCTR × 1000 = 0.5 × 0.01 × 1000 = $5。CPA 廣告為 CPA × pCTR × pCVR × 1000。eCPM 把不同計價模式換算成「每千次曝光的期望價值」,讓 exchange/媒體能在同一把尺上比較,也是 DSP 出價的基礎。因此 CTR/CVR 預估的準確度與校準直接影響營收:高估會出價過高而虧損,低估會輸掉拍賣、預算花不完。另外要注意 OpenRTB 的 price 以 CPM 表示,單次曝光實際成本是 price / 1000。

CTR、CVR、ROAS、win rate、fill rate、viewability 的定義?為什麼報表不能直接平均各 campaign 的 CTR?

CTR = clicks / impressions;CVR = conversions / clicks(需講清楚分母);ROAS = 歸因營收 / 廣告花費;win rate = 得標數 / 出價數;fill rate = 有填上廣告的請求 / 廣告請求數;viewability = 可見曝光 / 可量測曝光(MRC 標準:展示型 50% 像素至少 1 秒,影音 50% 像素至少連續 2 秒)。比率不可直接平均,因為各 campaign 的分母不同,簡單平均會被小樣本扭曲;正確做法是加總分子與分母後再相除。所以報表系統只預聚合可加總的計數,比率在查詢時計算。

描述一次 RTB 的完整流程與時間預算。DSP 如何確保不超時?

使用者打開 App/網頁 → SSP/exchange 組 OpenRTB bid request 同時發給多個 DSP(tmax 常見約 100ms 等級)→ DSP 解析、查使用者、找候選、預估、出價後回應或回 204 no-bid → exchange 決定贏家與價格、發 win notice → 素材顯示、回報曝光(billing notice)→ 點擊/轉換追蹤。tmax 包含來回網路時間,所以 DSP 內部可用的時間常只有數十毫秒甚至更少。做法:部署在 exchange 附近的區域、連線重用(keep-alive)、內部設定 deadline 並以 context 傳遞到所有下游呼叫、每個步驟有獨立 timeout 與降級(KV 逾時當新使用者、模型逾時用預設 CTR)、熱路徑不做同步寫入、log 非同步批次送出。

OpenRTB bid request 中有哪些重要欄位?DSP 不出價時要回什麼?

重要欄位:id、imp[](banner/video/native、bidfloor、tagid)、site/app(來源與類別)、device(ua、ip、geo、os、ifa、lmt)、user(id、buyeruid)、at(1 第一價、2 第二價)、tmax、bcat/badv(品牌安全)、regs(隱私/法規訊號)、source.schain(供應鏈)。回應的 seatbid[].bid[] 含 impid、price、adm(素材)、nurl(win notice)、burl(billing notice)、adomain、crid。不出價回 HTTP 204 No Content。${AUCTION_PRICE} 巨集由 exchange 替換為成交價。

第一價與第二價拍賣有何差異?為什麼業界轉向第一價?什麼是 bid shading?

第二價:最高出價者得標但支付第二高價加最小增額,理論上誠實出價是最佳策略。第一價:得標者支付自己的出價。在 header bidding 與多層拍賣下,第二價的實際規則不透明(各層都可能動底價),因此業界約在 2017–2019 年轉向第一價以求透明一致。第一價下誠實出價會多付,DSP 要做 bid shading:根據歷史資料估計得標所需價格的分布,最大化 P(win | bid) × (value − bid)。難點是資料是 censored 的(輸了只知道對手出得比較高)。

Header bidding 是什麼?對 DSP 的系統有什麼影響?

傳統 waterfall 依序詢問需求來源,先到先得,可能錯過出價更高的買家。Header bidding 讓媒體在頁面/App 載入時同時向多個 SSP/exchange 取得出價,再與直購需求一起比較,提升競爭與媒體收益(Prebid.js / Prebid Server 是主流實作)。對 DSP 而言,同一個曝光機會可能從多個路徑以多份 bid request 到達,造成 QPS 膨脹、自己跟自己競價、重複計算頻次等問題。解法包含供應路徑最佳化(SPO)、請求去重、依 schain 選擇較直接的路徑。

設計 DSP bidder:請說明處理管線與各階段的工程挑戰。

管線:解析驗證 → user lookup(ID 對應、segment、頻次)→ 候選檢索(記憶體內倒排索引)→ targeting 過濾(地理、裝置、品牌安全、預算)→ CTR/CVR 批次預估 → 出價(期望價值、bid shading、底價)→ pacing/預算檢查 → 組回應並非同步寫 log。挑戰:數十萬級 QPS 下的 p99 延遲(JSON 解析與 GC 成本、遠端 KV 長尾)、campaign 設定的控制面與資料面分離(不可變快照、原子替換)、模型 inference 延遲(批次、輕量模型、降級)、預算與頻次的分散式計數、log 不可阻塞熱路徑但遺失要可偵測。Bidder 本身無狀態可水平擴展。觀測:bid rate、win rate、timeout rate、no-bid 原因分布、p99。

Budget pacing 的目的與做法?分散式環境下預算計數怎麼設計?

目的是讓預算依計畫平滑花完,避免一早就花光、錯過高價值時段。模式有 ASAP 與 even(依流量曲線而非時間等分)。控制手段是 throttling(參與競價的機率)或調整出價倍率,控制器常用 PID:誤差 = 目標花費 − 實際花費,P 反應當下、I 修正長期偏差(需 anti-windup)、D 抑制震盪。分散式計數有三種取捨:集中式 Redis 計數(精確但熱點、多一次往返)、本地配額 lease(熱路徑全本地但可能閒置或需回收)、本地累計定期回報(低延遲但最終一致、可能小幅超支)。實際花費要等 win/billing notice 才知道,所以必須設計安全邊際並接受小幅超支。

如何實作 frequency capping?有哪些競態與規模問題?

出價時讀取使用者對各 campaign 的計數,超過上限就排除;在曝光事件發生時才累加(不是出價或得標時)。固定視窗用 Redis HINCRBY 將同一使用者的所有 campaign 計數放在一個 hash,並設 TTL,出價時一次 HMGET;滑動視窗用 sorted set 或每小時分桶計數。競態:同一使用者的並行請求可能讀到同樣的計數而都出價,略超上限,通常可接受,或在出價時暫時預留。規模:數億使用者需控制 key 數量與記憶體(hash 打包、TTL、只存有曝光的 campaign),並與 bidder 同機房以維持低延遲。

Last-click、multi-touch、view-through 歸因有何不同?歸因視窗的影響?

Last-click 把轉換全歸給最後一次點擊,簡單、行動廣告業界主流,但忽略上層漏斗;multi-touch(linear、time-decay、position-based、data-driven)把功勞分給多個接觸點,需要能串起跨渠道路徑的資料;view-through 計算沒點擊但看過廣告後的轉換,容易高估。歸因視窗決定點擊/曝光後多久內的轉換才算,越長歸到越多但越可能把自然轉換算進來。最可信的是 incrementality test(隨機對照組)。實作上 last-click 可用 SQL:conversion join 視窗內的 clicks,用 ROW_NUMBER 依點擊時間倒序取第一筆。

Pixel 追蹤與 S2S postback 的差異?Click ID 的作用?

Pixel 是在瀏覽器/頁面上觸發請求記錄事件,依賴瀏覽器環境,易受 ad blocker、cookie 限制與 ITP 影響。S2S postback 由廣告主伺服器或 MMP 直接呼叫平台 API,較可靠,但需要識別碼對應。Click ID 是點擊時產生的唯一 ID,透過跳轉帶到落地頁或 App,轉換時回傳,讓伺服器端能確定性地把轉換對到點擊;應加簽章與過期時間避免偽造。Postback 接收端要做冪等(同一轉換可能被回報多次)、驗證來源、處理延遲到達。

ATT、SKAdNetwork/AdAttributionKit 與第三方 cookie 的變化,對後端系統有什麼影響?

iOS 14.5 起讀取 IDFA 需經 ATT 同意,同意率不高,使用者層級的追蹤與再行銷受限;SKAdNetwork 與其後繼的 AdAttributionKit 以延遲、聚合、有隱私門檻的 postback 提供歸因,資料粗且延遲長。第三方 cookie 在 Safari、Firefox 早已被阻擋,Chrome 則改變了淘汰計畫,Privacy Sandbox 多數 API 也被宣布退役(細節面試前應再確認)。對後端的影響:更依賴第一方資料與 contextual 訊號;出價/最佳化要能處理延遲且粗粒度的回饋(建模推估轉換);同意狀態要成為資料的一級欄位並貫穿管線;identity resolution 以確定性比對為主。

GDPR、CCPA 與台灣個資法對資料系統設計有什麼具體要求?

共通要求:處理個資要有合法基礎/特定目的(如同意)、資料最小化、當事人權利(查詢、更正、刪除;CCPA 特別強調拒絕出售/分享)、安全維護與外洩通報。工程上:PII 分類標記、假名化(雜湊後的 email 仍屬個資)、事件帶 consent 狀態並在下游依目的過濾、建立刪除請求傳播到所有系統(KV、資料湖、衍生表、特徵)的流程並留稽核紀錄、設定資料保留期限、依地區儲存(data residency)、加密與最小權限、log 中不寫 PII。廣告協定中以 TCF consent string、US Privacy/GPP 字串傳遞同意狀態。GDPR 罰款上限可達全球年營業額 4% 或 2,000 萬歐元取高者。

常見的廣告詐欺有哪些?如何偵測 click injection 與 click spam?

常見類型:bot/無效流量、click spam(大量假點擊搶自然安裝的 last-click 歸因)、click injection / install hijacking(惡意 App 偵測到安裝即將完成時送出假點擊)、SDK spoofing(偽造 SDK 請求)、domain/app spoofing(冒充高價值媒體)、device farm。偵測:CTIT(點擊到安裝時間)分布——click injection 會異常集中在極短時間、click spam 則分布平坦且偏長、點擊轉安裝率極低;資料中心 IP 名單、異常 CTR、裝置特徵重複;ads.txt/app-ads.txt、sellers.json、schain 驗證供應來源;事件簽章與去重。分層處理:出價前規則過濾、事件接收時驗證、離線分析回饋名單並在計費時扣除。

什麼是 training-serving skew?feature store 如何解決?

模型訓練使用的特徵與線上推論時的特徵計算邏輯或時間點不一致,導致離線指標好、上線效果差。Feature store 以同一份特徵定義產生 online store(低延遲 KV,供 bidder 查)與 offline store(含時間戳的歷史特徵,供訓練),並提供 point-in-time correct join:產生訓練樣本時只用事件當下之前已知的特徵值,避免資料洩漏。另一個可靠做法是 log-and-wait:記錄線上推論時實際使用的特徵當作訓練資料。還要處理轉換的延遲回饋,以及監控線上特徵分布與預估分布的漂移。

廣告系統做 A/B test 有什麼特別要注意的?

分流用穩定雜湊 hash(experiment_id + user_id) 讓同一使用者固定在同組、不同實驗相互獨立。廣告特有問題:各組共用預算與同一個拍賣市場,A 組出價高會搶走 B 組的曝光並改變市場價格,造成干擾,所以常需預算切分或以 campaign/廣告主為單位分流。指標要有主要指標(CPA、ROAS)與護欄指標(latency、timeout、超支),事先決定樣本量與實驗時長、避免 peeking。新模型先 shadow 再小流量灰度,並能快速回滾。

要對 500 萬名使用者發送推播與 LINE 訊息,系統怎麼設計?

API 非同步:建立發送任務回 202 + task id,支援冪等鍵。排程器把名單切批寫入佇列(可依時區排程);多個 worker 消費,每個渠道一個速率限制器(token bucket,分散式用 Redis)與並行上限;發送前 suppression 檢查(退訂、頻次上限、勿擾時段、已轉換);以 (campaign, user, step) 作冪等鍵避免重試重發;429/5xx 指數退避,無效 token(APNs 410、FCM 未註冊)標記清理,失敗進 DLQ。LINE 可用 multicast 一次最多 500 人批次送出,並注意官方帳號方案的訊息量限制。發送結果寫入事件流供報表。另外要預先通知 App 後端應對點擊湧入,或刻意打散發送時間。

「客戶端 SDK 回報大量 JSON,設計統計報表系統」:請說明架構與延遲 vs 精確度的取捨。

SDK 批次壓縮上傳 → 無狀態收集端點驗證後寫 Kafka(每筆帶 event_id)→ 兩條路徑:串流(Flink)做去重與分鐘級 rollup 寫入 ClickHouse/Druid,提供近即時 dashboard;原始事件以 Parquet 依小時分區落地,每日批次在資料到齊後重算(去重、反作弊扣除、時區),覆寫為最終與計費數字。OLAP 只預聚合可加總計數,比率查詢時算,UU 用 HLL sketch 合併。查詢層做多租戶權限、快取與查詢限制。延遲 vs 精確度:即時數字用於操作決策,可能有小誤差並標示為預估;T+1 批次數字用於計費與對帳。注意遲到事件依 event time 歸桶、ClickHouse 要批次寫入、保留分鐘/小時粒度以支援不同時區。

如果面試官問「你覺得我們系統最難的地方是什麼」,你會怎麼回答?

挑一兩點深入並連到自己的能力,而不是臆測內部架構。例如:「以程式化廣告的業務特性,我覺得最難的是在極高 QPS 下同時維持低 p99 延遲與出價品質——每個遠端呼叫的長尾都會變成輸掉的拍賣,而每個請求的 CPU 成本乘上流量就是可觀的成本。另一個是線上線下一致性:模型特徵與多渠道使用者資料要即時整合且一致,不一致會悄悄傷害成效。我過去在 X 專案做過 profiling 與降低延遲的工作,很希望在這樣的規模下深入。」最後可以反問團隊實際遇到的瓶頸。