Hệ thống Dữ liệu Phái sinh/Stream join & exactly-once — ghép dòng, đảm bảo không trùng
12/21
Bài 12 / 21~13 phútXử lý stream (stream processing)Miễn phí lượt xem

Stream join & exactly-once — ghép dòng, đảm bảo không trùng

Ghép hai stream hoặc stream với bảng, và vì sao 'xử lý đúng một lần' khó: đảm bảo exactly-once qua idempotent + atomic commit, cùng fault tolerance của stream.

TL;DR: Stream join ghép hai topic thành kết quả mới; join hai stream bắt buộc có window để tránh state tích lũy vô hạn, còn join stream với KTable (bảng changelog) thì không cần. Exactly-once là thách thức lớn nhất trong stream — at-least-once mặc định tái gửi khi lỗi, gây đếm trùng. Kafka giải quyết bằng hai tầng: idempotent producer (producer ID + sequence number, dedup tại broker, từ Kafka 0.11.0, overhead ~0%) và transaction (atomic write nhiều partition + commit offset trong một transaction, overhead ~3% so với at-least-once). Pitfall kinh điển: bật enable.idempotence=true tưởng đủ exactly-once — nhưng chỉ bảo vệ phía ghi, toàn pipeline cần thêm transaction hoặc consumer idempotent.

Mỗi ngày một sàn thương mại điện tử xử lý triệu sự kiện từ hai dòng song song: orders (đơn đặt hàng) và payments (xác nhận thanh toán). Team muốn ghép hai stream này để ghi nhận "đơn thanh toán thành công" — nhưng gặp ngay hai bài toán: (1) payment và order đến không đồng bộ, lệch vài giây đến vài phút; (2) khi consumer crash rồi tái gửi, pipeline cộng doanh thu hai lần.

Bài này giải thích stream join (và vì sao ghép hai stream bắt buộc phải có cửa sổ thời gian), rồi đi vào exactly-once — hai tầng bảo vệ của Kafka để pipeline tính đúng dù lỗi tái gửi hay crash giữa chừng.

1. Analogy — hai băng chuyền song song

Hình dung hai băng chuyền chạy song song trong nhà kho: băng A mang kiện hàng từ kho lẻ, băng B mang phiếu thanh toán từ quầy thu ngân. Nhân viên ghép đơn cần khớp mỗi kiện với phiếu tương ứng — nhưng hai băng không đồng tốc, đôi khi kiện hàng tới trước phiếu vài phút.

Cách duy nhất để ghép: giữ kiện và phiếu trên bàn chờ trong một khoảng thời gian nhất định (ví dụ 10 phút). Không đặt giới hạn thời gian thì bàn chờ tích lũy mãi — nhà kho hết chỗ. Giới hạn quá ngắn thì phiếu tới trễ 11 phút bị bỏ sót. Đây chính xác là bài toán stream-stream windowed join.

Nhà khoStream processing
Băng chuyền A (kiện hàng)Stream A
Băng chuyền B (phiếu thanh toán)Stream B
Bàn chờ có giới hạn thời gianState buffer với window
Thời gian chờ tối đa 10 phútWindow size
Ghép được: kiện + phiếu → xuất khoMatch: emit kết quả join
Hết giờ chờ, không ghép đượcEvent nằm ngoài window → miss
💡 Cách nhớ

Window trong stream join = giới hạn "bàn chờ". Không có window = bàn chờ vô hạn = state vô hạn.

2. Stream join — stream-stream windowed join

Trong Kafka Streams, join hai KStream (KStream-KStream) yêu cầu chỉ định một window. Cả hai bên được buffer trong cùng window; một event ở stream A khớp với event ở stream B nếu key bằng nhau và timestamp nằm trong khoảng window.

flowchart LR
  SA["Stream A: orders"] --> BUFA["Buffer A (10min)"]
  SB["Stream B: payments"] --> BUFB["Buffer B (10min)"]
  BUFA --> JOIN["Windowed Join<br/>(same key, same window)"]
  BUFB --> JOIN
  JOIN -->|"khop"| OUT["paid-orders"]
  JOIN -->|"het window, khong khop"| MISS["miss / late event"]
