0

Đừng để Database "toang" vì Kafka: Giải quyết bài toán Out-of-order Messages từ gốc đến ngọn

Chào anh em cộng đồng Viblo!

Nếu anh em làm Microservices, chắc chắn đã từng hoặc sẽ dùng đến Message Queue (như Kafka, RabbitMQ). Đưa MQ vào kiến trúc giống như việc bạn lên đời từ xe đạp sang siêu xe vậy: scale dễ hơn, decouple các services, chịu tải cực tốt.

NHƯNG, siêu xe thì khó lái. Một trong những "cú lừa" đau đớn nhất mà anh em fresher/mid-level thường gặp khi chơi với Kafka chính là niềm tin ngây thơ: "Mình gửi message A trước, message B sau, thì thằng Consumer chắc chắn sẽ đọc A trước B".

Sự thật là: KHÔNG! Chào mừng bạn đến với cơn ác mộng mang tên "Out-of-order Messages". Bài viết này chúng ta sẽ mổ xẻ tận gốc tại sao hệ thống lại "ngáo" như vậy, và cách các Senior Backend khống chế nó bằng cả cấu hình hạ tầng lẫn code thực chiến.

Pha cà phê và bắt đầu nhé! ☕

1. Nỗi đau thực tế: Khi thứ tự bị đảo lộn (The Nightmare)

Hãy tưởng tượng bạn có một hệ thống E-commerce. Khi người dùng thao tác, Service A (Order Service) sẽ bắn sự kiện (event) vào Kafka, và Service B (Inventory Service) sẽ đọc để trừ/cộng kho.

Trình tự anh em bắn vào Kafka:

  1. ORDER_CREATED (ID: 100) -> Tạo đơn, giữ chỗ tồn kho.
  2. ORDER_UPDATED (ID: 100) -> Khách đổi ý, tăng số lượng mua -> Trừ thêm tồn kho.
  3. ORDER_CANCELLED (ID: 100) -> Khách hủy đơn -> Hoàn lại tồn kho.

Nếu mọi thứ hoàn hảo, Consumer chạy đúng thứ tự 1 -> 2 -> 3. Nhưng một ngày đẹp trời, vì "Out-of-order", Consumer của bạn nhận được theo thứ tự: 3 -> 1 -> 2.

Kết quả? Bạn hoàn tồn kho cho một cái đơn chưa từng tồn tại (lỗi), sau đó bạn tạo đơn (trừ kho), rồi lại update (trừ kho). Cuối cùng: Khách đã hủy đơn nhưng kho của bạn vẫn bị trừ! Data Consistency (Tính nhất quán dữ liệu) chính thức... vứt đi!

2. Tại sao Kafka lại "chia bài" lộn xộn? (The Root Cause)

Kafka không lỗi, nó được thiết kế như vậy để đổi lấy High Throughput (Hiệu suất cao). Có 3 thủ phạm chính gây ra Out-of-order:

Thủ phạm 1: Sự ngộ nhận về Partition

Kafka chỉ đảm bảo thứ tự của message bên trong MỘT Partition. Nếu Topic của bạn có 3 Partitions, và bạn bắn 3 message của cùng một Order vào 3 Partition khác nhau (do thuật toán Round-Robin mặc định), thì 3 Consumer đang lắng nghe sẽ đọc song song. Tốc độ xử lý của từng Consumer khác nhau dẫn đến thứ tự hoàn tất bị sai lệch.

Thủ phạm 2: Network Retries (Mạng lag)

Ví dụ Producer bắn Msg_1Msg_2 đi.

  • Msg_1 bị time-out mạng (dù đã tới Broker nhưng chưa nhận được ACK).
  • Msg_2 bay đến và được Broker ghi nhận thành công.
  • Producer thấy Msg_1 time-out, bèn tự động gửi lại (Retry) và thành công. => Kết quả nằm trong Partition lúc này là: [Msg_2, Msg_1]. Bùm! Sai thứ tự!

Thủ phạm 3: Multithreading ở Consumer

Dù nhận đúng thứ tự, nhưng để tối ưu, code Consumer của bạn lại chia message ra cho các Worker Threads/GoRoutines chạy song song. Thread 1 chạy Msg_1 bị kẹt DB lock mất 2 giây, trong khi Thread 2 chạy Msg_2 mất 0.1 giây và commit xong trước. Lại sai thứ tự!

3. Cách khống chế quái thú: Giải pháp "Từ gốc đến ngọn"

Để giải quyết, chúng ta phải can thiệp ở cả 2 đầu: Producer và Consumer.

Tầng 1: Chặn đứng ở Producer (Routing Key & Config)

1. Sử dụng Message Key (Bắt buộc cho các entity liên quan)

Thay vì để Kafka ném message ngẫu nhiên vào các Partition, hãy gán Key cho message. Các message có cùng Key sẽ luôn được băm (hash) và rơi vào CÙNG MỘT Partition.

Code Demo (Giả lập bằng PHP / Laravel):

// ❌ BAD: Không có key, Kafka phân phối ngẫu nhiên (Round-robin)
Kafka::publish('order-events')->withBody($orderCreatedEvent)->send();
Kafka::publish('order-events')->withBody($orderCancelledEvent)->send();

