0

Cấu trúc dữ liệu và Giải thuật Bài 97: Kiến trúc CQRS và Event Sourcing - Phá vỡ giới hạn hiệu năng bằng cách tách biệt Database Đọc và Ghi.

Trong các kiến trúc MVC truyền thống (như khi viết ứng dụng bằng Laravel Eloquent), chúng ta thường dùng chung một Entity Model và một Database MySQL cho cả thao tác Đọc (Read) và Ghi (Write). Tuy nhiên, trong một hệ thống E-commerce thực tế, tỷ lệ giao thông mạng hoàn toàn bất đối xứng: 99% request của người dùng là để lướt xem sản phẩm (Đọc), và chỉ 1% thực sự bấm nút thanh toán (Ghi).

Nếu dùng chung một Database, các lệnh INSERT/UPDATE từ 1% kia sẽ sinh ra Table Lock/Row Lock, làm nghẽn hàng triệu lượt SELECT đang cố lấy dữ liệu. Để phá vỡ giới hạn vật lý này, hệ thống cần được tái cấu trúc bằng CQRS (Command Query Responsibility Segregation) kết hợp cùng Event Sourcing.

1. CQRS: Phân ly Trách nhiệm Lệnh và Truy vấn

CQRS bẻ đôi hệ thống thành hai luồng hoàn toàn độc lập với nhau:

  • Command (Lệnh Ghi): Xử lý các thao tác làm thay đổi trạng thái (CreateOrder, UpdateInventory). Luồng này tập trung vào tính toàn vẹn (ACID), thường sử dụng cơ sở dữ liệu quan hệ (PostgreSQL, MySQL) với mô hình chuẩn hóa (Normalized).

  • Query (Lệnh Đọc): Xử lý việc hiển thị dữ liệu (GetOrderDetails, SearchProducts). Luồng này không làm thay đổi trạng thái, được phép đọc từ một cơ sở dữ liệu hoàn toàn khác, thiết kế phi chuẩn hóa (Denormalized) và tối ưu 100% cho tốc độ, chẳng hạn như Elasticsearch hoặc Redis.

image.png

Sự phân tách này mang lại quyền năng Mở rộng độc lập (Independent Scaling). Nếu lượng người dùng tìm kiếm sản phẩm tăng vọt, bạn chỉ cần nới rộng cụm Elasticsearch của luồng Query mà không cần cấp phát thêm tài nguyên cho cụm MySQL xử lý đơn hàng.

2. Event Sourcing: Lưu trữ Quá khứ thay vì Hiện tại

Làm sao để Database Đọc đồng bộ kịp thời với Database Ghi nếu chúng là 2 hệ thống khác biệt? Chúng ta không thể chạy lệnh copy-paste theo chu kỳ. Giải pháp triệt để nhất là Event Sourcing (Lưu vết sự kiện).

Trong các hệ thống CRUD cổ điển, khi đơn hàng chuyển từ Pending sang Paid, hệ thống chạy lệnh UPDATE đè lên bản ghi. Trạng thái cũ biến mất vĩnh viễn. Event Sourcing thay đổi tận gốc tư duy này: Không bao giờ lưu trạng thái hiện tại, chỉ lưu chuỗi các Sự kiện (Events) đã xảy ra.

Thay vì lưu một dòng Order(ID: 1, Status: Paid), hệ thống ghi vào Event Store (chỉ cho phép Append-only, cấm Update/Delete):

  1. OrderCreatedEvent (Time: 10:00, Payload: ID 1)

  2. PaymentProcessedEvent (Time: 10:05, Payload: ID 1)

  3. StatusChangedToPaidEvent (Time: 10:06, Payload: ID 1)

Trạng thái hiện tại của đơn hàng được tính ra bằng cách Phát lại (Replay) toàn bộ các sự kiện từ đầu. Cơ chế này giống hệt sổ cái kế toán hoặc tài khoản ngân hàng: Số dư hiện tại không phải là một con số tĩnh, mà là tổng của tất cả các giao dịch Nạp/Rút từ lúc mở thẻ.

3. Bản giao hưởng CQRS + Event Sourcing

Khi kết hợp hai pattern này, chúng ta có một kiến trúc phân tán luồng dữ liệu một chiều (Unidirectional Data Flow) cực kỳ mạnh mẽ:

  1. Command API: Nhận request tạo đơn hàng.

  2. Event Store: Lưu sự kiện OrderCreatedEvent. Sự kiện này là nguồn chân lý duy nhất (Single Source of Truth).

  3. Message Broker: Event Store đẩy sự kiện đó vào Kafka.

  4. Projections (Hình chiếu): Các Background Workers (Consumers) của luồng Query lắng nghe Kafka. Khi nhận OrderCreatedEvent, chúng lập tức "nhào nặn" dữ liệu và ghi vào Read Database theo định dạng tối ưu nhất cho giao diện UI.

  5. Query API: Khi Frontend gọi API tìm kiếm, hệ thống query thẳng vào Read DB với tốc độ cực thấp.

4. Triển khai cấu trúc CQRS bằng Golang

Dưới đây là mô hình phân tách logic cốt lõi trong mã nguồn Golang, thể hiện rõ việc chia đôi cấu trúc dữ liệu cho phần Command và phần Query:

