Chapter 13

系統設計方法與 Appier 情境題

先建立一套在 45–60 分鐘內穩定輸出的系統設計框架(釐清需求、估算、API、資料模型、架構、深入、取捨),再用它完整拆解 Appier 面試實際出現過的題目:SDK 事件統計報表、廣告投放系統、chatbot 平台、LINE 大量推播、多來源使用者資料整合,最後補上九道經典題與常見追問。Appier 的業務是高 QPS、低延遲、海量事件的 AdTech + MarTech,系統設計題幾乎都圍繞這三個關鍵字。

預估閱讀 120 分鐘

系統設計面試框架與時間分配 高頻

系統設計面試沒有標準答案,面試官評估的是思考過程:你能不能把模糊的需求變成清楚的問題、能不能用數字驅動設計決策、能不能看出瓶頸、能不能說清楚取捨。最常見的失敗模式不是「不會」,而是「沒有結構」:一開口就畫 Kafka + Redis + K8s,結果 40 分鐘後還沒講到核心問題。

建議把整場面試當成一個有固定節奏的流程,並且主動宣告你的流程:「我先花幾分鐘釐清需求和規模,接著估算、定 API 和資料模型,畫出高層架構,然後挑兩三個最關鍵的瓶頸深入,最後談取捨與擴展。」這句話本身就是加分,因為它讓面試官知道你有方法,也方便他在中途把你拉到他想考的點。

八步驟框架

  1. 釐清需求(Requirements):功能需求(functional:系統要做什麼)與非功能需求(non-functional:QPS、延遲、可用性、一致性、資料保留期、成本、安全/隱私)。明確寫下「在範圍內 / 不在範圍內」。
  2. 估算(Back-of-the-envelope):QPS(平均與峰值)、讀寫比、儲存量(每日、保留期總量)、頻寬、記憶體(cache 熱資料大小)。目的不是算得精準,而是決定「單機能不能撐」「要不要 sharding」「要不要 cache」。
  3. API 設計:列出 3–5 個核心 endpoint(REST 或 gRPC),含參數、回應、冪等性(idempotency key)、分頁、認證。API 讓需求變得具體。
  4. 資料模型:核心實體、主鍵、存取模式(access pattern)、索引、分片鍵(shard key)。先講 access pattern 再選 DB,而不是反過來。
  5. 高層架構(High-level design):畫出 client → LB → 服務 → 儲存 / 佇列 / cache 的資料流,並沿著一個請求「走一遍」(read path 與 write path 各一次)。
  6. 深入瓶頸(Deep dive):挑 2–3 個最難的點深入(熱點、一致性、去重、延遲預算、故障處理)。通常面試官會在這裡引導,順著他走。
  7. 取捨與擴展(Trade-offs & scaling):講出你放棄了什麼、為什麼;10 倍流量時哪裡先壞;單點故障在哪;如何監控。
  8. 總結(Wrap-up):用 30 秒複述設計、已知限制與下一步可改進之處。

時間分配建議

階段45 分鐘場60 分鐘場產出
釐清需求5 分5–8 分功能清單、非功能指標、範圍外清單
估算3–5 分5 分QPS、儲存、頻寬三個數字
API + 資料模型5–7 分8–10 分endpoint 列表、主要 table/key 設計
高層架構8–10 分10–12 分架構圖 + 走一遍 read/write path
深入瓶頸12–15 分15–20 分2–3 個關鍵問題的解法與替代方案
取捨、擴展、總結3–5 分5 分限制、監控、未來改進
flowchart LR A["1 釐清需求"] --> B["2 估算"] B --> C["3 API"] C --> D["4 資料模型"] D --> E["5 高層架構"] E --> F["6 深入瓶頸"] F --> G["7 取捨與擴展"] G --> H["8 總結"] F -. 面試官追問 .-> E B -. 數字推翻假設 .-> A
面試官想聽到什麼

(1) 你會先問問題而不是直接畫圖;(2) 你的設計決策有數字或需求當依據(「因為寫入峰值 5 萬/秒,單一 MySQL primary 撐不住,所以…」);(3) 你知道每個元件的代價(Kafka 帶來延遲與維運成本、cache 帶來一致性問題);(4) 你能主動指出自己設計的弱點。這四點比「用了多少潮流技術」重要得多。

實戰提示

白板或線上畫圖時,把畫面分成三區:左上角寫需求與估算數字(之後會一直回頭引用)、中間畫架構、右側列「待深入 / 取捨」清單。當面試官問到你沒想過的點,先把它記在右側清單,回答「這是個好問題,我先把主流程講完再回來處理」,可以避免被打亂節奏。

釐清需求:該問的問題清單 高頻

Appier 的題目通常只有一句話(「設計一個廣告投放系統」「SDK 會回報大量 JSON,設計統計報表系統」),刻意保留模糊空間。問對問題可以把題目縮到 45 分鐘講得完的範圍,也展現你理解業務。

功能需求(Functional)

  • 誰是使用者?終端消費者、廣告主、內部營運、其他服務?不同使用者的 SLA 完全不同。
  • 核心使用情境是哪 2–3 個?例如報表系統:是「看即時儀表板」還是「每天下載 CSV」?
  • 輸入/輸出長什麼樣?事件格式、欄位數、是否有 schema、大小。
  • 需要哪些維度與指標?(報表題)可以任意 group by 嗎?還是固定幾種報表?
  • 有沒有多租戶(multi-tenant)?Appier 的產品都是 B2B SaaS,幾乎都有。
  • 哪些功能明確不做?例如「計費和反作弊先不談」。

非功能需求(Non-functional)

面向要問的問題會影響的設計
規模DAU、每日事件數、峰值倍數、成長率是否分片、Kafka partition 數、叢集大小
延遲p99 要多少?讀和寫分別?同步 vs 非同步、cache、預計算
新鮮度資料多久後可見?秒級、分鐘級、隔日?串流 vs 批次(Flink vs Spark)
準確度要精確還是可以近似?可容忍重複/遺失嗎?exactly-once、去重、HLL
一致性使用者寫完要立刻讀到嗎?跨區域?強一致 DB vs 最終一致、read-your-writes
可用性99.9% 還是 99.99%?哪些功能可以降級?多 AZ、多 region、fallback
保留期原始資料保留多久?聚合資料多久?冷熱分層、S3 lifecycle、TTL
安全/隱私是否有 PII?GDPR/個資法刪除需求?雜湊化、加密、刪除流程
成本是否有預算限制?LLM 每次呼叫多少錢?取樣、預聚合、模型選擇
常見陷阱

問了一大串問題但沒有把答案「定下來」。正確做法是:問完後自己總結假設,例如「那我假設 DAU 5000 萬、每人每天 20 個事件、報表延遲 5 分鐘內可接受、unique user 可接受 1% 誤差」,寫在白板左上角。面試官沒給數字時,自己提出合理假設並請對方確認,不要卡住等答案。

Appier 情境

以 AdTech/MarTech 平台常見情況來說,幾個「預設值」很好用:廣告競價(RTB)回應需在約 100ms 內完成;事件量級為每天數十億筆;報表通常可接受分鐘級延遲,但計費相關數字必須精確且可稽核;所有資料都要以 tenant(廣告主/品牌客戶)隔離。主動說出這些,代表你理解這個產業。

估算技巧與常用數字 高頻 基礎

估算的目的只有一個:讓數字幫你做設計決策。所以要算的是「會改變架構的數字」:峰值 QPS(決定機器數與是否需要佇列緩衝)、總儲存量(決定 DB 選型與分片)、熱資料大小(決定 cache 能否放進記憶體)、頻寬(決定是否要壓縮、批次)。

QPS 換算

一天 86,400 秒,估算時可以近似成 105 秒(誤差約 16%,面試可接受;需要時再修正)。

每日量平均 QPS(精算)近似(÷105)峰值(×3~×5)
100 萬/天≈ 11.6/s10/s35–60/s
1,000 萬/天≈ 116/s100/s350–600/s
1 億/天≈ 1,160/s1,000/s3,500–6,000/s
10 億/天≈ 11,600/s10,000/s3.5 萬–6 萬/s
100 億/天≈ 116,000/s100,000/s35 萬–60 萬/s

峰值倍數:一般網路服務峰值約平均的 2–3 倍;有明顯晚間高峰或促銷活動(雙 11、推播發送瞬間)的系統,用 5–10 倍估比較安全。推播類系統更特殊:一次發送 100 萬則訊息,接著幾分鐘內的點擊/開啟回流會形成尖峰,要特別說明。

儲存量與頻寬

  • 儲存 = 每日筆數 × 每筆大小 × 保留天數 × 副本數(replication factor,常見 3)÷ 壓縮比。
  • 頻寬 = QPS × 每筆大小。注意要換成 bit:1 MB/s = 8 Mbps。
  • 記憶體 / cache:常用 80/20 法則,熱資料約 20%;或用「最近 N 小時的 key 數 × 每個 key 大小」。
  • 機器數 = 峰值 QPS ÷ 單機可承受 QPS(保留 30–50% 餘裕)。
# 面試時可以口算,這段只是把思路寫清楚
DAY = 86_400

def estimate(events_per_day: float, bytes_per_event: int, peak_factor: float = 4,
             retention_days: int = 90, replicas: int = 3, compression: float = 5):
    avg_qps = events_per_day / DAY
    peak_qps = avg_qps * peak_factor
    daily_raw_gb = events_per_day * bytes_per_event / 1e9
    stored_tb = daily_raw_gb * retention_days * replicas / compression / 1e3
    peak_mbps = peak_qps * bytes_per_event * 8 / 1e6
    return dict(avg_qps=round(avg_qps), peak_qps=round(peak_qps),
                daily_raw_gb=round(daily_raw_gb), stored_tb=round(stored_tb, 1),
                peak_ingress_mbps=round(peak_mbps))

print(estimate(1e9, 1_000))
# {'avg_qps': 11574, 'peak_qps': 46296, 'daily_raw_gb': 1000,
#  'stored_tb': 54.0, 'peak_ingress_mbps': 370}

2 的冪次與數量級

冪次近似值名稱常見用途
210約 1 千(1,024)KB一個小 JSON 事件約 0.5–2 KB
220約 1 百萬MB—
230約 10 億GB—
232約 43 億—32-bit int 上限、IPv4 位址數
240約 1 兆TB—
250約 1,000 兆PB大型事件資料湖量級
264約 1.8 × 1019—64-bit ID 空間

延遲數量級(每個工程師都該記得的數字)

下列為經典「Latency Numbers Every Programmer Should Know」的數量級,實際值依硬體而異,面試時記得「差幾個數量級」比記精確值重要。

操作約略延遲啟示
L1 cache 存取約 1 ns—
主記憶體存取約 100 nsin-process cache 極快
1 Gbps 網路傳 1 KB約 10 µs—
SSD 隨機讀 4 KB約 100–150 µs—
同資料中心網路 round trip約 0.5 ms一次 Redis 呼叫約 0.2–1 ms,在 100ms 預算下只能呼叫有限次數
SSD 循序讀 1 MB約 1 ms—
HDD seek約 10 ms線上路徑避免隨機磁碟 IO
跨洲 round trip約 100–150 msRTB bidder 必須部署在靠近 exchange 的區域

單機容量的粗略參考

以下是常被引用的「數量級」,用來判斷要不要水平擴展,實際仍需壓測(benchmark):

  • 一般無狀態 API 服務:單機每秒數千到上萬個簡單請求(取決於語言、邏輯與 IO)。
  • Redis 單一實例:簡單指令每秒約十萬級 ops;使用 pipeline 可更高;大 key、Lua 長腳本會拖慢(單執行緒執行命令)。
  • MySQL 單一 primary:簡單主鍵寫入每秒數千級,視硬體、索引與交易大小而定;讀可以靠 replica 擴展。
  • Kafka:單一 broker 可處理每秒數十 MB 到數百 MB 的吞吐,水平擴展靠 partition。
面試官想聽到什麼

估算後一定要下結論:「峰值 5 萬 QPS 寫入,每筆 1KB,等於約 50MB/s、400Mbps,單機 MySQL 寫不進去,而且資料是 append-only 的事件,所以前面放 Kafka 吸收尖峰,後面用列式儲存。」算完不用、或算完跟設計脫節,等於沒算。

構建積木(Building Blocks)速查 高頻

系統設計就像用樂高組裝:大部分題目都是十幾個標準元件的組合。關鍵是知道每個元件解決什麼問題、代價是什麼、何時不該用。

元件解決的問題典型選擇何時用代價 / 注意
負載平衡(LB)把流量分散到多台無狀態服務、健康檢查、TLS 終止L4:AWS NLB、LVS;L7:ALB、Nginx、HAProxy、Envoy任何需要多台服務的情況L7 可依路徑路由但成本較高;長連線(WebSocket/gRPC)要注意負載不均
CDN靜態資源與可快取內容就近回應CloudFront、Cloudflare、AkamaiSDK JS 檔、廣告素材(圖片/影片)、靜態頁失效(invalidation)延遲;個人化內容難以快取
Cache降低讀延遲與 DB 負載Redis、Memcached、本地 LRU讀多寫少、可接受短暫不一致一致性、cache stampede、熱 key、記憶體成本
關聯式 DB交易、join、強一致、schema 約束MySQL、PostgreSQL、Aurora帳務、campaign 設定、使用者帳號單機寫入上限;水平擴展需要 sharding
KV / 寬欄 NoSQL超高吞吐的 key 查詢、易水平擴展DynamoDB、Cassandra、ScyllaDB、HBase、Aerospikeuser profile、frequency cap、ID mapping查詢模式受限、需依 access pattern 設計 key
文件 DB彈性 schema 的文件存取MongoDB設定、對話紀錄、半結構化資料跨文件交易與 join 能力較弱;shard key 選錯很難改
訊息佇列(MQ)非同步、削峰填谷、解耦、重播Kafka、Pulsar、RabbitMQ、SQS、Pub/Sub事件收集、推播發送、任何「不需要立即完成」的工作端到端延遲增加、需處理重複與順序、維運成本
物件儲存便宜、近乎無限的大檔與資料湖S3、GCSraw event(Parquet)、素材、備份、上傳檔單次延遲較高(數十 ms 級)、不適合小隨機讀寫
搜尋引擎全文檢索、多欄位過濾、模糊查詢Elasticsearch、OpenSearch客服對話搜尋、log 搜尋、後台搜尋不是 source of truth;索引延遲;叢集維運
OLAP大量資料的快速聚合查詢ClickHouse、Druid、Pinot、BigQuery、Redshift報表、儀表板、ad-hoc 分析不適合高頻單筆更新與交易
串流處理即時聚合、視窗運算、event-time 處理Flink、Spark Structured Streaming、Kafka Streams即時報表、即時特徵、預算消耗狀態管理、checkpoint、遲到資料處理複雜
批次處理大量資料重算、回補Spark、Hive、MapReduce每日報表校正、identity merge、模型訓練資料延遲以小時計
排程 / 工作流定時任務與相依 DAGAirflow、Argo Workflows、cron、Temporal每日批次、排程推播、backfill需處理重跑冪等、補跑、相依失敗
分散式協調leader election、設定、鎖ZooKeeper、etcd、ConsulKafka/K8s 底層、少量關鍵鎖不適合高吞吐資料存取
flowchart LR U["Client / SDK"] --> CDN["CDN"] U --> LB["Load Balancer"] LB --> API["Stateless API 服務群"] API --> C["Redis Cache"] API --> DB[("OLTP DB")] API --> MQ["Kafka"] MQ --> SP["Stream Processor"] MQ --> S3[("Object Storage")] SP --> OLAP[("OLAP")] S3 --> BATCH["Batch Spark"] BATCH --> OLAP OLAP --> RAPI["Report API"]

上圖是一個「萬用骨架」:同步的 request path 盡量短(LB → API → cache/DB),把能延後的工作丟進 MQ,由下游非同步處理。大部分 AdTech 系統都可以從這個骨架變形出來。

常見陷阱

「什麼都放 Kafka + 微服務」。如果寫入量每秒只有幾十筆,MySQL + 一個 cron job 就夠了,引入 Kafka 只會增加維運負擔。面試官很喜歡問「為什麼這裡需要 Kafka?拿掉會怎樣?」,要能回答具體原因:削峰、解耦多個消費者、可重播、持久化緩衝。

可擴展性模式 進階

當估算顯示單機撐不住時,要知道有哪些「標準招式」可用,以及各自的副作用。

無狀態服務 + 水平擴展
把 session、狀態移到外部(Redis、DB、JWT),服務本身無狀態,就能在 LB 後面任意加機器、自動擴縮(K8s HPA)。這是第一個也最便宜的擴展手段。注意 in-process cache 會讓各實例資料不一致。
讀寫分離(Read replica)
寫入 primary、讀取 replica。適合讀多寫少。代價是複寫延遲(replication lag):剛寫入的資料在 replica 讀不到,需要 read-your-writes 策略(寫後短時間讀 primary、或依版本號判斷)。
分片(Sharding / Partitioning)
依 shard key 把資料拆到多個節點。方法:range(利於範圍查詢但易熱點)、hash(分布平均但範圍查詢困難)、一致性雜湊(節點增減只搬移部分資料)、目錄式(lookup table,最彈性)。難點:跨分片查詢/交易、熱點 key、重新分片(resharding)。
非同步化(Async)
同步路徑只做「必須立刻完成」的事(驗證、寫入佇列、回 202),其餘交給 worker。降低延遲、吸收尖峰,代價是最終一致與錯誤處理變複雜(重試、DLQ、狀態查詢 API)。
批次化(Batching)
把多筆小請求合併:SDK 批次上傳、DB bulk insert、Kafka producer 的 linger.ms/batch.size、Redis pipeline。用少量延遲換大幅吞吐提升,並減少每筆的 overhead(TCP、header、fsync)。
預計算(Precomputation)
把昂貴的計算移到寫入時或離線完成:報表 rollup、推薦結果預先算好放 KV、news feed fan-out on write。讀取變成 O(1) 查表,代價是儲存與「新鮮度」。
CQRS(Command Query Responsibility Segregation)
寫入模型與讀取模型分開。例如寫入 OLTP DB 或事件流,再透過 CDC/串流建出專門給查詢用的 read model(ES、OLAP、KV)。適合讀寫模式差異大的系統(例如 campaign 設定寫入 MySQL,投放服務讀取記憶體索引)。代價是讀模型落後寫模型。
Cache 分層
本地 cache(ns–µs)→ 分散式 cache(約 1ms)→ DB。熱點 key 用本地 cache 擋掉,搭配短 TTL 與主動失效。
背壓(Backpressure)與削峰
下游處理不了時,讓上游放慢(佇列長度上限、429、token bucket),而不是讓整個系統雪崩。
flowchart TB subgraph W["寫入路徑 Command"] UI["後台 UI"] --> CAPI["Campaign API"] CAPI --> MY[("MySQL")] MY --> CDC["CDC Debezium"] CDC --> K["Kafka topic campaign-changes"] end subgraph R["讀取路徑 Query"] K --> IDX["Index Builder"] IDX --> SNAP["記憶體索引快照"] SNAP --> BID["Bidder 實例群"] end

上圖是 CQRS 在廣告系統的典型應用:營運人員對 campaign 的修改寫入 MySQL(需要交易與約束),而每秒數十萬次的投放查詢讀的是記憶體中的倒排索引,兩者透過 CDC 事件同步,可接受數秒延遲。

Appier 情境

AdTech 的讀寫極度不對稱:campaign 設定一天可能只改幾千次,但每秒要被讀數十萬次;事件則相反,每秒寫入數萬筆,但報表查詢一天只有幾千次。這正是 CQRS + 預計算的最佳場景:寫入端與讀取端各自用最適合的儲存。

可用性與一致性取捨 進階

可用性數字與停機時間

可用性每年停機每月停機(30 天)每天停機
99%(two nines)約 3.65 天約 7.2 小時約 14.4 分鐘
99.9%(three nines)約 8.76 小時約 43.2 分鐘約 1.44 分鐘
99.95%約 4.38 小時約 21.6 分鐘約 43 秒
99.99%(four nines)約 52.6 分鐘約 4.3 分鐘約 8.6 秒
99.999%(five nines)約 5.26 分鐘約 26 秒約 0.86 秒