# Stream-stream windowed join
join(stream_orders, stream_payments, key=order_id, window=10min):

    for each order o at time t:
        payments <- buffer_B.get(key=o.order_id, range=[t-10min, t+10min])
        for each payment p in payments:
            emit(merge(o, p))         -- khop: ghi ra paid-orders

    -- symmetric: payment cung trigger check voi buffer A
    for each payment p at time t_prime:
        orders <- buffer_A.get(key=p.order_id, range=[t_prime-10min, t_prime+10min])
        for each order o in orders:
            emit(merge(o, p))

Kafka Streams tự quản lý state store cho buffer A và buffer B (dùng RocksDB). Mỗi bên giữ event chưa ghép trong khoảng window; event cũ hơn window tự động bị evict.

3. Vì sao stream-stream join bắt buộc phải có window?

Câu hỏi tưởng đơn giản: tại sao không join mà không có window — giữ toàn bộ lịch sử và khớp khi có dữ liệu mới?

Vấn đề là state không giới hạn. Mỗi event chưa khớp phải được giữ để chờ đối tác. Không có window, mọi event từ lúc khởi động pipeline đến nay đều cần lưu — state tăng tuyến tính theo thời gian chạy:

State voi window=10min (moi ben -> span ~20 phut): giu event trong [t-10min, t+10min]
    -- rate 1000 events/s moi stream: max state ~ 1.2 trieu event (van quan ly duoc)

State khong co window: giu tu luc bat dau
    -- sau 1 ngay: ~ 86.4 trieu events
    -- sau 1 tuan: ~ 604 trieu events --> OOM hoac disk full

Window giới hạn state: event cũ hơn window bị evict khỏi state store. Pipeline chạy mãi mà không rò rỉ bộ nhớ.

⚠️ Kích thước window ảnh hưởng đến miss rate

Window lớn: nhiều match hơn, nhưng state lớn và kết quả ra chậm. Window nhỏ: state nhỏ, kết quả sớm, nhưng event chậm vài phút là miss. Chọn window size dựa trên SLA nguồn dữ liệu.

Thử xác định loại join — trước khi đọc đáp án

Hai tình huống:

A. Join stream click_events với topic user_profiles (mỗi user có một profile, cập nhật không thường xuyên). Loại join nào?

B. Join stream impressions (lần quảng cáo hiển thị) với stream clicks (lần user click) để tính tỉ lệ click — cả hai stream đến liên tục, cần khớp trong 5 phút.

Ghi câu trả lời trước khi đọc phần tiếp theo.

Đáp án:

A → stream-table join (KTable): user_profiles là bảng trạng thái — key là user_id, giá trị là profile mới nhất. Không cần window vì bạn muốn khớp click với profile hiện tại của user, không phải profile trong vài phút trước.

B → stream-stream windowed join: cả impressionsclicks đều là event unbounded. Cần window 5 phút để khớp impression với click xảy ra trong cùng khoảng thời gian.

4. Stream-table join — KTable changelog không cần window

KTable là abstraction của Kafka Streams biểu diễn bảng trạng thái: topic compacted, mỗi key giữ giá trị mới nhất. Join một KStream với KTable là non-windowed lookup — với mỗi event từ stream, tìm giá trị hiện tại của key trong KTable.

# Stream-table join (non-windowed)
join(stream_clicks, kTable_profiles, key=user_id):

    for each click c at time t:
        profile <- kTable.get(c.user_id)    -- lookup gia tri HIEN TAI, khong window
        if profile != null:
            emit(merge(c, profile))

GlobalKTable là biến thể: mỗi instance ứng dụng giữ bản sao đầy đủ của bảng, loại bỏ yêu cầu co-partition với stream. Dùng khi bảng nhỏ và cần join với stream ở bất kỳ partition nào.

Loại joinWindowDùng khi
KStream-KStreamBắt buộcGhép hai dòng sự kiện, khớp trong khoảng thời gian
KStream-KTableKhông cầnLookup giá trị mới nhất của key theo stream
KStream-GlobalKTableKhông cầnNhư KTable nhưng không cần co-partition

