Sáu tháng với Event Sourcing: Nhìn lại quá trình vận hành thực tế
Sau khi quản lý một hệ thống tài chính xử lý hơn 50.000 giao dịch mỗi ngày, tôi nhận ra rằng mô hình CRUD (Create, Read, Update, Delete) truyền thống thường thất bại khi tính minh bạch là yếu tố tiên quyết. Khi khách hàng hỏi tại sao số dư của họ giảm 50$ qua đêm, một hàng duy nhất trong cơ sở dữ liệu chỉ hiển thị trạng thái hiện tại là vô dụng. Bạn cần một mốc thời gian (timeline). Bạn cần một bản ghi về mọi ý định và hành động dẫn đến kết quả đó.
Làm chủ các mô hình này là cầu nối giữa việc xây dựng các ứng dụng cơ bản và thiết kế các hệ thống xử lý hàng triệu đô la mà không gặp trở ngại nào. NestJS giúp quá trình chuyển đổi này dễ dàng hơn với gói @nestjs/cqrs. Nó cung cấp một cách có cấu trúc để tách biệt các mối quan tâm (separation of concerns). Tôi đã ghi lại các mô hình cụ thể mà đội ngũ của mình đã sử dụng để đi từ các ví dụ lý thuyết đến một môi trường production ổn định.
Thiết lập trong 5 phút
Thiết lập CQRS trong NestJS khá đơn giản. Trước tiên, bạn sẽ cần thư viện cốt lõi:
npm install @nestjs/cqrs
Chúng ta sẽ xây dựng một hệ thống giao dịch tinh gọn để minh họa luồng hoạt động. Bắt đầu bằng việc đăng ký CqrsModule trong feature module của bạn để kích hoạt command bus và event bus.
// transactions.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TransactionController } from './transaction.controller';
import { CreateTransactionHandler } from './commands/handlers/create-transaction.handler';
@Module({
imports: [CqrsModule],
controllers: [TransactionController],
providers: [CreateTransactionHandler],
})
export class TransactionsModule {}
Tiếp theo, định nghĩa một Command. Đây là một DTO đơn giản đại diện cho ý định thay đổi hệ thống của người dùng.
// create-transaction.command.ts
export class CreateTransactionCommand {
constructor(
public readonly accountId: string,
public readonly amount: number,
) {}
}
Cuối cùng, tạo Handler. Đây là nơi chứa logic nghiệp vụ của bạn. Handler sẽ tiếp nhận command và thực thi thao tác.
// create-transaction.handler.ts
import { CommandHandler, ICommandHandler, EventPublisher } from '@nestjs/cqrs';
import { CreateTransactionCommand } from '../impl/create-transaction.command';
@CommandHandler(CreateTransactionCommand)
export class CreateTransactionHandler implements ICommandHandler<CreateTransactionCommand> {
constructor(private readonly publisher: EventPublisher) {}
async execute(command: CreateTransactionCommand) {
const { accountId, amount } = command;
// Logic: Xác thực tài khoản, kiểm tra hạn mức, v.v.
return { success: true };
}
}
Cơ chế của trạng thái: Tại sao nên kết hợp CQRS với Event Sourcing?
CQRS tách biệt logic ghi (write) khỏi logic đọc (read). Trong môi trường của chúng tôi, điều này cho phép tối ưu hóa các bản sao SQL read-only cho các báo cáo phức tạp, trong khi vẫn giữ cho phía ghi dữ liệu tinh gọn và tập trung vào tính toàn vẹn. Đó là việc sử dụng đúng công cụ cho đúng mục đích.
Nguồn sự thật duy nhất: Event Store
Các hệ thống truyền thống lưu trữ số dư hiện tại. Các hệ thống sử dụng Event Sourcing thì ngược lại: chúng lưu trữ lịch sử. Bạn không lưu “Số dư: 130$”. Thay vào đó, bạn lưu Deposit($100), Withdraw($20), và Deposit($50). Số dư hiện tại của bạn chỉ đơn giản là tổng của các sự kiện này. Điều này tạo ra một Audit Log tự nhiên. Nếu bạn phát hiện ra lỗi tính toán, bạn có thể chạy lại (replay) toàn bộ lịch sử để xem chính xác thời điểm nào trạng thái bị sai lệch.
Hành trình của dữ liệu
- Command: Người dùng yêu cầu “Chuyển 500$”.
- Handler: Hệ thống xác minh người dùng có đủ tiền.
- Event: Một
MoneyTransferredEventđược ghi vào Event Store. - Projector: Một trình lắng nghe bất đồng bộ cập nhật bảng read-only để giao diện người dùng (UI) có thể hiển thị số dư mới ngay lập tức.
Sự tách biệt này cho phép chúng tôi mở rộng hệ thống theo chiều ngang. Chúng tôi có thể thay đổi yêu cầu dữ liệu của UI mà không cần chạm vào logic giao dịch cốt lõi.
Các mô hình nâng cao: Sagas và Snapshots
Các quy trình làm việc trong thực tế hiếm khi chỉ có một bước. Một lệnh chuyển khoản ngân hàng bao gồm việc rút tiền từ một tài khoản và gửi vào một tài khoản khác. Nếu bước thứ hai thất bại, bạn cần một cách để hoàn tác. Đây là lúc Sagas phát huy tác dụng.
Điều phối quy trình với Sagas
Một Saga là một tiến trình chạy lâu dài (long-running process) lắng nghe các sự kiện và kích hoạt các command mới. Trong NestJS, Sagas sử dụng RxJS observables để quản lý các luồng phức tạp này.
@Injectable()
export class TransactionSagas {
@Saga()
transactionCreated = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(MoneyWithdrawnEvent),
map((event) => new DepositMoneyCommand(event.targetId, event.amount)),
);
}
}
Tối ưu hiệu suất với Snapshots
Việc chạy lại 10.000 sự kiện để tính toán một số dư duy nhất là tác nhân gây ra timeout. Chúng tôi đã giải quyết vấn đề này bằng cách triển khai Snapshots. Cứ sau mỗi 100 sự kiện, chúng tôi lưu một “điểm kiểm tra trạng thái” (state checkpoint). Để tính số dư hiện tại, chúng tôi chỉ cần tải snapshot mới nhất và chạy lại một vài sự kiện xảy ra sau đó. Điều này đã cắt giảm thời gian tải dữ liệu tổng hợp từ 2,5 giây xuống còn dưới 40 mili giây.
Những bài học từ môi trường thực tế
Xây dựng các hệ thống này đã dạy cho tôi nhiều bài học mà các tài liệu hướng dẫn thường bỏ qua.
1. Sự kiện là bất biến (Immutable)
Không bao giờ được sửa đổi một sự kiện đã được lưu trữ. Nếu bạn đã áp dụng sai lãi suất, đừng chỉnh sửa cơ sở dữ liệu. Thay vào đó, hãy phát hành một Compensating Event (Sự kiện bù đắp) để điều chỉnh số dư. Điều này giữ cho nhật ký kiểm tra của bạn trung thực và tuân thủ về mặt pháp lý.
2. Đánh phiên bản cho sự kiện sớm
Yêu cầu kinh doanh thay đổi rất nhanh. TransactionCreatedEvent của bạn có thể cần thêm trường taxId vào tháng tới. Luôn bao gồm số phiên bản (version number) trong schema của sự kiện. Mã nguồn của bạn phải xử lý đồng thời các sự kiện v1 và v2 để tránh làm gián đoạn hệ thống trong quá trình cập nhật.
3. Tránh thiết kế quá mức (Over-Engineering)
CQRS làm tăng lượng code mẫu (boilerplate). Nếu bạn đang xây dựng một blog đơn giản hoặc một công cụ nội bộ dựa trên CRUD, đây là sự lãng phí. Chỉ sử dụng mô hình này cho các domain cốt lõi nơi lịch sử dữ liệu là yêu cầu bắt buộc. Nếu một câu lệnh UPDATE đơn giản có thể giải quyết vấn đề, hãy dùng nó. Nhưng nếu bạn cần chứng minh tại sao một giá trị thay đổi, NestJS CQRS là người bạn đồng hành tốt nhất.
4. Tận dụng hệ sinh thái
Đừng tự xây dựng một event store từ đầu trong lần thử đầu tiên. Các trường hợp biên (edge cases) liên quan đến tính đồng thời và sequence ID rất phức tạp. Hãy dựa vào các thư viện đã được kiểm chứng như nestjs-eventstore hoặc các giải pháp chuyên dụng như EventStoreDB để xử lý các công việc nặng nhọc.
Tôi không còn coi cơ sở dữ liệu chỉ là một bản chụp tức thời của hiện tại. Thay vào đó, nó là một tập hợp các câu chuyện về cách chúng ta đạt được trạng thái này. Trong một thế giới mà tính toàn vẹn của dữ liệu là không thể thương lượng, sự thay đổi trong tư duy đó sẽ thay đổi tất cả mọi thứ.

