Stream Joins & Fault Tolerance — Join và xử lý lỗi stream (DDIA)

Phong

Mở đầu

Ảnh: Tom Fisk — Pexels

Trong chapter trước của DDIA, các bạn đã thấy cách stream processing xử lý dữ liệu unbounded — không giới hạn thời gian. Nhưng khi bạn cần kết hợp (join) nhiều stream với nhau, hoặc đảm bảo dù hệ thống có crash thì dữ liệu vẫn không mất — đó là lúc Stream Joins và Fault Tolerance vào cuộc.

Phần 11.2 của DDIA đi sâu vào 2 chủ đề quan trọng này: làm sao để join dữ liệu đang chảy không ngừng, và làm sao để hệ thống streaming có thể chịu lỗi mà vẫn đảm bảo kết quả đúng đắn.

Ba kiểu Stream Join

Ảnh: Engin Akyurt — Pexels

Khác với join trong cơ sở dữ liệu truyền thống — nơi bạn có đầy đủ dữ liệu ở cả 2 bảng — stream join phải xử lý dữ liệu vô tận. DDIA phân loại thành 3 kiểu:

Stream-Stream Join (Window Join)

Cả 2 vế của join đều là stream đang chảy không ngừng. Bạn cần xác định một "window" (cửa sổ thời gian) để ghép các event liên quan với nhau. Ví dụ kinh điển: join clickstream với purchase stream — một user click vào quảng cáo, rồi 5 phút sau mua hàng. Cả 2 event đến ở các thời điểm khác nhau, bạn cần một sliding window 5 phút để kết nối chúng.

Điều này đặt ra câu hỏi: nếu event mua hàng đến sau 10 phút thay vì 5 phút thì sao? Window quá nhỏ thì mất dữ liệu, quá lớn thì tốn bộ nhớ và latency cao. Đó là lý do watermark và grace period ra đời — cho phép window "chờ" thêm một khoảng thời gian trước khi đóng.

Stream-Table Join (Enrichment)

Một vế là stream, vế kia là table (có thể tĩnh hoặc thay đổi chậm). Ví dụ: activity stream của user cần được enrich với thông tin profile từ database. Mỗi event trong stream được lookup thông tin từ table và ghép vào trước khi gửi đi.

Mô hình này cực kỳ phổ biến trong Kafka Streams — gọi là KStream-KTable join. Table được maintain dưới dạng changelog topic (compacted), đảm bảo có thể rebuild từ đầu nếu cần.

Table-Table Join (Materialized View)

Cả 2 vế đều là table đang thay đổi theo thời gian (CDC stream). Kết quả là một materialized view được cập nhật liên tục. Ví dụ: join bảng orders với bảng shipments. Mỗi khi có order mới hoặc shipment mới, view tự động cập nhật — giống như một database view nhưng được maintain bởi stream processor.

Thách thức khi join Stream

Ảnh: Petr Ganaj — Pexels

Join stream không đơn giản như join table trong SQL — vì dữ liệu không có điểm kết thúc. Các thách thức điển hình:

Out-of-order events: Event đến không đúng thứ tự — event A xảy ra trước event B nhưng B lại đến trước A. Window join cần xử lý được trường hợp này, thường bằng grace period để chờ event đến muộn.

State management: Để join được, hệ thống phải lưu trữ state — tạm thời giữ event trong window cho đến khi event ghép cặp xuất hiện. Nếu window quá lớn (ví dụ 1 giờ với hàng triệu event/giây), bạn cần distributed state store như RocksDB hoặc Redis.

Watermark: Cơ chế đánh dấu "đã nhận đủ dữ liệu trong window này". Nếu watermark vượt qua điểm cuối của window, hệ thống đóng window và emit kết quả. Chọn watermark quá nhanh thì mất event đến muộn, quá chậm thì latency cao.

Fault Tolerance trong Stream Processing

Đây là phần cốt lõi — stream processing chạy 24/7, không thể restart từ đầu mỗi lần crash.

Micro-batching (Spark Streaming): Dữ liệu được chia thành các batch nhỏ (~1-5 giây). Mỗi batch xử lý như một job riêng — nếu fail, chỉ cần recompute batch đó. Đánh đổi: latency cao hơn so với true streaming, và khó xử lý window chính xác.

Checkpointing + State Snapshots (Flink, Kafka Streams): Hệ thống chụp snapshot state định kỳ (ví dụ mỗi 30 giây). Khi crash, restore từ snapshot gần nhất và replay dữ liệu từ đó. Flink gọi đây là "consistent checkpoints" — cho phép exactly-once semantics ngay cả khi xử lý unbounded data.

Exactly-once Semantics: Đây là mục tiêu cuối cùng — mỗi event được xử lý đúng 1 lần, không thiếu không thừa. Thực tế, hầu hết hệ thống đạt được "effectively-once" thông qua:
- Idempotent writes: ghi đè kết quả nếu event được xử lý lại
- Transactional state stores: atomic commit kết quả + offset
- Changelog topics: ghi mọi thay đổi vào compacted topic, có thể rebuild state từ đó

Key Takeaways

  • Có 3 kiểu stream join trong DDIA: stream-stream (window), stream-table (enrichment), table-table (materialized view) — mỗi kiểu phù hợp một use case riêng
  • Out-of-order events và state management là 2 thách thức lớn nhất khi implement stream join
  • Fault tolerance dựa trên checkpointing, replay, và idempotency — không có giải pháp "one size fits all"
  • Exactly-once semantics thực tế là "effectively-once" — dùng idempotent writes kết hợp transactional state store

Kết

Stream join và fault tolerance là 2 mảnh ghép không thể thiếu trong bất kỳ hệ thống streaming nào — từ Kafka Streams, Apache Flink, cho đến Spark Streaming. Hiểu rõ các kiểu join và cơ chế đảm bảo chính xác sẽ giúp bạn thiết kế data pipeline vừa nhanh vừa đáng tin cậy.

Ở chapter tiếp theo, DDIA sẽ bàn về phân tích dữ liệu ở quy mô lớn với batch processing (MapReduce và các hệ thống tương tự) — một chủ đề cũng không kém phần thú vị. Hẹn gặp lại các bạn ở bài sau!

Glossary

Stream-Stream JoinJoin 2 stream dựa trên time window — cả 2 vế đều là dữ liệu unbounded
Stream-Table JoinEnrich stream với dữ liệu từ table (tĩnh hoặc CDC) — KStream-KTable trong Kafka Streams
WatermarkĐiểm đánh dấu "đã nhận đủ dữ liệu" trong window trước khi đóng và emit kết quả
CheckpointingChụp snapshot state định kỳ để recover khi crash — được dùng bởi Flink, Kafka Streams
Exactly-onceMỗi event được xử lý đúng 1 lần — thực tế là effectively-once nhờ idempotency
Grace PeriodKhoảng thời gian chờ thêm sau khi window kết thúc để nhận event đến muộn