Consistency: strong vs eventual, replication lag, CAP, PACELC
Tiếp đi: consistency. Bài trước nói báo cáo "eventual, trễ vài giây". Eventual cụ thể là gì và khi nào chấp nhận được?
Ta quay lại shop-app. Khi traffic tăng, đội đã thêm read replica cho DB (cơ chế replication xem Bài replication, partition, sharding):
WRITE ─► Primary ──replication──► Replica 1, 2, 3 ◄─ READ
1. Bài toán
User đổi tên hiển thị trên profile:
10:00:00.100 PUT /profile name = "Phong Nguyen" → Primary ✅ → 200 OK
10:00:00.150 GET /profile → route sang Replica → name = "Phong" ❌
10:00:00.300 replication tới Replica
10:00:01 refresh → "Phong Nguyen" (lại đúng 😂)
User: "Ủa? Mình vừa đổi tên mà?"
2. Vấn đề
- Replication lag: replication thường async, replica chậm hơn primary vài ms đến vài giây (lâu hơn khi primary bận hoặc replica chạy query nặng).
- Trong khoảng lag, primary và replica khác nhau. Đọc ở đâu thì thấy cái đó.
- Không phải dữ liệu nào cũng chịu được: số like chậm 2 like không sao; số dư vừa chuyển 9.000 mà vẫn hiện 10.000 thì nghiêm trọng; tồn kho sai thì bán vượt.
3. Giải pháp
Các mức consistency (từ mạnh đến yếu):
- Strong / linearizable: mọi read thấy write mới nhất đã được xác nhận, như thể chỉ có một bản dữ liệu.
- Read-your-writes: chính user vừa ghi thì đọc lại phải thấy. User khác có thể thấy trễ.
- Monotonic reads: đã thấy bản mới thì không bị "lùi" về bản cũ (không nhảy giữa replica nhanh và replica chậm).
- Eventual: nếu ngừng ghi, cuối cùng mọi bản sao sẽ giống nhau. Không hứa khi nào.
Cách đạt read-your-writes trong shop-app:
- Sau khi user ghi, các read của chính user đó đi về primary trong một khoảng (ví dụ 5s), hoặc với những màn hình nhạy cảm (profile, đơn vừa đặt, số dư).
- Hoặc so phiên bản: client nhớ
version/LSN vừa ghi, replica chưa tới version đó thì đọc primary. - Read không nhạy cảm (danh sách sản phẩm, review) cứ đọc replica.
4. Code từng bước
Bước 1: Dependency: spring-boot-starter-data-jpa, PostgreSQL driver.
Bước 2: Config: 2 datasource, credential từ biến môi trường:
app:
datasource:
primary:
url: jdbc:postgresql://pg-primary:5432/shop
username: ${DB_USER}
password: ${DB_PASSWORD}
replica:
url: jdbc:postgresql://pg-replica:5432/shop
username: ${DB_USER}
password: ${DB_PASSWORD}
read-your-writes-window: 5s
Bước 3: Code Java: routing datasource, read-only đi replica trừ khi user vừa ghi.
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return (readOnly && !RecentWrite.isRecent()) ? "replica" : "primary";
}
}
// Ghi nhận "user này vừa ghi" (bản thật: lưu trong Redis theo userId, hoặc cookie)
public final class RecentWrite {
private static final ThreadLocal<Boolean> RECENT = ThreadLocal.withInitial(() -> false);
public static void mark(boolean v) { RECENT.set(v); }
public static boolean isRecent() { return RECENT.get(); }
public static void clear() { RECENT.remove(); }
}
@Configuration
public class DataSourceConfig {
@Bean
@Primary
DataSource dataSource(@Qualifier("primaryDs") DataSource primary,
@Qualifier("replicaDs") DataSource replica) {
var routing = new ReadWriteRoutingDataSource();
routing.setTargetDataSources(Map.of("primary", primary, "replica", replica));
routing.setDefaultTargetDataSource(primary);
routing.afterPropertiesSet();
// Lazy: chỉ lấy connection khi đã biết transaction readOnly hay không
return new LazyConnectionDataSourceProxy(routing);
}
// primaryDs, replicaDs: tạo HikariDataSource từ app.datasource.*
}
@Service
public class ProfileService {
private final ProfileRepository repo;
private final RecentWriteStore recentWrites; // Redis: userId → TTL 5s
public ProfileService(ProfileRepository repo, RecentWriteStore recentWrites) {
this.repo = repo;
this.recentWrites = recentWrites;
}
@Transactional
public Profile rename(long userId, String name) {
Profile p = repo.findById(userId).orElseThrow();
p.setName(name);
recentWrites.mark(userId, Duration.ofSeconds(5));
return p;
}
@Transactional(readOnly = true)
public Profile get(long userId) {
return repo.findById(userId).orElseThrow(); // filter đã set RecentWrite từ Redis
}
}
Bước 4: Test bằng curl
curl -s -X PUT localhost:8081/profile/42 -H 'Content-Type: application/json' -d '{"name":"Phong Nguyen"}'
curl -s localhost:8081/profile/42 # trong 5s: đọc primary → "Phong Nguyen"
curl -s localhost:8081/profile/99 # user khác: đọc replica
5. Cách hoạt động
Không read-your-writes:
user ─PUT─► Primary (Phong Nguyen) ───lag 200ms───► Replica
user ─GET──────────────────────────────────────────► Replica (Phong) ❌
Có read-your-writes:
user ─PUT─► Primary ── mark(user 42, 5s)
user ─GET─► recent? yes ─► Primary (Phong Nguyen) ✅
user khác ─GET─► Replica (có thể còn cũ, chấp nhận được)
6. So sánh
| Dữ liệu trong shop-app | Mức cần | Đọc ở đâu |
|---|---|---|
| Danh sách sản phẩm, review, số like | Eventual | Replica, cache |
| Profile vừa sửa, đơn vừa đặt | Read-your-writes | Primary trong cửa sổ ngắn |
| Trừ tồn kho, trạng thái thanh toán, số dư | Strong | Primary, có lock/version |
| Báo cáo doanh thu | Eventual (trễ vài phút được) | Reporting DB |
7. Khi nào nên dùng / không nên dùng
- Nên hỏi "read này cần consistency mức nào?" cho từng loại dữ liệu, không áp một mức cho cả hệ thống.
- Không nên "mọi read cứ đưa sang replica" hoặc "mọi thứ phải strong" (mất khả năng scale và availability).
- Không nên quyết định nghiệp vụ (trừ kho, kiểm tra số dư) dựa trên dữ liệu đọc từ replica hay cache.
CAP là chọn 2 trong 3 đúng không? Vậy mình chọn CA, bỏ P, cho shop-app vừa consistent vừa available được không?
Đây là cách hiểu sai phổ biến nhất. Chỉnh lại: P không phải thứ bạn chọn bỏ. Network partition (mạng giữa các node bị đứt/chậm) chắc chắn sẽ xảy ra trong hệ phân tán. CAP nói: khi có partition, bạn buộc phải chọn giữa C và A.
- C (consistency ở đây = linearizable): mọi read thấy write mới nhất.
- A (availability): mọi request tới node còn sống đều nhận được response (không phải lỗi).
- P (partition tolerance): hệ thống vẫn chạy khi mạng giữa các node bị cắt.
Kho có 1 chiếc iPhone. Inventory chạy 2 node ở 2 datacenter.
Node HN ═══X═══ (mạng đứt) ═══X═══ Node HCM
stock=1 stock=1
User A mua User B mua
Chọn CP: node không liên lạc được với bên kia → từ chối ghi (503)
→ không bán vượt, nhưng một phía không mua được (mất A)
Chọn AP: cả hai node đều nhận → cả A và B đều mua được
→ bán vượt 1 chiếc, sau khi mạng nối lại phải xử lý xung đột (mất C)
"CA" chỉ có nghĩa với hệ một node (một Postgres đơn): không có mạng giữa các node thì không có partition. Nhưng đó là không phân tán.
Trong shop-app thường chọn theo từng tính năng:
- Trừ tồn kho, thanh toán: nghiêng về CP. Thà báo "thử lại sau" còn hơn bán vượt / trừ tiền sai.
- Giỏ hàng, danh sách sản phẩm, review: nghiêng về AP. Luôn trả lời được, sai lệch tạm thời rồi merge.
PACELC bổ sung phần CAP bỏ qua: if P then chọn A hoặc C, Else (lúc bình thường) chọn Latency hoặc Consistency. Ví dụ replication đồng bộ (chờ replica xác nhận) thì consistent hơn nhưng mỗi write chậm hơn; async thì nhanh nhưng có replication lag như bài toán ở trên. Thực tế, trade-off latency/consistency gặp hằng ngày, còn partition thì hiếm hơn.
Vậy eventual consistency giữa các service, ví dụ order-service và inventory-service qua Kafka, có bị replication lag giống vậy không?
Đúng, cùng một bản chất, chỉ khác tầng. Database-per-service (bài 14.4) làm mỗi service giữ bản sao dữ liệu của service khác, cập nhật qua event:
catalog-service: price 900 → 800 ──event──► Kafka ──► order-service.product_view
lag: ms đến vài giây (consumer chậm, rebalance...)
Trong khoảng lag, order-service vẫn thấy price = 900
Cách xử lý giống tư duy ở trên:
- Dữ liệu chỉ để hiển thị: chấp nhận bản sao hơi cũ.
- Dữ liệu dùng để quyết định (giá khi thanh toán): hỏi chủ sở hữu (catalog) tại thời điểm checkout, hoặc gửi kèm version và để chủ sở hữu từ chối nếu version cũ.
- Event có thể đến trễ, trùng, sai thứ tự → consumer phải idempotent và so
version(không so timestamp, nhớ bài 14.1): chỉ áp dụng event có version lớn hơn bản đang giữ.
// Chỉ cập nhật khi event mới hơn
@Modifying
@Query("update ProductView v set v.price = :price, v.version = :version " +
"where v.productId = :id and v.version < :version")
int applyIfNewer(String id, BigDecimal price, long version);
Còn khi nhiều service phải cùng thay đổi trạng thái (đặt hàng = trừ kho + trừ tiền) mà một bước fail thì sao? Đó là Saga, bài tiếp theo.
Phỏng vấn hỏi về consistency và CAP thì trả lời sao?
Q1. Strong consistency và eventual consistency khác nhau thế nào?
🇻🇳 Tiếng Việt:
Strong consistency bảo đảm mọi read thấy write mới nhất đã được xác nhận, như thể chỉ có một bản dữ liệu. Eventual consistency chỉ hứa rằng nếu không có write mới thì các bản sao cuối cùng sẽ giống nhau, trong lúc đó read có thể thấy dữ liệu cũ. Strong đổi lấy latency và availability; eventual thì scale tốt và chịu lỗi tốt hơn. Em chọn theo từng loại dữ liệu: tồn kho và thanh toán cần strong, catalog và review thì eventual.
🇬🇧 English:
Strong consistency guarantees every read sees the latest acknowledged write, as if there were a single copy. Eventual consistency only promises that replicas converge if writes stop, so reads may return stale data in the meantime. Strong consistency costs latency and availability, while eventual consistency scales and tolerates failures better. I choose per data type: inventory and payments need strong consistency, catalog and reviews can be eventual.
Q2. Giải thích CAP theorem.
🇻🇳 Tiếng Việt:
Trong hệ phân tán, network partition là điều không tránh được. CAP nói khi có partition, hệ thống phải chọn giữa consistency, tức từ chối request để không trả dữ liệu sai, và availability, tức vẫn trả lời nhưng có thể là dữ liệu cũ hoặc xung đột. Vì vậy "chọn CA" không có ý nghĩa với hệ phân tán. PACELC bổ sung: khi không có partition, vẫn phải đánh đổi giữa latency và consistency.
🇬🇧 English:
In a distributed system, network partitions are unavoidable. CAP says that during a partition the system must choose between consistency, rejecting requests rather than returning wrong data, and availability, answering anyway with possibly stale or conflicting data. So "choosing CA" is meaningless for a distributed system. PACELC adds that even without a partition, there is a trade-off between latency and consistency.
Q3. User sửa profile xong reload lại thấy dữ liệu cũ. Nguyên nhân và cách sửa?
🇻🇳 Tiếng Việt:
Write đi vào primary nhưng read đi vào replica đang bị replication lag, nên vi phạm read-your-writes. Cách sửa: sau khi user ghi, route read của chính user đó về primary trong một khoảng ngắn hoặc cho các màn hình nhạy cảm, hoặc so version/LSN để chỉ đọc replica đã bắt kịp. Read không nhạy cảm vẫn đọc replica.
🇬🇧 English:
The write went to the primary but the read hit a lagging replica, which breaks read-your-writes consistency. To fix it, route that user's reads to the primary for a short window after a write or for sensitive screens, or track the written version or LSN and only read from replicas that have caught up. Non-sensitive reads can still go to replicas.
Cách nhớ ngắn gọn
- Strong → read-your-writes → monotonic → eventual: chọn theo dữ liệu.
- Replica lag = eventual; vừa ghi xong thì đọc primary.
- CAP: P chắc chắn xảy ra → khi P chọn C hoặc A; "CA" = một node.
- PACELC: bình thường vẫn đổi Latency lấy Consistency.
- Kho/tiền CP, catalog/giỏ hàng AP.
Tự kiểm tra
Replication lag gây ra lỗi gì cho user?
Vừa ghi xong đọc lại thấy dữ liệu cũ (vi phạm read-your-writes), hoặc dữ liệu nhảy qua lại giữa mới và cũ.
Vì sao "chọn CA" không có nghĩa với hệ phân tán?
Partition chắc chắn xảy ra; khi đó phải chọn C hoặc A. CA chỉ đúng với hệ một node.
Inventory chạy 2 datacenter, mạng đứt. CP và AP khác nhau thế nào?
CP: từ chối ghi để không bán vượt. AP: cả hai bên nhận đơn, có thể bán vượt, xử lý xung đột sau.
PACELC thêm gì so với CAP?
Khi không có partition vẫn phải đánh đổi giữa latency và consistency (ví dụ replication sync vs async).
Consumer nhận event sai thứ tự thì tránh ghi đè dữ liệu mới bằng cũ thế nào?
So version: chỉ áp dụng event có version lớn hơn bản đang lưu, không so timestamp.