Go

package main

import (
	"fmt"
	"time"
)

// --- EVENT SOURCING ---
// Interface định nghĩa Event
type Event interface {
	EventType() string
}

type OrderCreatedEvent struct {
	OrderID   string
	ProductID string
	Amount    int
	Timestamp time.Time
}
func (e OrderCreatedEvent) EventType() string { return "OrderCreated" }

// --- COMMAND (Luồng Ghi) ---
type OrderCommandService struct {
	eventStore []Event // Giả lập Event Store (Kafka / EventStoreDB)
}

// Xử lý logic và phát sinh Event thay vì INSERT/UPDATE trực tiếp
func (cmd *OrderCommandService) CreateOrder(orderID, productID string, amount int) {
	fmt.Printf("[Command Service] Đang xử lý lệnh tạo đơn: %s\n", orderID)
	
	event := OrderCreatedEvent{
		OrderID:   orderID,
		ProductID: productID,
		Amount:    amount,
		Timestamp: time.Now(),
	}
	
	// Lưu vào Event Store (Đặc tính Append-only)
	cmd.eventStore = append(cmd.eventStore, event)
	fmt.Println("[Event Store] Đã lưu sự kiện OrderCreatedEvent thành công.")
	
	// Thực tế: Sau khi lưu, Event Store sẽ Publish sự kiện này ra Kafka
	// để Projection Worker ở luồng Query hứng lấy
	go ProjectionWorker(event)
}

// --- QUERY (Luồng Đọc) ---
// View Model được tối ưu hóa sẵn để đọc, phi chuẩn hóa (Denormalized)
type OrderReadModel struct {
	OrderID     string
	Description string // Cấu trúc đã nhào nặn để hiển thị thẳng trên UI
}

// Giả lập Read Database (Redis / Elasticsearch)
var ReadDatabase = make(map[string]OrderReadModel)

// Worker đồng bộ hóa Read Database khi có Event mới từ hệ thống (Projection)
func ProjectionWorker(event Event) {
	switch e := event.(type) {
	case OrderCreatedEvent:
		// Nhào nặn dữ liệu phù hợp cho việc Query
		desc := fmt.Sprintf("Đơn hàng mua %d sản phẩm mã %s", e.Amount, e.ProductID)
		ReadDatabase[e.OrderID] = OrderReadModel{
			OrderID:     e.OrderID,
			Description: desc,
		}
		fmt.Printf("[Projection Engine] Đã cập nhật View Model vào Read DB cho đơn %s\n", e.OrderID)
	}
}

type OrderQueryService struct{}

// Hàm Query chỉ đọc từ ReadDB, tuyệt đối không đụng vào luồng Event Store
func (q *OrderQueryService) GetOrderSummary(orderID string) OrderReadModel {
	fmt.Printf("[Query Service] Đang truy xuất thông tin đơn %s...\n", orderID)
	return ReadDatabase[orderID]
}

func main() {
	cmdService := &OrderCommandService{}
	queryService := &OrderQueryService{}

	// 1. Khách hàng thực hiện lệnh (Write)
	cmdService.CreateOrder("ORD-2026", "Hasaki-B5", 2)

	// Đợi Projection Worker đồng bộ ngầm qua mạng
	time.Sleep(100 * time.Millisecond)

	// 2. Khách hàng xem danh sách đơn hàng của mình (Read)
	summary := queryService.GetOrderSummary("ORD-2026")
	fmt.Printf("\nKết quả hiển thị UI: %+v\n", summary)
}

5. Đánh đổi (Trade-offs) của Kiến trúc

CQRS và Event Sourcing mang lại sức mạnh to lớn nhưng đi kèm với cái giá tương đương:

  • Tính nhất quán cuối cùng (Eventual Consistency): Khi một đơn hàng vừa được ghi vào Command DB, phải mất vài chục mili-giây để Kafka đẩy nó sang Query DB. Nếu Frontend gọi API Query ngay lập tức sau khi nhấn "Mua", họ có thể nhận về kết quả rỗng. Hệ thống giao diện phải tự che lấp độ trễ này (Ví dụ: Hiển thị trạng thái "Đang xử lý đơn hàng" ở local thay vì reload API ngay).

  • Dung lượng lưu trữ: Event Store sẽ phình to vô hạn theo thời gian. Giải pháp quản lý bộ nhớ là thiết lập các Snapshots (Lưu ảnh chụp trạng thái tổng hợp cứ mỗi 1000 events), để khi hệ thống cần Replay lại dữ liệu, nó không phải đọc lại từ con số 0.

  • Độ phức tạp mã nguồn: Số lượng Struct, Interface, và file sẽ tăng lên gấp 3-4 lần so với một hệ thống CRUD thông thường.

CQRS thường chỉ được áp dụng ở những Domain cốt lõi có logic cực kỳ phức tạp và chịu áp lực tải khổng lồ (như Hệ thống Kế toán, Quản lý Tồn kho, Xử lý Giao dịch). Đối với những tính năng hành chính đơn giản, kiến trúc CRUD vẫn là lựa chọn sắc bén nhất.


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í