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à kho | Stream 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 gian | State buffer với window |
| Thời gian chờ tối đa 10 phút | Window size |
| Ghép được: kiện + phiếu → xuất kho | Match: emit kết quả join |
| Hết giờ chờ, không ghép được | Event nằm ngoài window → miss |
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.

# 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ớ.
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.
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ả impressions và clicks đề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 join | Window | Dùng khi |
|---|---|---|
| KStream-KStream | Bắt buộc | Ghép hai dòng sự kiện, khớp trong khoảng thời gian |
| KStream-KTable | Không cần | Lookup giá trị mới nhất của key theo stream |
| KStream-GlobalKTable | Không cần | Như 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ức | Cơ chế | Kết quả khi lỗi |
|---|---|---|
| at-most-once | Gửi một lần, không retry | Có thể mất message (0 hoặc 1 lần) |
| at-least-once | Retry cho đến khi có ACK | Có thể ghi trùng (1 hoặc nhiều lần) |
| exactly-once | Idempotent + 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.

# 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.
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
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
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
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 VÀ transaction (gói ghi output + offset commit thành nguyên tử).
9. 📚 Deep Dive
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 có window — không có window = buffer vô hạn = OOM. Stream-table join (KTable) non-windowed: lookup giá trị mới nhất mỗi key tại thời điểm event tới.
- Ba mức: at-most-once / at-least-once (Kafka mặc định) / exactly-once (cần cơ chế thêm).
- Idempotent producer (Kafka 0.11.0): overhead ~0%, chỉ bảo vệ ghi cùng session. Kafka transaction: overhead ~3%; cần
isolation.level=read_committedphía consumer. - Pitfall lớn nhất:
enable.idempotence=truekhông đủ exactly-once sau crash — exactly-once đầy đủ cần transaction.
12. Tự kiểm tra
- Q1Vì sao stream-stream join bắt buộc phải có window, trong khi stream-table join thì không?
- Q2Kafka 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.
- Q3Idempotent producer dùng cơ chế gì để dedup? Overhead bao nhiêu, và giới hạn của nó là gì?
- Q4Kafka transaction bảo vệ điều gì mà idempotent producer không có? Giải thích vai trò của sendOffsetsToTransaction.
- Q5Overhead của Kafka transaction so với at-least-once là bao nhiêu, và overhead này nhỏ nhất khi nào?
- Q6Một đồng nghiệp bật
enable.idempotence=truevà 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 đủ. - Q7Bạn cần hai phép ghép: (A) stream
ordersvớiproducts(mỗi sản phẩm một bản ghi, đổi giá thưa); (B) hai stream cảm biếntemp_indoorvàtemp_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?
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 đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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
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