5. Ba mức delivery — at-most-once, at-least-once, exactly-once là gì?

Khi producer gửi message và broker crash trước khi gửi ACK, producer không biết message đã được ghi hay chưa. Cách xử lý tình huống này tạo ra ba mức bảo đảm:

MứcCơ chếKết quả khi lỗi
at-most-onceGửi một lần, không retryCó thể mất message (0 hoặc 1 lần)
at-least-onceRetry cho đến khi có ACKCó thể ghi trùng (1 hoặc nhiều lần)
exactly-onceIdempotent + transactionĐúng 1 lần dù có lỗi

at-least-once là mặc định Kafka (acks=all). Nguồn trùng lặp chính trong pipeline hiện đại là vòng consume-process-produce: consumer đọc event, xử lý, ghi kết quả — rồi crash trước khi commit offset. Khi restart, nó đọc lại event đó từ offset cũ, xử lý và ghi kết quả lần haiđếm trùng. (Trước Kafka 3.0, producer retry cũng có thể ghi lặp; từ 3.0 idempotent producer bật mặc định nên chặn lặp phía ghi trong cùng session — nhưng không chặn kịch bản reprocess ở trên.)

at-most-once (acks=0 hoặc không retry): gửi một phát, mất ACK thì mất luôn. Chấp nhận được cho log/metrics không quan trọng, không chấp nhận được cho billing.

Exactly-once phải giải quyết hai mặt: phía ghi (producer không tạo duplicate) và phía đọc–ghi–commit (consumer reprocess không cộng đôi). Kafka giải quyết bằng hai tầng riêng.

6. Exactly-once tầng 1 — idempotent producer

Từ Kafka 0.11.0 (2017), khi bật enable.idempotence=true, producer nhận một producer ID (PID) duy nhất từ broker khi khởi động. Mỗi message gửi đi kèm sequence number tăng đơn điệu theo từng partition.

sequenceDiagram
  participant P as Producer
  participant B as Broker
  P->>B: send(pid=42, seq=0, msg)
  B-->>P: ACK (mat tren mang)
  Note over P: Khong nhan ACK -- retry
  P->>B: retry: send(pid=42, seq=0, msg)
  B-->>P: ACK (seq=0 da co -- bo qua duplicate)
  Note over B: msg ghi dung 1 lan trong log
# Idempotent producer: dedup tai broker
producer.init():
    pid <- broker.allocatePID()    -- PID duy nhat cho session nay
    seq[partition] <- 0            -- sequence number theo partition

producer.send(msg, partition):
    msg.pid = pid
    msg.seq = seq[partition]
    seq[partition] <- seq[partition] + 1
    broker.append(msg)

# Broker logic khi nhan message:
if (pid, partition, seq) da co trong log:
    drop(msg)       -- dedup: bo qua, gui ACK lai
else:
    append(msg)     -- ghi moi, gui ACK

Overhead: ~0% — chỉ thêm vài byte int per message, không round-trip thêm.

Thử suy luận — trước khi đọc đáp án

Producer crash hoàn toàn (process die, khởi động lại). PID mới được cấp, sequence number reset về 0. Message cũ (PID cũ, seq cũ) đã nằm trong topic.

Idempotent producer có bảo vệ được pipeline trong tình huống này không?

Đáp án: Không. Idempotent producer chỉ dedup trong cùng một producer session (cùng PID). Khi process khởi động lại, PID mới được cấp — broker không biết message cũ là duplicate của session trước. Đây là giới hạn của idempotent producer: loại trùng do retry, không loại trùng do reprocess sau crash — đó là nhiệm vụ của transaction.

7. Exactly-once tầng 2 — Kafka transaction

Idempotent producer bảo vệ phía ghi (broker không nhận duplicate trong cùng session). Nhưng một pipeline stream điển hình cần làm ba việc nguyên tử: đọc từ input topic, ghi kết quả ra output topic, và commit offset của input. Nếu crash sau khi ghi output nhưng trước khi commit offset, consumer poll lại từ offset cũ và reprocess → ghi output lần hai.

Kafka transaction (từ 0.11.0) gói ba việc này vào một atomic transaction:

