0

Consistency vs. Performance: Bài toán truy vấn ít được thiết kế đúng trong Microservices

1. Một API đơn giản, một vấn đề không đơn giản

Hãy tưởng tượng bạn đang phát triển một hệ thống thương mại điện tử. Khi người dùng mở trang chi tiết sản phẩm, backend cần lấy thông tin từ nhiều service:

  • Product Service: tên, mô tả, hình ảnh sản phẩm.
  • Inventory Service: tình trạng hàng tồn kho.
  • Pricing Service: giá bán và chương trình khuyến mãi.
  • Review Service: điểm đánh giá và số lượng nhận xét.

Một cách triển khai phổ biến là API Gateway hoặc một service tổng hợp gọi các service trên, sau đó ghép dữ liệu thành một response.

Ban đầu, hệ thống hoạt động khá tốt. Nhưng khi traffic tăng lên, một số vấn đề bắt đầu xuất hiện:

  • P95 latency tăng dù database chính chưa đạt ngưỡng tải cao.
  • Một service phản hồi chậm khiến toàn bộ API bị kéo dài.
  • Cache giúp giảm tải database nhưng đôi khi trả về dữ liệu đã cũ.
  • Một dependency gặp sự cố khiến cả API tổng hợp không thể phản hồi.

Team bắt đầu tối ưu SQL, tăng connection pool, thêm Redis hoặc scale thêm instance. Tuy nhiên, những giải pháp này không phải lúc nào cũng giải quyết được nguyên nhân gốc rễ.

Có một câu hỏi quan trọng hơn cần được đặt ra:

Tất cả dữ liệu trong response có thực sự cần phải là dữ liệu mới nhất không?

Nếu số lượng review hiển thị trên trang sản phẩm chậm cập nhật vài giây, liệu trải nghiệm người dùng có bị ảnh hưởng đáng kể không? Ngược lại, nếu hệ thống sử dụng dữ liệu tồn kho đã cũ để xác nhận đơn hàng, chuyện gì sẽ xảy ra?

Hai truy vấn này đều là thao tác đọc dữ liệu. Nhưng xét theo yêu cầu nghiệp vụ, chúng hoàn toàn khác nhau.

Đây là một quyết định thiết kế thường bị bỏ qua trong Microservices: không phải truy vấn nào cũng cần cùng một mức độ nhất quán dữ liệu, và không phải truy vấn nào cũng cần đi qua cùng một đường xử lý.

2. Consistency và Performance không đơn giản là hai thái cực

Khi tối ưu hiệu năng, chúng ta thường nghe những lời khuyên quen thuộc:

  • Thêm cache để giảm latency.
  • Đọc dữ liệu từ replica để giảm tải database chính.
  • Sử dụng asynchronous processing để giảm coupling.
  • Tạo read model riêng để phục vụ những truy vấn phức tạp.

Tất cả đều là những kỹ thuật hữu ích. Nhưng mỗi kỹ thuật có một cái giá riêng.

Cache có thể trả kết quả nhanh hơn database nhưng dữ liệu có thể chưa được cập nhật. Read replica giúp phân tải đọc nhưng replication lag khiến kết quả chưa phản ánh thay đổi mới nhất. Một read model được cập nhật qua event có thể phục vụ lượng lớn request, nhưng không nhất thiết đồng bộ tức thời với database nguồn.

Điều đó không có nghĩa chúng ta phải chọn giữa một hệ thống nhanh nhưng sai và một hệ thống chính xác nhưng chậm.

Thực tế, consistency và performance không phải lúc nào cũng đối nghịch. Một câu SQL có điều kiện có thể vừa nhanh vừa bảo vệ được invariant. Ngược lại, một API gọi nhiều service theo kiểu synchronous fan-out có thể vừa chậm vừa không bảo đảm rằng dữ liệu từ các service phản ánh cùng một thời điểm.

Vì vậy, thay vì bắt đầu bằng câu hỏi nên dùng Redis hay database, chúng ta nên bắt đầu từ yêu cầu của nghiệp vụ.

Trong phạm vi thiết kế truy vấn, có thể phân loại thực dụng thành hai nhóm.

2.1. Consistency-critical queries

Đây là những truy vấn mà kết quả được sử dụng để đưa ra quyết định nghiệp vụ quan trọng. Hệ thống phải bảo đảm các invariant và quy tắc nghiệp vụ tương ứng được duy trì.

Ví dụ:

  • Kiểm tra và giữ tồn kho khi đặt hàng.
  • Kiểm tra số dư khả dụng trước giao dịch tài chính.
  • Xác nhận một tài nguyên chỉ được cấp cho một người tại một thời điểm.
  • Kiểm tra trạng thái hiện tại trước một chuyển đổi trạng thái quan trọng.

Điểm cần lưu ý là những nghiệp vụ này không nhất thiết phải đọc trực tiếp từ primary database trong mọi trường hợp. Cache vẫn có thể được sử dụng để lọc hoặc đưa ra quyết định sơ bộ.

Tuy nhiên, quyết định cuối cùng phải được bảo vệ bằng cơ chế phù hợp, chẳng hạn atomic update, transaction, optimistic locking hoặc một cơ chế điều phối tương đương.

2.2. Staleness-tolerant queries

Đây là những truy vấn mà nghiệp vụ chấp nhận kết quả chưa phản ánh thay đổi mới nhất trong một khoảng thời gian xác định.

Ví dụ:

  • Số lượt đánh giá trên trang sản phẩm.
  • Thống kê lượt xem.
  • Dashboard tổng hợp.
  • Báo cáo phân tích không yêu cầu realtime tuyệt đối.
  • Danh sách sản phẩm được cá nhân hóa dựa trên hành vi gần đây.

Với nhóm này, chúng ta có thể tận dụng cache, read replica, materialized view hoặc OpenSearch để tối ưu khả năng đọc.

Nhưng staleness-tolerant không có nghĩa là dữ liệu được phép sai tùy ý. Nếu dashboard chấp nhận dữ liệu trễ 30 giây, kết quả vẫn phải chính xác theo những dữ liệu đã được xử lý. Nếu giá hiển thị được cập nhật chậm, hệ thống vẫn phải xác định rõ giá nào được dùng để tính tiền khi checkout.

Độ trễ có thể chấp nhận phải được xác định từ nghiệp vụ, không phải do backend tự đặt ra để tiện tối ưu.

3. Cái giá của việc luôn đọc dữ liệu mới nhất

Quay lại API chi tiết sản phẩm. Giả sử mỗi request cần gọi bốn service.

Nếu gọi tuần tự, latency gần bằng tổng thời gian phản hồi của từng service. Nếu gọi song song, latency có thể giảm đáng kể, nhưng vẫn bị chi phối bởi service phản hồi chậm nhất.

Đồng thời, mỗi request tới API tổng hợp tạo ra nhiều request downstream. Khi traffic tăng, 1.000 request/giây ở API bên ngoài có thể tạo ra vài nghìn request/giây ở các service phía sau.

Hệ quả không chỉ là database chịu tải lớn hơn:

  • Connection pool tại nhiều service có thể bị cạn.
  • Network latency tăng khi hệ thống chịu tải cao.
  • Retry làm khuếch đại traffic trong lúc dependency đang quá tải.
  • Một service chậm có thể kéo theo hàng loạt service khác.
  • Các request đọc dữ liệu không quan trọng vẫn tiêu tốn tài nguyên của những service phục vụ nghiệp vụ cốt lõi.

Đây là lúc một thiết kế tưởng như hợp lý bắt đầu trở thành vấn đề vận hành.

Ví dụ, mỗi lần người dùng mở trang sản phẩm, hệ thống gọi Review Service để tính lại điểm đánh giá từ hàng nghìn bản ghi. Nếu số lượt review tăng lên hàng triệu, việc tối ưu index có thể giúp giảm chi phí nhưng không loại bỏ hoàn toàn công việc lặp lại.

Câu hỏi lúc này không còn là làm thế nào để query nhanh hơn. Câu hỏi là liệu có cần thực hiện phép tính đó trên mỗi request hay không.

