廣告生態系:誰在買、誰在賣、誰在中間 高頻 基礎
線上廣告本質上是一個即時的雙邊市場:一邊是想買使用者注意力的需求方(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 給各渠道。
受眾資料"] --> DSP DSP --- EX["Ad Exchange
拍賣"] EX --- SSP["SSP 供給方平台"] SSP --> PUB["Publisher 媒體
網站 / App"] PUB --> USER(("使用者")) USER -- "曝光 / 點擊 / 安裝 / 購買" --> MMP["MMP / 追蹤伺服器"] MMP -- "postback 歸因結果" --> DSP MMP --> ADV
依公開資訊,Appier 的產品線大致對應:CrossX —— 以 AI 驅動的程式化廣告(偏需求方,協助廣告主取得與再行銷使用者);AIQUA —— 跨渠道行銷自動化(推播、email、LINE、網頁訊息等);AIXON —— 資料科學與受眾分析平台(整合第一方資料、預測受眾);AIDEAL —— 電商個人化與推薦。換句話說,Appier 同時跨足「拉新」(廣告)與「留存/轉換」(行銷自動化、個人化),兩者共享使用者資料與 ML 能力。面試時不要說得好像你知道它的內部架構,而是說「以這類產品常見的技術挑戰來看……」。
一次廣告曝光的生命週期
- 使用者打開 App,媒體 SDK 向 SSP / ad server 發出廣告請求(帶有版位、裝置、粗略地理位置、同意狀態等)。
- SSP 把它包成 OpenRTB bid request,透過 exchange 同時送給多個 DSP。
- 各 DSP 在時間限制內(常見約 100ms 級別,以
tmax指定)回應出價與素材,或回 no-bid。 - Exchange 決定贏家與成交價,通知贏家(win notice),把素材回傳給 App 顯示。
- SDK 回報曝光(impression),使用者可能點擊(click),之後在一段時間內安裝或購買(conversion),由追蹤系統與 MMP 歸因回廣告。
- 所有事件流入資料管線:用於計費、報表、模型訓練與下一次出價。
計價模式:CPM、CPC、CPA、CPI、oCPM 與 eCPM 高頻 基礎
計價模式決定了風險由誰承擔:按曝光付費時,成效不好的風險在廣告主;按轉換付費時,風險轉到平台(平台得自己預估轉換率才知道每次曝光值多少錢)。這正是 AI 驅動的廣告平台創造價值的地方。
| 模式 | 全名 | 廣告主付費時機 | 風險承擔 | 常見用途 |
|---|---|---|---|---|
| CPM | Cost Per Mille(每千次曝光成本) | 每 1,000 次曝光 | 廣告主 | 品牌曝光、程式化競價的結算單位 |
| CPC | Cost Per Click | 每次點擊 | 平台承擔 CTR 風險 | 導流 |
| CPA | Cost Per Action/Acquisition | 每次轉換(註冊、購買) | 平台承擔 CTR × CVR 風險 | 成效型廣告 |
| CPI | Cost Per Install | 每次 App 安裝 | 平台 | App 拉新(user acquisition) |
| oCPM / oCPC | Optimized CPM / CPC | 仍按曝光/點擊計費,但出價由系統依目標轉換成本自動調整 | 共同承擔 | 主流的智慧出價(target CPA、target ROAS) |
| CPV / CPCV | Cost Per View / Completed View | 每次觀看 / 完整觀看 | — | 影音廣告 |
eCPM:把不同計價模式放在同一把尺上比較
Exchange 或媒體端要比較「一個 CPC 廣告」與「一個 CPM 廣告」誰比較值錢,就要換算成每千次曝光的預期收益:
| 計價 | eCPM 公式 | 例子 |
|---|---|---|
| CPM | eCPM = CPM 出價 | 出價 $2 CPM → eCPM $2 |
| CPC | eCPM = CPC × pCTR × 1000 | CPC $0.5、pCTR 1% → 0.5 × 0.01 × 1000 = $5 |
| CPA | eCPM = CPA × pCTR × pCVR × 1000 | CPA $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 | 點擊次數 | 需去除重複與無效點擊 |
| CTR | clicks / impressions | 素材與受眾的吸引力,常見在 1% 以下的量級,依格式差異很大 |
| CVR | conversions / clicks(有時以曝光為分母) | 落地頁與受眾品質;要說清楚分母 |
| CPA(實際) | spend / conversions | 廣告主最在乎的成本指標之一 |
| ROAS | attributed revenue / ad spend | 廣告投報率,例如 ROAS 4 代表每花 1 元帶來 4 元營收 |
| LTV | 使用者在生命週期內帶來的總價值(預估) | 決定能接受多高的 CPI / CPA;常用 D7、D30 營收預測 |
| Fill rate | filled ad responses / ad requests | 媒體端:有多少請求真的填上了廣告 |
| Bid rate | bids / bid requests | DSP 對收到的流量有多少回應出價 |
| Win rate | wins / bids | DSP 出價的競爭力;太低代表出價過低或目標太窄,太高可能代表出價過高 |
| Viewability | viewable impressions / measured impressions | 依 MRC 標準:展示型廣告 50% 像素在可視範圍內持續至少 1 秒,影音廣告 50% 像素至少連續 2 秒 |
| eCPM | revenue / 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 可達數十萬甚至更高等級,每一個都要在嚴格時限內回應。
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 資訊 |
device | ua、ip、geo、os、devicetype、ifa(廣告識別碼,可能為空或全 0)、lmt(限制追蹤) |
user | id(exchange 的 user id)、buyeruid(DSP 自己透過 cookie sync 對應的 ID)、consent |
at | 拍賣類型:1 = 第一價,2 = 第二價加價(second price plus) |
tmax | 允許的最長回應時間(毫秒),已包含網路延遲 |
bcat / badv | 封鎖的廣告類別 / 廣告主網域(品牌安全) |
regs | 法規訊號:coppa、gdpr、US 隱私字串等 |
source.schain | Supply 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。
以 DSP 角度看,header bidding 與多 exchange 接入讓同一個曝光機會可能以多份 bid request 到達,造成 QPS 膨脹與「自己跟自己競價」的問題。常見做法是在 bidder 前做請求去重或供應路徑最佳化(supply path optimization,SPO),只透過較直接、較便宜的路徑出價。這在面試時是一個能展現領域理解的點。
DSP Bidder 內部架構與工程挑戰 高頻 深入
「設計廣告投放系統」是 Appier 真實出現過的系統設計題。Bidder 是其中最核心、最吃效能的元件:它是一個高 QPS、低延遲、讀多寫少、可容忍部分降級的線上服務。以下以業界常見做法拆解其處理管線。
過濾不合法/黑名單流量"] 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;非同步寫 log | Log 不能阻塞回應路徑:寫入本地緩衝再批次送 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 Web | UA 解析成本高:快取解析結果 |
| 受眾 segment | 第一方資料(客戶上傳、網站/App 行為)、模型預測(高購買意願、lookalike) | Segment 由離線/近即時管線計算,寫入 user store;bidder 查詢 user → segment 列表 |
| Contextual | App 類別、網頁內容/關鍵字、版位 | 不依賴個人識別碼,在隱私限制下越來越重要 |
| 時段 / 頻次 / 再行銷 | 時間、frequency cap、使用者近期行為(看過商品、加入購物車) | 再行銷需要事件即時寫入使用者狀態 |
以 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。 |
| AdAttributionKit | Apple 於 2024 年推出的新歸因框架,與 SKAdNetwork 互通,並支援替代 App 市集等情境;可視為 SKAN 的後繼方向。 |
| GAID(Android) | Google Advertising ID 仍可使用,但使用者可重設或刪除;選擇不提供時取得的值為全 0,且 App 需宣告相關權限。 |
| 第三方 cookie | Safari(ITP)與 Firefox 早已預設阻擋第三方 cookie。Chrome 原計畫淘汰,但 Google 在 2024 年改變方向、不強制淘汰,2025 年又表示維持現行做法、不推出新的獨立選擇提示。 |
| Privacy Sandbox | Google 提出的一系列替代 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:後端怎麼做
- 資料分類與最小化:標記哪些欄位是個資(PII)、哪些是敏感資料;不需要的就不收。分析用資料盡量只存假名化 ID。
- 假名化與雜湊:email / 手機正規化後以 SHA-256 雜湊做比對鍵;但要知道雜湊後的 email 仍屬可識別的個人資料(可被字典攻擊),不能當成匿名化。
- 同意狀態一級化:每筆事件帶 consent 欄位(或可查詢的 consent 版本),下游任務依目的過濾,例如未同意個人化的使用者不得進入再行銷 segment。
- 刪除傳播:建立「刪除請求 → 所有系統」的工作流,包含 KV store、資料湖、衍生表、備份策略、模型特徵;用 lakehouse 的 delete / MERGE 處理資料湖。要能證明已刪除(稽核紀錄)。
- 保留期限(retention):原始事件設 TTL 或分區定期刪除,聚合後的報表可保留較久。
- 資料落地(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。
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 以「AI 公司」自我定位,ML 模型是產品的核心。對後端工程師而言,最相關的不是模型數學,而是:特徵如何在毫秒內取得、模型如何安全地上線與回滾、如何確保訓練與線上資料一致、如何觀測模型在線上的表現(預估分布漂移、校準度)。在面試中表達「我能幫 ML 團隊把模型穩定地、低延遲地服務到線上」會很對味。
行銷自動化(AIQUA 類)的後端工程 進階
行銷自動化平台讓行銷人員依使用者行為與分群,透過多個渠道在對的時間送出對的訊息。後端挑戰在於:大量分群計算、大量訊息的排程與節流、多渠道的不同 API 限制、使用者旅程的狀態管理。Appier 的真實面試題「設計發送 LINE 訊息的 API」就屬於這個範疇。
渠道與其特性
| 渠道 | 機制 | 工程重點 |
|---|---|---|
| iOS 推播(APNs) | Provider 以 HTTP/2 呼叫 APNs,使用 token-based(JWT,.p8 金鑰)或憑證認證,送到裝置 token | HTTP/2 多工下維持長連線、重用連線;處理無效 token(App 已刪除)並清理 |
| Android 推播(FCM) | 以 FCM HTTP v1 API 搭配 OAuth2 服務帳號憑證呼叫(舊版 legacy API 已停用) | 大量發送時注意配額與重試;topic 訊息適合廣播 |
| SMTP 或 email 服務商 API | 寄件網域信譽(SPF、DKIM、DMARC)、退信與抱怨處理、退訂連結、IP 暖機 | |
| LINE | LINE 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 天 → 若未開信則改發推播 → 若購買則離開旅程」。後端本質上是每位使用者一個狀態機實例,數百萬個實例同時存在,大部分時間在「等待」。
實作要點:狀態持久化(DB 或 workflow engine,例如 Temporal 一類的持久化工作流)、大量計時器的排程(時間輪、依到期時間分桶的延遲佇列、Redis sorted set 以到期時間為 score)、事件驅動的提早離開(使用者購買事件要能找到並結束對應實例)、每個步驟冪等(重試不重發)。
大量發送的節流與排程
一次活動要對 500 萬人發送推播,不能一口氣全打出去:外部 API 有 rate limit、自家 App 後端會被瞬間湧入的點擊打爆(thundering herd)、使用者所在時區不同。常見設計:
- Campaign 排程器把目標名單切成批次(例如每批 1,000 人),寫入佇列(Kafka / SQS),可依使用者時區排定不同發送時間。
- 多個 sender worker 消費佇列,每個渠道有自己的速率限制器(token bucket;分散式時可用 Redis 實作全域限流)與並行上限。
- 發送前做 suppression 檢查:退訂、頻次上限(例如每人每天最多 N 則行銷訊息)、勿擾時段、已轉換者排除。
- 以
(campaign_id, user_id, step)作為冪等鍵,避免 worker 重試造成重複發送。 - 處理回應: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 最典型的資料系統:寫入量極大(每次曝光/點擊都是一筆事件)、查詢以聚合為主、客戶希望近即時看到數字,且數字要能對帳。
無狀態, 批次接收, 驗證"] 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"]
設計重點
- 收集端點:無狀態、可水平擴展;SDK 端批次上傳並壓縮;伺服器盡快寫入 Kafka 後回應(不在請求路徑上做聚合);每筆事件帶唯一 event_id 以便去重。
- 去重與遲到資料:SDK 重試、網路重送都會造成重複;以 event_id 在一定時間視窗內去重。手機離線後補送的事件要依 event time 歸到正確的時間桶。
- 預聚合(rollup):明細事件量太大,dashboard 查詢以
(時間桶, campaign, creative, country, os)等維度的聚合表為主;只聚合可加總的計數,比率在查詢時計算;UU 用 HyperLogLog 等 sketch 合併。 - OLAP 儲存:ClickHouse(MergeTree 家族、materialized view 自動聚合)或 Druid / Pinot(從 Kafka 即時攝取)。依時間分區、依常用過濾維度排序。
- 延遲 vs 精確度:即時數字(分鐘級)來自串流、可能略有誤差;每日批次在資料到齊後重算(含去重、反作弊扣除、時區調整),覆寫為最終數字。在介面上標示「今日數據為預估,將於 T+1 校正」。
- 查詢層:多租戶權限(客戶只能看自己的資料)、查詢快取、限制可查詢的時間範圍與維度組合,避免單一大查詢拖垮叢集。
-- 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 日聚合就無法正確切日,分鐘或小時級聚合才能彈性換算。
回答報表系統題時,說出「即時數字與計費數字是兩條路徑,分別最佳化延遲與正確性」以及「只預聚合可加總的量、比率與 UU 特別處理」,就能顯示你理解 AdTech 報表的真實痛點。詳細的系統設計步驟請參考第 13 章。
面試時如何把領域知識融入回答 高頻
領域知識的價值在於讓你的技術回答有脈絡。不需要假裝是廣告專家,而是在適當時機展現「我知道這些技術在你們的業務裡為什麼重要」。
「為什麼想來 Appier?」
「我想做的是在真實規模下有挑戰的後端系統。以 Appier 的業務來說,程式化廣告需要在大約百毫秒等級的時限內完成競價,同時處理海量事件資料並讓 ML 模型在線上即時運作;行銷自動化則需要可靠地對大量使用者跨渠道發送訊息。這些都同時考驗低延遲、高吞吐、資料正確性與可觀測性,正是我想深入的方向。而且 Appier 從台灣出發、在亞洲多個市場營運,產品直接影響客戶的營收,工程決策的效果是看得見的。」然後接一個你自己相關的經驗(效能優化、資料管線、做過的專案)。
「你覺得我們系統最難的地方是什麼?」
挑一兩點深入,比列一長串好。可選的角度(以業界常見挑戰描述,不要假裝知道內部實作):
延遲與規模的同時要求
Bidder 在極高 QPS 下維持 p99 延遲,任何遠端呼叫的長尾都會直接變成輸掉的拍賣;每個請求的 CPU 成本乘上流量就是可觀的雲端費用。
線上線下一致性
模型訓練與線上推論的特徵一致、使用者資料從多渠道即時整合,任何不一致都會悄悄降低成效,而且很難察覺。
分散式狀態的取捨
預算、頻次這類全域計數,要在「精確」與「低延遲」之間取捨,並以業務能接受的誤差設計。
隱私變遷下的資料架構
識別碼受限、同意狀態與刪除請求要貫穿整條資料管線,同時維持模型與歸因的效果。
在技術題中自然帶入
| 被問到的題目 | 可以帶入的 AdTech 脈絡 |
|---|---|
| Redis / 快取 | Frequency cap 計數、使用者 segment 查詢、預算計數的熱點 key |
| TCP / HTTP | Bidder 與 exchange 間的 keep-alive、連線池、HTTP/2;APNs 使用 HTTP/2 |
| 資料庫 index | Campaign 管理後台的查詢 vs bidder 記憶體內倒排索引的差別 |
| 上億筆資料 merge | 跨渠道使用者資料整合、identity resolution(見第 12 章) |
| 訊息佇列 | Bid log、曝光點擊事件、大量推播發送的節流(見第 11 章) |
| 一致性 / CAP | 預算超支的容忍度、頻次控制的近似 |
| Profiling | Bidder 的 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 與降低延遲的工作,很希望在這樣的規模下深入。」最後可以反問團隊實際遇到的瓶頸。