Hình dung khu nhà bạn thu gom rác mỗi tuần một lần và xe chỉ lấy nguyên cả thùng đầy. Rác của bạn không biến mất ngay khi thùng đầy — nó nằm đó tới khi xe tới (chu kỳ quét), và một thùng vơi nửa vẫn ở lại (chỉ xoá nguyên khối). Sức chứa lại tính theo từng căn hộ, không theo cả toà nhà. Hai tham số giữ tin của Kafka cư xử đúng như cái xe rác đó: retention.ms là sàn chứ không phải hạn chót, và retention.bytes cho ra nhiều hơn con số bạn viết. Tôi đo cả hai bằng đồng hồ và bằng du.
retention.ms là mức sàn, không phải hạn chót
Ghi 20.000 tin vào topic có retention.ms=20000 (20 giây) và segment.bytes=200KB:
| Segment | Offset đầu | Dung lượng | |
|---|---|---|---|
| ngay sau khi ghi | 22 | 0 | 4,3 MB |
| sau 25 giây | 22 | 0 | không đổi |
| sau 45 giây | 22 | 0 | không đổi |
| sau ~70 giây | 1 | 20.000 | xoá sạch |
Ở giây thứ 25 và 45, dữ liệu đáng lẽ đã quá hạn vẫn còn nguyên. Tới khoảng giây 70 thì biến mất toàn bộ trong một lần.
Lý do: việc dọn không chạy liên tục mà theo chu kỳ log.retention.check.interval.ms, mặc định 300.000 ms — năm phút. Giữa hai lần chạy, dữ liệu quá hạn vẫn nằm đó và consumer vẫn đọc được.
Cách hiểu đúng: retention.ms bảo đảm dữ liệu sống ít nhất chừng đó, không bảo đảm nó chết sau chừng đó. Độ trễ thực tế nằm đâu đó từ 0 đến log.retention.check.interval.ms, tuỳ bạn rơi vào chỗ nào trong chu kỳ.
Điều này quan trọng với yêu cầu tuân thủ kiểu "dữ liệu cá nhân phải xoá sau 30 ngày". retention.ms=2592000000 không đáp ứng được yêu cầu đó một cách chặt chẽ — nó chỉ nói dữ liệu sẽ bị xoá sau 30 ngày, có thể muộn hơn năm phút, và nếu segment chưa đóng thì còn muộn hơn nữa.
Segment là đơn vị xoá, và nó lớn hơn bạn nghĩ
Kafka không xoá từng tin. Nó xoá cả tệp segment, và chỉ khi mọi tin trong segment đó đều quá hạn.
Với segment.bytes mặc định là 1 GB, một topic ít lưu lượng có thể mất hàng tuần để đóng một segment — và trong suốt thời gian đó không có gì bị xoá, dù retention.ms là bao nhiêu.
Đây là nguyên nhân phổ biến nhất của câu hỏi "sao đặt retention 1 ngày mà dữ liệu 3 tuần vẫn còn". Chữa bằng cách hạ segment.bytes, hoặc đặt segment.ms để buộc đóng segment theo thời gian:
kafka-configs.sh --bootstrap-server kf:9092 --alter \
--entity-type topics --entity-name ten-topic \
--add-config segment.ms=3600000 # đóng segment mỗi giờ
retention.bytes cho ra nhiều hơn con số bạn viết
Topic 3 partition, retention.bytes=1048576 (1 MB), ghi 12 MB rồi đợi dọn:
| Partition | Byte thật | So với giới hạn |
|---|---|---|
| rb-0 | 1.235.651 | +18% |
| rb-1 | 1.211.898 | +16% |
| rb-2 | 1.113.636 | +6% |
| tổng | 3.561.185 | gấp 3,4 lần |
Hai bội số chồng lên nhau.
retention.bytes tính theo từng partition, không theo topic. Viết "1 MB" trên topic 3 partition là cho phép 3 MB.
Xoá theo cả segment nên nó luôn dừng ở mức lớn hơn hoặc bằng giới hạn. Kafka bỏ segment cũ nhất chừng nào bỏ xong vẫn còn ≥ giới hạn; bỏ thêm một cái nữa là tụt xuống dưới, nên nó dừng. Dung lượng thật luôn nằm giữa retention.bytes và retention.bytes + segment.bytes.
Công thức dự trù đĩa
đĩa cần = (retention.bytes + segment.bytes)
× số partition
× replication-factor
Ví dụ cụ thể: retention.bytes=10GB, 12 partition, replication-factor=3:
(10 GB + 1 GB) × 12 × 3 = 396 GB
Không phải 10 GB. Và con số đó chia cho ba broker, nên mỗi broker cần 132 GB chỉ cho một topic.
Cộng thêm phần chỉ mục. Trong phép đo trên, du báo 1,3 MB cho phần log 1,2 MB — nhưng tệp chỉ mục thô của mỗi partition là 20,9 MB không gian địa chỉ (tệp thưa, đĩa thật gần như bằng 0). Với vài nghìn partition, đây là chỗ đáng kiểm tra hạn mức tệp mở của hệ điều hành hơn là hạn mức đĩa.
Đặt cả hai
retention.ms = 604800000 # 7 ngày
retention.bytes = 10737418240 # 10 GB mỗi partition
Cái nào tới trước thì cái đó thắng.
- Chỉ đặt
retention.ms: đĩa đầy khi lưu lượng tăng đột biến. Broker đầy đĩa là sự cố toàn phần, không phải sự cố một topic. - Chỉ đặt
retention.bytes: dữ liệu biến mất sớm hơn dự kiến khi lưu lượng tăng, và consumer chậm sẽ mất tin mà không có cảnh báo nào.
Đặt cả hai với ý định rõ ràng: retention.ms là chính sách, retention.bytes là lưới an toàn. Đặt retention.bytes đủ rộng để bình thường không bao giờ chạm tới, chỉ để chặn broker chết vì đầy đĩa.
retention.ms = -1: giữ mãi mãi
--add-config retention.ms=-1
Hợp lệ và có ích thật: sự kiện dựng lại trạng thái, nhật ký kiểm toán, dữ liệu nguồn cho việc tính lại. Kafka làm được vì cách lưu của nó — đã đo ở phần 16 — đọc từ offset 0 nhanh bằng đọc từ cuối.
Nhưng phải đi kèm retention.bytes hoặc một cơ chế chuyển dữ liệu cũ đi nơi khác. "Giữ mãi mãi" mà không có trần là hẹn giờ cho một sự cố đầy đĩa.
Từ Kafka 3.6, lưu trữ phân tầng cho phép đẩy segment cũ sang S3 và giữ trên đĩa broker chỉ phần nóng. Đó là cách đúng để giữ mãi mãi, nếu hạ tầng của bạn có.
Hai giá trị mặc định đáng đổi ngay
log.retention.hours=168 (7 ngày) là mặc định của broker và nó áp cho mọi topic không khai riêng — kể cả topic tạo tự động. Với topic lưu lượng cao mà không ai để ý, bảy ngày dữ liệu có thể là hàng trăm GB.
log.retention.bytes=-1 là mặc định, nghĩa là không có trần dung lượng nào cả. Đặt một giá trị ở mức broker là cách rẻ nhất để không bao giờ gặp cảnh đầy đĩa vì một topic mới ai đó vừa tạo.
Cùng một luật "thu hồi theo khối thô là cận dưới, không phải trần chính xác", đổi ngăn xếp
Chuyện retention.bytes cho ra 3,4 lần con số bạn viết không phải lỗi Kafka. Nó là hệ quả tất yếu của việc thu hồi theo nguyên khối đơn vị thô thay vì từng phần tử — một luật lặp lại ở mọi tầng lưu trữ.
Trong thế giới cơ sở dữ liệu, DELETE của PostgreSQL không trả đĩa lại ngay: nó đánh dấu tuple chết, không gian được thu hồi theo trang qua vacuum, và tệp bảng chỉ co lại nếu bạn VACUUM FULL (viết lại nguyên tệp) — nên dung lượng đĩa của một bảng luôn nằm trên kích thước dữ liệu sống. Chính vì lý do này mà cơ sở dữ liệu chuỗi thời gian phân mảnh theo ngày rồi DROP PARTITION: partition là đơn vị xoá thô — bỏ nguyên một ngày một lần, rẻ và gọn — đúng vai của segment ở đây.
Rộng hơn, đây là block/cluster của hệ tệp (một tệp 1 byte vẫn chiếm nguyên một block, nên du luôn làm tròn lên), và là cách GC theo thế hệ thu hồi theo vùng trong một đợt quét định kỳ chứ không theo từng object — footprint heap luôn nằm trên live set cho tới lần quét kế.
Mẫu số chung: một giới hạn được thực thi bằng cách thu hồi nguyên khối đơn vị thô (segment, trang, block, vùng) thay vì từng phần tử luôn là một cận dưới, không phải một trần chính xác — dung lượng hay tuổi thọ thật nằm giữa giới hạn và giới-hạn-cộng-một-đơn-vị, và việc thu hồi lại chạy theo một đợt quét định kỳ nên còn thêm một độ trễ nữa; muốn siết chặt bất kỳ cận nào, bạn phải thu nhỏ đơn vị (hạ segment.bytes) hoặc rút ngắn chu kỳ quét, chứ không phải chỉ hạ con số giới hạn. Nên câu hỏi đúng khi dựa vào một chính sách xoá không phải "tôi đặt giới hạn bao nhiêu" mà là "nó xoá từng phần tử hay nguyên khối thô, chạy liên tục hay theo đợt — vì hai điều đó quyết định dung lượng thật vượt con số tôi đặt bao nhiêu, và mất bao lâu dữ liệu quá hạn mới thật sự biến mất". Và với yêu cầu tuân thủ, khoảng cách đó là khác biệt giữa "sẽ xoá" và "đã xoá đúng hạn".
Muốn xem topic nào của bạn chưa có trần dung lượng, chạy thử:
for t in $(kafka-topics.sh --bootstrap-server kf:9092 --list); do
c=$(kafka-configs.sh --bootstrap-server kf:9092 --describe \
--entity-type topics --entity-name "$t")
echo "$c" | grep -q retention.bytes || echo "$t (dùng mặc định broker)"
done
Và đo dung lượng thật của một topic:
kafka-log-dirs.sh --bootstrap-server kf:9092 --describe --topic-list ten-topic \
| grep -oE '"size":[0-9]+' | cut -d: -f2 | paste -sd+ | bc
So con số đó với retention.bytes bạn đã đặt. Nếu nó lớn hơn nhiều lần, bạn vừa tìm ra hai bội số ở bài này.
Phần sau đo cleanup.policy=compact — và cho thấy vì sao "mỗi khoá còn một tin" là câu sai.