Nếu số lượt review chỉ thay đổi khi có review mới, chúng ta có thể tính toán khi dữ liệu thay đổi và lưu sẵn kết quả tổng hợp.

Thay vì liên tục tính lại, hệ thống chuyển công việc sang thời điểm dữ liệu được cập nhật. Đây là thay đổi về chiến lược truy vấn, không chỉ là tối ưu SQL.

4. Tách đường đọc khỏi đường ra quyết định

Một hướng tiếp cận thực tế là không sử dụng cùng một chiến lược truy vấn cho tất cả nhu cầu đọc.

4.1. Đường đọc ưu tiên throughput

Với dữ liệu chấp nhận được độ trễ, chúng ta có thể xây dựng một read model riêng.

Ví dụ, mỗi khi có review mới, Review Service phát ra event. Một consumer nhận event và cập nhật thông tin tổng hợp:

{
  "productId": "P001",
  "averageRating": 4.6,
  "reviewCount": 1280
}

API hiển thị sản phẩm chỉ cần đọc bản tổng hợp này thay vì tính toán từ toàn bộ review mỗi lần có request.

Kiến trúc có thể được tổ chức như sau:

Review Service
      |
      | ReviewCreated event
      v
Message Broker
      |
      v
Consumer / Projection Builder
      |
      v
Review Summary Read Model
      |
      v
Product Detail API

Read model có thể nằm trong database tối ưu cho truy vấn, cache hoặc OpenSearch tùy theo nhu cầu.

Khi người dùng tạo review mới, số lượt review hiển thị có thể chưa thay đổi ngay lập tức. Nhưng nếu nghiệp vụ cho phép độ trễ đó, hệ thống đổi lại được khả năng đọc nhanh hơn và giảm tải cho service nguồn.

Đây là một ứng dụng của CQRS — tách mô hình đọc và mô hình ghi khi nhu cầu thực sự yêu cầu.

Tuy nhiên, không nên áp dụng CQRS chỉ vì hệ thống đang sử dụng Microservices. Với một bảng dữ liệu nhỏ và lượng truy cập thấp, truy vấn trực tiếp có thể đơn giản và dễ vận hành hơn nhiều.

4.2. Đường đọc phục vụ quyết định nghiệp vụ

Bây giờ hãy xét bài toán tồn kho.

Một cách làm nguy hiểm là:

  1. Đọc số lượng tồn kho từ cache.
  2. Nếu thấy còn hàng, tạo đơn hàng.
  3. Giảm số lượng tồn kho sau đó.

Giả sử chỉ còn một sản phẩm. Hai request đến gần như đồng thời và cùng đọc được giá trị 1 từ cache. Cả hai đều cho rằng có thể đặt hàng.

Nếu không có cơ chế giữ hàng nguyên tử, cả hai đơn hàng có thể được chấp nhận dù chỉ còn một sản phẩm.

Điều đáng chú ý là ngay cả khi cả hai request đọc được dữ liệu hoàn toàn mới, race condition vẫn có thể xảy ra. Vấn đề không chỉ là dữ liệu cũ mà còn nằm ở khoảng thời gian giữa thao tác đọc và thao tác ghi.

Một giải pháp đơn giản trong database là cập nhật có điều kiện:

UPDATE inventory
SET available_quantity = available_quantity - 1
WHERE product_id = :productId
  AND available_quantity >= 1;

Sau đó kiểm tra số dòng được cập nhật:

  • Một dòng được cập nhật: thao tác giữ hàng thành công.
  • Không có dòng nào được cập nhật: không đủ hàng hoặc không có bản ghi phù hợp.

Trong điều kiện database thực thi câu lệnh nguyên tử, cách này giúp tránh việc hai request cùng giảm tồn kho vượt quá số lượng khả dụng.

Nếu thao tác cần cập nhật thêm nhiều bảng, hệ thống phải xác định transaction boundary phù hợp. Nếu inventory thuộc một service riêng, việc giữ hàng có thể được triển khai như một nghiệp vụ reservation độc lập, kết hợp idempotency để xử lý retry và tránh giữ hàng trùng lặp.

Điểm mấu chốt là:

Không chỉ đọc trạng thái hiện tại; phải bảo đảm thao tác thay đổi trạng thái vẫn đúng khi nhiều request xảy ra đồng thời.

5. Cache, Read Replica và Read Model: chọn đúng công cụ

Ba kỹ thuật thường được sử dụng để cải thiện hiệu năng đọc là cache, read replica và read model. Chúng giải quyết những vấn đề khác nhau.

Giải pháp Phù hợp khi Trade-off chính
Cache Dữ liệu được đọc nhiều, có thể tái sử dụng kết quả TTL, invalidation, cache miss
Read replica Cần phân tải đọc khỏi primary database Replication lag, read-after-write
Read model / CQRS Có truy vấn phức tạp hoặc dữ liệu tổng hợp Độ trễ đồng bộ, vận hành projection
Atomic update / transaction Cần bảo vệ invariant khi ghi Lock contention, thời gian transaction

Các kỹ thuật này không loại trừ lẫn nhau. Một hệ thống có thể sử dụng read model, đặt cache trước read model và vẫn dùng transaction ở đường xử lý ghi.

Ví dụ:

  • Trang danh sách sản phẩm: cache hoặc read model.
  • Dashboard: dữ liệu tổng hợp cập nhật định kỳ hoặc theo event.
  • Tìm kiếm sản phẩm: OpenSearch phục vụ tìm kiếm và lọc.
  • Giữ tồn kho: thao tác nguyên tử trên nguồn dữ liệu có thẩm quyền.
  • Chi tiết giao dịch: nguồn dữ liệu và cơ chế đọc phải đáp ứng yêu cầu consistency của nghiệp vụ.

Đặc biệt, cần cẩn thận với read replica.

Giả sử người dùng vừa cập nhật địa chỉ giao hàng thành công. Request tiếp theo đọc từ replica chưa đồng bộ có thể trả về địa chỉ cũ. Nếu API hứa hẹn người dùng sẽ thấy ngay thay đổi vừa ghi, hệ thống cần có chiến lược read-after-write phù hợp.

Có thể định tuyến một số request sang primary, sử dụng phiên bản dữ liệu hoặc áp dụng cơ chế đọc có điều kiện. Giải pháp cụ thể phụ thuộc vào database và yêu cầu của hệ thống.

Không phải mọi request đều cần consistency mạnh trên mọi đường đọc, nhưng kỳ vọng của API phải rõ ràng.

6. Eventual Consistency không có nghĩa là bỏ mặc dữ liệu

Khi sử dụng event-driven architecture, một sai lầm thường gặp là cho rằng chỉ cần phát event qua Kafka thì read model sẽ tự động luôn đúng.

Trên thực tế, consumer có thể bị chậm, xử lý lỗi, nhận lại event hoặc cập nhật dữ liệu không theo thứ tự mong muốn.

Giả sử hệ thống nhận hai event cập nhật số lượt review:

ReviewCountUpdated: 100
ReviewCountUpdated: 101

Nếu event cũ được xử lý sau event mới và consumer ghi đè trực tiếp giá trị, read model có thể quay ngược từ 101 về 100.

Tùy mô hình dữ liệu, có thể giải quyết bằng cách sử dụng version, sequence, timestamp có ý nghĩa phù hợp hoặc xử lý phép cập nhật theo hướng idempotent. Timestamp đơn thuần không phải lúc nào cũng đủ để bảo đảm thứ tự chính xác.

Ngoài ra, consumer cần có cơ chế retry, xử lý event trùng lặp, theo dõi lag và phục hồi khi thất bại. Nếu read model bị hỏng hoặc thiếu dữ liệu, team cũng cần biết cách xây dựng lại nó từ nguồn dữ liệu có thẩm quyền hoặc event log còn được lưu giữ.

Nếu nghiệp vụ chấp nhận dữ liệu trễ tối đa 30 giây, việc consumer lag kéo dài 10 phút không còn là một chi tiết kỹ thuật có thể bỏ qua.

Do đó, khi thiết kế eventual consistency, hãy xác định rõ:

  • Độ trễ dữ liệu mục tiêu.
  • Cách phát hiện consumer bị chậm.
  • Cách xử lý event trùng lặp và lỗi.
  • Cách phục hồi hoặc xây dựng lại read model.
  • Hành vi của API khi dữ liệu vượt quá ngưỡng stale cho phép.

Eventual consistency là một quyết định kiến trúc có điều kiện, không phải giấy phép để dữ liệu cập nhật tùy ý.

7. Những câu hỏi cần trả lời trước khi thiết kế query API

Trước khi quyết định sử dụng cache, replica hay read model, hãy làm rõ một số câu hỏi.

Dữ liệu được phép cũ bao lâu?

Số lượt review có thể chấp nhận độ trễ vài giây, trong khi trạng thái thanh toán cần yêu cầu chặt chẽ hơn. Không nên áp dụng cùng một SLA cho mọi field chỉ vì chúng nằm trong cùng một response.

Có yêu cầu read-after-write không?

Nếu người dùng vừa cập nhật dữ liệu và đọc lại ngay, họ có cần thấy thay đổi đó tức thời không? Nếu có, chiến lược đọc phải tính đến khả năng replica hoặc cache chưa được cập nhật.

Kết quả có được dùng để ra quyết định nghiệp vụ không?

Nếu kết quả chỉ phục vụ hiển thị, dữ liệu stale có thể chấp nhận được. Nếu nó quyết định việc thanh toán, giữ hàng hoặc chuyển trạng thái giao dịch, cần xác định cơ chế bảo vệ invariant thay vì chỉ dựa vào việc đọc dữ liệu mới nhất.

Điều gì xảy ra khi dependency bị lỗi?

Nếu Review Service không phản hồi, API có thể trả về thông tin sản phẩm cùng số lượt review gần nhất. Nhưng nếu hệ thống không thể xác nhận điều kiện bắt buộc của giao dịch, nó có thể phải từ chối hoặc tạm thời chưa xử lý giao dịch đó.

Đây là quyết định theo từng nghiệp vụ, không nên áp dụng cùng một fallback cho toàn hệ thống.

Có thể đo lường consistency và performance không?

Một số chỉ số hữu ích gồm:

  • P95/P99 latency của query và dependency.
  • Cache hit ratio và cache miss.
  • Thời gian từ lúc phát event đến khi read model cập nhật.
  • Consumer lag và số lượng event tồn đọng.
  • Tỷ lệ dữ liệu stale vượt ngưỡng cho phép.
  • Tỷ lệ reservation thất bại do hết hàng hoặc cạnh tranh đồng thời.

Nếu không đo lường được những chỉ số này, team khó xác định hệ thống đang hoạt động đúng theo yêu cầu hay chỉ đơn giản là chưa gặp sự cố rõ ràng.

Kết luận

Trong Microservices, tối ưu truy vấn không đơn thuần là giảm số lần gọi database hay giảm thời gian thực thi SQL.

Một thiết kế tốt bắt đầu từ việc hiểu kết quả truy vấn được sử dụng để làm gì, mức độ dữ liệu cũ nào có thể chấp nhận và invariant nào phải luôn được bảo vệ.

Với dữ liệu phục vụ hiển thị hoặc phân tích, cache, read replica và read model có thể giúp tăng throughput, giảm coupling và cải thiện khả năng chịu tải. Với những quyết định nghiệp vụ quan trọng, hệ thống cần cơ chế bảo đảm tính đúng đắn phù hợp, đặc biệt khi nhiều request có thể xảy ra đồng thời.

Không phải mọi truy vấn đều cần dữ liệu mới nhất. Nhưng mọi quyết định nghiệp vụ đều cần một mức độ bảo đảm đúng đắn được xác định rõ ràng.

Đừng bắt toàn bộ hệ thống trả giá cho consistency mà nghiệp vụ không cần — và cũng đừng đánh đổi tính đúng đắn chỉ để có một con số latency đẹp hơn.

Tài liệu tham khảo

  1. Microsoft Learn — Data considerations for microservices
  2. Microsoft Learn — CQRS pattern
  3. AWS Prescriptive Guidance — CQRS pattern
  4. microservices.io — CQRS

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í