← Bài trước | Series hub | Tiếp theo →

Chương 4: Xóa Sổ Cơn Ác Mộng Dual-Write

Ngay khi ứng dụng Golang của bạn dịch chuyển từ Monolith (kiến trúc nguyên khối) sang mô hình Event-Driven Microservices, bạn sẽ lập tức nếm mùi một cơn ác mộng kiến trúc: Vấn Đề Dual-Write (Ghi song song).

1. Vấn Đề Dual-Write Là Gì?

Answer-first: Dual-Write bộc phát lúc một ứng dụng thử cố gắng vừa lưu chép xuống Database vừa thả (publish) event tới Message Broker (Kafka) cùng một lúc. Do không có vỏ bọc của một transaction phân tán (distributed transaction), chỉ một lần vấp lỗi mạng cũng đủ sức khiến hai hệ thống trên rớt nhịp đồng bộ mãi mãi.

Thử ngó qua một mạch thanh toán (checkout flow) rất thân quen:

// Bước 1: Lưu chép order xuống DB
db.Save(&order)

// Bước 2: Bắn event "OrderCreated" vào Kafka gọi dịch vụ Delivery
kafka.Publish("order_events", orderEvent)

Trông cũng ổn thỏa đấy, nhưng chuyện gì sẽ ập tới nếu:

  • Kịch bản A: DB lưu trót lọt, nhưng Kafka tự nhiên tắt thở hay cá mập cắn cáp mạng. Dịch vụ hiện tại hô hào báo thành công, thế nhưng dịch vụ Delivery lại mòn mỏi chẳng nhận nổi cái event nào để đi ship hàng.
  • Kịch bản B (Đảo ngược code): Thử bắn qua Kafka trước, rồi mới thả vô DB. Rủi thay DB Save đụng ngay lỗi Unique constraint (vi phạm khóa duy nhất) và báo lệnh rollback (quay xe), thì dịch vụ Delivery lúc này đang hì hục lo đi ship một cái “Đơn Hàng Ma” chẳng mảy may có mặt dưới DB.

Nguyên cớ là thao tác ghi vào hệ PostgreSQL và lệnh publish gọi sang Kafka không thể xài chung một cái ACID Transaction, nên bạn tuyệt vọng chẳng thể nào đưa ra cam kết rằng cả hai bên phải cùng thành công hoặc phải cùng chết chùm.

2. Dùng Transactional Outbox Pattern Trị Bệnh

Answer-first: Outbox Pattern làm phép màu hoán chuyển cái công đoạn “publish một event” biến thành một lệnh insert thẳng vào một Database cục bộ. Kỹ thuật ép chung cấu trúc nghiệp vụ (business entity) và cái event đứng chung dưới ô một cái SQL transaction, tính nguyên tử (atomicity) sẽ được đảm bảo chắc chắn.

Mô hình Transactional Outbox cấy đặt thêm một “Outbox table” (bảng hộp thư đi) chui tọt vào trong chính cái primary database.

Luồng Bơm Dữ Liệu (The Writer Flow): Hủy ngay ý định bắn lệnh qua Kafka, mở chốt một vòng SQL Transaction (sql.Tx). Phía trong lòng transaction này, ta đệm lệnh INSERT bắn nguyên order vô bảng orders song song đó đánh tiếp INSERT cục event vô trong bảng outbox_events. Nhờ cậy ơn hai bảng này trú ngụ cùng một mái nhà DB, câu lệnh tx.Commit() giăng màng cam đoan độ chuẩn xác (atomicity): nếu có order ló mặt, event chắn chắn lọt vô nằm chờ tại Outbox.

tx := db.Begin()
// 1. Chốt lệnh lưu mảng Order cốt lõi
tx.Create(&order)

// 2. Chốt Event ném vào bảng Outbox
outboxEvent := OutboxEvent{
    AggregateID: order.ID,
    Type: "OrderCreated",
    Payload: jsonPayload,
    Status: "PENDING",
}
tx.Create(&outboxEvent)

tx.Commit() // Cực kỳ an tâm
graph TD
    A[Order Service] -->|1. Begin SQL Tx| DB[(PostgreSQL)]
    
    subgraph ACID Transaction
        DB -->|2. INSERT| T1[orders table]
        DB -->|3. INSERT| T2[outbox_events table]
    end
    
    T2 -.->|4. CDC / Polling| Kafka[Kafka Broker]
    Kafka -.->|5. Consume| B[Delivery Service]

3. Guồng Máy Chuyển Phát (Relay Engine): Gom Thư Từ Outbox Giao Kafka

Ở đoạn này, cái event mới chỉ tòn ten có mặt bên trong DB. Bắt buộc phải xài thêm một cổ máy “Relay” (tiếp sức) để bốc đám này đi ném sang Message Queue. Điểm qua hai chiêu thức thông dụng:

  • Chiêu 1: Polling Worker (Kiểu Startups/Dự án nhỏ). Một tiến trình ngầm Goroutine thức trực chốc chốc vài giây dội lệnh: SELECT * FROM outbox_events WHERE status='PENDING'. Nó lấy đám events tóm được quăng lên Kafka, rồi cộp mộc sửa status lại thành SENT. (Bình dân dễ xài, khổ nỗi nó cứ vắt kiệt sức gõ query nện liên tùng tục vào DB).
  • Chiêu 2: Change Data Capture (CDC - Đẳng cấp hệ thống lớn). Xài đồ chơi khủng như Debezium. Nó chọc ống hút thẳng vô cuống phổi Transaction Log của DB (Postgres WAL hay MySQL Binlog), tự động ngửi hơi hễ thấy bảng outbox_events có biến động nhúc nhích là stream ngay lên Kafka. Đồ chơi xịn mang tốc độ cập nhật lướt như thời gian thực (real-time) mà độ ồn ào ăn query vào DB lại là con số không tròn trĩnh.

Báo Động Đỏ: Lượng Giao Hàng At-Least-Once

Mô hình Outbox Pattern xử trí cực bén bài toán Dual-Write, tuy vậy cũng chỉ dám chắc mẩm tới hạng mức “At-least-once” (giao tới ít nhất một lần). Cứ hình dung anh chàng Polling Worker hớn hở quăng cục hàng vô Kafka trót lọt rồi, khốn thay trượt chân ngã sấp mặt sập nguồn trước khi kịp phệt lệnh UPDATE status='SENT', khi hồi tỉnh lại anh chàng này nhất quyết vác mặt lên nhồi đi nhồi lại bắn đúng cái event đó thêm lần nữa.

Tựu trung, người tiêu thụ (Consumer service) ở bến đáp downstream bắt buộc phải gài chốt Idempotency (tính toán đồng dạng kết quả) nhằm cam kết rào cản rằng dù ăn trùng cả rổ lặp events thì đích đến cuối cùng của logic kinh doanh kết tủa ra y xì đúc không có lấy nửa độ lệch. Chúng ta sẽ cùng lặn sâu ngụp lặn vào rốn Thiết kế Idempotency API tại tận Chương 7!