串聯降低可用性:一個請求依序依賴 3 個各 99.9% 的服務,整體約 99.9%3 ≈ 99.7%。並聯(冗餘)提升可用性:兩個獨立 99% 的副本,同時掛掉機率 1%×1% = 0.01%,整體約 99.99%(前提是故障彼此獨立,同一 AZ 停電就不獨立)。

提升可用性的手段

  • 冗餘(Redundancy):每層至少 N+1,消除單點故障(SPOF)。包括 LB、服務、DB、佇列、甚至 DNS。
  • Failover:主備切換(active-passive)或多活(active-active)。DB failover 要考慮複寫延遲造成的資料遺失(RPO)與切換時間(RTO)。
  • Multi-AZ / Multi-region:multi-AZ 處理機房級故障,延遲低(同區域內毫秒級),是預設;multi-region 處理區域級災難,但跨區延遲高、資料一致性難,通常只對關鍵服務做。
  • 優雅降級(Graceful degradation):非核心功能失敗時回預設值。例如 CTR 模型服務超時 → 用 campaign 的歷史平均 CTR;推薦服務掛掉 → 回熱門商品。
  • Timeout、重試、熔斷(circuit breaker)、隔艙(bulkhead):避免故障擴散。重試必須搭配指數退避 + jitter 與冪等性。
  • 限流與 load shedding:過載時主動丟棄低優先請求,保住核心功能。

一致性取捨

CAP 定理說明在網路分區(P)發生時,只能在一致性(C)與可用性(A)之間選擇;PACELC 進一步指出「沒有分區時(E),要在延遲(L)與一致性(C)之間取捨」。面試中比較實用的是依資料類型分類:

資料類型一致性需求常見做法
帳務、預算總額、計費強一致、可稽核RDBMS 交易、ledger(只增不改的帳本)、每日對帳
即時預算消耗、frequency cap近似即可,容許少量超支Redis 計數 + 定期與帳本校正
報表數字最終一致;隔日精確串流近即時 + 批次校正(Lambda 架構)
使用者 profile最終一致,但需 read-your-writes(後台)KV store、版本號、寫後讀 primary
對話狀態單一對話內需順序一致依 conversation_id 分區、樂觀鎖
面試官想聽到什麼

不要說「我要強一致」或「最終一致就好」一句帶過。要說清楚:哪一部分資料需要什麼程度的一致性、不一致的後果是什麼(超支 0.5% 預算可以接受嗎?重複發送推播會被客戶投訴嗎?)、以及你如何補償(對帳、校正、冪等)。

面試中的扣分行為與加分行為 高頻

扣分行為

  • 沒問需求就開始畫圖,或問了需求卻沒寫下假設。
  • 堆砌名詞(「用 K8s + Kafka + Flink + Cassandra」)卻講不出為什麼。
  • 只畫方塊不講資料怎麼流動,沒有走一遍 read/write path。
  • 忽略故障:完全沒提 timeout、重試、重複、資料遺失。
  • 一個元件講太深(例如花 15 分鐘講 Raft),沒時間完成整體設計。
  • 被挑戰就立刻放棄原設計,或相反地堅持不改。
  • 數字明顯不合理(例如每秒 1 億筆寫入單機 MySQL)卻沒察覺。
  • 沉默太久:思考時不說話,面試官無法給提示。

加分行為

  • 主動宣告流程並控制時間。
  • 用估算數字推導設計:「因為…所以選…」。
  • 每個選擇都說出至少一個替代方案與取捨。
  • 主動指出瓶頸與單點故障,並提出監控指標(QPS、p99、consumer lag、錯誤率)。
  • 考慮營運面:部署、回滾、backfill、資料修正、成本。
  • 從簡單版本開始,再演進到可擴展版本(「MVP 先這樣,10 倍流量時改成…」)。
  • 把設計連結到業務:計費要精確、報表可近似、推播重複會傷品牌。
  • 被追問時能說「我不確定,但我會這樣驗證」。
實戰提示

對實習/新鮮人職缺,面試官並不期待你有大規模系統的實戰經驗,而是看你的推理能力與基本功(DB index、cache、佇列、冪等性)。可以誠實說「我沒有實際操作過 Flink,但我理解它用 watermark 處理遲到資料,所以這裡我會…」,比硬掰更好。

情境題 1:SDK 回報大量 JSON 事件的統計報表系統 高頻 深入

題目(Appier 真題):「客戶的 App / 網站裝了我們的 SDK,SDK 會把使用者行為(開啟、瀏覽、加入購物車、購買、廣告曝光/點擊)以 JSON 回報給後端。請設計一個統計報表系統,讓客戶能查詢各種指標。」這是 AdTech/MarTech 最核心的資料管線,幾乎涵蓋了分散式系統的所有經典問題:高吞吐寫入、重複、遲到、近似計算、多租戶、重算。

需求釐清

功能需求

  • SDK 上傳事件(批次),後端可靠收下。
  • 客戶在 dashboard 查詢指標:事件數、unique users、轉換率、營收,依時間(小時/日)、事件名稱、國家、平台、campaign 等維度切分與篩選。
  • 匯出報表(CSV),可能是大範圍查詢 → 非同步。
  • 客戶可自訂事件名稱與屬性(自訂 schema)。
  • 範圍外(先宣告):使用者層級的漏斗/留存分析、反作弊、即時個人化。

非功能需求(假設,請面試官確認)

  • 每日 10 億事件,峰值為平均 4 倍。
  • 收事件 API:高可用(99.95%+),p99 低於 200ms;寧可延遲也不能遺失。
  • 報表新鮮度:近即時(5 分鐘內),隔日數字需精確(與計費相關的指標)。
  • 查詢延遲:常見查詢 p95 在 1–2 秒內。
  • unique users 可接受約 1% 誤差;事件數需精確(去重後)。
  • raw 資料保留 1 年供重算;多租戶隔離。

估算

  • 平均 QPS(事件):109 ÷ 86,400 ≈ 11,600/s;峰值約 46,000/s。
  • SDK 每批平均 20 筆 → HTTP 請求峰值約 2,300/s,幾台到十幾台 collector 即可(保留餘裕與 AZ 冗餘)。
  • 每筆 JSON 約 1 KB → 每日 1 TB raw;峰值 ingress 約 46 MB/s(約 370 Mbps);gzip 後傳輸量可降到數分之一。
  • Kafka 保留 3 天、replication factor 3:約 9 TB(壓縮前),壓縮後再小數倍。
  • S3 Parquet(列式 + 壓縮常見 5–10 倍):每日約 100–200 GB,一年約 40–70 TB,S3 成本可接受。
  • 聚合表:假設 5,000 個租戶 × 50 種事件 × 24 小時 × 維度組合數百 → 每天數千萬列的小時級聚合,ClickHouse 輕鬆處理。
面試官想聽到什麼

從估算得出三個結論:(1) 寫入是 append-only 高吞吐 → 用 Kafka + 列式儲存,不要用 MySQL 一筆一筆 insert;(2) 原始量(TB/天)不可能每次查詢都掃 → 需要預聚合;(3) collector 本身流量不大,瓶頸在下游處理與儲存,所以設計重點放在管線而不是 API 伺服器。

API 設計

POST /v1/events HTTP/1.1
Host: collect.example.com
X-App-Key: ak_live_xxx
Content-Type: application/json
Content-Encoding: gzip

{
  "sdk_version": "3.2.1",
  "device_id": "d-7f3a...",
  "sent_at": 1762156800.120,
  "events": [
    {"event_id": "2b1e6c0e-...", "name": "add_to_cart", "client_ts": 1762156790.002,
     "user_id": "u-123", "props": {"sku": "A-1", "price": 590, "currency": "TWD"}}
  ]
}

HTTP/1.1 202 Accepted
{"accepted": 1, "rejected": []}
# 413 Payload Too Large / 401 / 429 (帶 Retry-After) / 5xx:SDK 依碼決定是否重試
GET /v1/reports/query?app_id=42&metrics=events,unique_users,revenue
    &dimensions=date,country&filters=event_name:purchase,platform:ios
    &from=2026-10-01&to=2026-11-01&granularity=day&tz=Asia/Taipei
Authorization: Bearer <token>

200 OK
{"rows": [{"date": "2026-10-01", "country": "TW", "events": 18234, "unique_users": 9120, "revenue": 1203400}],
 "meta": {"approximate": ["unique_users"], "data_freshness": "2026-11-03T13:05:00Z"}}

POST /v1/reports/exports   → 202 {"job_id": "exp_91"}   # 大查詢非同步匯出
GET  /v1/reports/exports/exp_91 → {"status": "done", "url": "https://s3-presigned..."}

設計重點:事件 API 支援批次 + gzip;回應 202 Accepted 代表「已持久化到佇列」而非「已處理」;查詢 API 回傳 meta.approximate 告知哪些指標為近似值,以及資料新鮮度,讓前端顯示「資料更新至 13:05」。

資料模型

