Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 24 — Consistency Models — Strong, Eventual, Causal

Architecture Decision-Making Bài 24/60

Mở đầu — vì sao bài này quan trọng

Trong một hệ thống phân tán, dữ liệu không nằm ở một chỗ. Nó được nhân bản (replicate) qua nhiều máy chủ, nhiều vùng địa lý, đôi khi nằm ở Singapore, đôi khi ở Hà Nội, đôi khi trong một cache ngay cạnh người dùng. Câu hỏi tưởng chừng đơn giản — "khi tôi ghi một giá trị, người khác đọc lên có thấy ngay không?" — thực ra là một trong những quyết định kiến trúc khó nhằn và tốn kém nhất mà bạn sẽ phải ra.

Đó chính là bài toán về consistency models — các mô hình nhất quán. Nói đơn giản, một consistency model là một hợp đồng giữa hệ thống lưu trữ và người lập trình: nó quy định rằng, với một chuỗi thao tác đọc/ghi diễn ra đồng thời trên nhiều node, kết quả nào là hợp lệ và kết quả nào thì không.

Vì sao đây là kỹ năng của một architect chứ không chỉ của một backend engineer? Bởi vì việc chọn consistency model quyết định trực tiếp đến ba thứ mà cấp trên của bạn quan tâm: độ trễ (latency) người dùng cảm nhận được, độ sẵn sàng (availability) khi có sự cố mạng, và độ đúng đắn nghiệp vụ — số dư tài khoản có bị âm không, đơn hàng có bị đặt trùng không. Chọn strong consistency ở chỗ không cần thì bạn đốt tiền và làm chậm sản phẩm. Chọn eventual consistency ở chỗ cần chặt chẽ thì bạn tạo ra bug mà QA gần như không bao giờ bắt được, chỉ khách hàng bắt được — và họ bắt bằng cách mất tiền.

Bài này giúp bạn phân biệt rõ ba mô hình cốt lõi — Strong (Linearizable), Eventual, và Causal — hiểu bản chất của chúng, và quan trọng nhất là biết khi nào chọn cái nào. Đây thuần túy là bài về các mô hình nhất quán; những khái niệm liên quan như CAP hay PACELC đã được bàn ở các bài trước, còn ở đây ta đào sâu vào chính cái "C" đó thực sự nghĩa là gì.

Khái niệm cốt lõi

Hãy hình dung một phổ (spectrum) chạy từ mạnh nhất đến yếu nhất. Càng về phía mạnh, hệ thống càng dễ suy luận đúng nhưng càng chậm và càng dễ mất availability khi có network partition. Càng về phía yếu, hệ thống càng nhanh và bền bỉ, nhưng người lập trình phải gánh nhiều phức tạp hơn.

Strong Consistency (Linearizable)

Đây là mô hình mạnh nhất và cũng trực giác nhất. Linearizability nói rằng: mọi thao tác dường như xảy ra tức thời (atomically) tại một thời điểm duy nhất, nằm đâu đó giữa lúc bắt đầu và lúc kết thúc lời gọi. Và tất cả mọi client đều thấy các thao tác theo cùng một thứ tự toàn cục (global order) — cái thứ tự đó khớp với thời gian thực (real-time).

Hệ quả thực tế: nếu client A ghi x = 5 và lời ghi trả về thành công lúc 10:00:00, thì bất kỳ client nào đọc x sau thời điểm đó — dù đọc trên node nào — cũng phải thấy x = 5 (hoặc một giá trị mới hơn nữa), chứ không bao giờ được thấy giá trị cũ. Hệ thống hành xử như thể chỉ có một bản sao dữ liệu duy nhất, dù thực tế bên dưới nó nhân bản qua chục node.

Cái giá phải trả: để đảm bảo điều này, các node phải "đồng thuận" (coordinate) với nhau qua các giao thức như Raft hoặc Paxos trước khi trả lời. Điều đó nghĩa là mỗi thao tác phải chờ round-trip mạng đến quorum các node. Nếu database của bạn trải qua nhiều vùng, một lần ghi có thể mất 100–300ms chỉ vì phải chờ node ở vùng khác gật đầu. Và nếu mạng giữa các vùng đứt, để giữ đúng cam kết, hệ thống buộc phải từ chối phục vụ thay vì trả về dữ liệu có thể sai.