# Kafka transaction: atomic write + offset commit
producer.initTransactions(transactional_id="billing-eos-1")
    -- transactional_id: dinh danh ben vung qua cac lan restart

loop:
    records <- consumer.poll()
    producer.beginTransaction()

    for r in records:
        result <- process(r)
        producer.send("invoices", result)     -- ghi output

    producer.sendOffsetsToTransaction(consumer.position(), "billing-group")
    producer.commitTransaction()              -- atomic: ghi output + commit offset cung luc
    -- neu crash truoc commit: toan bo transaction abort
    -- consumer poll lai tu offset cu -> re-process -> cung ket qua
Hoàn thiện pseudocode — tự điền trước khi đọc

Hai dòng ??? cần được thay bằng gì để offset commit và write được atomic? Gợi ý: offset commit phải nằm trong transaction, và phải gọi trước khi commit.

producer.initTransactions("order-eos-1")

for records in consumer.poll():
    producer.beginTransaction()
    for r in records:
        producer.send("order-summary", transform(r))
    producer.???         -- commit offset vao trong transaction
    producer.???         -- commit toan bo transaction

Đáp án:

    producer.sendOffsetsToTransaction(consumer.position(), "order-group")
    producer.commitTransaction()

Thứ tự quan trọng: sendOffsetsToTransaction gắn offset commit vào bên trong transaction đang mở — không commit offset riêng lẻ ngoài transaction. commitTransaction() ghi atomic cả hai.

Consumer phía đọc output cần bật isolation.level=read_committed để chỉ thấy message từ transaction đã commit — message từ transaction chưa hoàn tất hoặc đã abort không hiển thị.

Overhead: ~3% so với at-least-once (đo với commit 100ms, message 1KB). Overhead nhỏ nhất khi commit interval lớn (vài giây) và batch lớn — chi phí round-trip tới Transaction Coordinator được amortize qua nhiều message.

8. Pitfall tổng hợp

Pitfall 1 — nhầm at-least-once là exactly-once

Pipeline mặc định Kafka là at-least-once. Consumer crash và reprocess → ghi output thêm lần nữa → double-count.

❌ SAI -- at-least-once tưởng là "ổn định":
   consumer crash -> poll lai tu offset cu -> process(r)
   -> send("invoices", result)  -- broker nhan them 1 ban sao
   -> billing dem 2 don hang

✅ ĐÚNG -- exactly-once qua transaction:
   producer.beginTransaction()
   send("invoices", result)
   sendOffsetsToTransaction(offset, group)
   commitTransaction()           -- abort -> re-process -> chi 1 ban sao
Pitfall 2 — bật enable.idempotence=true tưởng đủ exactly-once

enable.idempotence=true loại trùng do retry trong cùng producer session. Nó không loại trùng khi:

  • Consumer reprocess sau crash (đọc lại offset cũ → ghi output thêm lần nữa với PID mới).
  • Pipeline ghi nhiều output topic: ghi topic A xong crash trước khi ghi topic B → kết quả không nhất quán.

Exactly-once đầy đủ cần idempotent producer transaction (gói ghi output + offset commit thành nguyên tử).

9. 📚 Deep Dive

📚 Deep Dive — tài liệu gốc

Kafka Design — Delivery Semantics & Transactions:

  • Apache Kafka Design (v4.3) — mục "Message Delivery Semantics", "The Producer", "Transactions" — tài liệu chuẩn cho idempotent producer (producer ID + sequence number từ Kafka 0.11.0) và transactional producer (Transaction Coordinator, atomic write + offset commit).
  • Kafka Streams DSL — Joins — spec chính thức: KStream-KStream (bắt buộc window), KStream-KTable (non-windowed), GlobalKTable.

Flink end-to-end exactly-once:

  • An Overview of End-to-End Exactly-Once Processing in Apache Flink (Flink Blog, 2018) — Flink inject checkpoint barrier vào stream (biến thể Chandy-Lamport); khi barrier đi qua hết operator, state được snapshot. Sink dùng TwoPhaseCommitSinkFunction (Flink 1.4): pre-commit Kafka transaction khi barrier tới, commit transaction khi checkpoint hoàn tất. Checkpoint failure → abort pending transaction → restore từ checkpoint → reprocess.