層儲存Key / 分區用途
佇列Kafka events.raw、events.clean、events.dlqpartition key = hash(app_id, device_id),同一裝置事件有序削峰、解耦、可重播
原始資料湖S3 Parquets3://lake/events/dt=2026-11-03/hr=13/app_bucket=17/part-*.parquetsource of truth、重算、ad-hoc 分析(Athena/Spark)
聚合ClickHouse(或 Druid/Pinot)ORDER BY (app_id, hour, event_name, 維度...);PARTITION BY 日報表查詢
中繼資料MySQLapps、api_keys、event_schemas、report_jobs租戶設定、schema 定義、匯出工作狀態
查詢快取Redishash(tenant + 正規化後的查詢)熱門 dashboard 查詢
-- ClickHouse:小時級預聚合表(AggregatingMergeTree 會在背景合併相同 ORDER BY key 的列)
CREATE TABLE events_hourly
(
    app_id      UInt32,
    hour        DateTime,
    event_name  LowCardinality(String),
    country     LowCardinality(String),
    platform    LowCardinality(String),
    cnt         SimpleAggregateFunction(sum, UInt64),
    revenue     SimpleAggregateFunction(sum, Float64),
    users       AggregateFunction(uniq, String)        -- 近似 distinct 的中間狀態,可跨小時合併
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMMDD(hour)
ORDER BY (app_id, hour, event_name, country, platform)
TTL hour + INTERVAL 400 DAY;

-- 由明細表(或 Kafka engine 表)自動寫入聚合表
CREATE MATERIALIZED VIEW mv_events_hourly TO events_hourly AS
SELECT app_id, toStartOfHour(event_time) AS hour, event_name, country, platform,
       count() AS cnt, sum(revenue) AS revenue, uniqState(user_id) AS users
FROM events_detail
GROUP BY app_id, hour, event_name, country, platform;

-- 報表查詢:小時 rollup 到日;uniqMerge 合併 HLL 類狀態
SELECT toDate(hour, 'Asia/Taipei') AS day, country,
       sum(cnt) AS events, uniqMerge(users) AS unique_users, sum(revenue) AS revenue
FROM events_hourly
WHERE app_id = 42 AND event_name = 'purchase'
  AND hour >= '2026-10-01 00:00:00' AND hour < '2026-11-01 00:00:00'
GROUP BY day, country
ORDER BY day, country;

高層架構

flowchart LR SDK["App / Web SDK"] -->|"HTTPS gzip batch"| LB["L7 Load Balancer"] LB --> COL["Collector 無狀態 x N"] COL --> K1["Kafka events.raw"] K1 --> VAL["Validator + Enricher"] VAL -->|"schema 不合"| DLQ["Kafka events.dlq"] VAL --> K2["Kafka events.clean"] K2 --> FL["Flink 去重 + 視窗聚合"] K2 --> SINK["S3 Sink 寫 Parquet"] SINK --> S3[("S3 raw lake")] FL --> CH[("ClickHouse 聚合表")] FL -->|"遲到事件"| LATE["late side output"] LATE --> S3 S3 --> SP["Spark 每日校正 / backfill"] SP --> CH DASH["客戶 Dashboard"] --> QAPI["Report Query API"] QAPI --> RC["Redis 查詢快取"] QAPI --> CH MYSQL[("MySQL apps / schemas")] -.-> VAL MYSQL -.-> QAPI

Write path 走一遍:SDK 累積事件後 gzip 上傳 → LB → collector 驗證 app key、大小、配額,補上 server 時間,寫入 Kafka events.raw(等 ack 才回 202)→ validator 依租戶的 event schema 驗證、補 geo/IP 資訊,合格進 events.clean、不合格進 DLQ → Flink 依 event_id 去重、依 event time 做 1 分鐘/1 小時視窗聚合寫入 ClickHouse;同時 S3 sink 把明細寫成 Parquet → 每天 Spark 從 S3 重算前一天的精確數字覆蓋 ClickHouse 分區。

Read path:dashboard → Query API 驗證 token 並強制注入 tenant 過濾 → 查 Redis 快取 → miss 則把查詢轉成 ClickHouse SQL(只允許白名單維度與指標)→ 結果快取(TTL 依時間範圍:今天的資料 1 分鐘、歷史資料 1 小時)。

sequenceDiagram participant S as SDK participant C as Collector participant K as Kafka participant F as Flink S->>S: 累積 50 筆或 10 秒或 App 進背景 S->>C: POST /v1/events gzip C->>K: produce acks=all K-->>C: ack C-->>S: 202 Accepted Note over S,C: 若回應途中斷線,SDK 視為失敗 S->>C: 重送同一批,event_id 相同 C->>K: 重複事件寫入 K->>F: consume F->>F: event_id 已見過,丟棄

深入 1:SDK 端的 batching、壓縮與重試

SDK 是整條管線最不可控的一端(使用者網路差、App 被殺、時鐘不準),設計原則是「至少一次送達(at-least-once)+ 伺服器端去重」:

  • Batching:達到 N 筆、或經過 T 秒、或 App 進入背景時 flush。減少請求數與電量消耗。
  • 持久化佇列:事件先寫入本地 SQLite/檔案,送成功才刪除;App 被殺也不遺失。佇列有上限,滿了丟最舊或低優先事件。
  • 壓縮:JSON 重複欄位多,gzip 常能壓到原本的數分之一,對行動網路很重要。
  • 重試:只對網路錯誤、408、429、5xx 重試;指數退避 + full jitter;尊重 Retry-After;4xx(如 400 格式錯誤)不重試,避免毒藥訊息卡住佇列。
  • event_id:在 client 端產生 UUID,重送時不變,讓伺服器端能去重。
  • 時鐘偏移:同時送 client_ts(事件發生時間)與 sent_at(送出時間),伺服器以 server_received - sent_at 估算偏移量校正 event time。
  • 遠端設定:取樣率、flush 間隔可由伺服器下發,緊急時可降低流量(kill switch)。
import gzip, json, random, time, uuid
import requests

RETRYABLE = {408, 429, 500, 502, 503, 504}

class EventUploader:
    """簡化版 SDK 上傳器:真實 SDK 會在背景執行緒執行,並把 queue 存在磁碟。"""

    def __init__(self, endpoint, app_key, max_batch=50, max_wait=10.0, max_queue=5_000):
        self.endpoint, self.app_key = endpoint, app_key
        self.max_batch, self.max_wait, self.max_queue = max_batch, max_wait, max_queue
        self.queue = []
        self.last_flush = time.time()

    def track(self, name, user_id=None, **props):
        if len(self.queue) >= self.max_queue:
            self.queue.pop(0)                        # 背壓:丟最舊,避免無限成長
        self.queue.append({"event_id": str(uuid.uuid4()), "name": name,
                           "client_ts": time.time(), "user_id": user_id, "props": props})
        if len(self.queue) >= self.max_batch or time.time() - self.last_flush >= self.max_wait:
            self.flush()

    def flush(self):
        batch = self.queue[: self.max_batch]
        if not batch:
            return
        body = gzip.compress(json.dumps({"sent_at": time.time(), "events": batch}).encode())
        headers = {"X-App-Key": self.app_key, "Content-Type": "application/json",
                   "Content-Encoding": "gzip"}
        for attempt in range(6):
            try:
                resp = requests.post(self.endpoint, data=body, headers=headers, timeout=5)
                status = resp.status_code
            except requests.RequestException:
                resp, status = None, None            # 網路錯誤:可重試
            if status in (200, 202):
                break
            if status is not None and status not in RETRYABLE:
                break                                # 4xx:重試也不會成功,丟棄這批(並記錄)
            retry_after = float(resp.headers.get("Retry-After", 0)) if status == 429 else 0
            time.sleep(max(retry_after, random.uniform(0, min(60, 2 ** attempt))))  # full jitter
        else:
            return                                   # 重試用盡:保留在 queue,下次再送
        del self.queue[: len(batch)]
        self.last_flush = time.time()

深入 2:無狀態 collector 與「何時回 202」

Collector 只做最輕量的事:認證(app key 查本地 cache)、大小與配額檢查、解壓、補 server 欄位、寫 Kafka。重要決策是等 Kafka ack 再回 202(acks=all + idempotent producer),這樣回 202 就代表資料已持久化在多個 broker;代價是延遲多幾毫秒。若 Kafka 短暫不可用,可以寫入本地磁碟 spool 檔、稍後補送,或直接回 503 讓 SDK 重試(SDK 本身有持久化佇列,所以回 503 是安全的)。

import gzip, json, time
from fastapi import FastAPI, Header, HTTPException, Request
from aiokafka import AIOKafkaProducer

app = FastAPI()
producer = None
MAX_BODY = 1_000_000          # 壓縮後上限
MAX_INFLATED = 10_000_000     # 解壓後上限,防 gzip bomb
MAX_EVENTS = 500

@app.on_event("startup")
async def startup():
    global producer
    producer = AIOKafkaProducer(bootstrap_servers="kafka:9092", acks="all",
                                enable_idempotence=True, linger_ms=5,
                                compression_type="lz4")
    await producer.start()

@app.post("/v1/events", status_code=202)
async def ingest(request: Request, x_app_key: str = Header(...)):
    app_id = await auth_cache.get_app_id(x_app_key)          # 本地 LRU + Redis,避免每次打 DB
    if app_id is None:
        raise HTTPException(401)
    if not await tenant_quota.allow(app_id):                 # 每租戶 token bucket
        raise HTTPException(429, headers={"Retry-After": "30"})
    raw = await request.body()
    if len(raw) > MAX_BODY:
        raise HTTPException(413)
    if request.headers.get("content-encoding") == "gzip":
        raw = safe_gunzip(raw, limit=MAX_INFLATED)           # 超過上限就丟例外 → 413
    payload = json.loads(raw)
    received = time.time()
    skew = received - float(payload.get("sent_at", received))
    key = f"{app_id}:{payload.get('device_id', '')}".encode()
    pending, rejected = [], []
    for i, e in enumerate(payload.get("events", [])[:MAX_EVENTS]):
        if not isinstance(e, dict) or "event_id" not in e or "name" not in e:
            rejected.append(i)                               # 完整 schema 驗證留給下游
            continue
        e.update(app_id=app_id, server_ts=received,
                 event_ts=float(e.get("client_ts", received)) + skew)
        pending.append(await producer.send("events.raw", json.dumps(e).encode(), key=key))
    for fut in pending:
        await fut                                            # 全部 ack 後才回 202
    return {"accepted": len(pending), "rejected": rejected}
常見陷阱

在 collector 做太多事(查 DB 驗證每個欄位、即時聚合、寫多個儲存)。collector 一旦變慢,SDK 會大量重試,形成「重試風暴」。原則:收件服務越笨越好,所有重邏輯放到 Kafka 之後,因為 Kafka 之後的處理可以重播、可以慢慢追。

深入 3:Schema 驗證與 DLQ

  • Schema registry:每個租戶的自訂事件在後台定義(欄位名、型別、必填),存在 MySQL 並快取到 validator。內部 topic 之間建議用 Avro/Protobuf + schema registry,比 JSON 省空間且有相容性檢查(backward/forward compatibility)。
  • 驗證失敗不丟棄:送進 events.dlq,附上錯誤原因、原始 payload、schema 版本。提供 DLQ 儀表板讓客戶看到「你的 purchase 事件 price 欄位型別錯誤,佔 3%」。
  • 可重播:修正 schema 或程式後,從 DLQ 重新送回 events.clean。因為下游有 event_id 去重,重播是安全的。
  • 寬鬆 vs 嚴格:未知欄位保留在 props(JSON 欄位)而不是拒絕,避免 SDK 新版本上線時大量進 DLQ。

深入 4:串流聚合(Flink)vs 批次(Spark)

面向串流(Flink)批次(Spark)
新鮮度秒~分鐘小時~天
正確性受遲到資料、故障重啟影響;需 checkpoint + 冪等/交易 sink資料完整後一次算,容易做到精確
重算需重播 Kafka(受保留期限制)從 S3 任意日期重算
複雜度狀態管理、watermark、背壓、升級時的狀態遷移相對簡單,SQL 即可
成本常駐叢集按需執行

常見答案是兩者並用(Lambda 架構的變形):Flink 提供近即時的「暫定數字」,每天 Spark 從 S3 重算前一天並覆蓋 ClickHouse 的日分區,作為最終精確數字(計費依據)。另一派是 Kappa 架構:只有串流,重算時重播事件流;如果 Kafka 有長保留期(或 tiered storage)且 Flink 作業夠穩定,可以省去維護兩套邏輯的成本。面試中說出兩者取捨即可,維護兩套邏輯可能導致數字不一致是 Lambda 的主要缺點,可以用同一套 SQL(Flink SQL / Spark SQL)或共用函式庫緩解。

深入 5:去重、遲到事件與 watermark

重複從哪來:SDK 重送、collector 寫 Kafka 成功但回應前崩潰、consumer 處理後提交 offset 前崩潰(at-least-once)。所以去重必須在下游做,依據是 client 產生的 event_id:

  • 串流:Flink 以 event_id 為 key 的 keyed state(RocksDB backend)+ TTL(例如 24 小時),見過就丟。狀態大小 ≈ 每日事件數 × key 大小,10 億 × 約 40 bytes ≈ 數十 GB,分散在各 TaskManager 可行。若想省空間可用 Bloom filter(有少量誤判把新事件當重複)。
  • 批次:Spark 對當日分區做 dropDuplicates(["event_id"]),精確。
  • 跨很長時間的重複(裝置離線三天後重送)由批次重算處理。

遲到事件:手機離線時事件會晚到數小時甚至數天。報表要以 event time(事件發生時間)而非 processing time 分桶,否則「昨天的購買」會被算到今天。Flink 用 watermark 表示「我認為 event time 早於 W 的事件已經到齊」;視窗在 watermark 越過視窗結束時間時輸出。可設定 allowed lateness 讓稍晚的事件更新已輸出的結果(sink 需支援 upsert),更晚的事件走 side output 寫到 S3,由每日批次校正。

from collections import defaultdict

WINDOW = 60            # 1 分鐘 tumbling window(秒)
MAX_DELAY = 300        # watermark = 目前看過最大 event time - 5 分鐘

class WindowCounter:
    """示意 Flink 的 event-time 視窗 + watermark + 去重(單機版)。"""

    def __init__(self, sink):
        self.counts = defaultdict(int)       # (app_id, event_name, window_start) -> count
        self.seen = {}                       # event_id -> 過期時間;實務用帶 TTL 的 keyed state
        self.max_ts = 0.0
        self.sink = sink

    def on_event(self, e):
        if e["event_id"] in self.seen:
            return                           # 重複事件
        self.seen[e["event_id"]] = e["event_ts"] + 86_400
        self.max_ts = max(self.max_ts, e["event_ts"])
        watermark = self.max_ts - MAX_DELAY
        start = int(e["event_ts"]) // WINDOW * WINDOW
        if start + WINDOW <= watermark:
            self.sink.late(e)                # 視窗已關閉:side output,交給批次校正
            return
        self.counts[(e["app_id"], e["name"], start)] += 1
        self.fire(watermark)

    def fire(self, watermark):
        ready = [k for k in self.counts if k[2] + WINDOW <= watermark]
        for key in ready:
            self.sink.emit(key, self.counts.pop(key))   # sink 以 key upsert,重複輸出也冪等
實戰提示

watermark 是「全域」推進的,若某個 Kafka partition 沒有資料(idle),會拖住整體 watermark;Flink 有 idleness 設定處理這種情況。另外,若只看單一裝置的時間,一台時鐘錯亂(例如 2099 年)的手機會把 watermark 推到未來,導致所有正常事件都被當成遲到,所以要先過濾明顯不合理的時間戳(例如超過伺服器時間 + 1 天)。

深入 6:Exactly-once 的實際意義

端到端的 exactly-once 實際上是「at-least-once 傳遞 + 冪等/交易性處理」。Flink 透過 checkpoint 記錄 Kafka offset 與運算狀態,故障時從 checkpoint 恢復並重播;sink 端要嘛支援交易(Kafka transactional producer 的 two-phase commit),要嘛是冪等寫入(以 (app_id, event_name, window_start) 為 key upsert,重寫同樣的值不影響結果)。ClickHouse 這類 OLAP 的 insert 並非傳統交易,常見做法是:串流寫入「可覆蓋」的近即時表,最終數字由批次覆寫分區保證正確。

深入 7:預聚合 cube 與 rollup

若有 d 個維度,所有 group by 組合有 2d 種(cuboid)。10 個維度就是 1,024 種組合,而且高基數維度(如 user_id、sku)會讓聚合後的列數接近原始資料,失去預聚合的意義。實務策略:

  • 只預算常用組合:依 dashboard 實際查詢日誌決定要建哪些 cuboid(例如「日 × 國家 × 平台」「小時 × campaign」)。
  • 時間 rollup:分鐘級保留 7 天、小時級保留 90 天、日級保留數年,用 TTL 自動清除;查詢依時間範圍自動選表。
  • 高基數維度不進 cube:需要時走明細表(ClickHouse 明細表本身就能在數秒內掃描數十億列)或非同步匯出。
  • Druid 的 ingestion-time rollup:在寫入時就依時間粒度與維度合併,大幅降低儲存。代價是失去明細。

深入 8:精確 vs 近似(HyperLogLog)

unique users 是報表最貴的指標:count 可以直接相加,但「10/1 的 UU + 10/2 的 UU ≠ 兩天的 UU」,因為同一人可能兩天都來。精確計算需要保存完整的 user 集合。

方法空間誤差可合併適用
Set / 精確 distinct與 unique 數成正比0可(集合聯集,但很貴)計費、小規模
Roaring Bitmap壓縮後通常很省,需整數 ID0可(OR)、也可做交集受眾交集、精確 UU,需把 user 映射成整數
HyperLogLog固定大小(Redis 的 HLL 為 12 KB)Redis 實作標準誤差約 0.81%可(PFMERGE),不可相減dashboard UU、DAU/MAU
import redis
r = redis.Redis()

# 每個 (app, 日期) 一個 HLL;每個 key 最多約 12KB,與 unique 數無關
def record(app_id: int, day: str, user_id: str):
    r.pfadd(f"uu:{app_id}:{day}", user_id)

def unique_users(app_id: int, days: list):
    keys = [f"uu:{app_id}:{d}" for d in days]
    return r.pfcount(*keys)      # 多個 key 時回傳「聯集」的估計值,等同合併後再數
面試官想聽到什麼

主動說明「HLL 可以 merge(聯集)但不能 subtract,也不能可靠地算交集」,所以「這週新使用者 = 這週 UU − 上週也出現的人」這類指標不能直接用 HLL 做,需要 bitmap 或批次精確計算。並且在 API 回應中標示近似值,跟產品端講好誤差範圍。

深入 9:多租戶隔離

  • 資料隔離:每一層都帶 app_id/tenant_id;查詢 API 由伺服器端從 token 推出 tenant 並強制加入 WHERE 條件,絕不信任 client 傳來的 tenant 參數。ClickHouse 可加 row policy 作為第二道防線。
  • Noisy neighbor:每租戶的寫入配額(token bucket)與查詢併發上限;超大客戶可給專屬 Kafka topic/partition 或專屬 ClickHouse 叢集;查詢設 max_execution_time、max_memory_usage 防止一個大查詢拖垮叢集。
  • 熱點:如果 partition key 只用 app_id,大客戶會讓單一 partition 過熱,所以用 hash(app_id, device_id)。
  • 隱私:PII(email、電話)在 collector 或 validator 端雜湊化;支援依 user 刪除(GDPR),S3 上的 Parquet 需定期重寫或以加密金鑰銷毀(crypto-shredding)處理。

深入 10:回補(Backfill)與重算

  1. 觸發情境:聚合邏輯有 bug、新增指標要算歷史資料、DLQ 修復後重送、遲到事件校正。
  2. Spark 從 S3 讀指定日期分區的 raw Parquet,套用新版邏輯,產出聚合結果到暫存表。
  3. 驗證(列數、總和與舊版差異在預期內)後,以分區為單位原子替換(ClickHouse ALTER TABLE ... REPLACE PARTITION),查詢端不會看到半套資料。
  4. 工作必須冪等:同一日期重跑多次結果相同;由 Airflow 管理,可平行跑多天但限制併發,避免擠壓線上查詢資源。
  5. 清除受影響範圍的查詢快取。

瓶頸與取捨總結

潛在瓶頸症狀對策
流量尖峰(活動、推播後)collector CPU 滿、SDK 重試風暴HPA 自動擴展、Kafka 吸收、429 + Retry-After、遠端調降取樣
Kafka consumer lag報表延遲變長增加 partition 與 consumer 平行度;監控 lag 告警
Flink 狀態過大checkpoint 變慢、背壓去重 TTL、RocksDB incremental checkpoint、Bloom filter
ClickHouse 小量頻繁寫入parts 過多、合併壓力(too many parts)批次寫入(每批數萬列、每秒少量 insert)、async insert 或 Buffer
大範圍查詢查詢逾時、影響其他租戶選擇 rollup 表、查詢限制、改為非同步匯出

面試官可能的追問

如果 Kafka 整個叢集掛掉,事件會遺失嗎?

不應遺失。collector 只在 Kafka ack 後回 202;Kafka 不可用時回 503,SDK 的持久化佇列會保留事件並退避重試。若希望 collector 自己吸收,可寫本地磁碟 spool(需要處理 pod 被刪除時磁碟遺失的問題,K8s 上要用持久磁碟或接受此風險)。此外 Kafka 本身應跨 AZ 部署、replication.factor=3、min.insync.replicas=2,單一 broker 或單一 AZ 故障不影響寫入。

為什麼不直接把事件寫進 ClickHouse,而要經過 Kafka?

(1) 削峰:ClickHouse 喜歡大批次寫入,Kafka 讓下游以自己的節奏批次消費;(2) 解耦多個消費者:同一份事件要進 S3、Flink、即時特徵、反作弊,各自獨立消費、各自 offset;(3) 可重播:下游 bug 修正後可以從某個 offset 重新處理;(4) ClickHouse 維護或升級時,資料先堆在 Kafka 不會遺失。代價是多一個要維運的系統與數秒的延遲,對報表情境可接受。

同一個使用者的事件要保證順序嗎?

報表聚合不需要(加總與順序無關)。但如果下游有 session 化或漏斗分析,就需要同一裝置的事件依序處理:partition key 用 device_id 保證同裝置進同一 partition,且以 event time 排序(因為 SDK 重送可能讓到達順序錯亂),在視窗內依時間戳重排即可。

客戶要求「任意維度組合 + 即時」的查詢,怎麼辦?

先釐清真實需求:通常是少數常用組合 + 偶爾的探索。常用組合走預聚合;探索查詢走 ClickHouse 明細表(列式 + 稀疏索引 + 向量化執行,掃描數十億列仍可在數秒內完成),並設定資源限制;超大查詢走非同步匯出。若預算允許,可以對明細資料取樣(例如 10% 取樣表)提供快速近似結果,並在 UI 標示。

如何監控這條管線的健康?

四類指標:(1) 入口:collector QPS、錯誤碼分布、p99 延遲、每租戶流量;(2) 管線:Kafka consumer lag(最重要,代表報表延遲)、DLQ 比例、Flink checkpoint 時間與背壓;(3) 資料品質:每小時事件數與上週同時段比較(異常下降可能代表 SDK 壞了)、遲到事件比例、重複率;(4) 查詢:Query API p95、ClickHouse 查詢時間、快取命中率。並對「資料新鮮度」做 SLO:例如 99% 的時間報表延遲在 10 分鐘內。

如果要把成本降一半,你會從哪裡下手?

(1) Kafka 與內部傳輸改用二進位格式(Avro/Protobuf)+ 壓縮;(2) S3 用 lifecycle 把舊資料轉到較便宜的儲存層級;(3) 細粒度聚合縮短保留期;(4) 低價值的高頻事件(如 scroll)在 SDK 端取樣或只送聚合後的計數;(5) 批次作業用 spot instance;(6) 檢查 dashboard 查詢日誌,刪掉沒人用的 cuboid。

情境題 2:設計廣告投放系統(Ad Serving / RTB Bidder) 高頻 深入

題目(Appier 真題):「設計一個廣告投放系統。」範圍非常大,第一步一定要縮小:是自家網站/App 的廣告伺服器(ad server),還是在廣告交易平台(ad exchange)上即時競價的 DSP bidder?以程式化廣告平台常見情境,建議主動選擇 DSP 的 RTB bidder + campaign 管理 + 追蹤計費,因為它最能展現低延遲與分散式的設計能力。更多 AdTech 名詞(OpenRTB、first/second price、bid shading)請參考 第 14 章。

需求釐清

功能需求

  • 接收 ad exchange 的 bid request(OpenRTB),決定是否出價、出多少、用哪個素材。
  • 廣告主建立 campaign:預算(每日/總額)、出價策略、目標受眾(地區、裝置、受眾分群、時段)、素材、頻次上限。
  • 追蹤勝標(win notice)、曝光、點擊、轉換;依此扣預算與計費。
  • 報表:曝光、點擊、花費、CTR、CPA。

非功能需求(假設)

  • 峰值 50 萬 bid requests/s,多個 region。
  • exchange 給的回應時限約 100ms(含網路),bidder 內部處理 p99 需壓在數十 ms。
  • 高可用:bidder 故障時應回「不出價」而不是逾時。
  • 預算:可容忍極小比例超支(例如 1% 以內),但計費必須精確可稽核。
  • Campaign 設定修改後,數秒到一分鐘內生效即可。

估算

  • 流量:峰值 50 萬 QPS;假設單台 bidder 可處理約 5,000 QPS(含模型推論),需約 100 台以上,再加 30–50% 餘裕並分散到多個 region/AZ。
  • 頻寬:OpenRTB JSON 約 1–2 KB → 峰值約 0.5–1 GB/s 入流量(4–8 Gbps),回應只有出價時才有內容。
  • Campaign 數:數萬個 active line items,加上 targeting 規則,記憶體中數百 MB 以內 → 每台 bidder 可以放完整索引。
  • 使用者 profile:數億到十億個 user/device ID,每筆數百 bytes 到 1 KB → 數百 GB 到 TB,需要分散式低延遲 KV(Aerospike、Redis Cluster、ScyllaDB 等),單次查詢需在約 1–5ms 內。
  • 事件:若勝率約 10%,曝光約 5 萬/s;點擊率以 0.1–1% 計約數十到數百/s。bid log 全量可能每天數十 TB,通常取樣或只保留精簡欄位。

API 設計

# 1) Exchange → Bidder(OpenRTB,簡化)
POST /rtb/v1/bid
{"id": "req-1", "tmax": 100,
 "imp": [{"id": "1", "banner": {"w": 300, "h": 250}, "bidfloor": 0.5}],
 "app": {"bundle": "com.example.news"}, "device": {"ifa": "abcd-...", "geo": {"country": "TWN"}, "os": "iOS"},
 "user": {"id": "exch-user-9"}}

200 OK  → {"id": "req-1", "seatbid": [{"bid": [{"impid": "1", "price": 1.8, "crid": "cr-77",
          "adm": "...", "nurl": "https://win.example.com/w?b=...&p=${AUCTION_PRICE}"}]}]}
204 No Content  → 不出價(必須在 tmax 內回,否則等同不出價且可能被降流量)

# 2) 追蹤
GET /w?b=<signed_bid_token>&p=<encrypted_price>     win notice(exchange 回呼,帶清算價)
GET /i?t=<signed_token>                              impression pixel(回 1x1 或 204)
GET /c?t=<signed_token>                              click → 302 轉址到廣告主落地頁

# 3) Campaign 管理(給廣告主 / 營運)
POST  /v1/campaigns                  建立
PATCH /v1/campaigns/{id}             修改預算、狀態、targeting(需樂觀鎖 version)
GET   /v1/campaigns/{id}/stats?from=&to=

追蹤 URL 中的 token 由 bidder 在出價時簽章(HMAC),內含 bid_id、campaign_id、creative_id、時間戳等,tracker 驗章後即可得知所有資訊,不必查 DB,也防止偽造;token 帶過期時間以防重放。

資料模型

資料儲存存取模式
advertisers、campaigns、line_items、creatives、targeting 規則MySQL(source of truth)低頻寫、需交易與約束;透過 CDC 推送到 bidder
Targeting 倒排索引每台 bidder 的記憶體每次 bid 查詢;定期快照 + 增量更新
User profile(segments、近期行為)分散式 KV(記憶體/SSD)以 user/device id 點查,1–5ms
Frequency cap 計數Redis Cluster(Hash,每 user 一個 key)bid 時讀、曝光時 +1,帶 TTL
預算剩餘 / 花費Budget service(Redis + MySQL ledger)bidder 申請額度租約;花費由事件流累加
Bid / win / imp / click 事件Kafka → S3 + OLAP計費、報表、模型訓練

高層架構

flowchart LR EX["Ad Exchange"] -->|"bid request"| GLB["GeoDNS + LB"] GLB --> B["Bidder 實例群"] subgraph BIDDER["Bidder 內部 每台都有"] IDX["Targeting 索引 記憶體"] PM["Pacing / 預算租約"] CTR["CTR/CVR 模型"] end B --- BIDDER B --> UP[("User Profile KV")] B --> FC[("Redis 頻次計數")] B -->|"bid log 取樣"| K["Kafka"] EX -->|"win notice"| TR["Tracker 服務"] USER["使用者裝置"] -->|"imp / click"| TR TR --> K K --> SPEND["Flink 花費累計"] SPEND --> BUD["Budget Service"] BUD --> PM K --> BILL["計費 ledger"] K --> RPT["報表管線 S3 + OLAP"] K --> TRAIN["模型訓練"] TRAIN -->|"新模型"| CTR ADV["廣告主後台"] --> CAPI["Campaign API"] CAPI --> DB[("MySQL")] DB -->|"CDC"| IB["Index Builder"] IB -->|"快照 + 增量"| IDX TR -->|"曝光 +1"| FC
sequenceDiagram participant EX as Exchange participant B as Bidder participant KV as Profile KV participant R as Redis 頻次 participant M as CTR 模型 EX->>B: bid request tmax 100ms B->>B: 解析 + 過濾 黑名單 bidfloor 約 1ms par 平行查詢 B->>KV: 取 user segments 約 2ms B->>R: HMGET 頻次 約 1ms end B->>B: 倒排索引取候選 campaign 約 1ms B->>B: 預算 / pacing / 頻次過濾 B->>M: 批次預估候選 pCTR 約 5-10ms B->>B: 計算出價 + 挑最高 eCPM B-->>EX: bid response 或 204 EX-->>B: win notice 非同步

深入 1:100ms 延遲預算怎麼分

階段示意預算手段
Exchange ↔ Bidder 網路來回20–40 ms部署在 exchange 附近的 region、長連線 keep-alive、HTTP/2
解析 + 前置過濾約 1 ms高效 JSON parser、提早淘汰(不支援的尺寸、黑名單 app)
User profile + 頻次查詢2–5 ms平行發出、設嚴格 timeout(超時當作無資料)
候選召回(倒排索引)約 1 ms記憶體索引、bitmap 交集
模型推論5–10 msin-process 模型或同機 sidecar、批次推論候選、限制候選數
出價計算 + 組回應約 1 ms—
安全餘裕剩餘GC、排隊、尾延遲

關鍵原則:(1) 整體有一個 deadline,每個下游呼叫用「剩餘時間」當 timeout,時間快到就直接回 204;(2) 網路呼叫越少越好,能在記憶體做的就不要遠端查;(3) 注意尾延遲:一次請求平行呼叫 5 個服務,任一個慢就整體慢,所以要用 hedged request 或嚴格 timeout;(4) 語言 runtime 的 GC 停頓會吃掉預算,Go/Java 要減少配置、物件池化。

Appier 情境

以 DSP 常見做法為例,每次 bid 都在算「這個曝光對這個廣告主值多少錢」,ML 模型的品質直接決定營收,但模型越複雜推論越慢。這是 ML 與後端工程的經典拉扯:後端工程師的工作是讓模型在毫秒級延遲內服務數十萬 QPS(模型量化、特徵快取、批次推論、候選數限制),這也是職缺描述中「profiling 與效能調校關鍵元件」的具體場景。

深入 2:Targeting 倒排索引

暴力法是對每個 bid request 掃過所有 campaign 檢查 targeting 條件:數萬個 campaign × 數十萬 QPS 太慢。倒排索引把「屬性值 → 符合的 campaign 集合」預先建好,查詢時只做集合交集:

from collections import defaultdict

DIMENSIONS = ["country", "os", "ad_size"]      # 單值維度:請求只有一個值

class TargetingIndex:
    def __init__(self, campaigns):
        self.by_value = defaultdict(set)        # (dim, value) -> {campaign_id}
        self.unrestricted = defaultdict(set)    # dim -> 該維度沒設限制的 campaign
        self.by_segment = defaultdict(set)      # segment -> {campaign_id}
        self.no_segment = set()
        self.all_ids = set()
        for c in campaigns:
            cid, t = c["id"], c["targeting"]
            self.all_ids.add(cid)
            for dim in DIMENSIONS:
                values = t.get(dim)
                if values:
                    for v in values:
                        self.by_value[(dim, v)].add(cid)
                else:
                    self.unrestricted[dim].add(cid)
            if t.get("segments"):               # 多值維度:命中任一 segment 即可(OR)
                for s in t["segments"]:
                    self.by_segment[s].add(cid)
            else:
                self.no_segment.add(cid)

    def match(self, req, user_segments):
        result = set(self.all_ids)
        for dim in DIMENSIONS:                  # 各維度之間是 AND
            result &= self.by_value.get((dim, req.get(dim)), set()) | self.unrestricted[dim]
            if not result:
                return result                   # 提早結束
        seg_hits = set(self.no_segment)
        for s in user_segments:
            seg_hits |= self.by_segment.get(s, set())
        return result & seg_hits

idx = TargetingIndex([
    {"id": 1, "targeting": {"country": ["TW", "JP"], "os": ["iOS"], "segments": ["sports"]}},
    {"id": 2, "targeting": {"country": ["TW"]}},
])
print(idx.match({"country": "TW", "os": "iOS", "ad_size": "300x250"}, ["sports", "travel"]))  # {1, 2}
  • 生產級做法:集合改用 Roaring bitmap(campaign 編成連續整數),交集是位元運算;排除條件(NOT)用差集;複雜布林表達式可參考 Yahoo 的「Indexing Boolean Expressions」論文中的 conjunction index。
  • 更新:Index builder 從 MySQL CDC 收到變更,定期產生完整快照(例如每幾分鐘)並推送增量。bidder 用 double buffering:在背景建好新索引,再以原子指標交換(atomic pointer swap),讀取端完全不加鎖。
  • 預算與狀態:預算用完、暫停的 campaign 要能「秒級」從候選移除,這部分不走完整重建,而是由 pacing 模組的本地旗標過濾。

深入 3:CTR 預估服務與出價

  • 出價公式(以 CPC 計費的 campaign 為例):eCPM = pCTR × 廣告主願付 CPC × 1000;CPA 目標則是 pCTR × pCVR × target CPA × 1000。最後再依拍賣機制(first price 需 bid shading)調整。
  • 特徵:使用者(segments、近期行為)、情境(app、時段、裝置、地區)、廣告(campaign、素材歷史表現)與交叉特徵。線上特徵需要與訓練時一致(training-serving skew 是常見 bug 來源),所以用 feature store 統一定義。
  • 部署形式:in-process(最低延遲,但模型更新要重載、語言綁定)vs 獨立模型服務(gRPC,便於 GPU 與獨立擴展,但多一跳網路)。一個 request 有多個候選時,一次批次推論所有候選。
  • 降級:模型服務超時 → 使用 campaign 的歷史平均 CTR 或保守出價;新 campaign 冷啟動用同類平均值 + 探索(exploration)流量。
  • 上線:shadow mode(只算不用)比對 → 小流量 A/B → 逐步放量;持續監控預估 CTR 與實際 CTR 的校準(calibration)。

深入 4:Budget pacing 與分散式預算扣減

問題:同一個 campaign 同時在上百台 bidder、多個 region 被出價。如果每次出價都去中央 DB 檢查並扣款,中央節點會成為瓶頸且增加延遲;如果完全不協調,預算會嚴重超支。另外「出價」不等於「花費」:要等勝標(win notice)才知道實際花費,中間有時間差。

  1. 預算租約(budget lease):Budget service 把 campaign 剩餘預算切成小額度發給各 bidder(例如每次申請「約 1 分鐘的預估花費」或剩餘預算的一小比例)。bidder 在本地扣減,用完再申請;額度愈接近用完,發得愈小。最大超支 ≈ 所有 bidder 手上未結清額度 + 在途出價。
  2. 真實花費:win notice 與曝光事件 → Kafka → Flink 依 campaign 累加 → 寫回 budget service 與 MySQL ledger。這是計費的 source of truth。
  3. Pacing:預算不能在早上 9 點就花完(錯過晚間黃金時段)。依流量預測曲線算出「到現在為止應該花多少」,每分鐘比較實際花費,調整出價機率(throttling)或出價倍率。
  4. 預算耗盡:budget service 廣播停止訊號(pub/sub),bidder 立刻把 campaign 標記為不可出價;租約到期也會自然停止。
import redis
r = redis.Redis()

# 原子地從剩餘預算中切出一塊額度(單位:micro 元,用整數避免浮點誤差)
LEASE_LUA = r.register_script("""
local remaining = tonumber(redis.call('GET', KEYS[1]) or '0')
if remaining <= 0 then return 0 end
local grant = math.min(remaining, tonumber(ARGV[1]))
redis.call('DECRBY', KEYS[1], grant)
return grant
""")

class LocalBudget:
    """每台 bidder 對每個 campaign 持有的本地額度。"""
    def __init__(self, campaign_id, chunk_micros):
        self.key = f"budget:{{{campaign_id}}}:remaining"   # {} 為 Redis Cluster hash tag
        self.chunk = chunk_micros
        self.balance = 0

    def try_spend(self, bid_micros):
        if self.balance < bid_micros:
            self.balance += LEASE_LUA(keys=[self.key], args=[self.chunk])  # 實務上在背景預先補充
        if self.balance < bid_micros:
            return False                     # 預算不足,不出價
        self.balance -= bid_micros           # 以出價保守預扣;勝標後依清算價結算差額
        return True

class Pacer:
    """依計畫花費曲線調整出價機率(簡化的比例控制)。"""
    def __init__(self, daily_budget, planned_fraction_by_minute, k=0.5):
        self.budget, self.plan, self.k = daily_budget, planned_fraction_by_minute, k
        self.p = 0.5

    def update(self, minute_of_day, spent_so_far):
        planned = self.budget * self.plan[minute_of_day]      # 累積計畫花費
        error = (planned - spent_so_far) / self.budget         # 正:花太慢;負:花太快
        self.p = min(1.0, max(0.01, self.p + self.k * error * 10))
        return self.p                                          # bidder 以機率 p 參與競價
常見陷阱

直接在 bid 路徑上對 Redis 做 DECRBY 扣預算:(1) 每次出價都多一次網路呼叫;(2) 大部分出價不會勝標,扣了還要退;(3) 熱門 campaign 的 key 成為熱點。面試官想看到你意識到「出價 ≠ 花費」以及「中央計數器是瓶頸」,然後提出本地額度 + 非同步結算的方案,並說出超支上限如何控制。

深入 5:Frequency capping(Redis)

「同一使用者每天最多看到這個 campaign 3 次」。做法:每個使用者一個 Redis Hash,field 為 campaign:日期,bid 時一次 HGETALL/HMGET 取回所有計數並過濾候選;曝光時(tracker 收到 impression)才 HINCRBY。

FC_TTL = 7 * 86_400

def frequency_ok(r, user_id, candidates, caps, day):
    fields = [f"{cid}:{day}" for cid in candidates]
    counts = r.hmget(f"fc:{user_id}", fields)            # 一次網路往返
    return [cid for cid, n in zip(candidates, counts)
            if int(n or 0) < caps[cid]]

def on_impression(r, user_id, campaign_id, day):
    pipe = r.pipeline()
    pipe.hincrby(f"fc:{user_id}", f"{campaign_id}:{day}", 1)
    pipe.expire(f"fc:{user_id}", FC_TTL)                 # 不活躍使用者自動清除
    pipe.execute()
  • 近似可接受:bid 時讀、曝光時寫,中間有時間差,同一使用者在幾百毫秒內被多個 exchange 請求時可能超過上限 1 次,業界通常接受。
  • 省一次呼叫:若 user profile KV 也能存計數,與 profile 一起讀取,減少網路往返。
  • Redis 故障:fail-open(當作沒看過,可能過度曝光)或 fail-closed(不出價,損失營收)。通常對有嚴格頻次要求的 campaign fail-closed,其餘 fail-open,並告警。
  • 多 region:使用者通常固定在某區域,各 region 獨立計數即可,跨區只做非同步同步。

深入 6:曝光/點擊追蹤與計費

  • Tracker:高 QPS、極簡(驗 token → 寫 Kafka → 回 204/302)。點擊轉址的延遲直接影響使用者體驗與廣告主轉換率,必須快。
  • 去重:同一 bid_id 的 win notice / impression 可能重複(重試、瀏覽器重整),計費前以 bid_id/impression_id 去重(Flink keyed state 或 DB unique key)。
  • 計費 ledger:只增不改(append-only)的帳本記錄每筆花費(campaign、金額、清算價、時間、來源事件 id),金額用整數 micros 或 Decimal,不用浮點數。
  • 對帳:每天把自己的勝標紀錄與 exchange 提供的報告比對,差異超過門檻要告警與人工處理。
  • 反作弊:異常點擊率、同 IP 大量點擊、資料中心 IP、點擊時間過短等規則與模型(可另開題目)。

瓶頸與取捨

取捨選項 A選項 B建議
模型部署in-process,最低延遲獨立服務,獨立擴展與更新延遲極敏感時 in-process;需要 GPU 或大型模型時獨立服務 + 同機房
預算一致性中央強一致扣款本地額度、最終一致本地額度,超支上限可控
頻次計數故障fail-openfail-closed依 campaign 設定,預設 fail-open + 告警
Bid log全量保存取樣保存勝標全量、未勝標取樣(訓練需要負樣本)
索引更新每次變更即時更新快照 + 增量快照 + 增量 + 本地暫停旗標

面試官可能的追問

Bidder 實例要不要有狀態?如何做水平擴展?

Bidder 的「狀態」只有可重建的快取(索引快照、模型、本地預算額度),沒有不可遺失的資料,所以視為無狀態:任意擴縮,掛掉的實例手上的預算額度只是暫時鎖住,租約到期後由 budget service 收回。擴展依 CPU 與 p99 延遲做 HPA;新實例啟動時要先載入索引與模型(warm-up)再加入 LB,否則前幾秒會大量逾時。

多 region 下,同一個 campaign 的預算怎麼分?

以流量比例預先把每日預算分配到各 region(例如依過去流量),各 region 內部再用租約機制;定期(例如每 5–15 分鐘)依實際消耗重新平衡。避免每次扣款都跨 region 同步,因為跨區 RTT 動輒數十到上百 ms,不可能放在 bid 路徑上。

如果 exchange 突然把流量加倍,你的系統會怎樣?

先保護自己:bidder 有並發上限與佇列上限,超過就立即回 204(load shedding),寧可少出價也不要全體逾時(逾時率高 exchange 可能會降低送來的流量)。同時 HPA 擴容;也可以在 LB 層依 exchange、app 品質做取樣,丟掉價值最低的流量(「流量篩選」)。事後檢討容量規劃。

怎麼避免重複計費?

每次出價產生唯一 bid_id 並簽在 nurl/追蹤 token 裡。計費管線以 bid_id 去重(Flink keyed state + DB unique constraint 雙保險),ledger 寫入以 bid_id 為冪等鍵(INSERT ... ON DUPLICATE KEY 不重複累加)。另外 token 有時效,過期的 win notice 進入人工審查而非直接計費。

Campaign 剛建立沒有任何歷史資料,CTR 怎麼估?

冷啟動:用同廣告主、同類別、同素材尺寸的平均 CTR 作為先驗;保留一小部分探索流量(explore/exploit,例如 epsilon-greedy 或 Thompson sampling)收集資料;隨資料累積用貝氏平滑把先驗與觀測結合,避免少量點擊造成估計劇烈波動。

Go 或 Java 寫 bidder,GC 會不會破壞延遲?

會影響尾延遲。手段:減少每請求的物件配置(物件池 sync.Pool、預配置 buffer、避免反射式 JSON)、調整 GC 參數(Go 的 GOGC/GOMEMLIMIT、Java 的 G1/ZGC)、把大型唯讀資料(索引)放在少指標的結構中以減少 GC 掃描成本。用 pprof/async-profiler 找出配置熱點,這正是職缺強調的 profiling 能力。

情境題 3:設計 Chatbot 平台 高頻 深入

題目(Appier 真題):「設計一個 chatbot。」以 MarTech 平台常見情境,建議把它定義成多租戶 chatbot 平台:品牌客戶在後台設定對話流程與知識庫,終端使用者透過 LINE、Facebook Messenger、網站聊天視窗與機器人對話,必要時轉真人客服。核心難點:多渠道接入、對話順序、狀態管理、LLM 的延遲與成本、人工轉接。

需求釐清

功能需求

  • 接入 LINE、Messenger(webhook)與 Web(WebSocket)。
  • Bot 回覆:規則/流程(按鈕選單)、意圖辨識(NLU)、LLM 生成回答(以品牌知識庫做 RAG)。
  • 對話狀態:多輪對話上下文、填槽(slot filling,例如詢問訂單編號)。
  • 真人客服轉接:客服後台即時收發訊息、排隊與分派。
  • 對話紀錄查詢、基本分析(解決率、轉人工率)。
  • 範圍外:大量主動推播(見情境題 4)、語音。

非功能需求(假設)

  • 1,000 個租戶,每日 1,000 萬則使用者訊息,行銷活動時峰值為平均 10 倍。
  • Webhook 必須快速回 200(渠道平台對 webhook 回應時間有限制,逾時會重送或停用)。
  • Bot 回覆 p95:規則類 1 秒內;LLM 類數秒內,並有「處理中」提示。
  • 同一對話內訊息順序正確、不重複回覆。
  • LLM 成本可控:每租戶有配額。

估算

  • 入站訊息:107 ÷ 86,400 ≈ 116/s,峰值約 1,200/s。對 API 層不算大,瓶頸在 LLM。
  • 假設 30% 訊息需要 LLM,峰值約 360 次/s;每次 2–5 秒 → 依 Little's Law(並發數 = 到達率 × 平均處理時間),同時在途請求約 700–1,800 個 → worker 必須是非同步 IO(asyncio / goroutine),不能每個請求佔一條執行緒阻塞等待。
  • Token 成本:每次約 1,500 tokens(含檢索內容)× 每日約 300 萬次 ≈ 45 億 tokens/天,成本相當可觀 → 分層路由、快取、限制上下文長度。
  • 儲存:每則訊息約 1 KB → 每日約 10 GB,保留一年約 3.6 TB。

API 設計

POST /webhooks/line/{channel_id}          # LINE 平台呼叫;驗證 X-Line-Signature
POST /webhooks/messenger/{page_id}        # Messenger 呼叫;驗證 X-Hub-Signature-256
GET  /webhooks/messenger/{page_id}        # Messenger 訂閱驗證(hub.challenge)
GET  /ws/chat?widget_key=...&session=...  # Web 聊天視窗 WebSocket

# 內部 / 客服後台
POST /v1/conversations/{id}/handoff       {"reason": "user_request"}
POST /v1/conversations/{id}/messages      {"text": "...", "agent_id": "a-9"}   # 客服回覆
POST /v1/conversations/{id}/release       # 客服結束,交還 bot
GET  /v1/conversations?tenant=..&state=WAITING_AGENT
GET  /ws/agent                             # 客服後台 WebSocket(即時新訊息)

資料模型

實體主要欄位儲存
channelschannel_id、tenant_id、type、secret、access_token(加密)MySQL + 本地快取
conversationsconversation_id(tenant + channel + external_user_id)、state、assigned_agent、context(slots、摘要)、recent_event_ids、versionMongoDB/DynamoDB(文件);熱資料在 Redis
messagesconversation_id、seq、direction(in/out)、sender、content、channel_message_id、created_at以 conversation_id 為 partition key、seq 排序(Cassandra/DynamoDB/MongoDB)
bot 設定flows、intents、prompt 模板、知識庫文件MySQL + 物件儲存;知識庫 embedding 在向量資料庫
agentsagent_id、技能標籤、上限並發數、線上狀態MySQL + Redis(線上狀態)

高層架構

flowchart LR LINE["LINE 平台"] -->|"webhook"| GW["Channel Gateway 驗簽 + 正規化"] FB["Messenger"] -->|"webhook"| GW WEB["Web Widget"] -->|"WebSocket"| WSG["WS Gateway"] WSG --> GW GW -->|"key = conversation_id"| KIN["Kafka chat.inbound"] KIN --> CW["Conversation Worker 狀態機"] CW --- ST[("Conversation Store + Redis")] CW --> RT{"路由"} RT -->|"按鈕 / 規則"| FLOW["Flow Engine"] RT -->|"意圖"| NLU["NLU 小模型"] RT -->|"開放問答"| ORCH["LLM Orchestrator"] ORCH --> VDB[("向量資料庫 知識庫")] ORCH --> LLM["LLM API"] CW -->|"轉人工"| AR["Agent Router"] AR --> AWS["Agent WS Gateway"] AWS --> AGENT["客服後台"] CW --> KOUT["Kafka chat.outbound"] KOUT --> SND["Channel Sender 限流"] SND --> LINE SND --> FB SND --> WSG
sequenceDiagram participant U as 使用者 participant L as LINE 平台 participant G as Gateway participant K as Kafka participant W as Worker participant M as LLM U->>L: 傳送訊息 L->>G: webhook 事件 G->>G: 驗證簽章 G->>K: produce key=conversation_id G-->>L: 200 OK 立即回應 K->>W: consume W->>W: 去重 + 載入狀態 W->>L: 顯示載入中提示 選用 W->>M: 呼叫 LLM timeout 8s M-->>W: 回答 W->>K: outbound 訊息 K->>L: Sender 呼叫 reply 或 push API L->>U: 收到回覆

深入 1:Webhook 接入、驗簽與非同步回覆

  • 驗簽:LINE 以 channel secret 對 request body 做 HMAC-SHA256 再 Base64,放在 X-Line-Signature;Messenger 用 app secret 計算 X-Hub-Signature-256。必須用原始 body bytes 計算,並用常數時間比較(hmac.compare_digest)。
  • 立即回 200:gateway 只做驗簽、正規化、寫入 Kafka,絕不在 webhook 請求中呼叫 LLM。否則 LLM 一慢,平台判定逾時而重送,形成重複處理甚至被停用。
  • 正規化:把各渠道格式轉成內部統一的 InboundMessage(tenant_id、conversation_id、event_id、type、text、payload、timestamp),bot 邏輯只認內部格式,新增渠道只要寫 adapter。
  • 回覆方式:LINE 的 reply API 使用 webhook 事件附帶的 reply token(一次性、有時效);超過時效或需多次回覆時改用 push API(通常會計入訊息額度)。Messenger 有 24 小時標準訊息視窗的政策限制。這些渠道規則要封裝在 sender adapter 內。
import base64, hashlib, hmac, json
from fastapi import FastAPI, HTTPException, Request, Response

app = FastAPI()

def verify_line_signature(channel_secret: str, body: bytes, signature: str) -> bool:
    digest = hmac.new(channel_secret.encode(), body, hashlib.sha256).digest()
    return hmac.compare_digest(base64.b64encode(digest).decode(), signature)

@app.post("/webhooks/line/{channel_id}")
async def line_webhook(channel_id: str, request: Request):
    body = await request.body()                           # 必須用原始 bytes 驗簽
    channel = await channel_cache.get(channel_id)         # tenant、secret;本地快取
    if channel is None or not verify_line_signature(
            channel.secret, body, request.headers.get("x-line-signature", "")):
        raise HTTPException(401)
    for ev in json.loads(body).get("events", []):
        user_id = ev.get("source", {}).get("userId", "unknown")
        conv_id = f"{channel.tenant_id}:line:{channel_id}:{user_id}"
        msg = {
            "event_id": ev.get("webhookEventId"),         # 平台提供的事件 id,用來去重
            "conversation_id": conv_id,
            "tenant_id": channel.tenant_id,
            "type": ev.get("type"),
            "text": ev.get("message", {}).get("text"),
            "reply_token": ev.get("replyToken"),
            "ts": ev.get("timestamp"),
        }
        # key = conversation_id:同一對話進同一 partition,保證順序
        await producer.send_and_wait("chat.inbound", json.dumps(msg).encode(),
                                     key=conv_id.encode())
    return Response(status_code=200)

深入 2:訊息順序與 head-of-line blocking

使用者常連續傳好幾則短訊息(「你好」「我想問」「訂單 123 到哪了」)。若平行處理,可能先回第三則再回第一則,甚至用不完整的上下文回答。解法:以 conversation_id 為 Kafka partition key,同一對話的訊息進同一 partition,由同一個 consumer 依序處理。

但這帶來新問題:一個 partition 內有成千上萬個對話,如果 worker 嚴格逐筆處理,一個 LLM 呼叫卡 5 秒,同 partition 的其他對話全部排隊(head-of-line blocking)。進階做法:

  • Keyed executor:worker 從 partition 拉訊息後,依 conversation_id 分派到各自的記憶體佇列,「不同對話並行、同一對話序列」。offset 只能 commit 到「最小的尚未完成 offset」,避免崩潰時遺失。
  • Debounce 合併:收到訊息後等待短暫時間(例如 1–2 秒),把連續訊息合併成一次 LLM 呼叫,減少成本也讓回答更完整。
  • 取消重算:LLM 尚在生成時新訊息到達,可取消舊請求、用合併後的上下文重新生成(成本較高但體驗較好)。
  • 出站順序:outbound 也以 conversation_id 分區,sender 依序送出;每則訊息帶 seq,Web 端依 seq 排序顯示。

深入 3:對話狀態管理與人工轉接

stateDiagram-v2 [*] --> BOT BOT --> WAITING_AGENT: 要求真人或信心過低 WAITING_AGENT --> AGENT: 客服接手 WAITING_AGENT --> BOT: 等候逾時改為留言 AGENT --> BOT: 客服結束對話 AGENT --> CLOSED: 閒置逾時 BOT --> CLOSED: 閒置逾時 CLOSED --> BOT: 使用者再次傳訊
  • 狀態存放:conversation 文件存 state、slots、近期訊息摘要、version;熱資料快取在 Redis(TTL 例如 30 分鐘),寫入時用樂觀鎖(version 比對)避免 bot 與客服同時修改造成覆蓋。
  • 上下文長度:LLM 不能塞入完整歷史;保留最近 N 輪 + 滾動摘要(summary),並把關鍵 slot 結構化存放。
  • 轉人工觸發:使用者明確要求、NLU 信心低於門檻連續兩次、負面情緒、VIP 客戶、特定意圖(退款、客訴)。
  • 分派:Agent router 依技能標籤、語言、目前負載(每位客服並發上限)分派;沒有可用客服時進入佇列並告知預估等待時間,逾時改為留言單。
  • 即時推送給客服:客服後台透過 WebSocket 連到 Agent WS Gateway;gateway 有多台,用 Redis pub/sub 或 Kafka 依 agent_id 路由訊息到對應連線所在的節點。
  • AGENT 狀態時 bot 靜默:worker 收到訊息只轉發給客服;可提供「建議回覆」給客服(AI 輔助),但不直接送給使用者。
import asyncio

LLM_TIMEOUT = 8

async def handle_inbound(msg):
    conv = await store.load(msg["conversation_id"])        # Redis 優先,miss 時讀 DB
    if msg["event_id"] in conv.recent_event_ids:          # 去重紀錄與狀態一起持久化,重播安全
        return
    conv.recent_event_ids.append(msg["event_id"])
    conv.recent_event_ids = conv.recent_event_ids[-50:]
    await store.append_message(conv.id, msg)

    if conv.state in ("AGENT", "WAITING_AGENT"):
        await agent_bus.publish(conv.assigned_agent, conv.id, msg)   # bot 不回覆
    else:
        reply = await route_and_answer(conv, msg)
        if reply.handoff:
            conv.state = "WAITING_AGENT"
            await agent_router.enqueue(conv)
        await outbound.send(conv.id, reply.messages, reply_token=msg.get("reply_token"))
    await store.save(conv, expected_version=conv.version) # 樂觀鎖;衝突時重新載入再處理

async def route_and_answer(conv, msg):
    if (flow_reply := flow_engine.match(conv, msg)) is not None:  # 1) 規則 / 按鈕:最快最便宜
        return flow_reply
    intent = await nlu.classify(msg["text"])                       # 2) 小模型意圖辨識
    if intent.confidence > 0.85 and intent.name in conv.bot.faq:
        return conv.bot.faq[intent.name]
    if not await llm_quota.try_consume(conv.tenant_id):            # 3) 租戶 LLM 配額
        return Reply.handoff_or_default()
    try:
        return await asyncio.wait_for(llm_orchestrator.answer(conv, msg), LLM_TIMEOUT)
    except asyncio.TimeoutError:
        return Reply.text("目前查詢人數較多,我幫你轉給專人處理", handoff=True)

深入 4:NLU/LLM 呼叫的延遲與成本

手段降低延遲降低成本說明
分層路由(規則 → 小模型 → LLM)✔✔大部分訊息是固定問題,不需要 LLM
回答快取 / 語意快取✔✔對 FAQ 類問題以 embedding 相似度命中快取;要注意個人化內容不可快取
Prompt 精簡、上下文摘要✔✔輸入 token 數直接影響延遲與費用
Provider 端 prompt caching✔✔固定的 system prompt 與知識庫前綴可被快取(依供應商支援)
串流輸出(streaming)✔ 體感—Web 渠道可逐字顯示;LINE 等渠道只能整則送出,改用載入提示
模型分級✔✔簡單問題用小模型、複雜問題才用大模型
Timeout + fallback✔ 尾延遲—超時回固定訊息或轉人工
租戶配額與全域併發上限—✔防止單一租戶耗盡 LLM provider 的 RPM/TPM 限額
常見陷阱

忽略 LLM 的安全面:(1) prompt injection(使用者要求「忽略以上指示」)→ 系統提示與使用者輸入分離、輸出檢查;(2) 幻覺(回答不存在的優惠)→ RAG 只根據知識庫回答、找不到就說不知道或轉人工;(3) 個資外洩 → 送進 LLM 前遮罩 PII、依租戶隔離知識庫檢索(向量搜尋時必須帶 tenant filter)。

深入 5:速率限制

  • 入站:每租戶、每使用者限流,防止惡意洗訊息耗盡 LLM 配額(例如每使用者每分鐘 20 則)。
  • 出站:各渠道 API 有速率上限;sender 以每個 channel 帳號為單位做 token bucket(Redis 分散式),收到 429 時退避並降低速率。
  • LLM provider:全域 RPM/TPM 限制,用集中式的限流器 + 優先順序(付費方案租戶優先)。

面試官可能的追問

Web 渠道用 WebSocket,有幾十萬條長連線怎麼擴展?

WS gateway 本身無業務狀態,只維護連線;用 L4 LB 或支援 WebSocket 的 L7 LB 分散連線。連線與節點的對應(session_id → gateway 節點)存在 Redis,出站訊息由 sender 透過 pub/sub 送到正確節點。單機連線數受檔案描述符、記憶體與 epoll 能力限制,要調整 ulimit -n 與核心參數;部署更新時要優雅斷線讓 client 重連(加上 jitter 避免重連風暴)。

LINE 平台重送同一個 webhook 事件,怎麼避免回覆兩次?

以平台提供的事件 id(LINE 的 webhookEventId)去重,並把「已處理事件 id」與對話狀態在同一次寫入中持久化,worker 崩潰重播時也能判斷。出站發送若遇到逾時、不確定是否送達,可利用渠道支援的冪等機制(例如 LINE push 類 API 的 retry key)。完全的 exactly-once 對外部 API 做不到,最終取捨是「極少量重複」或「極少量遺失」,對客服情境通常選擇寧可不重複。

如果 LLM provider 整個掛了?

多 provider 備援(主要/次要模型,透過抽象層切換);熔斷器偵測錯誤率升高後快速失敗,不要讓每個請求都等到逾時;降級為規則與 FAQ 回答 + 告知使用者「稍後由專人回覆」並建立留言單;監控與告警。這也是為什麼分層路由很重要:LLM 掛了,大部分流量仍可被規則層服務。

如何評估 bot 的品質?

線上指標:自助解決率(沒轉人工就結束)、轉人工率、使用者回饋(讚/倒讚)、對話輪數;離線評估:建立測試集定期回歸測試(特別是換模型或改 prompt 時)、抽樣人工審核、LLM-as-judge 輔助。把每次回答的 prompt 版本、檢索到的文件、模型版本都記錄下來,才能追查錯誤回答的原因。

情境題 4:LINE / 推播訊息大量發送系統 高頻 進階

題目(Appier take-home 延伸):take-home 要求「合併兩個 JSON 檔 + 設計發送 LINE 訊息的 API」,面試時常延伸成:「行銷人員要對 1,000 萬名會員發送個人化 LINE 訊息,怎麼設計?」這是 MarTech 推播平台的核心:分眾、排程、扇出(fan-out)、上游限流、重試、去重、追蹤。

需求釐清

功能需求

  • 行銷人員建立 campaign:選擇受眾分群、訊息模板(含個人化變數)、渠道(LINE、App push、Email、SMS)、發送時間(立即/排程/依使用者時區)。
  • 單筆交易型訊息 API(訂單通知、OTP),優先級高於行銷訊息。
  • 可暫停、取消、查看進度。
  • 追蹤:已送出、送達(若渠道支援)、開啟、點擊、轉換。
  • 規則:退訂/封鎖不發、勿擾時段、跨 campaign 頻次上限。

非功能需求(假設)

  • 單一 campaign 最多 1,000 萬人,希望 30 分鐘內送完(受上游限流影響時需給出預估完成時間)。
  • 每日總量 1 億則。
  • 不可重複發送(重複推播傷品牌、也浪費訊息費用);可接受極少量失敗但要可查。
  • 交易型訊息 p99 數秒內送出。
  • 遵守上游(LINE、FCM/APNs)速率限制與政策。

估算

  • 1,000 萬人 ÷ 1,800 秒 ≈ 5,600 則/s。
  • 若內容相同、可用 LINE multicast(一次最多 500 位收件者):約 20,000 個請求,每秒約 11 個請求即可。
  • 若內容個人化(每人不同),每人一個 push 請求 → 5,600 req/s,很可能超過上游限制 → 系統必須以上游限制為準排程,並回報預估完成時間;或把「內容相同的人」分組後用 multicast。
  • 發送紀錄:1 億則/天 × 約 200 bytes ≈ 20 GB/天。
  • 回流:發送後數分鐘內點擊湧入,tracker 要能承受尖峰(例如 1,000 萬 × 5% 點擊在 10 分鐘內 ≈ 830/s,峰值更高)。

API 設計(take-home 會考的部分)

# 單筆 / 交易型:非同步,回 202 + message_id;Idempotency-Key 防止 client 重試造成重複
POST /v1/messages
Idempotency-Key: 6f1c...-order-123-shipped
{"channel": "line", "to": {"user_id": "u-1"}, "template_id": "order_shipped",
 "variables": {"order_id": "123"}, "priority": "high"}
→ 202 {"message_id": "msg_88", "status": "queued"}

GET /v1/messages/msg_88 → {"status": "sent", "sent_at": "...", "provider_request_id": "..."}

# 大量 / 行銷 campaign
POST /v1/campaigns
{"name": "雙11預熱", "segment_id": "seg_cart_abandon_30d", "channel": "line",
 "template_id": "promo_1111", "schedule": {"type": "at", "time": "2026-11-10T20:00:00+08:00"},
 "throttle_per_sec": 3000, "respect_quiet_hours": true}
→ 201 {"campaign_id": "cmp_7", "status": "SCHEDULED", "estimated_recipients": 8423110}

POST /v1/campaigns/cmp_7:pause    POST /v1/campaigns/cmp_7:resume    POST /v1/campaigns/cmp_7:cancel
GET  /v1/campaigns/cmp_7/stats → {"targeted": 8423110, "sent": 5120033, "failed": 2310,
                                  "skipped_unsubscribed": 40211, "clicked": 120334, "eta": "..."}
面試官想聽到什麼

take-home 的「發送 LINE 訊息 API」最常被追問的三件事:(1) 為什麼回 202 而不是 200(發送是非同步的,API 只保證已排入佇列);(2) Idempotency-Key 如何實作(存 key → 回應的對應,TTL 24 小時,同 key 不同 body 回 422);(3) LINE API 回 429 或逾時時你怎麼辦(退避重試 + retry key,不能無腦重送)。

資料模型

Table / Store關鍵欄位說明
campaigns(MySQL)id、tenant_id、status(DRAFT/SCHEDULED/RUNNING/PAUSED/DONE/CANCELED)、scheduled_at、segment_snapshot_uri、throttle狀態機,狀態轉換用條件更新(WHERE status = 'SCHEDULED')
send_batchesbatch_id、campaign_id、user_ids(或 S3 範圍)、retry_key、status、attempts扇出的工作單位,可續傳
deliveries(Cassandra/DynamoDB/分區 MySQL)(campaign_id, user_id) 唯一、status、provider_request_id、sent_at、error去重依據 + 查詢個別使用者狀態
user_channelsuser_id → line_uid、push tokens、email、退訂/封鎖旗標由 profile 服務提供(見情境題 5)
idempotency_keys(Redis/DB)key → request_hash、response、TTLAPI 層冪等
message_events(Kafka → OLAP)sent / failed / click / open / conversion統計報表,沿用情境題 1 的管線

高層架構

flowchart LR MKT["行銷後台"] --> CAPI["Campaign API"] APP["客戶系統 交易訊息"] --> MAPI["Message API 冪等"] CAPI --> DB[("MySQL campaigns")] SCH["Scheduler 領取到期 campaign"] --> DB SCH --> SEG["Segment Resolver"] SEG --> AUD[("受眾服務 / 資料湖")] SEG -->|"名單快照"| S3[("S3")] SEG --> FAN["Fan-out Worker 切 batch + 過濾"] FAN -->|"退訂 勿擾 頻次"| PREF[("偏好與頻次 Redis")] FAN --> QL["Kafka send.low 行銷"] MAPI --> QH["Kafka send.high 交易"] QH --> SND["Sender Worker"] QL --> SND SND --> RL["分散式限流 Redis token bucket"] SND --> LINEAPI["LINE Messaging API"] SND --> FCM["FCM / APNs"] SND -->|"可重試錯誤"| RETRY["Retry 佇列 延遲"] RETRY --> SND SND -->|"重試耗盡"| DLQ["DLQ"] SND --> EV["Kafka message.events"] CLICK["點擊轉址 Tracker"] --> EV EV --> STAT["統計 + deliveries 狀態"]

深入 1:分眾快照與排程

  • 名單快照:在發送開始時把 segment 解析成固定名單(user_id 列表寫到 S3),之後所有 batch 都基於這份快照。好處:進度可計算、暫停/續傳可定位、重跑不會因分群資料變動而發給不同的人。
  • 排程器:多個 scheduler 實例以 DB 鎖領取到期 campaign,避免重複觸發:
-- MySQL 8 / PostgreSQL:多實例同時執行也不會領到同一個 campaign
START TRANSACTION;
SELECT id FROM campaigns
WHERE status = 'SCHEDULED' AND scheduled_at <= NOW()
ORDER BY scheduled_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 對選到的 id:
UPDATE campaigns SET status = 'RUNNING', started_at = NOW()
WHERE id IN (/* 上面選到的 id */) AND status = 'SCHEDULED';
COMMIT;
  • 依使用者時區發送(「每個人當地晚上 8 點」):把名單依時區分組,產生多個子排程。
  • 勿擾時段與頻次:fan-out 時過濾;被勿擾時段擋下的可延後到允許時段,而不是直接丟棄(依 campaign 設定)。
  • 大名單扇出:fan-out worker 讀取快照,以 500 人一組(配合 multicast 上限)或每人一則產生 batch,寫入 send_batches 與 Kafka;本身也要可續傳(記錄已處理到快照的哪個位置)。

深入 2:上游 rate limit 與優先級

  • 全域限流:上游限制是針對「整個 channel 帳號」的,但 sender 有很多台,所以限流器必須是分散式的:Redis token bucket(Lua 原子操作,見經典題:rate limiter),或由協調者把總速率平均分配給各 worker(各自本地限流,worker 數變動時重新分配)。
  • 自適應:收到 429 時採 AIMD(加法增、乘法減):立即把速率減半,之後慢慢加回,類似 TCP 擁塞控制。尊重 Retry-After。
  • 優先級隔離:交易型與行銷型分開 topic 與 worker pool,或在 token bucket 中保留一部分額度給高優先級,避免 1,000 萬則行銷訊息堵住 OTP。
  • 多租戶公平性:同一平台上多個品牌各自有自己的 LINE 帳號與限額;共用的 worker 要避免大客戶 campaign 佔滿所有 worker → 依 tenant 分 partition 或 weighted fair queuing。

深入 3:重試、去重與「不確定結果」

情況是否重試處理
429 Too Many Requests是退避 + 降低速率,尊重 Retry-After
5xx / 連線錯誤是指數退避 + jitter,次數上限後進 DLQ
逾時(不知道對方是否收到)是,但必須冪等使用同一個 retry key 重送,讓上游去重
400(格式錯誤、無效收件者)否標記失敗,修正模板或資料
401 / 403(token 失效、權限)否(需人工)暫停該帳號的發送並告警
使用者封鎖帳號否更新 user_channels 旗標,之後不再發送

去重的三道防線:(1) API 層 Idempotency-Key;(2) 系統內 (campaign_id, user_id) 唯一鍵:發送前寫入 deliveries 狀態為 SENDING(條件寫入,已存在就跳過);(3) 對上游使用 retry key:LINE Messaging API 的 push/multicast 等端點支援 X-Line-Retry-Key header(UUID),在有效期間內用同一個 key 重送不會重複發送,若該 key 已被接受會回 409。retry key 必須在建立 batch 時產生並持久化,而不是每次重試才產生,否則失去意義。

import asyncio, random
import aiohttp

MULTICAST_URL = "https://api.line.me/v2/bot/message/multicast"
MAX_ATTEMPTS = 6

async def send_batch(session, limiter, account, batch):
    """batch: {"batch_id", "user_ids" (最多 500), "messages", "retry_key"(建立 batch 時產生)}"""
    for attempt in range(MAX_ATTEMPTS):
        await limiter.acquire(account.id)                       # 分散式 token bucket
        try:
            async with session.post(
                MULTICAST_URL,
                json={"to": batch["user_ids"], "messages": batch["messages"]},
                headers={"Authorization": f"Bearer {account.access_token}",
                         "X-Line-Retry-Key": batch["retry_key"]},
                timeout=aiohttp.ClientTimeout(total=10),
            ) as resp:
                status = resp.status
                retry_after = float(resp.headers.get("Retry-After", 0))
        except (aiohttp.ClientError, asyncio.TimeoutError):
            status, retry_after = None, 0                       # 結果不確定 → 用同一 retry key 重送

        if status in (200, 409):                                # 409:此 retry key 已被接受過
            return await mark_batch(batch, "SENT")
        if status == 429:
            limiter.decrease(account.id)                        # AIMD:速率減半
        elif status is not None and 400 <= status < 500:
            return await mark_batch(batch, "FAILED", f"http {status}")   # 不可重試
        backoff = random.uniform(0, min(60, 2 ** attempt))      # full jitter
        await asyncio.sleep(max(backoff, retry_after))
    await dead_letter.put(batch)                                # 重試耗盡:人工 / 稍後重放

深入 4:送達與開啟追蹤

  • 送出(sent):上游 API 回 200 即記錄,並保存上游回傳的 request id 以便追查。
  • 送達 / 開啟:各渠道能力不同。App push 可由 SDK 回報「收到」與「開啟」事件(走情境題 1 的事件管線);Email 常用追蹤像素(但會受隱私保護機制影響,數字偏高或不準);LINE 等平台通常提供彙總層級的統計 API,而非逐人送達回條,所以實務上以點擊追蹤為主。
  • 點擊追蹤:訊息中的連結替換成短網址/轉址服務(https://trk.example.com/c/abc123),轉址時記錄 campaign、user、時間後 302 到原始網址;短碼內含簽章避免竄改。
  • 轉換歸因:點擊後一段時間內的購買事件,依歸因規則(例如 7 天內最後點擊)記到 campaign。

面試官可能的追問

發到一半行銷人員按了「取消」,怎麼停下來?

campaign 狀態改為 CANCELED(條件更新)。fan-out worker 每處理一批就檢查狀態;sender 在送出前檢查 campaign 狀態(本地快取幾秒,避免每則都查 DB),已在 Kafka 中的 batch 被消費時直接標記為 CANCELED 並跳過。所以取消是「盡快停止」而非「瞬間停止」,會有少量在途訊息仍送出,要在 UI 上說明。暫停/續傳同理,因為有名單快照與 batch 狀態,可以從中斷處繼續。

Sender worker 送出成功後、更新狀態前崩潰,會重複發送嗎?

Kafka 會把該 batch 重新交給其他 worker(offset 未 commit)。第二次送出時帶同一個 retry key,上游會回 409(已接受),我們視為成功,不會重複發送。若上游不支援冪等機制,只能靠「送出前寫 SENDING 狀態」縮小窗口,並在重試前查詢上游狀態(若有 API),剩餘的極小機率重複需要被業務接受。

每個人的內容都不同(個人化),還能用 multicast 嗎?

先看個人化的程度:若只有少數幾種變體(例如依會員等級三種文案),把同變體的人分組後各自 multicast;若真的每人不同(含姓名、專屬折扣碼),只能逐一 push,發送速率就受上游限制,需要在建立 campaign 時就算出預估完成時間並提示行銷人員,或建議改用「通用訊息 + 個人化落地頁」的設計。

怎麼合併兩個 JSON 檔(take-home 的另一半)?

把兩份 List[dict] 依 id 建 dict(O(n+m)),對相同 id 的 record 依衝突規則合併欄位(例如以較新的 updated_at 為準、非空值優先、陣列取聯集),只出現在一邊的照常保留。要說明時間與空間複雜度、輸入 id 重複或缺失時的處理、以及檔案大到放不進記憶體時改用外部排序或依 id hash 分桶。完整程式碼見情境題 5。

情境題 5:合併多來源使用者資料(Identity / Profile 整合) 高頻 深入

題目來源:Appier coding 題「合併兩個 List[dict] 中相同 id 的資料」與追問「兩份資料如何 merge?資料量到上億筆怎麼辦?」放大後就是 MarTech 平台的核心元件:Customer Data Platform(CDP)的身分解析(identity resolution)與統一使用者輪廓(unified profile)。面試時可以從 coding 題一路延伸到系統設計,展現「從演算法到分散式」的連貫思考。

需求釐清

功能需求

  • 資料來源:CRM(會員編號、email、電話)、Web SDK(cookie id,登入後有 user_id)、App SDK(device id、user_id)、LINE(綁定後的 LINE uid)、線下 POS。
  • 身分解析:判斷哪些識別碼屬於同一個人,給出穩定的 unified_id。
  • 屬性合併:同一人在不同來源的屬性有衝突時,依規則產生單一 profile,並保留來源(provenance)。
  • 線上查詢:用任一識別碼查到 unified profile(給推薦、推播、廣告使用)。
  • 離線批次:每日全量重算;即時:新登入/綁定事件數秒到分鐘內生效。
  • 依使用者要求刪除(隱私法規)。

非功能需求(假設)

  • 約 3 億個識別碼、對應約 1 億人。
  • 線上查詢 p99 約 10ms,峰值 1–2 萬 QPS。
  • 錯誤合併(把兩個人併成一個)比漏合併更糟:會把 A 的個資推播給 B → 預設保守,只用確定性(deterministic)規則。
  • 全量批次需在數小時內完成。

估算

  • ID 對應表:3 億 × 約 100 bytes ≈ 30 GB → 分散式 KV,或 Redis Cluster 全放記憶體。
  • Profile:1 億 × 約 2 KB ≈ 200 GB → 分散式 KV(DynamoDB、Cassandra、ScyllaDB、HBase),熱資料加 cache。
  • 原始來源資料:每日全量或增量檔數十到數百 GB → 存 S3,Spark 處理。
  • 批次 join:上億筆 × 多來源 → 單機記憶體放不下,需要依 id 分區的分散式處理。

從 coding 題開始:合併兩個 List[dict]

面試的 coding 版本通常是:給兩個 list,每個元素是含 id 的 dict,把相同 id 的資料合併。先講清楚衝突規則再寫程式(這本身就是加分):誰覆蓋誰?空值怎麼辦?陣列欄位要合併還是取代?

from typing import Any

MULTI_VALUED = {"tags", "emails"}          # 多值欄位:取聯集

def merge_two(old: dict, new: dict) -> dict:
    """以 updated_at 較新的為主;空值不覆蓋;多值欄位取聯集。"""
    if new.get("updated_at", "") >= old.get("updated_at", ""):
        newer, older = new, old
    else:
        newer, older = old, new
    out = dict(older)
    for field, value in newer.items():
        if field in MULTI_VALUED:
            out[field] = sorted(set(older.get(field) or []) | set(value or []))
        elif value not in (None, ""):
            out[field] = value               # 較新且非空才覆蓋
    return out

def merge_lists(a: list[dict], b: list[dict], key: str = "id") -> list[dict]:
    merged: dict[Any, dict] = {}             # 保持插入順序(Python 3.7+ dict 有序)
    for rec in (*a, *b):
        k = rec.get(key)
        if k is None:
            continue                         # 無 id 的資料:記錄後另外處理,不要默默丟掉
        merged[k] = merge_two(merged[k], rec) if k in merged else dict(rec)
    return list(merged.values())

a = [{"id": 1, "name": "Amy", "email": None, "tags": ["vip"], "updated_at": "2026-10-01"}]
b = [{"id": 1, "name": "Amy Lin", "email": "amy@x.com", "tags": ["line"], "updated_at": "2026-10-05"},
     {"id": 2, "name": "Bob", "updated_at": "2026-09-01"}]
print(merge_lists(a, b))
# [{'id': 1, 'name': 'Amy Lin', 'email': 'amy@x.com', 'tags': ['line', 'vip'], 'updated_at': '2026-10-05'},
#  {'id': 2, 'name': 'Bob', 'updated_at': '2026-09-01'}]

複雜度:時間 O(n + m)(假設每筆欄位數為常數),空間 O(n + m)。追問「同一個 list 內 id 重複怎麼辦」:上面的寫法自然處理(同樣走 merge_two)。追問「保持 a 的順序」:dict 保留插入順序,a 的 id 會先出現。

追問:資料量到上億筆怎麼辦?

方法適用規模原理注意
記憶體 dict放得進單機記憶體(數百萬到數千萬筆)hash joinPython dict 每筆額外開銷大,實際記憶體可能是資料量的數倍
外部排序 + merge join單機磁碟放得下兩檔各自依 id 外部排序(sort 指令或分塊排序 + k-way merge),再像 merge sort 一樣雙指標串流合併記憶體 O(1),IO 為主;適合一次性作業
Hash 分桶(grace hash join)單機磁碟放得下依 hash(id) % N 把兩檔切成 N 個桶,同 id 必在同桶;逐桶載入記憶體合併N 要讓每桶放得進記憶體;hash 要穩定
Spark / 分散式 join上億到數百億筆依 id shuffle 到各 executor(分散式的 hash 分桶)資料傾斜(某 id 特別多)需 salting;小表用 broadcast join
資料庫 bulk load + upsert需要持續增量更新先 bulk load 到 staging table,再 INSERT ... ON DUPLICATE KEY UPDATE / MERGE大量 upsert 時注意 index 維護與 binlog 量
import json, zlib

def bucket_of(record_id, n):
    # 不要用內建 hash():字串的 hash 每個 process 隨機(PYTHONHASHSEED),跨程序不穩定
    return zlib.crc32(str(record_id).encode()) % n

def split_into_buckets(path, out_dir, tag, n):
    outs = [open(f"{out_dir}/{tag}-{i}.jsonl", "w", encoding="utf-8") for i in range(n)]
    try:
        with open(path, encoding="utf-8") as f:
            for line in f:                                 # 串流讀取,不整檔載入
                outs[bucket_of(json.loads(line)["id"], n)].write(line)
    finally:
        for fh in outs:
            fh.close()

def merge_buckets(out_dir, n, out_path):
    with open(out_path, "w", encoding="utf-8") as out:
        for i in range(n):                                 # 每次只有一個桶在記憶體中
            merged = {}
            for tag in ("a", "b"):
                with open(f"{out_dir}/{tag}-{i}.jsonl", encoding="utf-8") as f:
                    for line in f:
                        rec = json.loads(line)
                        k = rec["id"]
                        merged[k] = merge_two(merged[k], rec) if k in merged else rec
            for rec in merged.values():
                out.write(json.dumps(rec, ensure_ascii=False) + "\n")
# PySpark 版本:每個欄位取「最新且非空」的值(max_by 需 Spark 3.3+)
from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("profile-merge").getOrCreate()
crm = spark.read.parquet("s3://lake/crm/dt=2026-11-03/").withColumn("src", F.lit("crm"))
app = spark.read.parquet("s3://lake/app/dt=2026-11-03/").withColumn("src", F.lit("app"))
rows = crm.unionByName(app, allowMissingColumns=True)

fields = [c for c in rows.columns if c not in ("id", "updated_at", "src")]
merged = rows.groupBy("id").agg(
    # 值為 null 時排序鍵設為 null → 不會被選中,等同「非空值中最新的那個」
    *[F.max_by(F.col(c), F.when(F.col(c).isNotNull(), F.col("updated_at"))).alias(c)
      for c in fields],
    F.max("updated_at").alias("updated_at"),
)
merged.write.mode("overwrite").parquet("s3://lake/profile_merged/dt=2026-11-03/")
面試官想聽到什麼

追問「上億筆」時,面試官想看你是否理解「同一個 key 必須落在同一個處理單元」這個核心觀念:不論是單機分桶、外部排序或 Spark shuffle,本質都是依 key 分區,讓每一份可以獨立在記憶體中處理。再補充穩定 hash、資料傾斜、增量處理(只處理今天變動的 id)就很完整。

身分解析:Identity Graph 與 Union-Find

真實情況不是「兩份資料有相同的 id」,而是每個來源有不同的識別碼:CRM 有 email + 會員編號;App 登入後有 user_id + device_id;Web 登入後 cookie 與 user_id 綁定。把每個識別碼當成圖的節點,「同一筆紀錄中同時出現」就連一條邊,一個連通分量(connected component)就是一個人。

flowchart LR E["email: amy@x.com"] --- M["member: M001"] M --- U["user_id: u-9"] U --- D1["device: iPhone-A"] U --- C1["cookie: ck-1"] E --- L["line_uid: Uab12"] D2["device: iPad-B"] --- U X["cookie: ck-77"] --- Y["device: Pixel-C"]

上圖有兩個連通分量:左邊 7 個識別碼屬於同一人(Amy),右邊兩個屬於另一個尚未登入的匿名使用者。

class UnionFind:
    def __init__(self):
        self.parent, self.size = {}, {}

    def find(self, x):
        if x not in self.parent:
            self.parent[x], self.size[x] = x, 1
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        while self.parent[x] != root:                 # 路徑壓縮
            self.parent[x], x = root, self.parent[x]
        return root

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return ra
        if self.size[ra] < self.size[rb]:            # union by size,樹高 O(log n)
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        return ra

def resolve(records, max_degree=50):
    """records: 每筆是一個來源紀錄中同時出現的識別碼,例如 {"email": ..., "device": ...}"""
    degree = {}
    for r in records:
        for k, v in r.items():
            if v:
                degree[(k, v)] = degree.get((k, v), 0) + 1
    uf = UnionFind()
    for r in records:
        ids = [(k, v) for k, v in r.items() if v and degree[(k, v)] <= max_degree]  # 排除超級節點
        for other in ids[1:]:
            uf.union(ids[0], other)
        if ids:
            uf.find(ids[0])
    groups = {}
    for node in uf.parent:
        groups.setdefault(uf.find(node), []).append(node)
    return list(groups.values())
  • 超級節點(super node):共用裝置(網咖電腦)、測試帳號(test@test.com)、預設值(電話 0000000000)會把成千上萬人串成一個分量。對策:度數上限、黑名單、只信任特定類型的邊(例如 email 邊比 IP 邊可靠)。上面程式中的 max_degree 是度數上限的簡化版(實務上通常依識別碼類型分別設定)。
  • 確定性 vs 機率性比對:確定性(相同的雜湊 email、登入綁定)準確;機率性(相同 IP + 相似裝置指紋)覆蓋率高但會誤合併。預設只用確定性規則,機率性結果另存且標記信心分數,供廣告受眾擴展等容錯度高的場景使用。
  • 分散式連通分量:上億節點時用 Spark GraphFrames 的 connected components,或自行實作迭代式 label propagation(每輪每個節點取鄰居的最小 label,直到不再變化)。
  • 正規化:email 轉小寫並去空白、電話轉 E.164 格式、再做 SHA-256 雜湊;正規化不一致會造成漏合併。

屬性衝突規則

規則適用欄位範例
來源優先序(source priority)有權威來源的欄位姓名、生日以 CRM 為準,其次 App,最後 Web 表單
最新者勝(last-write-wins)會變動的狀態地址、偏好語言、最後活躍時間
非空優先所有欄位的基本規則新資料該欄位為空時不覆蓋舊值
聯集多值欄位興趣標籤、擁有的裝置、訂閱的渠道
聚合行為統計總消費金額(加總)、最近購買日(取最大)
最常出現(majority)雜訊大的推論欄位推論性別、常用城市
同意/退訂取最嚴格隱私相關任一來源退訂即視為退訂

每個欄位保留 provenance(值來自哪個來源、何時更新),方便除錯與向客戶解釋「為什麼這個人的生日是這個」。規則要可設定(每個租戶不同),以設定檔驅動合併引擎,而不是寫死在程式碼。

API 與資料模型

GET  /v1/profiles:lookup?id_type=email_sha256&id_value=9f86d0...   → 200 {"unified_id": "P123", "profile": {...}}
GET  /v1/profiles/P123
POST /v1/profiles:batchGet          {"unified_ids": ["P1", "P2", ...]}       # 推播 fan-out 時批次查
POST /v1/identities:link            {"ids": [{"type": "user_id", "value": "u-9"},
                                              {"type": "device_id", "value": "iPhone-A"}],
                                     "source": "app_login", "observed_at": "..."}
DELETE /v1/profiles/P123            # 隱私刪除:非同步,回 202 + 追蹤 id
StoreKeyValue
id_map(KV)tenant + id_type + id_valueunified_id
profiles(KV)tenant + unified_id合併後屬性 + provenance + version
identity_edges(S3 / 圖儲存)(id_a, id_b)來源、類型(確定/機率)、首次/最後觀察時間
id_alias(KV)舊 unified_id合併後存活的 unified_id
source_records(S3 Parquet)來源 + 日期分區原始紀錄,重算用

高層架構:離線批次 + 線上增量

flowchart TB subgraph SRC["資料來源"] CRM["CRM 每日檔案"] SDK["SDK 事件 登入/綁定"] LINEB["LINE 綁定"] end CRM --> S3[("S3 source_records")] SDK --> K["Kafka identity.events"] LINEB --> K K --> S3 subgraph OFF["離線 每日全量"] S3 --> NORM["Spark 正規化 + 雜湊"] NORM --> EDGE["產生 identity edges"] EDGE --> CC["連通分量 + 穩定 ID 指派"] CC --> MERGE["屬性合併 依規則"] MERGE --> SNAP[("Profile 快照 Parquet")] SNAP --> LOAD["Bulk load 新版本表"] end subgraph ON["線上 增量"] K --> FL["Flink 增量 linker"] FL --> IDM[("id_map KV")] FL --> PRO[("profiles KV")] end LOAD --> IDM LOAD --> PRO API["Profile API"] --> CACHE["Redis cache"] API --> IDM API --> PRO PUSH["推播 / 推薦 / 廣告"] --> API
  • 離線全量:每天從 S3 重算完整 identity graph 與 profile,結果寫入新版本的表,驗證後原子切換(版本指標或表別名)。全量重算能修正增量路徑累積的錯誤,也是規則變更後的回補方式。
  • 線上增量:使用者登入時產生 link 事件 → Flink 查兩個識別碼目前的 unified_id:都沒有 → 建立新的;一個有 → 把另一個加入;兩個不同 → 合併兩個 profile(保留較舊的 unified_id 作為存活者,另一個寫入 id_alias,更新 id_map,屬性依規則合併)。同一個 unified_id 的更新依 key 分區序列化,避免併發寫入衝突。
  • 批次與串流的銜接:快照代表時間點 T 的狀態;切換後,Flink 從 T 之前一點的 offset 重新套用增量事件(操作要冪等),確保快照之後的變更不遺失。
  • 穩定 ID:每天重算時,新的連通分量要盡量沿用昨天的 unified_id(例如取分量中最舊的 unified_id),否則下游(推播紀錄、受眾名單)會全部對不上。

面試官可能的追問

錯誤合併了兩個人,怎麼拆開(unmerge)?

這就是為什麼要保存原始邊與來源紀錄,而不是只存合併後的結果。拆分流程:把造成錯誤的邊(例如某個測試 email)加入黑名單 → 對受影響的分量重新跑連通分量 → 產生兩個 profile(一個沿用原 unified_id,另一個新建)→ 屬性依來源重新合併。如果只保存合併結果,資料無法還原。這也是預設保守合併的理由:錯誤合併的修復成本遠高於漏合併。

Spark join 時某些 id(例如空字串、預設值)特別多,造成資料傾斜怎麼辦?

先在來源清掉無效值(空字串、null、unknown、測試帳號),這通常就解決大部分傾斜。真正的熱門 key 可用 salting:在大表的 key 後加隨機後綴 0..N-1,小表對應複製 N 份,join 後再彙總;Spark 3 的 AQE(Adaptive Query Execution)也能自動拆分傾斜分區。小表(例如黑名單、維度表)用 broadcast join 避免 shuffle。

線上查詢 1–2 萬 QPS、p99 10ms,怎麼做?

id_map 與 profile 放在低延遲分散式 KV(單次點查約 1–5ms),熱門 profile 再放 Redis 或本地 cache(TTL 短,profile 更新時發送失效事件)。lookup 需要兩次查詢(id → unified_id → profile),可以把常用欄位反正規化到 id_map 的 value 裡省一次往返;批次查詢 API 讓推播 fan-out 一次取上百筆。容量上,2 萬 QPS 對分散式 KV 不大,重點在 p99 穩定:避免大 value、熱點 key、GC。

使用者要求刪除個資,系統要怎麼處理?

建立刪除工作流程:(1) 線上:刪除 profile 與 id_map、加入「已刪除識別碼」清單,避免下次批次又把資料建回來;(2) 離線:S3 的歷史 Parquet 需要重寫移除該使用者(成本高,可批次每週處理),或採用每位使用者獨立加密金鑰、刪除金鑰使資料不可讀(crypto-shredding);(3) 下游:發出刪除事件讓推播、推薦、廣告受眾等系統同步刪除;(4) 留下稽核紀錄(只記錄刪除動作,不留個資)。

經典題精簡版 高頻 進階

以下九題是系統設計面試的「基本功」,Appier 的情境題常以它們為零件(例如推播需要 rate limiter 與 unique ID、報表需要分散式快取)。每題只列出核心需求、關鍵設計與最常被追問的點。

1. URL Shortener(短網址)

需求:長網址 → 短碼;短碼轉址;可選自訂短碼、過期時間、點擊統計。估算:每月 1 億個新短網址(約 40/s 寫入),讀寫比 100:1(約 4,000/s 讀,峰值數倍);5 年 60 億筆。Base62 7 碼可表示 627 ≈ 3.5 兆個,足夠。每筆約 500 bytes → 5 年約 3 TB。

flowchart LR C["Client"] -->|"POST /urls"| API["Shorten API"] API --> IDG["ID 產生器 號段 / Snowflake"] API --> DB[("KV / MySQL code → long_url")] C -->|"GET /abc123"| RD["Redirect 服務"] RD --> CACHE["Redis 熱門短碼"] RD --> DB RD -->|"302 Location"| C RD --> K["Kafka 點擊事件"] K --> STAT["統計"]
  • 短碼產生:(a) 全域唯一數字 ID → Base62 編碼(無碰撞,但連續 ID 可被猜測,可先做可逆的位元混淆);(b) 對長網址做雜湊取前 7 碼(需檢查碰撞、重試加鹽);(c) 預先產生短碼池。面試推薦 (a) + 號段發號(每台服務一次向 DB 取 1,000 個 ID 區間)。
  • 301 vs 302:301 永久轉址會被瀏覽器快取,降低伺服器負載但無法統計每次點擊;需要統計時用 302。
  • 讀多:cache-aside + Redis,熱門短碼命中率高;不存在的短碼要快取空結果或用 Bloom filter 擋(cache penetration)。
  • 同一長網址是否回同一短碼:需要 long_url → code 的反向索引(雜湊後索引),依需求決定。
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def encode62(n: int) -> str:
    if n == 0:
        return ALPHABET[0]
    out = []
    while n:
        n, r = divmod(n, 62)
        out.append(ALPHABET[r])
    return "".join(reversed(out))

def decode62(s: str) -> int:
    n = 0
    for ch in s:
        n = n * 62 + ALPHABET.index(ch)
    return n

assert decode62(encode62(3_521_614_606_207)) == 3_521_614_606_207   # 62**7 - 1 → "ZZZZZZZ"

2. 分散式 Rate Limiter(Redis + Lua)

演算法原理優點缺點
Fixed window每個時間窗一個計數器(INCR + EXPIRE)最簡單、省記憶體窗邊界可能瞬間通過 2 倍流量
Sliding window log存每個請求時間戳(ZSET),移除窗外的再計數精確記憶體與請求數成正比
Sliding window counter前一窗計數 × 重疊比例 + 本窗計數省記憶體、近似平滑近似值
Token bucket以固定速率補充 token,請求消耗 token允許突發(bucket 容量)、平均速率受控需存 token 數與時間
Leaky bucket請求進佇列,以固定速率流出輸出平滑突發請求會排隊延遲

分散式的關鍵是「讀取 → 計算 → 寫回」必須原子,否則多台 gateway 同時讀到相同 token 數會超賣。Redis 執行 Lua 腳本時不會穿插其他命令,因此天然原子:

import redis

r = redis.Redis()

TOKEN_BUCKET = r.register_script("""
local key       = KEYS[1]
local rate      = tonumber(ARGV[1])      -- 每秒補充幾個 token
local capacity  = tonumber(ARGV[2])      -- bucket 容量(允許的突發量)
local requested = tonumber(ARGV[3])
local t   = redis.call('TIME')           -- 用 Redis 伺服器時間,避免各 client 時鐘不一致
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data   = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts     = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + (now - ts) / 1000 * rate)
local allowed = 0
if tokens >= requested then
  tokens  = tokens - requested
  allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)
return allowed
""")

def allow(client_id: str, rate: float = 10, capacity: int = 20, cost: int = 1) -> bool:
    try:
        return TOKEN_BUCKET(keys=[f"rl:{{{client_id}}}"], args=[rate, capacity, cost]) == 1
    except redis.RedisError:
        return True     # fail-open:限流器故障時放行(保護性限流可改為 fail-closed)
  • 放在哪:API gateway / Nginx / Envoy(全域)+ 服務內(細粒度,例如每租戶每功能)。
  • 回應:429 + Retry-After,常見還會回 X-RateLimit-Limit、X-RateLimit-Remaining。
  • 效能:每個請求一次 Redis 往返;超高 QPS 時可用「本地 token bucket + 定期向 Redis 申請配額」的混合方案(與廣告預算租約同一思路),犧牲少量精確度換延遲。
  • Redis Cluster:key 用 hash tag {client_id},確保腳本存取的 key 在同一 slot。

3. 分散式 Unique ID

方案長度有序優點缺點
UUID v4128 bit否無需協調、任何地方都能產生長;作為 B-tree 主鍵時隨機插入造成頁分裂與快取效率差
UUID v7(RFC 9562)128 bit大致依時間無需協調且對索引友善仍是 128 bit;洩漏產生時間
DB auto-increment64 bit是簡單單點與寫入瓶頸;多主可用不同 offset/step 但擴充困難
號段模式(segment)64 bit大致有序每次向 DB 取一段(如 1,000 個),DB 壓力小服務重啟會浪費號段;跨節點非嚴格遞增
Snowflake64 bit依時間大致有序無中央協調、每節點每毫秒 4,096 個依賴時鐘(時鐘回撥);worker id 需分配

Snowflake 結構:1 bit 符號 + 41 bit 毫秒時間戳(約 69 年)+ 10 bit 機器 ID(1,024 台)+ 12 bit 序號(每毫秒 4,096 個)。

import threading, time

EPOCH_MS = 1_704_067_200_000                 # 自訂起點 2024-01-01 UTC,延長可用年限
WORKER_BITS, SEQ_BITS = 10, 12
MAX_WORKER = (1 << WORKER_BITS) - 1
MAX_SEQ = (1 << SEQ_BITS) - 1

class Snowflake:
    def __init__(self, worker_id: int):
        if not 0 <= worker_id <= MAX_WORKER:
            raise ValueError("worker_id out of range")
        self.worker_id, self.last_ms, self.seq = worker_id, -1, 0
        self.lock = threading.Lock()

    def next_id(self) -> int:
        with self.lock:
            now = int(time.time() * 1000)
            if now < self.last_ms:
                raise RuntimeError("clock moved backwards")   # 小幅回撥可等待追上
            if now == self.last_ms:
                self.seq = (self.seq + 1) & MAX_SEQ
                if self.seq == 0:                            # 本毫秒 4096 個已用完
                    while now <= self.last_ms:
                        now = int(time.time() * 1000)
            else:
                self.seq = 0
            self.last_ms = now
            return ((now - EPOCH_MS) << (WORKER_BITS + SEQ_BITS)) | (self.worker_id << SEQ_BITS) | self.seq

追問:worker id 怎麼分配?(K8s 中可用 StatefulSet 序號、或啟動時向 ZooKeeper/etcd/Redis 註冊租約);時鐘回撥怎麼辦?(NTP 只做緩慢調整(slew)、偵測到回撥時等待或拒絕服務、保留備用 worker id)。

4. News Feed / 推薦結果服務

經典 news feed 的核心取捨是 fan-out 時機:

Fan-out on write(push)

發文時寫入每個追蹤者的 feed(Redis list)。讀取 O(1) 快;但大 V 有千萬粉絲時寫入爆炸,且不活躍使用者的 feed 白算。

Fan-out on read(pull)

讀取時才合併所追蹤者的最新貼文。寫入便宜;讀取慢,需要 k-way merge 與快取。

混合

一般使用者 push、大 V pull,讀取時合併兩者。是業界常見答案。

Appier 版本:推薦結果服務(例如電商「為你推薦」)。模式是「離線預計算 + 線上即時調整」:

flowchart LR EVT["行為事件"] --> TRAIN["離線訓練 + 批次推論"] TRAIN -->|"每人 top-200 候選"| KV[("KV user → 候選清單")] EVT --> RT["即時特徵 最近瀏覽"] RT --> FS[("Redis 即時特徵")] C["網站 / App"] --> API["Recommendation API"] API --> KV API --> FS API --> RANK["線上 re-rank 輕量模型"] RANK --> FILT["過濾 缺貨 已購買 黑名單"] FILT --> C API -.->|"無資料或逾時"| POP["熱門商品 fallback"]
  • 召回(candidate generation)離線做,排序(ranking)線上做,兼顧品質與延遲。
  • 過濾必須即時:缺貨、已購買、價格變動不能等明天的批次。
  • 冷啟動與失敗時的 fallback:熱門、同類別熱銷。
  • A/B 測試:依 user hash 分流,記錄每次曝光使用的模型版本以便歸因。

5. 即時排行榜

Redis Sorted Set(底層為 skiplist + hash table):ZINCRBY 更新分數、ZREVRANGE 取前 N 名、ZREVRANK 查名次,皆為 O(log N)(取範圍為 O(log N + M))。

import redis
r = redis.Redis()

def add_score(board: str, user: str, delta: float):
    r.zincrby(board, delta, user)

def top_n(board: str, n: int = 10):
    return r.zrevrange(board, 0, n - 1, withscores=True)

def my_rank(board: str, user: str):
    rank = r.zrevrank(board, user)
    return None if rank is None else rank + 1          # 0-based → 1-based

def around_me(board: str, user: str, k: int = 5):
    rank = r.zrevrank(board, user)
    if rank is None:
        return []
    return r.zrevrange(board, max(0, rank - k), rank + k, withscores=True)

# 每日榜:key 帶日期並設 TTL,例如 board:2026-11-03,週榜可用 ZUNIONSTORE 合併 7 天
  • 同分排序:把分數編碼成 score * 10^k + (MAX_TS - ts),先達到者排前面(注意 double 精度上限約 253)。
  • 規模:單一 ZSET 存數千萬成員仍可運作,但單 key 成為熱點且無法分散;超大規模可依分數區間分片、或只對前 N 名精確排名,其餘給近似百分位。
  • 持久化:分數的 source of truth 放 DB 或事件流,Redis 可由事件重建。

6. 通知系統

與情境題 4 的推播系統高度重疊,差異在於「通知」多為事件觸發、單一使用者的:

flowchart LR SVC["各業務服務"] -->|"通知事件"| NAPI["Notification API"] NAPI --> PREF[("使用者偏好 / 退訂")] NAPI --> TPL["模板渲染 多語系"] TPL --> PQ["優先級佇列 high / normal / digest"] PQ --> W["Channel Workers"] W --> PUSH["FCM / APNs"] W --> MAIL["Email 服務"] W --> SMS["SMS 供應商"] W --> INBOX[("站內信 inbox")] W --> LOG["發送紀錄 + 追蹤"]
  • 使用者偏好:每類通知每個渠道可開關、勿擾時段、語言。
  • 合併摘要(digest):「你有 12 則新留言」而不是 12 則推播;用延遲佇列聚合一段時間內的同類事件。
  • 站內信:每使用者的通知清單(依 user_id 分區、時間排序)與未讀數(Redis 計數器)。
  • 可靠性:冪等鍵(事件 id + 渠道)、重試、DLQ、供應商故障時切換備援供應商(例如 SMS 雙供應商)。

7. 分散式快取

設計一個類 Memcached/Redis Cluster 的分散式快取,核心問題:資料怎麼分布、節點增減怎麼辦、怎麼淘汰、怎麼確保可用。

import bisect, hashlib

class ConsistentHashRing:
    """一致性雜湊 + 虛擬節點:增減一個節點只影響約 1/N 的 key。"""

    def __init__(self, nodes=(), vnodes=150):
        self.vnodes = vnodes
        self.ring = []            # 排序後的 hash 值
        self.owner = {}           # hash 值 -> 實體節點
        for n in nodes:
            self.add(n)

    @staticmethod
    def _hash(key: str) -> int:
        return int.from_bytes(hashlib.md5(key.encode()).digest()[:8], "big")

    def add(self, node: str):
        for i in range(self.vnodes):           # 虛擬節點讓分布更平均
            h = self._hash(f"{node}#{i}")
            bisect.insort(self.ring, h)
            self.owner[h] = node

    def remove(self, node: str):
        for i in range(self.vnodes):
            h = self._hash(f"{node}#{i}")
            self.ring.remove(h)
            del self.owner[h]

    def get(self, key: str) -> str:
        idx = bisect.bisect(self.ring, self._hash(key)) % len(self.ring)   # 順時針第一個
        return self.owner[self.ring[idx]]

ring = ConsistentHashRing(["cache-a", "cache-b", "cache-c"])
print(ring.get("user:42"))
問題現象對策
Cache stampede(擊穿)熱門 key 過期瞬間大量請求同時打 DB互斥鎖/single-flight(只讓一個請求回源)、提前非同步刷新、機率性提前過期
Cache avalanche(雪崩)大量 key 同時過期或快取叢集故障TTL 加隨機 jitter、多層快取、DB 端限流與熔斷
Cache penetration(穿透)查詢不存在的 key 每次都打 DB快取空值(短 TTL)、Bloom filter
Hot key單一節點 CPU/頻寬打滿本地快取、key 複製多份加後綴分散讀取
一致性DB 更新後快取仍是舊值cache-aside「先更新 DB 再刪快取」、延遲雙刪、CDC 驅動失效

其他要點:淘汰策略(LRU、LFU、TTL);複製(主從,主掛時提升從節點);寫入策略(cache-aside 最常見、write-through、write-behind)。Redis Cluster 實際上用的是 16,384 個 hash slot 而非一致性雜湊環,但目的相同:節點增減只搬移部分資料。

8. Web Crawler

估算:每月抓 10 億頁 → 約 385 頁/s;平均每頁 100 KB → 每月約 100 TB 原始內容。

flowchart LR SEED["種子 URL"] --> FR["URL Frontier 優先級 + 每網域佇列"] FR --> FET["Fetcher 群"] FET --> DNS["DNS 快取"] FET --> ROB["robots.txt 快取"] FET --> CS["內容去重 hash / simhash"] CS --> STORE[("S3 網頁內容")] CS --> PARSE["Parser 抽取連結"] PARSE --> UF["URL 正規化 + 已見過 Bloom filter"] UF --> FR
  • 禮貌性(politeness):每個網域一個佇列,同一網域同時只有一個連線並設間隔;遵守 robots.txt。
  • 優先級:依 PageRank、更新頻率、網域重要性決定先抓哪些。
  • 去重:URL 層用正規化 + Bloom filter(數十億 URL 也只需數 GB),內容層用 hash 精確去重、simhash 近似去重。
  • 陷阱:無限生成的 URL(日曆頁)、spider trap → 限制深度與每網域頁數。
  • 分散:依網域 hash 分配到 fetcher 節點,讓同網域的禮貌性控制在單一節點完成。

9. 檔案上傳 / 大檔分段上傳

原則:檔案不要經過應用伺服器。API 伺服器只負責授權與中繼資料,檔案本體由 client 透過預簽章 URL(presigned URL)直接上傳到物件儲存。

sequenceDiagram participant C as Client participant A as Upload API participant S as S3 participant Q as 處理佇列 C->>A: POST /uploads 檔名 大小 類型 A->>S: CreateMultipartUpload S-->>A: upload_id A-->>C: upload_id + 每個 part 的 presigned URL loop 每個 part 可平行 可續傳 C->>S: PUT part N 附 checksum S-->>C: ETag end C->>A: POST /uploads/id/complete 附 part 清單與 ETag A->>S: CompleteMultipartUpload A->>Q: 發送 uploaded 事件 Q->>Q: 病毒掃描 轉檔 縮圖
  • S3 multipart upload:除最後一個 part 外每個 part 至少 5 MiB、最多 10,000 個 part;part 可平行上傳、失敗只重傳該 part。
  • 續傳:client 記錄 upload_id,斷線後用 ListParts 查詢已完成的 part,只補傳缺少的。
  • 完整性:每個 part 帶 checksum,完成後比對整體 checksum。
  • 清理:未完成的 multipart upload 仍會佔用儲存費用,用 lifecycle rule 自動中止超過 N 天的上傳。
  • 安全:presigned URL 設短效期、限制 content-type 與大小;上傳完成前檔案不可被公開存取;後處理(掃毒、轉檔)透過事件非同步完成。

系統設計常見追問 Q&A 彙整 高頻

不論題目是什麼,面試官的追問常落在下列幾類。每題都準備一段 1–2 分鐘的回答,並能連結到前面的情境題。

SQL 和 NoSQL 你怎麼選?

從存取模式與一致性需求出發,而不是流行度。需要交易、多表 join、複雜查詢、強約束(帳務、campaign 設定、訂單)→ 關聯式資料庫;存取模式固定為 key 查詢、量極大、需要輕鬆水平擴展(user profile、頻次計數、對話訊息)→ KV/寬欄資料庫;schema 多變的文件 → 文件資料庫;大量聚合分析 → OLAP。實務上一個系統常同時使用多種(polyglot persistence),並說清楚哪個是 source of truth、其他如何同步(CDC、事件)。詳見 第 5 章 與 第 6 章。

熱點(hot key / hot partition)怎麼處理?

先判斷是讀熱點還是寫熱點。讀熱點:本地快取、快取多副本(key 加隨機後綴分散到不同節點)、CDN。寫熱點:拆分計數器(把一個計數器拆成 N 個子計數器,讀取時加總)、本地聚合後批次寫入(例如每秒合併一次)、改變分區鍵(加入第二維度,如 hash(app_id, device_id))。監控層面要能找出熱點 key(Redis 的 hotkeys 分析、Kafka 各 partition 流量)。

怎麼讓 API 具備冪等性?

GET/PUT/DELETE 依語意本來就應冪等;POST 需要 Idempotency-Key:client 產生唯一 key,伺服器第一次處理時以 key 為主鍵存下請求摘要與回應(狀態 processing → done),之後同 key 的請求直接回存下的回應;同 key 但 body 不同回 422;處理中則回 409 或等待。key 的儲存要與業務寫入在同一個交易中,或用唯一約束保證。消費訊息端也一樣:以事件 id 去重或讓操作本身冪等(upsert、設定絕對值而非增量)。

快取和資料庫怎麼保持一致?

最常用 cache-aside:讀時 miss 從 DB 載入並寫快取;寫時「先更新 DB,再刪除快取」(刪除而不是更新,避免併發寫入造成舊值覆蓋新值)。仍有極小的競態視窗(讀到舊值的請求在刪除後才寫回快取),可以用延遲雙刪、短 TTL 兜底、或由 DB 的 CDC(binlog)驅動快取失效,確保失效一定發生。若業務無法接受任何舊值(例如餘額),就不要快取或只快取不可變資料。

如何做零停機的資料庫 schema 遷移或換資料庫?

用 expand-contract(擴張-收縮)模式:(1) 先加新欄位/新表(向後相容);(2) 程式改為雙寫(新舊都寫);(3) 回補歷史資料;(4) 影子讀取(同時讀新舊並比對差異);(5) 切換讀取到新的;(6) 停止寫舊的;(7) 最後刪除舊欄位/表。每一步都可回滾。大表 ALTER 在 MySQL 用 online DDL 或 gh-ost/pt-online-schema-change 避免長時間鎖表。

資料庫寫入撐不住了,有哪些選項?

依成本由低到高:(1) 優化:批次寫入、移除不必要的索引、縮小交易;(2) 垂直擴展:更大的機器、更快的磁碟;(3) 非同步化:前面加佇列削峰,worker 以可承受速率寫入;(4) 換儲存:append-only 的事件不該放 OLTP,改寫入 Kafka + 列式儲存;計數類改 Redis 定期落盤;(5) 分片:依 tenant 或 user 分片,代價是跨分片查詢與交易。面試時要說明為什麼選這個而不是下一個。

同步呼叫和訊息佇列,什麼時候用哪個?

呼叫者需要立即得到結果才能繼續(例如 bid 時查 user profile)→ 同步(REST/gRPC),並設 timeout 與降級。不需要立即結果、需要削峰、一個事件有多個消費者、需要可重播 → 佇列。佇列的代價:延遲增加、需要處理重複與順序、debug 變困難(需要分散式追蹤,trace id 要跟著訊息走)。詳見 第 11 章。

跨服務的交易怎麼做(例如扣預算 + 建立訂單)?

避免分散式 2PC(阻塞、協調者單點、效能差)。常用 Saga:把大交易拆成多個本地交易,每步有補償動作,失敗時反向補償。發送事件與寫 DB 要一致時,用 transactional outbox:業務資料與「待發事件」在同一個本地交易中寫入 DB,再由 relay(輪詢或 CDC)把 outbox 的事件送進 Kafka,保證「DB 有寫就一定會發事件」。消費端要冪等。詳見 第 10 章。

如果整個 region 掛掉呢?

先問業務需求:RTO(多久要恢復)與 RPO(能接受遺失多久的資料)。多數服務 multi-AZ 已足夠;需要跨 region 時:無狀態服務在兩個 region 都部署,DNS/全域 LB 做健康檢查切換;資料層用跨區非同步複寫(RPO 數秒)或全球分散式資料庫(延遲較高);Kafka 用 MirrorMaker 之類工具跨區複製。定期演練(game day)才能確認切換真的可行。AdTech 的 bidder 通常各 region 獨立運作,一個 region 掛了只是少了那區的流量。

你會監控哪些指標?

服務層用 RED(Rate、Errors、Duration)或 Google 的四個黃金訊號(latency、traffic、errors、saturation);資源層用 USE(Utilization、Saturation、Errors);佇列看 consumer lag;資料管線看新鮮度與資料量異常;業務指標(出價率、勝率、花費速度、推播成功率)往往最早發現問題。告警要基於 SLO(例如 error budget 消耗速率),避免告警疲勞。詳見 第 9 章。

流量變成 10 倍,你的設計哪裡先壞?

這題要你自己找瓶頸。系統性地沿著資料流檢查:入口(LB 連線數、TLS CPU)→ 無狀態服務(可水平擴展,通常不是問題)→ 共享依賴(DB primary 寫入、Redis 單一熱 key、外部 API 的 rate limit)→ 非同步管線(Kafka partition 數決定最大平行度,事後增加 partition 會改變 key 的分布)→ 儲存成本。回答時點出「最先壞的是 X,因為它是單一寫入點;解法是 Y」。

怎麼降低 p99 延遲?

先量測:分散式追蹤找出哪一段慢,profiling 找 CPU/配置熱點。常見手段:減少串行的網路呼叫(平行化、批次、合併);設定 timeout 並對冗餘副本發 hedged request;避免 GC 停頓與鎖競爭;預熱快取與連線池;把慢的工作移到非同步;控制佇列長度(排隊是尾延遲的主因)。也要注意 fan-out 放大效應:一個請求平行呼叫 100 個節點,只要每個節點有 1% 的機率慢,整體就有約 63% 的機率會慢。

Push 和 pull 模型怎麼選(例如設定下發、資料同步)?

Push(伺服器主動推)即時性好,但伺服器要知道所有接收者、處理接收者離線與背壓;pull(接收者定期拉)簡單、接收者自己控制節奏,但有延遲且空拉浪費資源。常見折衷:long polling 或「push 通知 + pull 資料」(通知有新版本,接收者再拉完整內容)。例如 bidder 的 campaign 索引:定期拉快照(可靠)+ 訂閱變更事件(即時)。

多租戶系統要注意什麼?

(1) 資料隔離:每層帶 tenant_id,由伺服器端從身分推導而非信任參數,必要時用 row-level security 或獨立 schema/資料庫;(2) 資源隔離:每租戶配額、限流、查詢資源上限,避免 noisy neighbor;(3) 大客戶專屬資源(獨立 partition、叢集);(4) 可依租戶設定的規則(schema、合併規則、頻次上限);(5) 依租戶計量計費與監控。Appier 的產品都是 B2B,這點幾乎每題都適用。

自我檢查表與延伸閱讀 基礎

面試中的自我檢查表

畫完架構後,用這張表快速掃一遍自己的設計,通常能找到面試官接下來要問的點:

類別檢查問題
需求我寫下功能/非功能需求與範圍外清單了嗎?數字假設有得到確認嗎?
估算峰值 QPS、儲存量、頻寬算了嗎?有用它們做出至少一個設計決策嗎?
API寫入 API 冪等嗎?非同步操作回 202 並提供狀態查詢嗎?有分頁、認證、限流嗎?
資料每個 store 的 key、分區方式、access pattern 清楚嗎?哪個是 source of truth?
擴展服務是否無狀態?哪裡是單一寫入點?熱點 key 在哪?
可靠性每個元件掛掉會怎樣?有沒有 timeout、重試(含退避與 jitter)、DLQ、降級?
正確性重複、亂序、遲到的訊息怎麼處理?哪些數字需要精確、哪些可近似?
一致性快取與 DB、批次與串流、跨服務之間的不一致會造成什麼後果?如何補償?
維運如何監控(指標、日誌、追蹤)?如何部署與回滾?如何 backfill?
安全認證授權、多租戶隔離、PII 處理、簽章防竄改?
成本最貴的元件是什麼?有便宜一個數量級的替代方案嗎?
演進MVP 長什麼樣?10 倍流量時要改哪裡?

練習方法

  1. 每題限時 45 分鐘,計時並大聲講出來(或錄音),模擬真實面試。
  2. 先畫出本章五個情境題的架構圖(不看筆記),再對照補漏。
  3. 對每題準備「一句話總結」:例如報表系統 =「SDK 批次至少一次送達 → 無狀態 collector → Kafka → Flink 去重聚合 + S3 原始資料 → ClickHouse 預聚合 → 批次校正」。
  4. 對每個元件練習回答「為什麼用它?拿掉會怎樣?替代方案是什麼?」。
  5. 找朋友互相扮演面試官,專門追問故障與邊界情境。

推薦延伸閱讀

Designing Data-Intensive Applications(DDIA,Martin Kleppmann)
分散式資料系統的聖經:複製、分區、交易、一致性與共識、批次與串流處理。本章的 exactly-once、watermark、CDC、partitioning 都能在書中找到完整理論。強烈建議至少讀第 5–9 章與第 10–11 章。
System Design Interview – An Insider's Guide Vol. 1 / Vol. 2(Alex Xu 等)
Vol. 1 涵蓋 rate limiter、一致性雜湊、unique ID、URL shortener、web crawler、通知系統、news feed、chat 等;Vol. 2 有 ad click event aggregation(與本章情境題 1 高度相關)、即時排行榜、分散式 message queue、支付系統等。是面試題型的最佳練習本。
經典論文
Amazon Dynamo(最終一致 KV、一致性雜湊)、Google Bigtable、Kafka 原始論文、Google 的 The Dataflow Model(event time、watermark 的理論基礎)、The Tail at Scale(尾延遲與 hedged request)、HyperLogLog 原始論文與 Google 的 HyperLogLog in Practice、Yahoo 的 Indexing Boolean Expressions(廣告 targeting 索引)、Google 的 Ad Click Prediction: a View from the Trenches 與 Facebook 的 Practical Lessons from Predicting Clicks on Ads at Facebook(CTR 預估工程)。
公司工程部落格(類型)
大型網路公司(Netflix、Uber、LinkedIn、Pinterest、Airbnb、Discord、Cloudflare)的技術部落格常寫真實的擴展故事與事故檢討;AdTech/MarTech 公司的部落格可看 RTB、bidder、即時競價與資料管線的實務;雲端供應商(AWS Architecture Blog、Google Cloud Blog)的參考架構可學習託管服務的組合方式。面試前也建議搜尋 Appier 公開的技術分享與產品文件,了解其產品線(CrossX、AIQUA、AIXON、AIDEAL)的使用情境。
工具文件
Kafka、Flink、ClickHouse、Redis 官方文件中的「設計」或「架構」章節(例如 Flink 的 event time 與 watermark、ClickHouse 的 MergeTree 家族),比部落格文章更精確。
實戰提示

本章內容與其他章節相互支撐:分散式理論見 第 10 章、Kafka 與事件驅動見 第 11 章、上億筆資料處理見 第 12 章、AdTech 名詞與競價機制見 第 14 章、部署與雲端元件見 第 15 章。

面試自測題

1. 每天 5 億筆事件、每筆 800 bytes,請估算平均與峰值 QPS、每日原始資料量、峰值入流頻寬。

平均 QPS = 5 × 108 ÷ 86,400 ≈ 5,800/s;以 4 倍估峰值約 23,000/s。每日原始資料 = 5 × 108 × 800 B = 400 GB。峰值頻寬 ≈ 23,000 × 800 B ≈ 18.4 MB/s ≈ 147 Mbps。結論:入口服務只需少量機器,但每日 400 GB、保留一年近 150 TB(未壓縮)→ 需要物件儲存 + 列式壓縮 + 預聚合,而不是放在 OLTP 資料庫。

2. 99.9% 與 99.99% 可用性每月分別允許停機多久?三個 99.9% 的服務串聯後整體可用性約多少?

以 30 天計:99.9% 約 43 分鐘、99.99% 約 4.3 分鐘。串聯可用性相乘:0.9993 ≈ 0.997,即約 99.7%,每月約 2.2 小時停機。所以關鍵路徑上的依賴越少越好,非關鍵依賴要能降級(失敗時回預設值而不是整個請求失敗)。

3. SDK 事件報表系統中,重複事件可能從哪些地方產生?你如何去重?

來源:SDK 重送(沒收到回應就重試)、collector 寫入 Kafka 成功但回應前崩潰、consumer 處理完但 commit offset 前崩潰。做法:client 端產生 event_id(UUID),重送時不變;串流層以 event_id 為 key 的 keyed state + TTL 去重(或 Bloom filter 節省空間);批次層對每日分區做精確 distinct;sink 以聚合 key upsert 讓重複輸出冪等。整體是 at-least-once 傳遞 + 冪等處理。

4. 什麼是 watermark?如何處理離線好幾天才上傳的手機事件?

watermark 是串流處理器對「event time 進度」的估計:watermark = W 表示認為 event time 早於 W 的事件已到齊,可以關閉並輸出結束時間早於 W 的視窗。常見設定為「看過的最大 event time 減去容許延遲」。稍晚的事件可透過 allowed lateness 更新已輸出的結果;超過容許範圍的事件走 side output 寫入 S3,由每日批次重算校正對應日期的報表。另外要過濾時間明顯錯誤的事件,避免把 watermark 推到未來。

5. 為什麼 unique users 不能直接把每天的數字相加?HyperLogLog 能解決哪些、不能解決哪些?

同一個人可能在多天出現,相加會重複計算。HLL 用固定大小(Redis 實作每個 key 約 12 KB)估算基數,標準誤差約 0.81%,且可以合併(聯集),所以可以存每日 HLL、查詢任意日期區間時合併,得到區間 UU。不能做的:相減(無法算「新使用者」)、可靠的交集(A 與 B 受眾重疊)、精確值(計費)。這些需要 bitmap(如 Roaring)或批次精確計算。

6. 為什麼 collector 要等 Kafka ack 才回 202?如果 Kafka 不可用怎麼辦?

202 代表「資料已持久化」,SDK 收到後就會刪除本地資料;若在寫入 Kafka 前就回 202,collector 崩潰就會遺失資料。使用 acks=all + min.insync.replicas=2 確保多副本持久化。Kafka 不可用時回 503,讓 SDK 依退避策略重試(SDK 有持久化佇列,不會遺失);或 collector 寫本地 spool 檔稍後補送,但要考慮容器磁碟的生命週期。

7. RTB bidder 的 100ms 延遲預算你會怎麼分配?有哪些關鍵手段?

扣除與 exchange 之間的網路往返(數十 ms),bidder 內部只剩數十 ms:解析與前置過濾約 1ms、平行查詢 user profile 與頻次 2–5ms、記憶體倒排索引召回約 1ms、批次模型推論 5–10ms、出價計算約 1ms,其餘為尾延遲餘裕。手段:整體 deadline 傳遞、下游以剩餘時間當 timeout、平行化、能放記憶體就不遠端查、限制候選數、減少 GC,超時直接回 no-bid。

8. 上百台 bidder 同時為同一 campaign 出價,如何避免嚴重超支又不讓中央服務成為瓶頸?

預算租約:budget service 用原子操作(Redis Lua)把剩餘預算切成小額度發給各 bidder,bidder 本地扣減,用完再申請,預算愈少發得愈小;實際花費以 win notice 事件經串流累加,作為 ledger 的 source of truth;預算耗盡時廣播停止。超支上限約為所有未結清額度加在途出價,可透過額度大小控制。再搭配 pacing(依計畫花費曲線調整出價機率)避免太早花完。

9. 用 Redis 實作 frequency capping,讀寫時機為何?Redis 故障時怎麼辦?

每個 user 一個 Hash(field = campaign:日期),bid 時一次 HMGET 取回候選 campaign 的計數並過濾;曝光事件到達時才 HINCRBY 並設 TTL(出價不等於曝光)。這會有短暫時間差,可能超出上限一兩次,通常可接受。Redis 故障:依 campaign 設定 fail-open(照常出價,可能過度曝光)或 fail-closed(不出價),並告警;也可把計數與 user profile 存在同一個 KV 以減少依賴。

10. Chatbot 平台的 webhook 為什麼要立即回 200?如何保證同一對話的訊息順序?

渠道平台對 webhook 回應有時限,逾時會重送甚至停用 webhook;LLM 回應需要數秒,不能在 webhook 請求內同步處理。gateway 只驗簽、正規化、寫入 Kafka 後立即回 200。順序:以 conversation_id 為 partition key,同對話進同 partition;worker 端用 keyed executor 讓「不同對話並行、同對話序列」,避免 head-of-line blocking,offset 只 commit 到最小未完成的位置;出站同樣依對話分區並帶 seq。

11. 如何控制 chatbot 的 LLM 成本?

分層路由(按鈕/規則 → 小模型意圖辨識 → 必要時才用 LLM);FAQ 類問題做回答快取或語意快取;精簡 prompt、用滾動摘要取代完整歷史;利用 provider 的 prompt caching;依問題難度選擇模型大小;debounce 合併連續訊息;每租戶配額與全域併發上限;監控每租戶 token 用量並計費。

12. 大量推播時,如何避免同一使用者收到重複訊息?

三道防線:(1) API 層 Idempotency-Key,防止呼叫端重試建立重複任務;(2) 系統內以 (campaign_id, user_id) 為唯一鍵,發送前以條件寫入標記 SENDING,已存在則跳過;(3) 對上游使用冪等機制,例如 LINE Messaging API 的 X-Line-Retry-Key,在 batch 建立時產生並持久化,逾時重試時沿用同一個 key,上游回 409 代表已接受過、視為成功。並以名單快照確保重跑時對象不變。

13. 上游 API 有速率限制,而你有 50 台 sender worker,怎麼做全域限流?收到 429 怎麼辦?

用 Redis + Lua 的 token bucket 作為集中式限流器,每次發送前取 token(原子操作,用 Redis TIME 避免時鐘差異);或由協調者把總速率平均分配給各 worker 做本地限流,worker 數變動時重新分配(延遲更低)。收到 429 時尊重 Retry-After,並以 AIMD 降低速率(減半後緩慢回升);交易型訊息與行銷訊息分開佇列或保留額度,避免互相影響。

14. 合併兩個 List[dict](依 id),資料量到上億筆時怎麼辦?

小規模:以 id 建 dict,O(n+m) 合併,先定義衝突規則(較新者勝、非空優先、多值聯集)。上億筆:核心是「相同 id 落在同一個處理單元」。單機可用 hash 分桶(穩定 hash 如 crc32,不可用 Python 內建 hash)後逐桶合併,或外部排序後雙指標 merge join;分散式用 Spark 依 id shuffle(groupBy/join),注意資料傾斜(清理無效 id、salting、AQE)與只處理增量。若需線上查詢,結果 bulk load 到 KV。

15. 身分解析中的「超級節點」是什麼?為什麼錯誤合併比漏合併更危險?

超級節點是被大量使用者共用的識別碼(網咖電腦的裝置 ID、test@test.com、預設電話號碼),在 identity graph 中會把成千上萬人串成同一個連通分量。對策:度數上限、黑名單、只信任可靠類型的邊。錯誤合併會把 A 的個資、訂單、推播發給 B,造成隱私事故與客訴,而且拆分(unmerge)困難;漏合併只是少了一些個人化。所以預設採確定性規則,並保存原始邊以便重算與拆分。

16. 比較 token bucket 與 sliding window counter,並說明為何分散式限流要用 Lua。

Token bucket 以固定速率補充 token,允許最多 bucket 容量的突發,長期平均速率受控,適合 API 限流與上游配額;sliding window counter 以「前一窗計數 × 重疊比例 + 本窗計數」近似滑動視窗,省記憶體、無固定視窗的邊界突刺問題,但為近似值。分散式時「讀取狀態 → 計算 → 寫回」若分成多個命令,多台 gateway 會讀到相同狀態而超賣;Redis 執行 Lua 腳本期間不會穿插其他命令,因此整段是原子的。

17. Snowflake ID 的結構?時鐘回撥會造成什麼問題?

64 bit:1 bit 符號、41 bit 毫秒時間戳(約 69 年)、10 bit 機器 ID(1,024 個)、12 bit 序號(每毫秒每機器 4,096 個)。ID 依時間大致遞增,對 B-tree 索引友善。時鐘回撥時,新產生的時間戳可能與之前相同或更小,加上序號重置就可能產生重複 ID。對策:偵測到回撥時等待追上(小幅)或拒絕產生(大幅)並告警、NTP 設定只做緩慢調整、備用 worker id。

18. 什麼是 cache stampede、avalanche、penetration?各自如何防範?

Stampede(擊穿):熱門 key 過期瞬間大量請求同時回源 → single-flight/互斥鎖只讓一個請求回源、提前非同步刷新。Avalanche(雪崩):大量 key 同時過期或快取叢集故障 → TTL 加 jitter、多層快取、DB 前限流與熔斷。Penetration(穿透):查詢不存在的資料每次都打 DB → 快取空值(短 TTL)、Bloom filter 預先過濾。

19. 報表系統中,串流(Flink)與批次(Spark)為什麼常常同時存在?缺點是什麼?

串流提供分鐘級新鮮度但受遲到資料與故障影響;批次在資料到齊後從 S3 原始資料精確重算,作為最終與計費依據,也方便回補與修 bug。缺點是兩套邏輯可能不一致、維護成本高;緩解方式是共用 SQL/函式庫,或在 Kafka 保留期足夠長、串流作業穩定時改採 Kappa 架構(只用串流,重算時重播)。

20. 系統設計面試前 5 分鐘你會做什麼?請示範一段開場。

宣告流程並釐清需求。示範:「我想先花幾分鐘確認需求與規模,再估算、定 API 和資料模型、畫架構,最後深入兩三個瓶頸。先確認功能:報表需要哪些維度和指標?要即時還是隔日?是否多租戶?非功能:每天大約多少事件?unique users 可以接受近似嗎?資料保留多久?如果沒有特定數字,我先假設每天 10 億事件、報表 5 分鐘延遲、UU 可接受 1% 誤差,您看這樣合理嗎?」把假設寫在白板左上角,後續設計都引用它。