// ✅ GOOD: Gắn key là Order_ID
// Tất cả event của đơn hàng #100 sẽ chung một Partition, đảm bảo thứ tự!
$orderId = 100;
Kafka::publish('order-events')
    ->withKey((string) $orderId) // <-- KEY CHÍNH LÀ ĐÂY
    ->withBody($orderCreatedEvent)
    ->send();

2. Bật cờ "Idempotent Producer"

Để chống lại "Thủ phạm 2" (Network Retries gây đảo lộn), từ bản 0.11, Kafka hỗ trợ tính năng cực mạnh: Idempotence. Hãy cấu hình Producer của bạn như sau:

  • enable.idempotence=true (Mặc định là true ở Kafka >= 3.0)
  • max.in.flight.requests.per.connection=5 (Nên <= 5)

Bản chất: Kafka sẽ gắn một Sequence ID cho mỗi message. Nếu Msg_1 bị retry và đến sau Msg_2, Broker check Sequence ID và biết ngay đây là message cũ, nó sẽ tự động nắn lại thứ tự hoặc drop duplicate.

Tầng 2: Thiết kế "Phòng thủ" ở Consumer (The Ultimate Backend Pattern)

Dù Producer làm tốt đến đâu, Consumer của bạn vẫn có thể sập, hoặc bạn chủ động xử lý đa luồng. Một Senior Backend không bao giờ tin tưởng 100% vào hạ tầng, họ thiết kế code để miễn nhiễm với Out-of-order.

Đó là kỹ thuật Idempotent Consumer kết hợp Versioning / Sequence ID.

Nguyên tắc: Mỗi Event bắn ra phải kèm theo một version hoặc updated_at (timestamp chuẩn). Consumer chỉ cập nhật DB nếu version của Event đến lớn hơn version đang lưu trong DB.

Code Demo (Laravel Eloquent) mô phỏng thiết kế phòng thủ:

class OrderConsumer
{
    public function handle(KafkaMessage $message)
    {
        $payload = $message->getBody();
        $orderId = $payload['order_id'];
        $eventVersion = $payload['version']; // VD: 2 (Update)
        
        // Dùng DB Transaction
        DB::transaction(function () use ($orderId, $eventVersion, $payload) {
            
            // 1. SELECT FOR UPDATE để tránh Race Condition nếu chạy multithread
            $currentOrder = Order::where('id', $orderId)->lockForUpdate()->first();
            
            if ($currentOrder) {
                // 2. CHECK VERSION: Chỉ update nếu Event mới hơn Data hiện tại
                if ($eventVersion <= $currentOrder->version) {
                    Log::info("Bỏ qua Event cũ/Out-of-order. Event Version: {$eventVersion}, DB Version: {$currentOrder->version}");
                    return; // Nhận Cancel (v=3) trước Update (v=2), khi Update tới thì Drop luôn!
                }
            } else {
                // Nếu chưa có (Nhận Update/Cancel trước khi nhận Create)
                // Phải tạo một bản ghi "Shadow" hoặc lưu vào bảng tạm để chờ bản Create tới bù.
                if ($payload['event_name'] !== 'ORDER_CREATED') {
                    $this->saveToDeadLetterQueue($message); 
                    return;
                }
            }

            // 3. Xử lý logic và LƯU VERSION MỚI
            $currentOrder->status = $payload['status'];
            $currentOrder->version = $eventVersion; // Update version mới nhất
            $currentOrder->save();
        });
    }
}

4. Kinh nghiệm "Xương máu" chia sẻ thêm

1. Dead Letter Queue (DLQ) là cứu cánh: Trong trường hợp Out-of-order quá nặng (nhận sự kiện Xóa trước khi nhận sự kiện Tạo), đừng ném Exception làm văng hệ thống. Hãy đẩy message không hợp lệ đó vào một Topic gọi là DLQ. Sẽ có một cronjob (worker) chạy sau để quét DLQ và xử lý lại khi hệ thống đã đồng bộ.

  1. Sự đánh đổi (Trade-off): Cố gắng bắt Kafka giữ thứ tự tuyệt đối trên toàn cục (Global Ordering) bằng cách chỉ dùng 1 Partition là tự bóp dái hiệu năng. Hãy chia Partition mạnh tay, nhưng dùng Message Key để nhóm các dữ liệu có quan hệ nhân quả vào với nhau.

  2. Optimistic Locking: Nếu không muốn dùng lockForUpdate (Pessimistic) vì sợ nghẽn DB, hãy dùng Optimistic Locking bằng điều kiện WHERE id = ? AND version < ?. Nếu affected_rows == 0, nghĩa là Event này đã out-of-date.

5. Lời kết

Message Queue sinh ra để giải quyết bài toán scale, nhưng nó mang theo một hệ lụy đau đầu về tính nhất quán dữ liệu (Data Consistency). Xử lý "Out-of-order" không chỉ là config vài dòng trong Kafka, mà nó là tư duy thiết kế hệ thống.

Bằng cách kết hợp Routing Key ở Producer và Thiết kế Idempotent (Versioning) ở Consumer, hệ thống của bạn sẽ trở nên "mình đồng da sắt" trước mọi sự cố mạng mẽo hay lệch nhịp.

Anh em ở công ty đang dùng Kafka hay RabbitMQ? Đã từng ăn hành vụ data chạy lộn xộn này bao giờ chưa? Cùng bình luận chém gió nhé! Đừng quên upvote nếu thấy bài viết gãi đúng chỗ ngứa!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí