logoMột hệ thống khó hiểu thì cũng khó thay đổi
Thống kê số lượng Product theo Brand - Aggreation Query, Composition Pattern, và CQRS vào cuộc?

Giới thiệu

OLTP và OLAP

Bài trước đã bàn về cách thiết kế linh hoạt cho các use case truy vấn dữ liệu: Repository pattern phù hợp với các trường hợp truy vấn đơn giản nhưng khi độ phức tạp gia tăng, Query Object pattern nên được ưu tiên triển khai.

Một điểm nữa cần lưu ý, các use case tìm kiếm ta bàn tới đều trả về một hoặc một danh sách Aggregate Product. Khi có trong tay danh sách Aggregate, User có thể xem chi tiết một phần tử rồi thực hiện cập nhật. Những hành động như vậy tạo nên một loại hệ thống gọi là OnLine Transaction Processing (OLTP). Dạng ứng dụng này là những ứng dụng tác nghiệp mà ta hay gặp. Đặc trưng của nó là:

  • Người dùng chỉ thao tác với một đơn vị dữ liệu nghiệp vụ.
  • Số lượng người dùng lớn (concurrent users)
  • Cần đảm bảo tính giao dịch cao (ACID).
  • Mô hình dữ liệu (Data Model) thường triển khai ở Relational Database và đề cao chuẩn hóa (đạt 3NF hoặc BCNF).
  • Page file thường nhỏ (32/64KB) tối ưu với Indexing.

Nhược điểm của loại hệ thống này là nếu ta cần truy vấn, tính toán trên lượng lớn dữ liệu thì lại không tối ưu. Ví dụ, với mô hình Product đơn giản đang có, trên màn hình Admin của Marketplace cần hiển thị các chỉ số thống kê như

  • Số lượng Product đang hoạt động
  • Số lượng Product theo Brand
  • ...

Thông thường, những dữ liệu này nằm trong những báo cáo Business Intelligence (BI) hiển thị trên những dashboard giúp quản trị doanh nghiệp có thêm insight để đưa ra quyết định tối ưu cho doanh nghiệp của mình. Một ví dụ đơn giản là "Số lượng Product theo Brand" tăng giảm theo từng tháng mang lại những thông tin hữu ích: Tại sao trong 5 tháng liên tiếp vừa qua, số lượng Product bị giảm dần? Số lượng Product theo Brand cũng thể hiện quy mô của Brand: Marketplace của ta đang tập trung chủ yếu sản phẩm theo những nhãn hàng nào, thuộc lĩnh vực nào, ...?

Những báo cáo như vậy cần truy vấn lượng dữ liệu rất lớn, có thể từ hàng trăm cho tới hàng chục nghìn bản ghi. Việc sử dụng mô hình OLTP không còn phù hợp vì như vậy sẽ làm tải của database hoặc ứng dụng tăng vọt. Lúc này, hệ thống tính toán OnLine Analytics Processing (OLAP) sẽ phù hợp hơn. Đặc trưng của loại hệ thống này là:

  • Khối lượng dữ liệu lớn đến cực lớn.
  • Số lượng người dùng thấp hơn.
  • Tính giao dịch không cần quá chặt chẽ
  • Mô hình dữ liệu thường theo hướng phi chuẩn (denormalization)
  • Trong một số hệ thống chuyên dụng, block file lưu trữ dưới database có thể rất lớn (1-2 MB) giúp tối ưu việc đọc lượng lớn dữ liệu.

Một triển khai điển hình của loại hệ thống này là các Enterprise Data Warehouse (EDW). Một vấn đề với các hệ thống EDW truyền thống là tính đáp ứng chậm. Hãy nhìn vào mô hình điển hình dưới đây:

Hình 1: Context Diagram của EDW với các nguồn và client

Hình trên cho thấy một kiến trúc cấp cao điển hình bao gồm:

  • Các service nghiệp vụ (dạng OLTP) được các team riêng phụ trách.
  • Một EDW thực hiện đồng bộ dữ liệu theo định kỳ hoặc real-time, do Team EDW phụ trách. Dữ liệu đầu tiên sẽ "hạ cánh" tại vùng Staging, trải qua quá trình làm sạch, biến đổi rồi đưa vào những mô hình phân tích chuyên dụng cho bài toán OLAP, thường là Dimensional Model (như Star Schema). Mô hình dữ liệu này sau đó được chuyển thành nhiều Data Mart khác nhau, mỗi mart chuyên trách cho một nghiệp vụ nhỏ hơn cho những dịch vụ, báo cáo đặc thù.
  • Phía khai thác, có thể là người dùng nghiệp vụ thực hiện các truy vấn adhoc (dạng truy vấn thủ công nhằm tìm hiểu dữ liệu hoặc trả về kết quả mong muốn nào đó); hoặc có thể là những engine báo cáo BI chuyên dụng hoặc trên các dịch vụ khai thác, v.v.

