Bên trong mã nguồn Kafka - P1
Lời mở đầu
Tiếp tục series hỏi hỏi từ các repo mã nguồn mở. Nay chúng ta sẽ đọc thử Kafka, chắc cũng không xa lạ gì với anh em.
Ở phần này ta sẽ đi tìm hiểu module core của Kafka.
1. Kafka là gì ?
Apache Kafka là một nền tảng xử lý luồng sự kiện phân tán (event streaming platform) mã nguồn mở được sử dụng để xây dựng các đường ống dữ liệu thời gian thực và các ứng dụng truyền phát dữ liệu. Ban đầu, Kafka được phát triển bởi LinkedIn, sau đó được đóng góp cho Apache Software Foundation và hiện nay được duy trì bởi cộng đồng mã nguồn mở cùng công ty Confluent.
2. Mô hình Threading Reactor Tùy Biến
Bài toán:
- Làm thế nào để một Broker duy nhất xử lý đồng thời hàng chục ngàn kết nối TCP hoạt động liên tục (một số gửi gigabytes log - Producer, một số poll dữ liệu liên tục - Consumer, số khác gửi metadata heartbeats) mà không gặp tắc nghẽn luồng, độ trễ thấp và băng thông tối đa?
- Việc đọc ghi đĩa chậm hoặc xử lý nghiệp vụ nặng không được chặn các luồng mạng I/O.
Giải pháp:
Kafka triển khai kiến trúc Reactor Pattern đa tầng cực kỳ tối ưu, phân rã trách nhiệm mạng (I/O) và xử lý logic (CPU/Disk) thành 3 tầng luồng riêng biệt:
- Acceptor Thread (1 luồng cho mỗi Listener): Chỉ lắng nghe sự kiện kết nối TCP mới, chấp nhận socket và đẩy sang hàng đợi không chặn của một Processor thread theo thuật toán Round-Robin.
- Processor Threads (N luồng cho mỗi Listener): Mỗi Processor chạy một NIO Selector độc lập. Chịu trách nhiệm hoàn toàn việc đọc byte từ socket gom thành Request, và ghi các Response từ hàng đợi phản hồi ra socket. Khi đọc đủ một request, Processor đóng gói nó và gửi vào hàng đợi dùng chung.
- KafkaRequestHandlerPool (M luồng worker): Các luồng worker chạy đồng thời, liên tục lấy request từ RequestChannel, xử lý nghiệp vụ thông qua KafkaApis (đọc/ghi đĩa, tương tác bộ nhớ) và đặt Response vào hàng đợi phản hồi của chính Processor đã nhận request ban đầu.
Lợi ích: Tách biệt hoàn toàn phần I/O đĩa chậm khỏi I/O mạng nhanh. Nếu đĩa bị nghẽn (disk bottleneck), Processor vẫn có thể tiếp tục nhận kết nối mới và đọc dữ liệu từ các socket khác mà không bị đứng hình.
3. Truyền dữ liệu trực tiếp không sao chép
Bài toán: Khi Consumer gửi fetch request để đọc log từ Broker, cách lập trình socket truyền thống yêu cầu:
- OS đọc dữ liệu từ Disk vào Read Buffer (Kernel space).
- OS copy dữ liệu từ Read Buffer vào Application Buffer của JVM (User space).
- JVM copy dữ liệu từ Application Buffer vào Socket Buffer của OS (Kernel space).
- OS copy dữ liệu từ Socket Buffer vào NIC (Network Interface Card) Buffer.
Tiến trình này mất 4 lần copy dữ liệu và 4 lần chuyển đổi ngữ cảnh (context switch) giữa Kernel và User Space, gây áp lực rác lớn lên Garbage Collector (GC) của Java và ngốn CPU vô ích.
Giải pháp:
- Kafka sử dụng cơ chế Zero-Copy tận dụng API Java NIO FileChannel.transferTo() (dựa trên system call sendfile của Linux).
- Khi Consumer kéo dữ liệu, Kafka truyền trực tiếp file descriptor của Log segment cho socket thông qua transferTo.
- Hệ điều hành Linux sẽ copy trực tiếp dữ liệu từ Page Cache (Kernel space) sang NIC Buffer (hoặc thông qua socket buffer nhưng không đi qua User space).
- Lợi ích: Giảm context switch từ 4 xuống còn 2. Giảm số lần copy bộ nhớ từ 4 xuống 0 (trong trường hợp tối ưu của phần cứng hỗ trợ. Dữ liệu không hề đi qua JVM heap, triệt tiêu hoàn toàn gánh nặng GC khi truyền tải dữ liệu dung lượng lớn.
4. Quản lý Timeout Hàng Triệu Request
Bài toán:
Rất nhiều request trong Kafka cần chờ đợi sự kiện thỏa mãn mới được phản hồi: ví dụ Producer gửi tin nhắn với acks=all phải đợi bản sao ghi xong trên các Replica khác, hoặc Consumer gửi fetch request nhưng phải đợi Partition có dữ liệu mới (Long Polling).
Nếu Broker duy trì 1 luồng để kiểm tra timeout cho mỗi request thì hàng triệu thread sẽ làm crash JVM ngay lập tức.
Nếu dùng hàng đợi ưu tiên thông thường (như DelayQueue hay PriorityQueue), thao tác thêm và xóa một request timeout tốn chi phí . Với hệ thống hàng triệu request trễ đồng thời, chi phí này ngốn quá nhiều CPU.
Giải pháp: Kafka thiết kế DelayedOperationPurgatory kết hợp cấu trúc Hierarchical Timing Wheel (Vòng quay thời gian phân tầng):
- Một vòng quay thời gian (Timing Wheel) cơ bản là một mảng vòng tròn chứa các bucket. Mỗi bucket đại diện cho một khoảng thời gian cụ thể (ví dụ 1ms). Mảng có kích thước cố định .
- Thêm và hủy bỏ timer chỉ mất chi pháp vì chỉ cần tính toán modulo chỉ số bucket:
bucketId = (expiration / tickMs) % wheelSize. - Để giải quyết vấn đề vượt ngưỡng giới hạn của vòng quay đơn, Kafka xếp chồng các tầng Timing Wheel lên nhau:
- Tầng 1: tickMs = 1ms, wheelSize = 20. Khoảng bao phủ = 20ms.
- Tầng 2: tickMs = 20ms, wheelSize = 20. Khoảng bao phủ = 400ms.
- Tầng 3: tickMs = 400ms, wheelSize = 20. Khoảng bao phủ = 8000ms.
- Khi một task có thời gian timeout xa vượt ngưỡng tầng 1, nó được chuyển lên tầng cao hơn. Khi thời gian trôi qua, các tầng cao quay và phân tách lại các task hết hạn đẩy trở lại các tầng thấp hơn.
- Đặc biệt, để tránh việc phải quay vòng các bucket rỗng một cách vô ích, Kafka dùng duy nhất một DelayQueue của Java để lưu trữ các bucket không trống. Việc này giúp nhảy vọt thời gian đến bucket tiếp theo có task một cách tức thời mà không tốn chu kỳ CPU rảnh.
Lợi ích: Quản lý hàng triệu timer bất đồng bộ với hiệu năng cực cao ( chèn/xóa), bảo đảm độ chính xác mili-giây mà CPU sử dụng gần như bằng không.
5. Sparse Indexing & Memory-Mapped Files
Bài toán:
Mỗi partition chứa lượng data cực lớn (hàng Terabytes log ghi tuần tự). Khi Consumer muốn đọc từ một offset cụ thể , làm thế nào để tìm ra vị trí vật lý của offset đó trong file log nhanh chóng () mà không cần quét tuần tự toàn bộ file từ đầu?
Việc duy trì chỉ mục (index) cho từng offset của mọi message sẽ tiêu tốn lượng RAM khổng lồ, khiến Broker không thể phục vụ các phân vùng lớn.
Giải pháp:
- Kafka sử dụng mô hình Log Segments kết hợp Sparse Indexing (Chỉ mục thưa) và Memory-Mapped Files (mmap):
- Dữ liệu của partition được chia nhỏ thành các file phân đoạn LogSegment (mặc định 1GB).
- Đi kèm với file dữ liệu .log là file chỉ mục .index (Offset Index) và .timeindex (Time Index).
- Thay vì đánh chỉ mục cho từng tin nhắn, Kafka sử dụng Chỉ mục thưa: mặc định cứ mỗi 4KB dữ liệu ghi vào file .log, Kafka mới chèn một dòng ghi vị trí vật lý vào file .index.
- File .index được ánh xạ trực tiếp vào bộ nhớ ảo của Hệ điều hành bằng mmap (Memory-Mapped Files).
Quy trình tìm kiếm Offset :
- Tìm phân đoạn LogSegment phù hợp thông qua tìm kiếm nhị phân trên danh sách các Base Offset của các segment (danh sách này rất ngắn và luôn nằm trên RAM).
- Thực hiện tìm kiếm nhị phân trực tiếp trên file .index đã mmap để tìm ra điểm đánh dấu lớn nhất có offset . Đây là tìm kiếm cực nhanh trên RAM.
- Lấy vị trí vật lý vừa tìm được trong file .log, từ đó quét tuần tự tối đa 4KB dữ liệu tiếp theo để tìm ra chính xác tin nhắn có offset .
Lợi ích: Tốc độ tìm kiếm đạt độ phức tạp với độ trễ micro-giây, trong khi dung lượng file index cực kỳ nhỏ gọn, tận dụng hoàn hảo cơ chế ảo hóa bộ nhớ của OS mà không chiếm dụng JVM Heap.
All Rights Reserved