> Lưu ý phân biệt: Linearizability nói về một object đơn lẻ và thứ tự real-time. Serializability nói về giao dịch (transaction) gồm nhiều object. Hai khái niệm này hay bị gộp làm một nhưng không giống nhau. Khi ai đó nói "strong consistency", họ thường ngụ ý linearizable.

Eventual Consistency

Ở đầu kia của phổ, eventual consistency đưa ra một cam kết rất lỏng: nếu ngừng ghi mới, thì sau một khoảng thời gian đủ lâu, tất cả các bản sao sẽ hội tụ về cùng một giá trị. Nó không hứa hẹn gì về việc khi nào chuyện đó xảy ra, cũng không đảm bảo thứ tự bạn thấy các cập nhật.

Điều này nghe có vẻ đáng sợ, nhưng nó cực kỳ hữu ích. Bằng cách bỏ đi nhu cầu đồng thuận trước khi trả lời, một node có thể trả về ngay giá trị nó có trong tay — nhanh, và vẫn phục vụ được ngay cả khi bị cô lập khỏi các node khác. Đây là nền tảng của các hệ như Amazon DynamoDB (chế độ mặc định), Apache Cassandra, hay hầu hết các CDN.

Vấn đề của eventual consistency là các "dị thường" (anomaly) mà nó cho phép:

  • Đọc thấy giá trị cũ (stale read): bạn vừa cập nhật ảnh đại diện, refresh lại vẫn thấy ảnh cũ vài giây.
  • Đọc không đơn điệu (non-monotonic read): đọc lần 1 thấy giá trị mới, đọc lần 2 (trên node khác) lại thấy giá trị cũ — như thể thời gian chạy ngược.
  • Xung đột ghi (conflict): hai node cùng nhận ghi khác nhau và phải hòa giải sau, thường bằng "last write wins" hoặc CRDT.

Causal Consistency

Nằm ở giữa phổ, causal consistency là một sự thỏa hiệp thông minh và thường bị đánh giá thấp. Ý tưởng: hệ thống không cần đảm bảo mọi thao tác đều có chung thứ tự toàn cục, nhưng nó phải tôn trọng quan hệ nhân–quả (causality) giữa các thao tác.

Nếu thao tác B phụ thuộc vào thao tác A — ví dụ B là câu trả lời cho câu hỏi A — thì mọi client thấy B cũng phải thấy A trước đó. Ngược lại, hai thao tác độc lập (concurrent, không nhân quả với nhau) thì các client được phép thấy theo thứ tự khác nhau, và điều đó không sao cả.

Ví dụ kinh điển: trong một ứng dụng chat, nếu An hỏi "Cả nhà đi ăn tối nay không?" rồi Bình trả lời "Có, 7 giờ nhé", thì không client nào được phép thấy câu trả lời của Bình trước câu hỏi của An — vì như vậy đoạn hội thoại trở nên vô nghĩa. Nhưng nếu hai người cùng gửi hai tin nhắn không liên quan gần như đồng thời, thứ tự hiển thị của chúng có thể khác nhau ở mỗi máy, và chẳng ai bận tâm.

Causal consistency đủ mạnh để loại bỏ hầu hết các dị thường "gây khó chịu" cho người dùng, nhưng vẫn giữ được phần lớn tốc độ và availability của eventual. Đây thường là điểm ngọt (sweet spot) mà nhiều kiến trúc sư bỏ lỡ vì họ chỉ biết hai thái cực.

Read-your-writes và Monotonic — các đảm bảo "phía client"

Ngoài ba mô hình chính, có một nhóm đảm bảo yếu hơn nhưng cực kỳ thực dụng, thường được gọi là session guarantees:

  • Read-your-writes: bạn luôn đọc lại được thứ chính bạn vừa ghi. (Nếu thiếu cái này, người dùng đổi mật khẩu rồi đăng nhập lại báo sai — thảm họa.)
  • Monotonic reads: một khi bạn đã thấy giá trị mới, bạn không bao giờ bị "lùi" về giá trị cũ hơn trong cùng phiên.