Vấn đề của kiến trúc này là tính đáp ứng chậm: Nếu Business User yêu cầu một loại báo cáo mới, họ cần phải phối hợp với các bên liên quan để xem dữ liệu mong muốn bắt nguồn từ dịch vụ OLTP nào rồi kéo theo team EDW để thể hiện mong muốn. Các team làm việc với nhau để xem cụ thể dữ liệu liên quan. Team EDW thiết kế ra một Dimensional Model và các Data Processing Pipeline để xử lý. Rất nhiều việc cần làm và thông thường mất thời gian vì điểm yếu cốt lõi luôn tồn tại: Nơi tập trung dữ liệu báo cáo (EDW) không biết cặn kẽ về dữ liệu thành phần (Service 1, 2, 3) bởi họ không phải là data owner nên quá trình tìm hiểu, phân tích, và rồi mô hình hóa luôn cần thời gian.

Bài toán

Tính số lượng Product thỏa mãn điều kiện

Các câu hỏi đặt ra:

  • Phương thức này nên đặt tại đâu?
  • Chữ ký hàm là gì?

Khá dễ dàng để trả lời các câu hỏi này: Phương thức này nên đặt tại ProductRepository đang có vì nó có liên quan tới Product; hàm này có chữ ký long cound(ProductQuery).

Có lẽ không cần mô tả thêm cài đặt chi tiết vì mọi thứ đã rõ ràng qua bài viết trước. Giờ ta đi tới bài toán tiếp theo:

Trả về top N Brand có số lượng Product nhiều nhất

Thông tin trả về lúc này là:

  • BrandId
  • Số lượng Product tương ứng

"Trên đá chiến thắng" với QOP, hãy áp dụng nó để giải quyết bài toán này. Bài toán yêu cầu đếm số lượng Product theo Brand cho thấy khả năng tái sử dụng ProductQuery đang có. Hãy thử xem thế nào. Các việc cần làm bao gồm:

  • Bổ sung tiêu chí trong ProductQuery.
  • Xem xét ProductRepository có cần thêm method mới.

Chữ ký hàm:

 

 

 

 

public interface ProductRepository {
    ...
    List findTopNBrands(ProductQuery query, int n);
}

 

Nhớ lại định nghĩa mà Eric Evans đưa ra: "Repository là cơ chế để truy vấn các đối tượng Domain và thêm, xóa chúng vào kho lưu trữ như thể chúng là một tập hợp trong bộ nhớ."

Với phương thức count, điều này không bị phá vỡ nhưng với findTopNBrands thì định nghĩa này đã không còn bởi nó trả về một danh sách BrandStats thay vì danh sách Product làm vỡ một bộ sưu tập trừu tượng.

Vậy ta sẽ tạo ra một Repository mới chuyên trách: ProductCountByBrandReportRepository, đối tượng query giờ là: ProductCountByBrandQuery:

public interface ProductCountByBrandReportRepository {
    List find(ProductCountByBrandQuery query);
}

Bên trong ProductCountByBrandQuery ngoài những tiêu chí tìm kiếm còn có thêm tham số topN:

public final class ProductCountByBrandQuery {
    // --- Tiêu chí LỌC (Filtering) ---
    private final ProductStatus status;
    ...
    private final int topN; // Bắt buộc
    ...
    
    public static class Builder {
        private final int topN; // Bắt buộc
        private ProductStatus status;
        ...
        public ProductCountByBrandQuery build() {
            return new ProductCountByBrandQuery(this);
        }
    }
}

 

Một số câu hỏi:

  • Tại sao tên của Repository lại là ProductCountByBrandReportRepository mà không phải là BrandReportRepository?
  • Và khi sử dụng tên BrandReportRepository thì liệu ta có thể bổ sung các tiêu chí GroupBy linh hoạt vào một đối tượng query có tên ProductCountByQuery?

Ý định của tên cụ thể là kết quả luôn được thống kê theo Brand. Ý định này bên cạnh tên của Repository, còn thể hiện ở cấu trúc kết quả trả về, là ProductCountByBrand. Nếu sau này xuất hiện một loại thống kê khác: số lượng Product theo ngày, thì chúng sẽ được cài đặt trên một Repository tương tự. 

Tiếp tục, nếu trong ProductCountByQuery có hỗ trợ các loại GroupBy như groupByBrand, groupByDate nhằm mang lại sự uyển chuyển cho client sử dụng thì đây lại là một rủi ro về cú pháp truy vấn.

