Data Engineering 5 — Kafka & Xử lý luồng (Streaming)
Vì sao cần xử lý thời gian thực?
Trong các bài trước, ta đã làm việc với dữ liệu theo lô (batch): gom dữ liệu cả ngày, chạy một job lúc 2 giờ sáng, sáng hôm sau có báo cáo. Mô hình này đơn giản và mạnh mẽ, nhưng có một điểm yếu chí mạng: độ trễ. Nếu một quyết định cần được đưa ra ngay bây giờ, chờ đến sáng mai là quá muộn.
Hãy nhìn vào ba tình huống thực tế:
- Phát hiện gian lận (fraud detection): Một thẻ tín dụng vừa quẹt ở Hà Nội lúc 10:00, rồi lại quẹt ở São Paulo lúc 10:03. Nếu hệ thống chỉ phát hiện điều bất thường này vào sáng hôm sau khi chạy batch, tiền đã mất rồi. Quyết định "chặn giao dịch" phải xảy ra trong vài trăm mili-giây.
- Dashboard realtime: Bộ phận vận hành muốn thấy số lượng giao dịch, tỷ lệ lỗi, độ trễ hệ thống cập nhật từng giây, không phải một biểu đồ tĩnh của ngày hôm qua.
- Sự kiện (event-driven): Khách hàng vừa mở tài khoản → cần gửi email chào mừng, tạo hồ sơ tín dụng, kích hoạt chương trình khuyến mãi. Các phản ứng này nên nối tiếp ngay sau "sự kiện mở tài khoản", chứ không chờ một job tổng hợp.
Điểm chung: dữ liệu được sinh ra liên tục như một dòng chảy (stream) các sự kiện, và giá trị của mỗi sự kiện giảm theo thời gian. Đó chính là lý do tồn tại của xử lý luồng.
Batch vs Streaming: đào sâu
Ở bài trước ta đã phân biệt batch và streaming ở mức khái niệm. Giờ ta đào sâu hơn vào sự khác biệt về mô hình tư duy.
Batch: dữ liệu hữu hạn, biên giới rõ ràng
Batch xử lý một tập dữ liệu có biên giới (bounded): "tất cả giao dịch của ngày 29/06". Bạn biết chính xác dữ liệu bắt đầu và kết thúc ở đâu. Bạn có thể đọc lại nhiều lần, sắp xếp, join thoải mái. Nếu job lỗi, chạy lại từ đầu cũng được vì dữ liệu vẫn nằm yên đó.
Streaming: dữ liệu vô hạn, không bao giờ "xong"
Streaming xử lý một dòng vô hạn (unbounded): sự kiện cứ đến, đến mãi, không có điểm "kết thúc". Điều này đẻ ra những câu hỏi mới mà batch không có:
- Khi nào thì "đếm xong" số giao dịch trong một phút, nếu phút đó về lý thuyết chưa bao giờ đóng lại hoàn toàn (vẫn có thể có sự kiện đến muộn)?
- Làm sao xử lý sự kiện đến không đúng thứ tự (out-of-order)?
- Trạng thái (state) tích lũy — ví dụ "tổng tiền khách A đã tiêu trong 5 phút qua" — được lưu ở đâu và sống sót thế nào khi máy chết?
Cách trả lời những câu hỏi này (window, watermark, state) chính là phần khó và thú vị nhất của stream processing. Ta sẽ quay lại bên dưới.
Một góc nhìn hợp nhất đáng nhớ: batch chỉ là một trường hợp đặc biệt của streaming — một dòng dữ liệu mà tình cờ có biên giới. Các engine hiện đại như Apache Flink xem mọi thứ là stream, batch chỉ là stream "đã đóng".
Pub/Sub và Message Queue
Trước khi nói về Kafka, cần hiểu nền tảng kiến trúc: làm sao để các thành phần phần mềm trao đổi sự kiện mà không phụ thuộc chặt vào nhau?
Cách "ngây thơ" là service A gọi trực tiếp HTTP đến service B. Nhưng nếu B đang sập, A cũng kẹt. Nếu thêm service C cũng cần dữ liệu của A, ta phải sửa code của A. Đây là coupling chặt, rất khó mở rộng.
Giải pháp là chèn một lớp trung gian — message broker — vào giữa:
- Message Queue (hàng đợi): A đẩy message vào queue, một consumer lấy ra xử lý rồi message biến mất. Mỗi message được tiêu thụ đúng một lần bởi một worker. Phù hợp cho mô hình "phân chia công việc" (task distribution). Ví dụ: RabbitMQ, AWS SQS.
- Publish/Subscribe (pub/sub): A "công bố" (publish) message lên một chủ đề, nhiều subscriber cùng nhận được bản sao của message đó. Phù hợp cho mô hình "phát tin" (broadcast). Một sự kiện, nhiều bên cùng phản ứng.
Apache Kafka thú vị ở chỗ nó kết hợp cả hai mô hình thông qua khái niệm consumer group (sẽ thấy bên dưới), đồng thời thêm một đặc tính then chốt: message không biến mất sau khi đọc mà được lưu lại như một log bền vững. Đây là điều làm Kafka khác biệt.
Apache Kafka: kiến trúc cốt lõi
Kafka thường được mô tả là một "distributed commit log" — một cuốn nhật ký phân tán, chỉ ghi thêm (append-only). Hãy bóc tách từng khái niệm.
Broker
Broker là một máy chủ Kafka (một tiến trình). Một cụm (cluster) Kafka gồm nhiều broker chạy cùng nhau, chia sẻ dữ liệu và chịu tải. Nếu một broker chết, các broker khác gánh thay. Tính phân tán này là lý do Kafka chịu được throughput hàng triệu message/giây.
Topic
Topic là một danh mục/tên kênh logic cho một loại sự kiện. Ví dụ: topic transactions, topic account-opened, topic clicks. Producer ghi vào topic, consumer đọc từ topic. Topic tương tự một "bảng" trong cơ sở dữ liệu — một cách phân loại dữ liệu.
Partition
Đây là khái niệm quan trọng nhất của Kafka. Mỗi topic được chia thành nhiều partition. Mỗi partition là một log có thứ tự, chỉ append, được lưu trên một broker (và được sao chép sang broker khác).
Vì sao cần partition? Vì một topic nếu chỉ là một log duy nhất thì throughput bị giới hạn bởi một máy. Chia thành N partition cho phép:
- Mở rộng song song (parallelism): N partition có thể nằm trên N broker khác nhau, ghi/đọc đồng thời. N consumer có thể đọc N partition song song.
- Khả năng chứa: Dữ liệu của topic trải đều trên nhiều đĩa, nhiều máy.
Offset
Trong mỗi partition, mỗi message có một số thứ tự tăng dần gọi là offset: 0, 1, 2, 3... Offset là vị trí cố định của message trong partition đó. Consumer theo dõi "tôi đã đọc đến offset bao nhiêu" để biết đọc tiếp từ đâu. Đây cũng là cách Kafka cho phép tua lại (replay): chỉ cần đặt lại offset về một giá trị cũ là đọc lại được dữ liệu cũ — điều không message queue truyền thống nào làm được.
Producer
Producer là ứng dụng ghi (publish) message vào topic. Khi gửi, producer quyết định message vào partition nào. Quy tắc phổ biến: nếu message có key (ví dụ account_id), Kafka băm (hash) key đó để chọn partition. Hệ quả cực kỳ quan trọng: mọi message cùng một key luôn vào cùng một partition, do đó được giữ đúng thứ tự với nhau. Nếu không có key, message được rải vòng tròn (round-robin) để cân bằng tải.
Consumer và Consumer Group
Consumer là ứng dụng đọc message từ topic. Consumer group là tập hợp các consumer cùng một nhãn (group id) hợp tác để tiêu thụ một topic.
Quy tắc vàng: mỗi partition chỉ được gán cho đúng một consumer trong cùng group. Hệ quả:
- Nếu topic có 4 partition và group có 4 consumer → mỗi consumer xử lý 1 partition, song song hoàn hảo.
- Nếu group có 2 consumer → mỗi consumer xử lý 2 partition.
- Nếu group có 6 consumer nhưng chỉ 4 partition → 2 consumer sẽ ngồi không. Đây là lý do số partition đặt giới hạn trên cho mức song song.
Đồng thời, nhiều group khác nhau cùng đọc một topic một cách độc lập, mỗi group giữ offset riêng. Đây chính là chỗ Kafka thể hiện cả pub/sub (nhiều group cùng nhận) lẫn queue (trong một group, công việc được chia).
Replication (sao chép)
Mỗi partition có một leader và một hoặc nhiều replica (follower) trên các broker khác. Producer/consumer làm việc với leader; follower âm thầm sao chép dữ liệu. Nếu broker chứa leader chết, một follower được bầu lên làm leader mới. Cấu hình replication.factor=3 nghĩa là mỗi partition có 3 bản — chịu được mất 2 broker mà không mất dữ liệu.
Retention (giữ dữ liệu)
Khác với queue truyền thống xóa message sau khi đọc, Kafka giữ message theo chính sách retention: theo thời gian (ví dụ 7 ngày) hoặc theo dung lượng. Trong thời gian đó, bất kỳ consumer nào cũng đọc lại được. Điều này biến Kafka thành "nguồn sự thật" (source of truth) cho dòng sự kiện, không chỉ là đường ống truyền tải.
Sơ đồ luồng dữ liệu Kafka
Sơ đồ trên thể hiện: nhiều producer ghi vào topic transactions gồm 3 partition; group fraud có 3 consumer chia nhau 3 partition; trong khi group dashboard đọc cùng topic đó một cách độc lập để cập nhật biểu đồ.
Đảm bảo thứ tự
Một hiểu lầm phổ biến: "Kafka đảm bảo thứ tự". Chính xác hơn: Kafka chỉ đảm bảo thứ tự bên trong một partition, không phải trên toàn topic.
Vì sao? Vì các partition độc lập, nằm trên các máy khác nhau, đọc song song — không có cách nào ràng buộc thứ tự giữa chúng mà vẫn giữ được tốc độ.
Ứng dụng thực tế: nếu bạn cần các sự kiện của cùng một tài khoản được xử lý đúng thứ tự (nạp tiền trước, rút tiền sau), hãy dùng account_id làm key. Khi đó mọi sự kiện của tài khoản đó luôn vào cùng một partition và giữ đúng trình tự. Các tài khoản khác nhau có thể nằm ở partition khác và xử lý song song — đó là sự đánh đổi đẹp giữa thứ tự và song song.
Delivery Semantics (đảm bảo giao nhận)
Khi mạng có thể lỗi, máy có thể chết, một câu hỏi nền tảng nảy sinh: một message được xử lý bao nhiêu lần? Có ba mức đảm bảo:
| Mức | Ý nghĩa | Cơ chế | Rủi ro | Khi nào dùng |
|---|---|---|---|---|
| At-most-once | Mỗi message xử lý tối đa 1 lần (có thể 0) | Commit offset trước khi xử lý | Có thể mất message nếu crash giữa chừng | Metrics, log mà mất vài bản tin không sao |
| At-least-once | Mỗi message xử lý ít nhất 1 lần (có thể nhiều) | Commit offset sau khi xử lý xong | Có thể trùng lặp message khi retry | Mặc định phổ biến; kết hợp xử lý idempotent |
| Exactly-once | Mỗi message có hiệu ứng đúng 1 lần | Producer idempotent + transaction | Phức tạp, throughput thấp hơn | Tài chính, đếm tiền — không được sai |
Hiểu trực giác về exactly-once
"Exactly-once" nghe như phép màu, vì trong hệ phân tán không thể đảm bảo một message được gửi đúng một lần (mạng luôn có thể buộc gửi lại). Mẹo là chuyển trọng tâm từ "gửi đúng một lần" sang "hiệu ứng đúng một lần" (effectively-once):
- Idempotent producer: Mỗi message mang một số thứ tự; broker phát hiện và loại bỏ bản trùng do retry sinh ra.
- Transaction: Kafka cho phép gói "đọc message → xử lý → ghi kết quả → commit offset" vào một giao dịch nguyên tử. Hoặc tất cả thành công, hoặc không gì cả. Không có trạng thái nửa vời "đã ghi kết quả nhưng chưa commit offset" (sẽ gây xử lý lại).
Trong thực tế, nhiều hệ thống chọn at-least-once + idempotent ở phía consumer (ví dụ "insert nếu chưa tồn tại" theo transaction_id) vì nó đơn giản hơn và đạt cùng kết quả cuối.
Stream Processing: xử lý ngay trên dòng
Kafka tự nó chỉ là đường ống lưu trữ và truyền tải sự kiện. Để biến đổi, join, tổng hợp dòng dữ liệu, ta cần một stream processing engine. Ba lựa chọn phổ biến:
- Kafka Streams: Một thư viện Java chạy ngay trong ứng dụng của bạn, không cần cụm riêng. Nhẹ, gắn chặt với Kafka. Phù hợp khi nguồn và đích đều là Kafka và team đã quen Java/JVM.
- Apache Flink: Một engine xử lý luồng độc lập, mạnh mẽ, xem mọi thứ là stream. Hỗ trợ state lớn, exactly-once, windowing tinh vi, độ trễ thấp. Là lựa chọn hàng đầu cho stream processing nghiêm túc, quy mô lớn.
- Spark Structured Streaming: Mở rộng của Spark sang luồng, dùng mô hình "micro-batch" (gom dữ liệu thành các lô nhỏ vài giây). Hấp dẫn nếu team đã dùng Spark cho batch và muốn tái dùng cùng API.
Lựa chọn thường xoay quanh: độ trễ cần thấp đến đâu (Flink/Kafka Streams thấp hơn micro-batch Spark), team quen công nghệ nào, và độ phức tạp state.
Windowing và Watermark (trực giác)
Vì dòng là vô hạn, ta không thể "đếm tất cả". Thay vào đó ta chia thời gian thành các cửa sổ (window):
- Tumbling window: Cửa sổ cố định, không chồng lấn. "Đếm giao dịch mỗi 1 phút." 10:00–10:01, 10:01–10:02...
- Sliding window: Cửa sổ trượt, có chồng lấn. "Tổng tiền trong 5 phút gần nhất, cập nhật mỗi 1 phút."
- Session window: Gom các sự kiện gần nhau theo thời gian thành một phiên, đóng lại khi im lặng quá lâu. Hữu ích cho phân tích hành vi người dùng.
Vấn đề khó: dữ liệu đến không đúng thứ tự và đến muộn. Một giao dịch xảy ra (event time) lúc 10:00:59 nhưng vì mạng trễ, đến hệ thống (processing time) lúc 10:01:05. Nó thuộc cửa sổ 10:00–10:01, nhưng cửa sổ đó "tưởng" đã đóng rồi.
Watermark là câu trả lời. Hãy hình dung watermark như một lời tuyên bố của hệ thống: "Tôi tin rằng mọi sự kiện có event time trước thời điểm T thì đến giờ đã đến hết rồi." Khi watermark vượt qua mốc cuối của một cửa sổ, cửa sổ đó được phép "đóng" và phát ra kết quả. Watermark thường được đặt trễ một chút so với thời gian thực (ví dụ "trễ 10 giây") để bao dung cho dữ liệu đến muộn — một sự đánh đổi giữa độ trễ (chờ lâu hơn) và độ chính xác (bắt được nhiều sự kiện muộn hơn).
Event-Driven Architecture
Khi Kafka trở thành "xương sống sự kiện" của cả tổ chức, ta có kiến trúc hướng sự kiện: các service không gọi nhau trực tiếp, mà phát ra sự kiện và phản ứng với sự kiện của nhau.
Ví dụ: service "Tài khoản" phát sự kiện account-opened. Service "Email" lắng nghe và gửi thư chào mừng. Service "Tín dụng" lắng nghe và tạo hồ sơ. Service "Khuyến mãi" lắng nghe và kích hoạt ưu đãi. Service "Tài khoản" không cần biết ai đang lắng nghe — thêm một service mới chỉ việc subscribe topic, không phải sửa code cũ. Đây là decoupling ở mức kiến trúc, cho phép tổ chức mở rộng độc lập từng phần.
Ví dụ: luồng giao dịch ngân hàng realtime
Ghép tất cả lại trong một tình huống phát hiện gian lận:
- Sinh sự kiện: Mỗi lần khách quẹt thẻ, hệ thống thanh toán đẩy một message vào topic
transactions, dùngaccount_idlàm key (để mọi giao dịch của một tài khoản nằm cùng partition, giữ thứ tự). - Đường ống Kafka: Topic có 12 partition, replication factor 3, retention 7 ngày — vừa chịu tải cao, vừa an toàn dữ liệu, vừa cho phép replay khi điều tra.
- Stream processing (Flink): Một job Flink đọc
transactions, giữ state cho mỗi tài khoản: vị trí giao dịch gần nhất, số lần quẹt trong cửa sổ trượt 5 phút. Với mỗi giao dịch mới, nó tính: "Khoảng cách địa lý / thời gian có vượt tốc độ phi lý không? Có quá nhiều giao dịch trong 5 phút không?" Dùng watermark trễ vài giây để bao dung sự kiện đến muộn. - Phát hiện và phản ứng: Nếu nghi gian lận, job phát một sự kiện vào topic
fraud-alerts. Một consumer nhận và chặn thẻ ngay, đồng thời gửi SMS cho khách. Toàn bộ chu trình từ lúc quẹt đến lúc chặn diễn ra trong dưới một giây. - Dashboard: Một group khác đọc cùng
transactionsđể cập nhật biểu đồ throughput và tỷ lệ cảnh báo theo thời gian thực — độc lập hoàn toàn với pipeline gian lận, nhờ mô hình nhiều consumer group. - Đảm bảo đúng đắn: Pipeline đếm tiền dùng exactly-once (transaction của Flink + Kafka) để không bao giờ tính trùng hay sót một giao dịch.
Đây là bức tranh tổng hợp mọi khái niệm: partition cho song song, key cho thứ tự, consumer group cho phân chia và broadcast, retention cho replay, windowing/watermark cho tổng hợp theo thời gian, và exactly-once cho tính đúng đắn tài chính.
Tóm tắt
- Xử lý thời gian thực ra đời vì giá trị dữ liệu giảm theo thời gian: gian lận, dashboard, phản ứng theo sự kiện đều cần kết quả ngay.
- Streaming xử lý dòng vô hạn, đẻ ra các bài toán mới: thứ tự, dữ liệu đến muộn, state — mà batch (dữ liệu hữu hạn) không gặp.
- Apache Kafka là một commit log phân tán: broker, topic, partition (chìa khóa của song song), offset, producer, consumer & consumer group, replication và retention.
- Kafka chỉ đảm bảo thứ tự trong một partition; dùng key để giữ thứ tự cho các sự kiện liên quan.
- Ba mức delivery semantics: at-most-once (có thể mất), at-least-once (có thể trùng), exactly-once (đúng một lần — qua idempotent + transaction).
- Stream processing (Kafka Streams, Flink, Spark Structured Streaming) biến đổi dòng dữ liệu; windowing chia thời gian, watermark xử lý dữ liệu đến muộn.
- Kiến trúc hướng sự kiện dùng Kafka làm xương sống để các service phản ứng với nhau mà không phụ thuộc chặt.
Tự kiểm tra
- Vì sao partition là cơ chế cho phép Kafka mở rộng song song, và điều gì đặt giới hạn trên cho số consumer hữu ích trong một group?
- Nếu cần các sự kiện của cùng một tài khoản được xử lý đúng thứ tự, bạn làm gì, và vì sao điều đó vẫn cho phép xử lý song song giữa các tài khoản?
- Phân biệt at-least-once và exactly-once. Vì sao "at-least-once + xử lý idempotent" thường được chọn thay cho exactly-once thuần?
- Giải thích bằng lời của bạn watermark là gì và nó giải quyết vấn đề gì trong windowing.
- Một topic được hai consumer group cùng đọc. Việc một group đọc chậm có ảnh hưởng đến group kia không? Vì sao?
- Trong ví dụ phát hiện gian lận, retention 7 ngày mang lại lợi ích cụ thể nào ngoài việc lưu trữ?
Đọc tiếp
Bài viết liên quan
CSV/JSON/Parquet/Avro, lưu trữ theo hàng vs cột, nén, và OLTP vs OLAP.
Data Engineering là gì, vai trò trong vòng đời dữ liệu, và bức tranh hệ sinh thái công cụ.
Vì sao xử lý phân tán, mô hình Spark (RDD/DataFrame), lazy evaluation, shuffle và tối ưu.
Đảm bảo chất lượng & minh bạch dữ liệu bằng dbt: data tests dựng sẵn (unique, not_null, relationships, accepted_values), singular test, unit test, và tài liệu tự sinh kèm lineage graph.