Ghi chú: Flink EOS mạnh hơn Kafka transaction thuần vì bảo vệ cả state của stateful operator (window aggregation, join state store). Kafka transaction bảo vệ write-read atomicity nhưng không snapshot operator state — nếu operator crash, nó mất state giữa chừng trừ khi kết hợp với checkpoint.

10. Liên hệ các bài khác

  • Bài 03 — Windowing: window trong stream join đến thẳng từ khái niệm windowing ở bài trước — tumbling/hopping/session window đều có thể làm join window; event-time + watermark quyết định khi nào window đóng và emit kết quả join.
  • Bài 05 — Thiết kế stream pipeline: lab tổng hợp nơi bạn tự dựng pipeline ôm join + exactly-once và chọn window size, delivery guarantee phù hợp với SLA.
  • Bài 02 — Log-based broker: idempotent producer và transaction xây trên cơ sở append-only log của Kafka — offset, partition, consumer group đều là nền cho cả hai cơ chế EOS.

11. Tóm tắt

  • Stream-stream join bắt buộc phải có window để giới hạn state; không có window = buffer vô hạn = OOM theo thời gian chạy.
  • Stream-table join (KStream-KTable) non-windowed: KTable biểu diễn giá trị mới nhất mỗi key (changelog compacted), lookup tại thời điểm event tới.
  • Ba mức delivery: at-most-once (có thể mất), at-least-once (có thể trùng — mặc định Kafka), exactly-once (không mất, không trùng — cần cơ chế thêm).
  • Idempotent producer (Kafka 0.11.0): producer ID + sequence number, broker dedup tự động, overhead ~0%. Chỉ bảo vệ phía ghi trong cùng session.
  • Kafka transaction: atomic write nhiều partition + commit offset trong một transaction, overhead ~3%. Cần isolation.level=read_committed phía consumer đọc output.
  • Pitfall lớn nhất: bật enable.idempotence=true tưởng đủ exactly-once — idempotent producer không bảo vệ reprocess sau crash. Exactly-once đầy đủ cần transaction.

12. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao stream-stream join bắt buộc phải có window, trong khi stream-table join thì không?

Stream-stream join: cả hai bên đều là dòng sự kiện unbounded — không biết trước event đối tác sẽ đến lúc nào, nên phải buffer mỗi bên để chờ. Không có window = buffer mãi mãi = state tăng tuyến tính theo thời gian → hết bộ nhớ. Window đặt giới hạn: chỉ giữ event trong khoảng thời gian cố định, event cũ hơn bị evict.

Stream-table join: KTable biểu diễn giá trị mới nhất theo key (changelog compacted). Với mỗi event từ stream, chỉ cần lookup key trong KTable tại thời điểm event tới — không cần buffer hai bên để chờ đối tác.

Q2
Kafka at-least-once khác exactly-once thế nào? Cho ví dụ cụ thể tình huống at-least-once gây đếm trùng.

At-least-once: producer retry khi không nhận ACK → message có thể được ghi 2 lần vào broker. Consumer xử lý cả hai bản sao → cộng đôi kết quả.

Ví dụ cụ thể: billing pipeline đọc orders, tính invoice, ghi ra invoices. Broker ghi invoice nhưng ACK mất trên mạng → producer retry → broker ghi lần hai → consumer invoices thấy 2 invoice cho cùng đơn hàng → khách bị tính tiền hai lần.

Exactly-once: idempotent producer dedup retry trong cùng session; transaction đảm bảo write + offset commit nguyên tử — crash thì cả hai fail cùng nhau, consumer reprocess ra cùng kết quả không có bản sao thừa.

Q3
Idempotent producer dùng cơ chế gì để dedup? Overhead bao nhiêu, và giới hạn của nó là gì?

Cơ chế: khi khởi động, producer nhận producer ID (PID) duy nhất từ broker. Mỗi message kèm sequence number tăng đơn điệu theo partition. Broker theo dõi cặp (PID, partition, seq) đã nhận — nếu thấy lại thì drop (dedup) và gửi ACK.