Tóm lại, cách cài đặt trên là một ví dụ thường gặp.

Hoạt động tại tầng database và consumer

Như trên đã đề cập, dạng truy vấn "Top N Product theo Brand" là dạng truy vấn tổ hợp, thường tiêu tốn tài nguyên lớn trong những hệ thống OLTP. Các lựa chọn cài đặt database của ta đều không dễ gì trả lời được câu hỏi này. Điều này dẫn chúng ta đến một dạng lưu trữ dư thừa thông tin như sau:

Hình 2: Lưu trữ dư thừa thông tin tại BRANDS

Hình 2 cho thấy cách bổ sung thông tin dư thừa vào Option 3. Chú ý rằng mô hình này vẫn đạt chuẩn 3NF (ít nhất là vậy) nhưng thông tin lại bị dư thừa. Transaction 2 ngoài việc bổ sung một dòng trong BRAND_PRODUCTS thì còn nâng số đếm ở PRODUCT_COUNT lên 1 đơn vị. Mọi thứ chạy là hoàn hảo cho tới khi bạn nhận ra dữ liệu trong mô hình này (của transaction 2) có vẻ bị ghi đè. Vấn đề xảy ra là do ta đang sử dụng mô hình thông báo như bài trước:

Hình 3: Luồng nghiệp vụ hoàn thành qua 2 transaction: Tạo mới Product và Đăng ký Product vào Brand

Vấn đề là ProductCreatedEvent lại được publish lên một message queue Kafka có partition key là ProductId. Hãy xem sâu vào message topic này:

Hình 4: Topic chứa ProductCreatedEvent và các Use case Instance đại diện cho Transaction 2

Topic trên được đánh partition key theo ProductId nên có thể xuất hiện các Event của các Product thuộc cùng một Brand rải rác trên các Shard khác nhau của topic. Như trên hình, cả ba use case instance chuẩn bị consume các event của ba Product khác nhau của cùng Brand 1. Nếu xử lý không tốt, vấn đề mất dữ liệu do ghi đè sẽ xảy ra. Xem Offline Concurrency Control - Phần 1: Hiện tượng.

Tóm lại, cách thiết kế bổ sung thông tin PRODUCT_COUNT bên trong BRANDS là cách làm tốt, chỉ cần chú ý tới vấn đề concurrency là được. Nó dẫn ta tới một dạng kiến trúc hiện đại có tên CQRS. Vậy CQRS là gì?

CQRS là gì?

Để biết rõ hơn vai trò của CQRS, hãy giả sử ta có tính huống thế này: Có hai microservice: Product Service và Brand Service. Giả sử Brand Service không chứa thông tin Product, còn Product Service có BrandId.

Câu hỏi cần trả lời là: Tìm kiếm danh sách Product theo Brand Name.

Hình 5: Hai microservices: Product Service và Brand Service

Để trả lời truy vấn này, ta phải làm các bước lần lượt như sau:

  • Tìm kiếm trong Brand Service danh sách Brand với tên phù hợp.
  • Sau đó mang danh sách đó sang để truy vấn Product Service với danh sách Brand Id tương ứng. Tổng hợp kết quả cuối cùng rồi trả về.

Vậy ta cần đặt một trình điều phối Orchestrator này ở đâu? Các lựa chọn và đánh giá:

  • Tại Brand Service hoặc Product Service. Ưu tiên Product Service vì đây là truy vấn về Product. Nhưng dù đặt ở đâu thì hai dịch vụ này bị bó buộc chặt chẽ với nhau - là điều nên tránh khi có thể.
  • Tại một dịch vụ trung gian: Product Report Service, hoặc chính API Gateway đang có sẵn. Đây là dạng pattern Backend For Frontend (BFF) thường gặp. Ta có hình sau:
Hình 6: BFF đóng vai trò điều phối truy vấn

Tới đây có thể thấy rằng BFF hoạt động ổn với dạng truy vấn join nhiều service. Một rủi ro tiềm ẩn là nguy cơ OOM của BFF: Do dữ liệu lấy về BFF có thể thô và từ nhiều nguồn dịch vụ khác nhau trước khi nó thực hiện join và lấy ra dữ liệu tinh, lượng dữ liệu có thể rất lớn. Ngoài ra, thành phần triển khai BFF sẽ trở thành một tài nguyên chung cho nhiềm nhóm phát triển nên việc tổ chức module tốt là yếu tố quan trọng tạo ra ranh giới an toàn giữa họ.

(còn nữa)

Bình luận
Gửi bình luận
Bình luận