From Homegrown to Flink: Migrating a Stateful Ad Event Join at Scale

Bài tóm tắt Zalando 2026 4. Điều chỉnh Độ khó: Nâng cao

Zalando · 2026 · 4. Điều chỉnh · Độ khó: Nâng cao — bài gốc

Bối cảnh

  • Mảng quảng cáo (Sponsored Products) phải ghép mỗi lượt xem hoặc click với phiên đấu giá gốc trong cửa sổ 15 phút để tính tiền. Event đến sau 15 phút bị bỏ, nên tỷ lệ ghép là chỉ số sống còn.
  • Giải pháp tự viết từ hơn 7 năm trước (từ startup ad-tech mà Zalando mua lại): ứng dụng Java phân tán, đọc Kinesis và Nakadi (lớp trên Kafka), giữ bid event trong cache bộ nhớ; tải tới khoảng 200 MB/s.
  • Hai giới hạn:
    • không chia partition gọn theo cả hai stream được, nên mỗi pod phải đọc toàn bộ interaction event;
    • không có checkpoint, nên mỗi lần deploy hay rescale mất tới 15 phút event đang đệm. Hệ quả là họ cấp dư pod để tránh rescale.

Thay đổi chính

  • Chọn Flink:
    • Nakadi SQL bị loại vì PoC chỉ ghép được khoảng 50%;
    • Spark bị loại vì các team khác báo Flink tốt hơn về latency và autoscale;
    • rủi ro: Flink khi đó mới ở mức TRIAL trên Tech Radar nội bộ và team ít kinh nghiệm. Có guild Flink nội bộ và nhiều team đã dùng production nên họ chấp nhận.
  • Kiến trúc: RocksDB state backend, checkpoint tăng dần mỗi 3 phút; đệm cả hai stream nên ghép được cả interaction đến trước bid, tăng tỷ lệ ghép khoảng 0,5%.
  • Những gì thực sự xảy ra:
    • Flink managed của AWS dễ bắt đầu nhưng thiếu linh hoạt (chỉ autoscale theo CPU, chỉ Flink v1). Họ chuyển sang tự chạy trên Kubernetes với template nội bộ.
    • Interval join tốn 15–30% CPU chỉ để seek RocksDB (mỗi phần tử một lần seek, hàng đợi timer khoảng 70 triệu mục). Họ viết join riêng bằng KeyedCoProcessFunction với point lookup và TTL bằng compaction. Riêng operator join giảm từ 30–60 xuống 10 core, từ 150–300 GB xuống 50 GB RAM.
    • Không còn timer nên state phình; phải tinh chỉnh RocksDB (buffer, compaction, dọn sau rescale, nén).
    • Connector Kinesis không đọc được event gộp theo Protobuf nên phải tự viết. Hai consumer cùng đọc một stream bị lỗi throughput cho tới khi bật Enhanced Fan-Out. Cuối cùng chuyển stream sang Nakadi.
    • Karpenter evict pod trong lúc Flink đang tự rescale; phải chặn bằng annotation hoặc PodDisruptionBudget.
    • OOM do kernel giết (không phải Java OOM): RocksDB dùng native memory, và cấu hình auto-tuning gợi ý là sai. Họ tăng pod từ 16 lên 20 GB.
  • Migration:
    • dựng shadow pipeline đầy đủ (stream và bảng riêng), chạy 4 tuần;
    • so tỷ lệ làm giàu event (khớp 99,9%, ngưỡng 99–101%), số liệu theo campaign theo ngày, latency trong cửa sổ 15 phút, tỷ lệ trùng khoảng 0,2%;
    • sau đó A/B test một tuần theo kết quả campaign, rồi chuyển bằng một thay đổi config.

Kết quả (theo Zalando)

Nhờ checkpoint, autoscale an toàn: trung bình từ 20 pod cố định xuống khoảng 5 (dao động 1–8). CPU từ 30 xuống khoảng 20 core, RAM từ 320 xuống khoảng 100 GB, chi phí EC2 từ khoảng 80 xuống khoảng 30 € mỗi ngày.

Bài học cho SWE

  • Thiếu checkpoint buộc phải cấp dư: khi rescale làm mất trạng thái thì không ai dám rescale. Thêm khả năng phục hồi trạng thái mới làm autoscale và tiết kiệm chi phí khả thi (Chi phí hạ tầng và build vs buy).
  • Toán tử cấp cao của framework có thể sai về thuật toán cho workload của mình. Flame graph chỉ ra được, và hạ xuống API cấp thấp thì giải quyết được.
  • Hai autoscaler (Kubernetes và Flink) cùng điều khiển một thứ sẽ đá nhau.
  • Migration stream có trạng thái: shadow pipeline với output riêng, tiêu chí so sánh định lượng, rồi A/B theo kết quả kinh doanh, cùng tinh thần parallel run của chính Zalando (Zalando 2021, Migration không downtime).

Liên quan