Chương 7: Mô Hình Hóa Chiều Thời Gian
"Hãy đưa tôi xem sơ đồ luồng (flowcharts) của bạn và giấu đi các bảng dữ liệu (tables), tôi sẽ tiếp tục bị hoang mang bí hiểm. Nhưng hãy đưa cho tôi xem các bảng dữ liệu của bạn, và tôi thường sẽ chẳng cần đến các sơ đồ luồng nữa; mọi thứ sẽ sáng tỏ như ban ngày." — Fred Brooks, Tác giả cuốn sách kinh điển "The Mythical Man-Month" (1974)
Giới Thiệu: Chiều Thời Gian Trong Mô Hình Nghiệp Vụ
Trong chương trước, chúng ta đã nghiên cứu chuyên sâu về mẫu thiết kế Mô hình Miền (Domain Model pattern): các khối xây dựng cốt lõi, mục đích kiến trúc, và ngữ cảnh áp dụng thực tế. Mẫu thiết kế Mô hình Miền Dựa trên Nguồn Sự kiện (Event-Sourced Domain Model pattern) được xây dựng dựa trên cùng một tiền đề nền tảng: nó dành cho logic nghiệp vụ phức tạp, thuộc về Core Subdomain, và sử dụng trọn vẹn cùng các khối chiến thuật: Value Objects, Aggregates, và Domain Events.
Điểm khác biệt cốt tử duy nhất giữa hai mẫu triển khai này nằm ở cách thức trạng thái của các Aggregate được lưu trữ lâu bền (persisted).
Mô hình Miền thông thường quản lý trạng thái của Aggregate bằng cách lưu trữ trạng thái hiện tại (current state) vào cơ sở dữ liệu. Ngược lại, Event-Sourced Domain Model áp dụng mẫu kiến trúc Event Sourcing để quản lý toàn bộ vòng đời của Aggregate: thay vì ghi đè trạng thái sau mỗi thao tác, mô hình sản sinh ra các Domain Event mô tả chi tiết từng thay đổi vừa diễn ra, và sử dụng chính chuỗi sự kiện bất biến này làm Nguồn Chân lý Duy nhất (Source of Truth) cho toàn bộ dữ liệu của thực thể.
Event Sourcing: Nguyên Lý Cốt Lõi
Để thấu hiểu sâu sắc động cơ đằng sau Event Sourcing, chúng ta hãy so sánh cách tiếp cận lưu trữ trạng thái truyền thống với việc lưu trữ sự kiện trong một tình huống thực tế thường gặp.
Mô hình Trạng thái (State-Based) vs Mô hình Nguồn Sự kiện (Event-Sourced)
Hãy quan sát Bảng 7-1 dưới đây, mô tả dữ liệu khách hàng tiềm năng trong một hệ thống bán hàng qua điện thoại (telemarketing). Hãy thử phân tích xem bạn có thể học hỏi và rút ra được những thông tin gì từ bảng dữ liệu này:
| lead-id | first-name | last-name | status | phone |
|---|---|---|---|---|
12 |
Casey | Davis | CONVERTED | 555-8101 |
Bảng 7-1. Dữ liệu khách hàng tiềm năng theo mô hình dựa trên trạng thái (State-based model)
Rõ ràng bảng trên được sử dụng để quản lý các khách hàng tiềm năng (leads) trong hệ thống tiếp thị. Với bản ghi có lead-id = 12, chúng ta biết được họ tên hiện tại là Casey Davis, số điện thoại là 555-8101, và trạng thái hiện tại là CONVERTED (đã chuyển đổi thành khách hàng thực thụ nhờ chốt đơn thành công).
Nhưng hãy tự hỏi: Chuyện gì đã thực sự xảy ra trong suốt vòng đời của lead số 12 này?
- Nhân viên bán hàng đã phải gọi điện bao nhiêu lần trước khi khách hàng đồng ý chốt đơn (CONVERTED)? Một cuộc gọi hay mười cuộc gọi?
- Khách hàng đồng ý mua hàng ngay lập tức hay đã trải qua một hành trình thuyết phục gian nan, đổi lịch hẹn nhiều lần?
- Dựa trên dữ liệu lịch sử, liệu có đáng để tiếp tục gọi cho một người sau 5 lần hẹn lại, hay đóng hồ sơ để chuyển sang ứng viên tiềm năng khác sẽ tối ưu chi phí hơn?
- Thông tin liên lạc có từng bị ghi sai và sửa lại không?
Không có bất kỳ câu trả lời nào trong Bảng 7-1! Tất cả những gì chúng ta có là một bức ảnh tĩnh chụp trạng thái hiện tại. Toàn bộ hành trình lịch sử — vốn chứa đựng tri thức nghiệp vụ vô giá — đã bị câu lệnh UPDATE đè nát và xóa sạch khỏi bộ nhớ.
Hành trình Sự kiện của Casey Davis
Bây giờ, hãy xem xét cùng một thực thể đó khi được quản lý thông qua chuỗi các Domain Events diễn ra theo trục thời gian thực tế:
[
{
"lead-id": 12,
"event-id": 1,
"event-type": "lead-initialized",
"first-name": "Casey",
"last-name": "David",
"phone-number": "555-2951",
"timestamp": "2020-05-20T09:52:55.75Z"
},
{
"lead-id": 12,
"event-id": 2,
"event-type": "contacted",
"timestamp": "2020-05-20T12:30:01.12Z"
},
{
"lead-id": 12,
"event-id": 3,
"event-type": "followup-set",
"followup-on": "2020-05-27T12:00:00.00Z",
"timestamp": "2020-05-20T12:32:08.24Z"
},
{
"lead-id": 12,
"event-id": 4,
"event-type": "contact-details-updated",
"first-name": "Casey",
"last-name": "Davis",
"phone-number": "555-8101",
"timestamp": "2020-05-20T12:32:08.24Z"
},
{
"lead-id": 12,
"event-id": 5,
"event-type": "contacted",
"timestamp": "2020-05-27T12:02:12.51Z"
},
{
"lead-id": 12,
"event-id": 6,
"event-type": "order-submitted",
"payment-status": "pending",
"timestamp": "2020-05-27T12:05:43.08Z"
},
{
"lead-id": 12,
"event-id": 7,
"event-type": "payment-confirmed",
"status": "converted",
"timestamp": "2020-05-27T12:31:01.32Z"
}
]
Nhìn vào chuỗi sự kiện trên, bức tranh toàn cảnh về nghiệp vụ bừng sáng rõ rệt:
- Sự kiện 1: Ngày 20/05, thông tin khách hàng được khởi tạo ban đầu với họ là David và số điện thoại
555-2951. - Sự kiện 2: Khoảng 2.5 giờ sau, điện thoại viên thực hiện cuộc gọi đầu tiên (
contacted). - Sự kiện 3 & 4: Trong cuộc gọi, khách hàng hẹn gọi lại sau 1 tuần (
followup-setvào ngày 27/05). Đồng thời điện thoại viên phát hiện họ bị gõ sai thành "David" và số điện thoại cũ không chuẩn, nên cập nhật lại họ thành Davis và số mới555-8101(contact-details-updated). - Sự kiện 5: Đúng hẹn vào ngày 27/05, cuộc gọi thứ hai được thực hiện thành công.
- Sự kiện 6 & 7: Chỉ sau 3 phút trao đổi, khách hàng quyết định đặt hàng (
order-submitted). Khoảng 25 phút sau đó, thanh toán được xác nhận và lead chính thức trở thành khách hàng (payment-confirmed).
Chúng ta không chỉ biết trạng thái hiện tại là gì, mà còn nắm trọn vẹn TẠI SAO nó đạt đến trạng thái đó, mất bao nhiêu công sức và diễn biến ra sao!
Phép Chiếu Trạng Thái (Projections)
Trong mô hình Event Sourcing, trạng thái (state) không phải là dữ liệu cố định trong bảng, mà là một sản phẩm phái sinh được tính toán từ các sự kiện. Quá trình tính toán này được gọi là Phép chiếu (Projection): duyệt tuần tự qua các sự kiện trong quá khứ và áp dụng logic biến đổi trạng thái tương ứng.
Công thức toán học rất trực quan: Trạng_thái_hiện_tại = Fold(f, Trạng_thái_ban_đầu, Danh_sách_sự_kiện).
Phép chiếu Tối ưu Hóa Tìm Kiếm (LeadSearchModelProjection)
Giả sử nhân viên chăm sóc khách hàng muốn tìm kiếm hồ sơ của Casey Davis. Nhưng khách hàng có thể gọi đến và xưng tên là "Casey David" (theo thông tin cũ) hoặc cung cấp số điện thoại cũ 555-2951. Nếu lưu theo Bảng 7-1, câu truy vấn tìm kiếm theo số cũ sẽ thất bại hoàn toàn!
Với Event Sourcing, chúng ta có thể thiết kế một mô hình chiếu đặc thù phục vụ tìm kiếm, lưu trữ toàn bộ các tên và số điện thoại mà người này từng sử dụng:
public class LeadSearchModelProjection
{
public long LeadId { get; private set; }
public HashSet<string> FirstNames { get; private set; }
public HashSet<string> LastNames { get; private set; }
public HashSet<PhoneNumber> PhoneNumbers { get; private set; }
public int Version { get; private set; }
public void Apply(LeadInitialized @event)
{
LeadId = @event.LeadId;
FirstNames = new HashSet<string> { @event.FirstName };
LastNames = new HashSet<string> { @event.LastName };
PhoneNumbers = new HashSet<PhoneNumber> { @event.PhoneNumber };
Version = 1;
}
public void Apply(ContactDetailsChanged @event)
{
FirstNames.Add(@event.FirstName);
LastNames.Add(@event.LastName);
PhoneNumbers.Add(@event.PhoneNumber);
Version += 1;
}
public void Apply(Contacted @event) => Version += 1;
public void Apply(FollowupSet @event) => Version += 1;
public void Apply(OrderSubmitted @event) => Version += 1;
public void Apply(PaymentConfirmed @event) => Version += 1;
}
Khi áp dụng chuỗi sự kiện của Casey Davis vào phép chiếu tìm kiếm trên, ta thu được kết quả:
FirstNames: ['Casey']
LastNames: ['David', 'Davis']
PhoneNumbers: ['555-2951', '555-8101']
Version: 7
Giờ đây, dù khách hàng tìm kiếm bằng tên họ cũ hay mới, số điện thoại trước hay sau khi sửa, hệ thống đều truy vết ra ngay lập tức!
Phép chiếu Tối ưu Hóa Phân Tích (LeadAnalysisModelProjection)
Bên cạnh tìm kiếm, bộ phận phân tích kinh doanh (Business Analytics) muốn đánh giá hiệu quả của chiến dịch tiếp thị: trung bình cần bao nhiêu lần hẹn gặp lại (follow-up) thì một lead mới chuyển đổi thành công?
public class LeadAnalysisModelProjection
{
public long LeadId { get; private set; }
public int Followups { get; private set; }
public LeadStatus Status { get; private set; }
public int Version { get; private set; }
public void Apply(LeadInitialized @event)
{
LeadId = @event.LeadId;
Status = LeadStatus.NEW;
Followups = 0;
Version = 1;
}
public void Apply(FollowupSet @event)
{
Status = LeadStatus.FOLLOWUP_SET;
Followups += 1; // Đếm số lần hẹn gặp lại
Version += 1;
}
public void Apply(OrderSubmitted @event)
{
Status = LeadStatus.PENDING_PAYMENT;
Version += 1;
}
public void Apply(PaymentConfirmed @event)
{
Status = LeadStatus.CONVERTED;
Version += 1;
}
public void Apply(Contacted @event) => Version += 1;
public void Apply(ContactDetailsChanged @event) => Version += 1;
}
Điểm kỳ diệu nhất ở đây là: Bạn có thể sáng tạo ra bất kỳ mô hình chiếu mới nào trong tương lai!
Ngay cả khi hôm nay bạn chưa hề nghĩ ra nhu cầu phân tích X hay thuật toán máy học Y, toàn bộ chuỗi sự kiện thô nguyên bản vẫn đang được lưu giữ vĩnh viễn trong Event Store. 2 năm nữa, khi doanh nghiệp cần bài toán mới, bạn chỉ việc viết thêm một class Projection và chạy lại từ sự kiện số 1 để xây dựng mô hình dữ liệu lịch sử hoàn hảo mà không cần lo lắng về việc dữ liệu đã bị bỏ lỡ.
Source of Truth & Event Store
Để mẫu thiết kế Event Sourcing vận hành chính xác, mọi thay đổi đối với trạng thái của đối tượng bắt buộc phải được đại diện và lưu giữ dưới dạng các Domain Events. Các sự kiện này trở thành Nguồn Chân lý Duy nhất (Source of Truth) của toàn bộ hệ thống.
Cơ sở dữ liệu lưu trữ các sự kiện này là nơi lưu trữ duy nhất đòi hỏi tính nhất quán mạnh (strongly consistent storage). Thuật ngữ tiêu chuẩn trong ngành phần mềm dành cho loại cơ sở dữ liệu chuyên biệt này là Event Store.
Giao diện IEventStore & Quản Lý Tương Tranh Lạc Quan
Một Event Store thực chất là một cơ sở dữ liệu chỉ thêm (append-only). Nó tuyệt đối cấm các thao tác cập nhật (UPDATE) hay xóa (DELETE) các sự kiện đã lưu. Ở mức tối thiểu, Event Store phải hỗ trợ hai thao tác cốt lõi: nạp tất cả sự kiện của một thực thể và thêm các sự kiện mới vào cuối chuỗi:
public interface IEventStore
{
IEnumerable<Event> Fetch(Guid instanceId);
void Append(Guid instanceId, Event[] newEvents, int expectedVersion);
}
Tham số expectedVersion trong phương thức Append mang tính quyết định để triển khai Quản lý Tương tranh Lạc quan (Optimistic Concurrency Control):
- Khi client muốn phát sinh sự kiện mới, nó gửi kèm phiên bản hiện tại mà nó dựa vào để ra quyết định (ví dụ: đang ở phiên bản 5).
- Nếu trong thời gian client đang tính toán, một tiến trình khác đã ghi thêm sự kiện và đẩy phiên bản lên 6, Event Store sẽ phát hiện
expectedVersion != actualVersionvà ngay lập tức ném ra ngoại lệConcurrencyException. - Nhờ đó, hệ thống ngăn chặn hoàn toàn tình trạng ghi đè mù quáng (lost updates) và bảo vệ tuyệt đối các bất biến nghiệp vụ.
Thực chất, Event Sourcing không phải là một phát minh mới lạ trong khoa học máy tính! Ngành tài chính kế toán đã áp dụng nguyên lý này hàng trăm năm qua dưới hình thức Sổ cái (Ledger). Một cuốn sổ cái luôn là một nhật ký chỉ-ghi-thêm ghi nhận từng giao dịch thu/chi. Không bao giờ kế toán viên tẩy xóa số tiền giao dịch cũ. Số dư tài khoản hiện tại (account balance) luôn luôn được suy ra bằng cách "chiếu" (project / tổng kết) tất cả các giao dịch trong sổ cái từ trước đến nay.
Event-Sourced Domain Model
Trong khi Mô hình Miền gốc (State-based Domain Model) duy trì cấu trúc trạng thái của Aggregate và tùy nghi phát sinh một vài Domain Event ra bên ngoài, thì Event-Sourced Domain Model sử dụng các Domain Event một cách độc quyền để mô hình hóa toàn bộ vòng đời của Aggregate.
Mọi thao tác thực thi một Command lên một Event-Sourced Aggregate đều tuân thủ nghiêm ngặt kịch bản 4 bước sau:
- Nạp sự kiện (Load): Tải toàn bộ chuỗi domain events của Aggregate từ Event Store.
- Tái cấu trúc trạng thái (Reconstitute State): Chiếu tuần tự các sự kiện vào một đối tượng trạng thái trong bộ nhớ để phục vụ việc kiểm tra logic nghiệp vụ.
- Thực thi Command (Execute): Gọi phương thức nghiệp vụ của Aggregate. Aggregate kiểm tra các bất biến (invariants) dựa trên trạng thái vừa tái tạo và sinh ra các Domain Event mới.
- Cam kết (Commit): Lưu các Domain Event mới vào Event Store kèm theo kiểm tra
expectedVersion.
Triển Khai Mã Nguồn Chi Tiết: Ticket Aggregate
Quay trở lại ví dụ về Aggregate Ticket trong hệ thống hỗ trợ kỹ thuật ở Chương 6, chúng ta hãy xem cách chuyển đổi nó sang phiên bản Event-Sourced.
Đầu tiên, Application Service (Lớp ứng dụng) điều phối luồng thực thi:
public class TicketAPI
{
private ITicketsRepository _ticketsRepository;
public void RequestEscalation(TicketId id, EscalationReason reason)
{
// Bước 1: Nạp toàn bộ sự kiện lịch sử của Ticket
var events = _ticketsRepository.LoadEvents(id);
// Bước 2: Tái sinh đối tượng Aggregate từ chuỗi sự kiện
var ticket = new Ticket(events);
var originalVersion = ticket.Version;
// Bước 3: Thực thi nghiệp vụ - Aggregate kiểm tra và sinh sự kiện TicketEscalated
var cmd = new RequestEscalation(reason);
ticket.Execute(cmd);
// Bước 4: Lưu sự kiện mới vào Event Store kèm phiên bản gốc
_ticketsRepository.CommitChanges(ticket, originalVersion);
}
}
Bên trong Aggregate Ticket, hàm khởi tạo nạp các sự kiện và chuyển tiếp chúng vào bộ chiếu trạng thái TicketState:
public class Ticket
{
private List<IDomainEvent> _domainEvents = new List<IDomainEvent>();
private TicketState _state;
public int Version => _state.Version;
// Hàm khởi tạo rehydrate từ danh sách sự kiện lịch sử
public Ticket(IEnumerable<IDomainEvent> events)
{
_state = new TicketState();
foreach (var e in events)
{
AppendEvent(e);
}
}
private void AppendEvent(IDomainEvent @event)
{
_domainEvents.Add(@event);
// Điều phối động (Dynamic Dispatch) gọi đúng phương thức Apply tương ứng
((dynamic)_state).Apply((dynamic)@event);
}
public void Execute(RequestEscalation cmd)
{
// Kiểm tra bất biến nghiệp vụ dựa trên trạng thái đã chiếu (_state)
if (!_state.IsEscalated && _state.RemainingTimePercentage <= 0)
{
var escalatedEvent = new TicketEscalated(_state.Id, cmd.Reason);
AppendEvent(escalatedEvent);
}
}
}
Và đây là lớp biểu diễn trạng thái nội bộ TicketState:
public class TicketState
{
public TicketId Id { get; private set; }
public int Version { get; private set; }
public bool IsEscalated { get; private set; }
public double RemainingTimePercentage { get; private set; }
public void Apply(TicketInitialized @event)
{
Id = @event.Id;
Version = 0;
IsEscalated = false;
RemainingTimePercentage = 100.0;
}
public void Apply(TicketEscalated @event)
{
IsEscalated = true;
Version += 1;
}
}
Tại Sao Gọi Là "Event-Sourced Domain Model"?
Tác giả Vlad Khononov nhấn mạnh việc dùng cụm từ Event-Sourced Domain Model thay vì chỉ nói chung chung là "Event Sourcing". Bởi vì kỹ thuật biểu diễn sự kiện có thể áp dụng cho bất kỳ hệ thống nào (kể cả hệ thống không dùng DDD, ví dụ Transaction Script). Cụm từ đầy đủ này khẳng định rõ ràng rằng chúng ta đang tích hợp sức mạnh của Event Sourcing vào sâu bên trong Aggregate của Domain Model để giải quyết các bài toán logic nghiệp vụ phức tạp nhất.
Ưu Điểm Của Event-Sourced Domain Model
So với mô hình truyền thống (chỉ lưu trạng thái hiện tại), Event-Sourced Domain Model đòi hỏi nhiều nỗ lực tư duy và thiết kế hơn. Tuy nhiên, nó đem lại những lợi thế vô song mà không một kiến trúc nào khác có thể so bì:
1. Du Hành Thời Gian (Time Traveling) & Gỡ Lỗi Hồi Tố (Retroactive Debugging)
Vì toàn bộ chuỗi sự kiện được lưu giữ, bạn có thể tái tạo lại trạng thái của Aggregate tại bất kỳ thời điểm nào trong quá khứ một cách chuẩn xác 100%. Bạn chỉ cần dừng phép chiếu tại sự kiện mong muốn hoặc mốc thời gian chỉ định:
- Phân tích hành vi hệ thống: Giám đốc nghiệp vụ muốn biết chính xác hợp đồng khách hàng trông như thế nào vào 14:00 ngày 15/03/2021 trước khi ký phụ lục? Event Sourcing tái tạo ngay trong một nốt nhạc.
- Gỡ lỗi hồi tố (Retroactive Debugging): Người dùng báo một lỗi kỳ lạ xảy ra vào tuần trước. Thay vì phải "đoán mò" dữ liệu lúc đó hay cố tái tạo môi trường bằng tay, bạn chỉ cần nạp các sự kiện đến đúng thời điểm xảy ra lỗi, đưa Aggregate về chính xác trạng thái lúc đó trong Unit Test, và nhấn Debugger!
2. Thấu Suốt Sâu Rộng (Deep Insight) & Nhật Ký Kiểm Toán (Audit Log) Hoàn Hảo
Trong hầu hết các doanh nghiệp, các quyết định giá trị nhất đến từ việc nghiên cứu hành trình của người dùng chứ không phải kết quả cuối cùng:
- Khách hàng đã thêm và xóa sản phẩm khỏi giỏ hàng bao nhiêu lần trước khi thanh toán?
- Họ đã sửa đổi thông tin gì trước khi hủy đơn hàng?
Đối với các ngành đòi hỏi sự tuân thủ pháp lý khắt khe (tài chính, y tế, bảo hiểm), Event Store tự thân nó chính là một Nhật ký Kiểm toán Nhất quán Mạnh (Strongly Consistent Audit Log). Vì sự kiện là nguồn chân lý trực tiếp của hệ sinh thái phần mềm, không thể có sự lệch pha giữa "những gì đã xảy ra trên thực tế" và "những gì được ghi trong log".
3. Quản Lý Tương Tranh Lạc Quan Nâng Cao (Advanced Optimistic Concurrency)
Trong mô hình trạng thái truyền thống, nếu hai người dùng cùng đọc bản ghi phiên bản 1 và cùng gửi lệnh cập nhật, người gửi sau chắc chắn sẽ bị lỗi xung đột (concurrency exception).
Trong Event Sourcing, vì thay đổi được diễn đạt thành các sự kiện mang ý định nghiệp vụ cụ thể, chúng ta có thể triển khai chiến lược hòa giải xung đột thông minh: nếu hai sự kiện không triệt tiêu hay mâu thuẫn lẫn nhau (ví dụ: một người cập nhật địa chỉ giao hàng, người kia đổi tên người nhận), hệ thống hoàn toàn có thể tự động gộp (merge) cả hai sự kiện vào Event Store một cách an toàn mà không bắt người dùng phải làm lại từ đầu!
Nhược Điểm & Thách Thức
Không có viên đạn bạc trong công nghệ phần mềm. Event Sourcing mang lại sức mạnh to lớn nhưng cũng đi kèm những thách thức không nhỏ:
- Đường cong học tập dốc (Learning Curve): Đa số lập trình viên quen với tư duy quan hệ / CRUD (Create, Read, Update, Delete) và mô hình hóa trạng thái tĩnh. Chuyển sang tư duy hướng sự kiện đòi hỏi một bước nhảy vọt về nhận thức và thời gian đào tạo bài bản.
- Tiến hóa và phiên bản hóa mô hình (Model Evolution & Versioning): Các sự kiện đã ghi vào Event Store là bất biến vĩnh viễn. Nhưng nghiệp vụ thì luôn thay đổi! Khi schema của sự kiện đổi tên trường, thêm dữ liệu bắt buộc hoặc thay đổi cấu trúc, bạn phải xử lý việc biến đổi schema lịch sử một cách cực kỳ cẩn trọng (kỹ thuật Upcasting, đa phiên bản Event). Tác giả đặc biệt khuyến nghị đọc cuốn "Versioning in an Event Sourced System" của Greg Young.
- Độ phức tạp ngẫu nhiên nếu dùng sai chỗ: Nếu áp dụng Event Sourcing cho các Subdomain hỗ trợ (Supporting) hoặc dùng chung (Generic) — nơi logic chỉ là CRUD đơn giản — bạn đang tự biến hệ thống thành một cỗ máy cồng kềnh quá mức cần thiết.
Câu Hỏi Thường Gặp (Frequently Asked Questions)
1. Về Hiệu Năng: Tái tạo trạng thái từ hàng ngàn sự kiện có bị chậm không?
Đây là nỗi lo lắng số 1 của các kỹ sư khi lần đầu tiếp cận Event Sourcing: "Nếu một thực thể có hàng ngàn sự kiện, mỗi lần thao tác lại phải đọc và Apply hết thì hệ thống có sụp đổ không?"
Câu trả lời dựa trên đo đạc thực nghiệm:
- Trong đại đa số các hệ thống nghiệp vụ, vòng đời trung bình của một Aggregate hiếm khi vượt quá 100 sự kiện. Việc đọc và áp dụng 100 sự kiện trong bộ nhớ RAM diễn ra trong vòng chưa đầy 1 mili-giây!
- Sự suy giảm hiệu năng đáng chú ý chỉ bắt đầu xuất hiện khi một thực thể tích lũy từ 10,000+ sự kiện trở lên.
Giải pháp: Mẫu Snapshot (Snapshot Pattern)
Trong những trường hợp hiếm hoi mà thực thể thực sự sống lâu và tích lũy số lượng sự kiện khổng lồ, chúng ta áp dụng Snapshot Pattern (Mẫu Ảnh chụp nhanh - Hình 7-2):
Quy trình vận hành của Snapshot rất thanh lịch:
- Một tiến trình nền liên tục duyệt qua các sự kiện mới, tạo ảnh chụp trạng thái định kỳ (ví dụ cứ sau 100 sự kiện) và lưu vào bộ nhớ đệm (Cache hoặc CSDL nhanh).
- Khi cần tái tạo Aggregate: Hệ thống chỉ cần lấy Snapshot gần nhất (ví dụ bản Snapshot tại Version 500), sau đó chỉ đọc thêm các sự kiện từ Version 501 đến hiện tại từ Event Store và áp dụng tiếp.
Nếu một Aggregate của bạn sinh ra hơn 10,000 sự kiện trong điều kiện vận hành bình thường, trước khi vội vã triển khai Snapshot, hãy lùi lại một bước và kiểm tra lại ranh giới của Aggregate! Rất có thể bạn đã thiết kế Aggregate quá lớn, gom quá nhiều khái niệm nghiệp vụ không liên quan vào cùng một ranh giới tính nhất quán.
2. Về Khả Năng Mở Rộng: Dữ liệu sự kiện phình to thì mở rộng ra sao?
Mô hình Event Sourcing cực kỳ thân thiện với việc mở rộng quy mô theo chiều ngang (horizontal scaling). Bởi vì tất cả các thao tác trên Aggregate đều diễn ra trong ngữ cảnh của duy nhất một Aggregate Instance tại một thời điểm.
Do đó, Event Store có thể được phân mảnh (sharded) theo Aggregate ID một cách hoàn hảo: toàn bộ sự kiện của một thực thể luôn nằm trọn vẹn trên cùng một shard, đảm bảo tính tuần tự và loại bỏ hoàn toàn nhu cầu về phân tán khóa liên shard (Hình 7-3).
3. Về Xóa Dữ Liệu: Làm sao xóa vật lý để tuân thủ luật bảo vệ dữ liệu (GDPR)?
Event Store là cơ sở dữ liệu chỉ-thêm (append-only), nhưng các đạo luật bảo mật như GDPR lại quy định "Quyền được lãng quên" (Right to be Forgotten) — bắt buộc phải xóa thông tin cá nhân của người dùng khi được yêu cầu. Làm sao giải quyết mâu thuẫn này?
Giải pháp chuẩn mực là mẫu thiết kế Forgettable Payload Pattern:
- Mọi thông tin cá nhân nhạy cảm trong các Domain Events đều được lưu dưới dạng đã mã hóa.
- Khóa giải mã (encryption key) được lưu trữ tại một hệ thống quản lý khóa riêng biệt (Key-Value Store ngoại vi), được ánh xạ theo
AggregateId. - Khi nhận được yêu cầu xóa dữ liệu theo GDPR: Bạn chỉ cần xóa khóa mã hóa của người đó trong hệ thống quản lý khóa!
- Kết quả là dữ liệu nhạy cảm nằm trong các sự kiện lịch sử lập tức trở thành một chuỗi byte vô nghĩa vĩnh viễn không thể khôi phục, đáp ứng trọn vẹn yêu cầu pháp lý mà không làm rách hay phá vỡ tính toàn vẹn của chuỗi sự kiện trong Event Store.
Tại Sao Tôi Không Thể Chỉ...? (Why Can't I Just...?)
Khi được thuyết phục về giá trị của lịch sử sự kiện, nhiều lập trình viên thường tự hỏi: "Tại sao phải đổi sang Event Sourcing phức tạp? Chẳng lẽ tôi không thể chỉ..." Hãy cùng xem xét ba giải pháp chắp vá phổ biến và lý do tại sao chúng luôn thất bại:
1. "Tại sao tôi không thể chỉ ghi log ra một file text song song với database?"
Ghi dữ liệu đồng thời vào một cơ sở dữ liệu và một file log là một thao tác cực kỳ rủi ro. Về bản chất, đó là một giao dịch phân tán giữa hai hệ thống lưu trữ khác nhau. Nếu giao dịch database bị rollback do lỗi, sẽ chẳng có ai đi dọn dẹp các dòng log đã lỡ ghi ra file text. Kết quả là file log của bạn không bao giờ nhất quán, mà trở thành nhật ký nhất quán ngẫu nhiên (eventually inconsistent logs) — hoàn toàn vô giá trị khi kiểm toán.
2. "Tại sao tôi không giữ mô hình trạng thái, nhưng trong cùng transaction ghi thêm một bảng logs?"
Về mặt hạ tầng, cách này giải quyết được tính nhất quán giao dịch ACID. Nhưng về mặt con người và thiết kế, nó là một thảm họa tiềm ẩn:
- Một lập trình viên mới gia nhập đội ngũ trong tương lai hoàn toàn có thể quên gọi lệnh chèn log khi cập nhật một trường dữ liệu. Không có cơ chế nào của ngôn ngữ hay kiến trúc bắt buộc họ phải nhớ điều đó.
- Vì bảng trạng thái chính vẫn là Source of Truth, bảng log phụ sẽ nhanh chóng bị bỏ rơi, schema suy thoái và ngập tràn dữ liệu rác không ai kiểm soát chất lượng.
3. "Tại sao tôi không dùng Database Trigger tự động chụp ảnh bản ghi sang bảng history?"
Giải pháp này tránh được việc lập trình viên quên ghi log. Tuy nhiên, trigger cơ sở dữ liệu chỉ ghi nhận được các sự kiện kỹ thuật khô khốc: "Cột A đổi từ giá trị X sang Y".
Nó hoàn toàn đánh mất ngữ cảnh nghiệp vụ: TẠI SAO cột A lại đổi? Đó là do khách hàng đổi ý? Do nhân viên tiếp thị điều chỉnh? Do vi phạm hợp đồng hay do chính sách khuyến mãi? Thiếu vắng chữ "TẠI SAO", bảng lịch sử trigger không khác gì một đống rác dữ liệu và không thể nào dùng để tái sinh hay dự phóng các mô hình nghiệp vụ mới trong tương lai.
Tổng Kết Chương
Chương này đã đi sâu khám phá mẫu thiết kế Event Sourcing và cách áp dụng nó để mô hình hóa chiều thời gian trong các Aggregate của Mô hình Miền:
- Trong Event-Sourced Domain Model, toàn bộ sự thay đổi trạng thái của Aggregate được thể hiện dưới dạng một chuỗi các Domain Events bất biến.
- Các sự kiện này là Nguồn Chân lý Duy nhất (Source of Truth) và được lưu trữ trong một cơ sở dữ liệu chỉ-thêm mang tên Event Store.
- Trạng thái hiện tại hoặc bất kỳ trạng thái nào trong quá khứ được tái tạo thông qua các Phép chiếu (Projections) bằng cách duyệt tuần tự và áp dụng các sự kiện.
- Mô hình mang lại các năng lực vượt trội: du hành thời gian, gỡ lỗi hồi tố, phân tích sâu, nhật ký kiểm toán không thể chối cãi, và kiểm soát tương tranh lạc quan tinh vi.
- Để phát huy tối đa sức mạnh của Event Sourcing và giải quyết bài toán truy vấn hiệu năng cao, chúng ta cần một mẫu kiến trúc tách biệt giữa luồng ghi và luồng đọc. Đó chính là Command-Query Responsibility Segregation (CQRS) — chủ đề hấp dẫn mà chúng ta sẽ cùng chinh phục trong Chương 8!
Bài Tập Ôn Tập & Thảo Luận Nghiệp Vụ
Hãy kiểm tra và củng cố tri thức của bạn về Event Sourcing qua 4 câu hỏi dưới đây. Bấm vào đáp án để xem phản hồi và giải thích chính thức từ tác giả Vlad Khononov (Appendix B).
Giải thích chi tiết (Appendix B): Một Domain Event đại diện cho một thông điệp bất biến mô tả một sự kiện thực tế đã xảy ra trong miền. Để mô tả chi tiết các thuộc tính của sự kiện đó một cách biểu cảm và an toàn kiểu dữ liệu, Domain Event tận dụng triệt để các Value Object (ví dụ:
TicketId, EscalationReason, Money, PhoneNumber). Chúng không hề thay thế hay triệt tiêu lẫn nhau.
Giải thích chi tiết (Appendix B): Bản chất của chuỗi sự kiện là nhật ký bất biến thô mô tả mọi sự việc đã diễn ra. Do đó, từ cùng một chuỗi sự kiện, chúng ta có thể tạo ra vô số các biểu diễn trạng thái khác nhau tùy thuộc vào mục đích sử dụng (tìm kiếm, báo cáo phân tích, kiểm toán, màn hình hiển thị...) và bạn hoàn toàn có thể viết thêm các phép chiếu hoàn toàn mới bất kỳ lúc nào trong tương lai mà không đòi hỏi sự kiện phải thay đổi.
Giải thích chi tiết (Appendix B): State-Based Aggregate vẫn có thể phát ra Domain Event khi cần, nhưng trạng thái trong CSDL mới là nguồn chân lý của nó. Trong khi đó, với Event-Sourced Aggregate, Domain Event vừa là cơ chế bắt buộc cho mọi chuyển dịch trạng thái, vừa là Nguồn Chân lý Duy nhất để lưu trữ và phục hồi thực thể.
Chức năng hoàn hảo nhất để áp dụng Event-Sourced Domain Model trong WolfDesk chính là Thuật toán Quản lý Vòng đời Vé Hỗ trợ (Support Ticket Lifecycle Algorithm) thuộc Core Subdomain.
Lý do cụ thể:
- Độ phức tạp nghiệp vụ cao: Việc chuyển giao trạng thái vé, leo thang vi phạm cam kết chất lượng (SLA escalation), và phân bổ nhân sự hỗ trợ phụ thuộc chặt chẽ vào toàn bộ chuỗi diễn biến lịch sử.
- Phục vụ phân tích & Tự động hóa: Việc lưu giữ toàn bộ các sự kiện tương tác của Ticket cho phép WolfDesk xây dựng các mô hình chiếu tối ưu phục vụ Phát hiện gian lận (Fraud Detection) và huấn luyện mô hình Hỗ trợ tự động thông minh (Support Autopilot) — những lợi thế cạnh tranh cốt tử của công ty.