Overhead: ~0% — chỉ thêm vài byte int per message, không round-trip thêm.

Giới hạn: chỉ dedup trong cùng một producer session. Khi process khởi động lại, PID mới được cấp — message cũ từ session trước không được nhận ra là duplicate. Bảo vệ reprocess sau crash cần thêm transaction.

Q4
Kafka transaction bảo vệ điều gì mà idempotent producer không có? Giải thích vai trò của sendOffsetsToTransaction.

Idempotent producer chỉ bảo vệ phía ghi (broker không nhận duplicate trong cùng session). Transaction bảo vệ tính nguyên tử giữa ghi output và commit offset: cả hai cùng commit hoặc cùng abort.

Không có transaction, pipeline rơi vào trạng thái: ghi output thành công nhưng crash trước khi commit offset → consumer poll lại từ offset cũ → reprocess → ghi output thêm lần nữa. Transaction loại trừ trạng thái này: crash trước commit → toàn bộ abort → consumer reprocess ra cùng kết quả.

sendOffsetsToTransaction đưa việc commit offset vào bên trong transaction đang mở — khi commitTransaction() thực thi, cả write và offset commit được broker ghi nguyên tử cùng lúc.

Q5
Overhead của Kafka transaction so với at-least-once là bao nhiêu, và overhead này nhỏ nhất khi nào?

Overhead: ~3% so với at-least-once (đo với commit interval 100ms, message 1KB). Nguồn overhead chủ yếu là round-trip tới Transaction Coordinator mỗi lần commitTransaction().

Overhead nhỏ nhất khi: (1) commit interval lớn (vài giây) — chi phí round-trip được amortize qua nhiều message trong batch; (2) batch lớn — mỗi transaction ôm nhiều message, chi phí cố định chia nhỏ. Tốn nhất khi mỗi transaction chỉ ôm ít message trên nhiều partition.

Q6
Một đồng nghiệp bật enable.idempotence=true và tuyên bố "pipeline của chúng ta đã exactly-once". Giải thích tại sao tuyên bố này chưa đủ.

enable.idempotence=true chỉ bảo vệ retry trong cùng producer session: broker dedup message trùng sequence number từ cùng PID. Nhưng khi consumer crash và khởi động lại, nó poll lại từ offset cũ (chưa commit) và reprocess — đây không phải retry của producer, mà là reprocess của pipeline.

Reprocess → producer mới (PID mới) gửi message ra output topic → broker không nhận ra đây là duplicate (PID khác) → output topic có thêm bản sao → double-count.

Exactly-once đầy đủ cần transaction: gói write output + offset commit vào một atomic transaction. Crash trước commit → abort → reprocess ra cùng kết quả, không tạo thêm bản sao.

Q7
Bạn cần hai phép ghép: (A) stream orders với products (mỗi sản phẩm một bản ghi, đổi giá thưa); (B) hai stream cảm biến temp_indoortemp_outdoor để so chênh lệch trong cửa sổ 3 phút. Mỗi phép dùng loại join nào, và vì sao stream-stream (B) bắt buộc có window còn stream-table (A) thì không?

(A) → stream-table (KStream-KTable) join, non-windowed. products là bảng trạng thái (key = product_id, giá trị = bản ghi mới nhất). Mỗi order chỉ cần lookup thông tin sản phẩm hiện tại — một phép tra O(1), không cần giữ lịch sử theo thời gian.

(B) → stream-stream windowed join, window 3 phút. Cả temp_indoortemp_outdoor đều unbounded; một reading phải chờ reading tương ứng của stream kia trong khoảng thời gian để so chênh lệch.

Vì sao (B) cần window mà (A) không: trong stream-stream join, mỗi event chưa khớp phải được buffer để chờ đối tác. Không có window, buffer giữ mọi event từ lúc khởi động → state tăng vô hạn. Window giới hạn: event cũ hơn cửa sổ bị evict. KTable ngược lại là một bảng chỉ giữ giá trị mới nhất mỗi key — kích thước bị chặn bởi số key, nên lookup không cần buffer theo thời gian.

Bài tiếp theo: Thiết kế stream pipeline — chọn kiến trúc đúng cho bài toán thực

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên