Kafka nhanh vì nó gần như không làm gì phức tạp trên đĩa. Bài này mở tệp chỉ mục ra đọc từng dòng, và đo xem tìm một offset thật sự tốn bao nhiêu.
Chỉ mục thưa đến mức bất ngờ
Ghi 50.000 tin với segment.bytes=1MB, rồi đổ tệp chỉ mục đầu tiên ra:
kafka-dump-log.sh --files 00000000000000000000.index
offset: 155 position: 16377
offset: 233 position: 32754
offset: 311 position: 49131
offset: 389 position: 65508
...
tổng 63 mục
Segment đó chứa offset 0 đến 4991 — 4.992 tin — mà chỉ mục chỉ có 63 dòng.
Một dòng cho khoảng 79 tin. Đây không phải chỉ mục theo nghĩa cơ sở dữ liệu, nơi mỗi bản ghi có một mục. Nó là chỉ mục thưa: chỉ đủ để nhảy gần đúng, rồi quét nốt.
Mỗi dòng gồm hai số nguyên 4 byte — offset tương đối và vị trí byte. 63 × 8 = 504 byte, đúng bằng kích thước tệp .index mà ls báo. Số học khớp.
Tần suất ghi mục do index.interval.bytes quyết định (mặc định 4.096), nhưng chỉ ở ranh giới lô. Producer của tôi gộp lô 16.377 byte, nên mỗi lô được một mục — và khoảng cách 16377, 32754, 49131 trong bảng trên chính là bội số của con số đó.
Hệ quả: lô càng to, chỉ mục càng thưa. Đây là một chỗ batch.size ảnh hưởng tới phía đọc mà ít ai nghĩ tới.
Tìm một offset: hai bước tìm nhị phân
Muốn đọc từ offset 20.100 trong một topic 50.000 tin:
Bước 1 — tìm nhị phân trên tên tệp.
00000000000000000000.log
00000000000000004992.log
00000000000000009984.log
00000000000000014976.log
00000000000000019968.log <- 20.100 nằm ở đây
00000000000000024960.log
Tên tệp chính là offset đầu tiên trong khúc, và danh sách đã sắp xếp sẵn. Tìm nhị phân trên vài chục tên là chuyện tức thì.
Bước 2 — tìm nhị phân trên 63 mục của 19968.index.
Được vị trí byte của mục gần nhất không vượt quá 20.100, rồi mở .log ở đúng vị trí đó và quét tiến tối đa 79 tin.
Không có bước nào quét từ đầu tệp.
Đo thật
Đọc một tin ở ba vị trí khác nhau trên topic 50.000 tin:
offset 0: 877 ms
offset 25.000: 894 ms
offset 49.000: 897 ms
Ba con số bằng nhau trong sai số. Và cả ba đều là thời gian khởi động JVM của công cụ dòng lệnh — phần 2 đã đo con số đó là 0,86 giây.
Nghĩa là thời gian tìm offset ở giữa một topic 50.000 tin không đo được bằng công cụ này: nó nhỏ hơn nhiễu.
Đây là điều đáng nhớ khi thiết kế: đọc lại từ một offset bất kỳ gần như miễn phí. Không có lý do kỹ thuật nào để tránh việc tua lại consumer, và cũng không cần một "chỉ mục nhanh" nào ở tầng ứng dụng.
Chỉ mục 10 MB chỉ tồn tại ở khúc đang mở
Phần 3 nêu chuyện topic rỗng cũng chiếm 20 MB tệp chỉ mục. Đây là câu trả lời đầy đủ:
.log |
.index |
.timeindex |
|
|---|---|---|---|
| khúc đã đóng | 1.048.128 | 504 | 72–204 |
| khúc đã đóng | 1.048.128 | 504 | 72–204 |
| (10 khúc như vậy) | |||
| khúc đang mở | 16.856 | 10.485.760 | 10.485.756 |
Kafka cấp phát trước 10 MB cho chỉ mục của khúc đang ghi và ánh xạ bộ nhớ nó — làm vậy để khỏi phải nới tệp liên tục trong lúc ghi. Khi khúc đóng lại, nó cắt xuống đúng kích thước thật.
Toàn bộ chỉ mục cho 50.000 tin: 10 × 504 = 5.040 byte cho 10 MB log. Tỉ lệ 1 : 2.080.
Chỉ mục gần như miễn phí — sau khi khúc đóng lại. Cái duy nhất đáng để ý là số partition, vì mỗi partition có đúng một khúc đang mở, và mỗi khúc đang mở giữ 20 MB không gian địa chỉ. Phần 11 đã đo hệ quả: 2.414 partition đẩy bộ nhớ broker lên 973 MB.
Đổi được bằng segment.index.bytes, nhưng gần như không ai cần: đó là không gian địa chỉ ánh xạ, không phải bộ nhớ thật, và tệp là tệp thưa nên đĩa cũng không mất.
.timeindex — chỉ mục thứ hai
timestamp: 1788026072512 offset: 155
timestamp: 1788026072513 offset: 233
timestamp: 1788026072514 offset: 311
Cùng cấu trúc, nhưng ánh xạ dấu thời gian → offset thay vì offset → vị trí. Nó là thứ khiến hai việc sau chạy được:
# tua consumer về một mốc thời gian
kafka-consumer-groups.sh --bootstrap-server kf:9092 --group nhom \
--topic ten-topic --reset-offsets --to-datetime 2026-08-30T00:00:00.000 --execute
# tra offset tương ứng một mốc
kafka-get-offsets.sh --bootstrap-server kf:9092 --topic ten-topic --time 1788000000000
Và quan trọng hơn: .timeindex quyết định khi nào một khúc hết hạn lưu trữ. Kafka so retention.ms với dấu thời gian lớn nhất trong khúc, không so với thời điểm tệp được tạo.
Điều đó dẫn tới một cái bẫy thật: nếu producer đặt CreateTime sai — ví dụ nhập dữ liệu cũ với dấu thời gian gốc — thì khúc chứa chúng hết hạn ngay lập tức và bị xoá trước khi consumer kịp đọc. Chuyển topic sang message.timestamp.type=LogAppendTime để broker tự đóng dấu là cách chặn chuyện đó.
Vì sao kiến trúc này nhanh
Ba tính chất, và cả ba đều đến từ chỗ Kafka không làm gì:
Ghi luôn là ghi nối tiếp vào cuối tệp. Không sửa tại chỗ, không cấu trúc cây phải cân bằng lại, không phân mảnh. Đây là thao tác mà cả ổ quay lẫn ổ thể rắn đều làm nhanh nhất.
Đọc dùng bộ đệm trang của hệ điều hành. Kafka không giữ bộ nhớ đệm riêng — phần 2 đo được bộ nhớ JVM gần như không đổi khi dữ liệu tăng. Dữ liệu vừa ghi vẫn còn trong bộ đệm trang, nên consumer bám sát leader đọc thẳng từ RAM mà không cần Kafka làm gì thêm.
Gửi bằng sendfile. Với dữ liệu không nén và không lọc giao dịch, broker chuyển thẳng từ bộ đệm trang ra socket, không đi qua vùng nhớ của JVM. Đó là lý do một broker 290 MB bộ nhớ phục vụ được hàng trăm MB/giây.
Xoá là xoá cả tệp. Hết hạn thì bỏ nguyên một khúc, không quét tìm bản ghi cũ. Chi phí không phụ thuộc số tin.
Cả bốn đều là hệ quả trực tiếp của việc chọn cấu trúc "log nối thêm + chỉ mục thưa" thay vì một cấu trúc dữ liệu tổng quát hơn.
Thử ba mươi giây
Mở chỉ mục của chính bạn ra xem:
docker exec kf sh -c 'ls -l /tmp/kafka-logs/<topic>-0/'
docker exec kf /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/<topic>-0/00000000000000000000.index | head -10
Chia số dòng chỉ mục cho số tin trong khúc. Nếu tỉ lệ của bạn thưa hơn 1:79 nhiều, lô của producer đang rất to — và đó thường là tin tốt.
Phần sau đo lưu trữ theo thời gian: dữ liệu ở lại bao lâu, và vì sao retention.ms không bao giờ chính xác.