Chúng nằm giữa eventual và causal, và thường được cài đặt bằng cách "ghim" (pin) client vào một node hoặc gắn token phiên bản vào phiên làm việc.

Tình huống thực tế

Ví dụ 1 — Ví điện tử tại Việt Nam: nơi bắt buộc phải strong

Hãy tưởng tượng một ví điện tử kiểu MoMo hoặc ZaloPay, xử lý hàng triệu giao dịch mỗi ngày. Anh Tuấn có số dư 200.000đ. Anh mở app trên điện thoại và mở luôn trên máy tính bảng, rồi bấm thanh toán 150.000đ ở cả hai gần như cùng lúc.

Nếu hệ thống dùng eventual consistency cho bảng số dư: node A (phục vụ điện thoại) thấy số dư 200.000đ, chấp nhận trừ 150.000đ. Node B (phục vụ máy tính bảng) cũng thấy 200.000đ (vì cập nhật của A chưa lan tới), cũng chấp nhận trừ 150.000đ. Kết quả: tiêu 300.000đ trong khi chỉ có 200.000đ. Số dư âm, hoặc tệ hơn, hệ thống dùng "last write wins" và một giao dịch biến mất — công ty mất tiền thật.

Đây là chỗ bắt buộc strong consistency (linearizable). Thao tác trừ tiền phải đi qua đồng thuận: chỉ một trong hai giao dịch được xác nhận trước, và giao dịch thứ hai đọc lên phải thấy số dư đã cập nhật là 50.000đ, do đó bị từ chối. Người ta chấp nhận độ trễ vài chục ms cho luồng thanh toán, vì cái giá của một dị thường ở đây là tiền và niềm tin.

Bài học: ở đâu có invariant tuyệt đối (số dư ≥ 0, không tiêu quá số tiền có), ở đó bạn cần strong consistency cho chính dữ liệu đó — nhưng chỉ cho dữ liệu đó, không phải cho toàn bộ hệ thống.

Ví dụ 2 — Sàn thương mại điện tử Đông Nam Á: eventual là đủ, và là khôn ngoan

Một sàn kiểu Shopee hay Tiki hiển thị số lượt xem sản phẩm, số lượt đánh giá, và số "đã bán 12k". Giả sử một sản phẩm áo thun đang hot có 12.340 lượt bán. Nếu người dùng ở Đà Nẵng thấy con số 12.340 còn người dùng ở TP.HCM thấy 12.338 vì cập nhật chưa lan kịp, thì… chẳng có hậu quả gì. Vài giây sau cả hai đều hội tụ.

Nếu bắt buộc strong consistency cho những con số này, mỗi lần tăng bộ đếm sẽ phải đồng thuận qua nhiều vùng, tạo ra độ trễ và điểm nghẽn khủng khiếp vào giờ cao điểm sale 12.12. Thay vào đó, sàn dùng eventual consistency: mỗi node tự cộng dồn cục bộ rồi đồng bộ nền. Trang tải nhanh, chịu tải tốt, và không ai phàn nàn về việc con số lệch 2 đơn vị trong 3 giây.

Bài học: với dữ liệu có tính "gợi ý" (số đếm, lượt thích, gợi ý sản phẩm), eventual consistency vừa nhanh vừa rẻ vừa bền. Đừng áp cam kết đắt tiền lên dữ liệu không cần nó — đó là lãng phí kiến trúc.

Ví dụ 3 — Mạng xã hội và bình luận: nơi causal tỏa sáng

Một startup mạng xã hội ở Việt Nam gặp bug lạ: người dùng phản ánh rằng đôi khi họ thấy một câu trả lời trong phần bình luận, nhưng comment gốc mà nó reply lại chưa xuất hiện. Trải nghiệm cực kỳ khó hiểu — như đọc một cuộc cãi nhau mà thiếu vế đầu.

Nguyên nhân: hệ thống dùng eventual consistency thuần. Comment gốc của chị Lan được ghi ở node Singapore, còn reply của anh Hòa (bấm reply ngay) lại được ghi và lan tới node Hà Nội trước khi comment gốc kịp lan tới. Người dùng ở Hà Nội thấy reply trước gốc.

Giải pháp không phải là nhảy sang strong consistency (quá đắt cho một mạng xã hội cần tốc độ), mà là causal consistency. Bằng cách gắn "dependency" — reply của Hòa mang theo thông tin rằng nó phụ thuộc vào comment của Lan — node Hà Nội sẽ giữ reply lại cho đến khi comment gốc tới, rồi mới hiển thị cả hai đúng thứ tự. Với các comment không liên quan nhau, thứ tự vẫn có thể lệch nhẹ giữa các máy, và điều đó hoàn toàn chấp nhận được.

Bài học: khi vấn đề là thứ tự nhân–quả chứ không phải tính đúng tuyệt đối của một giá trị, causal consistency là câu trả lời đúng — nó chữa bug mà không phải trả giá của strong.

Hướng dẫn từng bước

Khi đứng trước một phần dữ liệu và phải chọn consistency model, hãy đi theo quy trình sau:

  • Chia nhỏ theo từng loại dữ liệu, đừng chọn cho cả hệ thống. Sai lầm lớn nhất là hỏi "hệ thống này nên strong hay eventual?". Câu hỏi đúng là "mỗi loại dữ liệu này cần gì?". Số dư ví cần strong; bộ đếm lượt xem cần eventual; luồng bình luận cần causal — tất cả có thể sống trong cùng một sản phẩm.
  • Xác định các invariant nghiệp vụ. Viết ra những điều "không bao giờ được sai": số dư không âm, một chỗ ngồi máy bay không bán cho hai người, mã giảm giá dùng một lần. Mỗi invariant tuyệt đối như vậy là một tín hiệu mạnh cần strong consistency cho dữ liệu liên quan.
  • Hỏi: người dùng có nhận ra sự lệch không, và nếu có thì hậu quả là gì? Nếu lệch chỉ gây khó chịu nhẹ (con số đếm), eventual ổn. Nếu lệch phá vỡ ý nghĩa (reply trước comment gốc), cân nhắc causal. Nếu lệch gây mất tiền hoặc sai nghiệp vụ, cần strong.
  • Kiểm tra ràng buộc về độ trễ và đa vùng. Strong consistency qua nhiều vùng địa lý rất đắt về latency. Nếu sản phẩm bắt buộc phản hồi dưới 50ms toàn cầu, có thể bạn phải giới hạn dữ liệu strong về một vùng, hoặc chấp nhận mô hình yếu hơn cho phần còn lại.
  • Bổ sung các session guarantees ở tầng client. Ngay cả khi backend là eventual, hãy đảm bảo tối thiểu read-your-writesmonotonic reads cho phiên người dùng. Đây là mức nâng cấp rẻ nhưng cải thiện trải nghiệm rõ rệt.
  • Ghi lại quyết định. Đây là một quyết định kiến trúc điển hình cần ghi vào ADR: bạn chọn model nào cho dữ liệu nào, đánh đổi gì, và vì sao. Sáu tháng sau khi có người hỏi "sao chỗ này lại eventual?", tài liệu này cứu bạn.

Lỗi thường gặp & mẹo

Lỗi 1 — Tưởng "strong consistency" là mặc định miễn phí. Nhiều người mặc định mọi database đều linearizable. Không đâu. DynamoDB mặc định là eventual (phải chủ động yêu cầu strongly consistent read). Nhiều replica đọc của MySQL/PostgreSQL trả về dữ liệu trễ so với primary. Đọc từ read-replica = bạn đang ở eventual mà không biết.

Lỗi 2 — Nhầm giữa "durable" và "consistent". Ghi thành công (đã lưu bền, không mất) không có nghĩa là mọi người đọc lên đều thấy ngay. Đó là hai thuộc tính khác nhau.

Lỗi 3 — Áp strong cho toàn bộ vì "cho an toàn". Đây là cách nhanh nhất để tạo ra hệ thống chậm, đắt và dễ sập khi partition. An toàn không nằm ở việc dùng model mạnh nhất mọi nơi, mà ở việc dùng đúng model đúng chỗ.

Lỗi 4 — Quên read-your-writes. Bug kinh điển: người dùng cập nhật hồ sơ, load lại thấy dữ liệu cũ, tưởng thao tác thất bại nên làm lại — tạo dữ liệu trùng. Luôn đảm bảo người dùng đọc lại được thứ họ vừa ghi.

Mẹo 1 — "Last write wins" là một cái bẫy im lặng. Nó âm thầm vứt bỏ dữ liệu khi có xung đột, và đồng hồ giữa các máy không bao giờ khớp hoàn hảo. Với dữ liệu quan trọng, cân nhắc CRDT hoặc hòa giải xung đột theo nghiệp vụ thay vì LWW.

Mẹo 2 — Dùng ngôn ngữ nghiệp vụ để giao tiếp với sếp. Đừng nói "chỗ này cần linearizable". Hãy nói "chỗ này không được để hai người mua cùng một vé, nên nó phải chậm hơn 30ms một chút — đây là đánh đổi". Người ra quyết định hiểu tiền và rủi ro, không hiểu thuật ngữ.

Mẹo 3 — Causal thường là câu trả lời bị bỏ quên. Khi bạn thấy mình phân vân giữa "eventual thì có bug thứ tự khó chịu" và "strong thì quá chậm", hãy dừng lại và hỏi: liệu causal consistency có giải quyết được không? Rất thường là có.

Bài tập thực hành

  • Phân loại dữ liệu. Lấy một sản phẩm bạn biết rõ (ví dụ một app đặt đồ ăn kiểu ShopeeFood/GrabFood). Liệt kê ít nhất 6 loại dữ liệu (số dư ví khuyến mãi, trạng thái đơn hàng, vị trí tài xế real-time, số sao đánh giá quán, danh sách món yêu thích, tồn kho món...). Với mỗi loại, quyết định strong / causal / eventual và viết một câu lý do.
  • Tìm dị thường. Với hai loại dữ liệu bạn chọn eventual ở bài 1, hãy mô tả một kịch bản cụ thể mà stale read hoặc non-monotonic read có thể xảy ra, và đánh giá: người dùng có thực sự bị ảnh hưởng không?
  • Chữa bug thứ tự. Cho tình huống: hệ thống thông báo hiển thị "Bạn đã được duyệt vào nhóm" trước "Yêu cầu tham gia nhóm của bạn đã được gửi". Đây là dị thường loại gì? Consistency model nào sẽ chữa được nó, và giải thích cơ chế phụ thuộc mà bạn sẽ dùng.
  • Viết mini-ADR. Chọn một loại dữ liệu bắt buộc strong consistency. Viết một đoạn ngắn (5–7 câu) theo tinh thần ADR: bối cảnh, quyết định, đánh đổi (latency và availability bạn hy sinh), và phương án thay thế bạn đã cân nhắc và loại bỏ.

Tóm tắt

Consistency model là hợp đồng giữa hệ thống lưu trữ và bạn, quy định kết quả đọc/ghi nào là hợp lệ khi dữ liệu được nhân bản qua nhiều node. Ba mốc trên phổ cần nắm chắc:

  • Strong (Linearizable): mọi thao tác như xảy ra tức thời theo một thứ tự toàn cục khớp thời gian thực. Dễ suy luận, nhưng chậm và dễ mất availability khi partition. Dùng cho dữ liệu có invariant tuyệt đối — tiền bạc, tồn kho, chỗ ngồi.
  • Eventual: các bản sao rồi sẽ hội tụ, không hứa khi nào và không hứa thứ tự. Nhanh, bền, rẻ. Dùng cho dữ liệu gợi ý — bộ đếm, lượt thích, gợi ý.
  • Causal: tôn trọng quan hệ nhân–quả; ai thấy hệ quả cũng phải thấy nguyên nhân trước. Điểm ngọt giữa hai thái cực, lý tưởng cho luồng hội thoại, bình luận, thông báo.
Nguyên tắc vàng: đừng chọn một model cho cả hệ thống — chọn theo từng loại dữ liệu. Bắt đầu từ invariant nghiệp vụ, cân nhắc hậu quả của sự lệch với người dùng, và luôn thêm các session guarantee rẻ tiền như read-your-writes. Cuối cùng, ghi lại quyết định vào ADR — vì lựa chọn consistency là một trong những đánh đổi kiến trúc đắt giá nhất bạn sẽ giải trình về sau.