Có một cái bẫy suy nghĩ khi ta bỏ một thành phần: tưởng rằng bỏ công cụ là bỏ luôn công việc nó làm. Nhưng công cụ chỉ là chỗ chứa công việc, và nếu công việc đó là nội tại của bài toán thì nó không biến mất — nó dời sang chỗ khác. Kafka bỏ ZooKeeper là một ca kinh điển của chuyện này: gánh nặng vận hành giảm thật, nhưng yêu cầu về đồng thuận đa số thì còn nguyên, chỉ chuyển vào trong Kafka. Tôi sẽ dựng cả hai kiểu và đo.
Suốt mười năm Kafka cần ZooKeeper để lưu siêu dữ liệu. Từ Kafka 4.0 thì không còn. Bài này dựng cả hai kiểu cạnh nhau và đo.
Dựng cả ngăn xếp từ con số không
ZooKeeper (confluentinc/cp-kafka:7.6.1) |
Thời gian | Bộ nhớ |
|---|---|---|
| ZooKeeper sẵn sàng | 16,5 giây | 91 MB |
| broker sẵn sàng | 9,8 giây | 349 MB |
| tổng | 26,3 giây | 450 MB, 2 container |
KRaft (apache/kafka:3.9.0), cùng máy, cùng lúc |
||
|---|---|---|
| sẵn sàng | 2,8 giây | 339 MB, 1 container |
Chín lần nhanh hơn, ít hơn một trăm megabyte, và bớt một tiến trình phải vận hành.
Con số 26,3 giây kia là phần lớn lý do ai từng dựng Kafka trước 2022 đều nhớ nó là "nặng". Rất nhiều ấn tượng đó thuộc về ZooKeeper chứ không thuộc về Kafka.
(Hai image khác nhau nên phép so sánh không hoàn toàn công bằng ở mức từng mili giây. Nhưng khoảng cách chín lần thì lớn hơn nhiều so với mọi khác biệt do đóng gói.)
Khởi động lại với 200 topic: chênh ít hơn tôi tưởng
KRaft (200 topic) 2,8 giây
ZooKeeper (200 topic) 3,7 giây
Chỉ 1,3 lần. Tôi chờ đợi con số lớn hơn nhiều, dựa trên những gì hay được viết về KRaft.
Lợi thế lớn của KRaft nằm ở quy mô hàng chục nghìn partition: ở đó, controller kiểu cũ phải đọc lại toàn bộ trạng thái từ ZooKeeper mỗi lần bầu lại, và việc đó tính bằng phút. Ở 200 topic thì chưa thấy gì, và tôi ghi lại đúng như đo được thay vì mượn con số của người khác.
Còn một phép đo tôi đã làm và nó vô nghĩa: tạo 200 topic mất 160,3 giây với KRaft và 158,9 giây với ZooKeeper. Hai con số gần bằng nhau vì cả hai đều là 200 lần khởi động JVM của công cụ dòng lệnh — 200 × 0,86 giây ≈ 172 giây, đúng cỡ đó. Phép đo này đo kafka-topics.sh, không đo Kafka. Đây là chính cái bẫy đã nói ở phần 2, và tôi vẫn vấp lại.
ZooKeeper chết thì Kafka còn làm được gì
Đây là câu hỏi thú vị nhất, và câu trả lời không phải "mọi thứ dừng".
docker kill zk, rồi thử từng thao tác:
| Thao tác | Kết quả |
|---|---|
| ghi 50 tin | OK — offset 200 → 250 |
| đọc theo partition | OK — x1 x2 x3 |
| đọc bằng nhóm consumer | OK — 250 tin |
| liệt kê topic | OK — 201 dòng |
| tạo topic mới | HỎNG — TimeoutException |
Mặt phẳng dữ liệu chạy tiếp. Mặt phẳng điều khiển dừng.
Broker giữ một bản sao siêu dữ liệu trong bộ nhớ, nên nó vẫn biết partition nào ở đâu, vẫn nhận ghi, vẫn phục vụ đọc. Cái nó không làm được là thay đổi siêu dữ liệu: tạo topic, đổi cấu hình, bầu lại leader khi có broker chết.
Đây chính là chỗ nguy hiểm. Sự cố ZooKeeper thường bị phát hiện rất muộn — mọi thứ vẫn chạy bình thường cho tới lần đầu tiên có ai đó cần tạo topic, hoặc tới lần đầu tiên một broker chết và không ai bầu được leader thay. Lúc đó thì đã có hai sự cố chồng lên nhau.
Một chi tiết tôi quan sát được trong lúc đo: lần đọc đầu tiên ngay sau khi giết ZooKeeper trả về 0 tin, lần thử lại sau đó trả đủ 250. Có một khoảng chuyển tiếp ngắn khi việc điều phối nhóm chưa ổn định. Tôi không xác định được chính xác nó dài bao lâu, nên chỉ ghi lại là nó tồn tại.
Bật lại ZooKeeper thì tạo topic hoạt động lại ngay, không cần khởi động lại broker.
KRaft khác chỗ nào
ZooKeeper là một hệ thống riêng biệt: cụm riêng, giao thức riêng, cách theo dõi riêng, phiên bản riêng cần nâng cấp. Siêu dữ liệu Kafka nằm trong đó dưới dạng cây znode, và mỗi lần controller đổi thì nó phải đọc lại.
KRaft đưa siêu dữ liệu vào chính Kafka: một topic nội bộ tên __cluster_metadata, sao chép bằng giao thức Raft giữa các node controller. Không có hệ thống thứ hai.
Ba hệ quả thực tế:
Bầu lại controller nhanh hơn. Controller mới đã có sẵn siêu dữ liệu vì nó là một bản sao của log đó, không phải đọc lại từ đâu cả.
Một cách vận hành duy nhất. Cùng công cụ, cùng cách theo dõi, cùng cách nâng cấp. Trước đây phải học hai hệ thống.
Số node quorum vẫn cần đa số. Đây là chỗ dễ nhầm: KRaft không xoá bỏ yêu cầu về quorum, nó chỉ chuyển quorum đó vào Kafka. Ba controller thì chịu được một chết; năm thì chịu được hai. Phần 14 đã đo hậu quả khi mất quorum — kể cả min.insync.replicas=1 cũng không ghi được.
Gộp vai hay tách vai
Với cụm nhỏ, mỗi node mang cả hai vai:
process.roles=broker,controller
Đơn giản, nhưng phần 14 đã cho thấy cái giá: mất hai trong ba node là mất luôn quorum, và cả cụm dừng — không cấu hình topic nào cứu được.
Với cụm sản xuất, tách ra:
# 3 hoặc 5 node controller nhỏ
process.roles=controller
# các node broker
process.roles=broker
Khi ấy bảo trì broker không bao giờ chạm tới quorum, và controller có thể chạy trên máy nhỏ vì chúng chỉ giữ siêu dữ liệu. Phần 15 phải dựng đúng cấu hình này mới dựng nổi kịch bản mất dữ liệu — với cụm gộp vai thì không dựng được, vì cụm chết trước khi kịch bản kịp xảy ra.
Mốc thời gian
| Phiên bản | |
|---|---|
| Kafka 2.8 | KRaft ở dạng xem trước |
| Kafka 3.3 | KRaft ổn định cho môi trường thật |
| Kafka 3.5 | ZooKeeper bị đánh dấu lỗi thời |
| Kafka 4.0 | ZooKeeper bị gỡ hẳn |
Với cụm đang chạy trên ZooKeeper, việc chuyển đổi có công cụ hỗ trợ (chế độ migration từ 3.4) nhưng là một quy trình nhiều bước cần lên kế hoạch. Cụm mới thì không có gì phải cân nhắc — dùng KRaft.
Cùng một lằn ranh "bỏ công cụ không bỏ được yêu cầu nội tại", đổi ngăn xếp
Chuyện KRaft bỏ ZooKeeper mà quorum vẫn còn không phải đặc sản Kafka. Nó là một ca của phân biệt kinh điển giữa độ phức tạp ngẫu nhiên (do công cụ, bỏ được) và độ phức tạp bản chất (do bài toán, chỉ dời được) — cái mà Fred Brooks gọi tên từ lâu.
Trong thế giới cơ sở dữ liệu, đây đúng là ảo tưởng "bỏ Redis đi cho gọn": bỏ được cái hệ thống Redis, nhưng công việc nó làm — đệm bớt tải đọc — thì không biến mất; bạn hoặc dời nó vào cache trong tiến trình, hoặc trả lại bằng độ trễ. Bỏ ORM không bỏ được việc ánh xạ hàng thành object, chỉ chuyển nó sang tay bạn viết. Bỏ bộ cân bằng tải không bỏ được nhu cầu chia tải.
Trong thế giới đồng thuận phân tán, Raft, Paxos, ZAB (thứ ZooKeeper dùng) là ba cách cài đặt cùng một bài toán nội tại, và đổi cài đặt nào thì yêu cầu 2f + 1 để chịu được f node chết vẫn còn y nguyên — vì nó là định lý, không phải lựa chọn kỹ thuật. KRaft đổi từ ZAB sang Raft và gói gọn vào Kafka, nhưng "cần đa số mới quyết được" thì không cách nào bỏ.
Mẫu số chung: khi bỏ hay thay một thành phần, câu hỏi đúng là nó đang cung cấp một dịch vụ ngẫu nhiên (bỏ được thật) hay đang thoả một yêu cầu nội tại của bài toán (chỉ dời được, không bỏ được) — đồng thuận và quorum là nội tại của việc giữ siêu dữ liệu phân tán nhất quán, nên KRaft dời chúng vào trong Kafka chứ không xoá; gánh nặng vận hành giảm (một hệ thống thay vì hai), nhưng yêu cầu về đa số thì còn nguyên. Nên câu hỏi đúng trước khi ăn mừng "đã bỏ được X" không phải "còn bao nhiêu hệ thống phải vận hành" mà là "cái X đang làm là một tiện ích ngẫu nhiên hay một yêu cầu nội tại của bài toán — nếu nội tại, thì nó vừa dời đi đâu, và tôi đã hiểu nó ở chỗ mới chưa".
Muốn xem cụm của bạn đang chạy chế độ nào, chạy thử:
# có trả lời = KRaft
kafka-metadata-quorum.sh --bootstrap-server kf:9092 describe --status
# đếm dòng log nhắc ZooKeeper
docker compose logs kafka 2>&1 | grep -ic zookeeper
Trong phép đo của tôi, broker chạy ZooKeeper có 80 dòng log nhắc tới nó ngay trong lần khởi động đầu tiên. Broker KRaft thì không có dòng nào.
Phần sau đo lược đồ dữ liệu — và một tin hỏng duy nhất khiến consumer ném hơn tám triệu ngoại lệ trong mười hai giây.