Chương 9. Microservices và System Design cơ bản
Rất nhiều CV ghi "có kinh nghiệm microservices", nhưng khi tôi hỏi "service A gọi service B bị timeout thì đơn hàng ở trạng thái gì" thì phần lớn im lặng. Ở mức 2–4 năm, tôi không kỳ vọng bạn thiết kế hệ thống cho trăm triệu người dùng; tôi kỳ vọng bạn hiểu vì sao chia service, cái giá phải trả khi gọi qua mạng, và biết làm một bài system design nhỏ có cấu trúc, có ước lượng, có trade-off. Chương này đi từ các khái niệm cốt lõi đến cách làm bài system design trong buổi phỏng vấn.
Người phỏng vấn muốn kiểm tra
- Biết khi nào không nên dùng microservices và modular monolith là gì.
- Hiểu các cơ chế resilience: timeout, retry có backoff và jitter, circuit breaker, bulkhead, rate limit.
- Giải thích được vì sao tránh 2PC, Saga hoạt động ra sao, compensating transaction là gì.
- Hiểu CAP đúng nghĩa và eventual consistency trong hệ thống thật.
- Làm được bài system design nhỏ theo khung rõ ràng, có ước lượng con số.
9.1 Monolith, microservices và modular monolith
Microservices là cách tổ chức hệ thống thành nhiều service nhỏ, mỗi service sở hữu một nghiệp vụ (bounded context) và dữ liệu riêng, deploy độc lập. Lợi ích thật sự nằm ở chỗ nhiều team deploy độc lập và scale độc lập từng phần, không phải ở chỗ "code gọn hơn".
Giải thích các từ trong đoạn trên bằng lời thường:
- Monolith: cả hệ thống là một ứng dụng, build ra một file (ví dụ một
app.jar), chạy chung một process, thường dùng chung một database. Các phần gọi nhau bằng gọi hàm thông thường. - Microservices: hệ thống gồm nhiều ứng dụng nhỏ chạy riêng, mỗi cái một process, một database, gọi nhau qua mạng (HTTP, gRPC, message).
- Bounded context: một vùng nghiệp vụ có ngôn ngữ và quy tắc riêng. Ví dụ chữ "sản phẩm" ở catalog nghĩa là mô tả, ảnh, giá niêm yết; ở kho nghĩa là số lượng, vị trí kệ. Mỗi bounded context là ứng viên tự nhiên cho một service.
- Deploy độc lập: sửa và phát hành service thanh toán mà không phải build lại, test lại, dừng service catalog.
| Tiêu chí | Monolith | Microservices |
|---|---|---|
| Deploy | Một artifact, đơn giản | Nhiều service, cần CI/CD và orchestration tốt |
| Transaction | ACID trong một DB | Phân tán, phải dùng Saga, eventual consistency |
| Gọi giữa các module | Gọi hàm, nano giây | Gọi mạng, mili giây, có thể lỗi |
| Scale | Scale cả khối | Scale riêng phần nóng |
| Quan sát, debug | Một log, một stacktrace | Cần tracing, log tập trung |
Khi nào không nên dùng microservices: team nhỏ (dưới khoảng 10 người), sản phẩm mới chưa rõ ranh giới nghiệp vụ, chưa có CI/CD, monitoring, tracing, hoặc lý do duy nhất là "cho hiện đại". Chia sai ranh giới tạo ra distributed monolith: các service phải deploy cùng nhau, gọi nhau dây chuyền, dùng chung DB, mang toàn bộ nhược điểm của cả hai mô hình.
Modular monolith là lựa chọn tôi hay khuyên: một ứng dụng deploy duy nhất nhưng chia module theo nghiệp vụ, mỗi module có package riêng, chỉ lộ API public, không truy cập bảng của module khác. Khi một module thật sự cần scale hoặc đổi nhịp release riêng, tách nó ra thành service sẽ dễ hơn nhiều. Spring Modulith hoặc ArchUnit giúp kiểm tra ranh giới module bằng test.
Mẹo từ người phỏng vấn
Câu trả lời làm tôi đánh giá cao: "Dự án của em chia 8 service, nhưng nhìn lại thì order và payment luôn deploy cùng nhau, lẽ ra nên gộp." Nhận ra sai lầm kiến trúc của chính mình là dấu hiệu trưởng thành.
Ví dụ cụ thể: một shop online ở ba mô hình
Hệ thống bán hàng gồm các nghiệp vụ: catalog, giỏ hàng, đơn hàng, kho, thanh toán, thông báo. Cùng một thao tác "đặt hàng" trông như sau ở từng mô hình:
| Mô hình | Cấu trúc | Luồng "đặt hàng" |
|---|---|---|
| Monolith | 1 shop.jar, 1 PostgreSQL, code lẫn lộn giữa các package | Một method @Transactional ghi orders, trừ inventory, ghi payments; lỗi thì rollback hết |
| Modular monolith | 1 shop.jar, package order, inventory, payment chỉ gọi nhau qua interface public, mỗi module sở hữu bảng riêng | Module order gọi InventoryApi.reserve(), vẫn chung một transaction |
| Microservices | 6 service, 6 database, Kafka ở giữa | order-service lưu đơn PENDING, gọi inventory-service qua HTTP, payment qua event; lỗi thì chạy bù trừ (mục 9.5) |
Giải thích: với team 6 người và 2.000 đơn/ngày, monolith hoặc modular monolith đủ dùng, transaction ACID đơn giản, debug một stacktrace. Khi công ty có 5 team, catalog cần scale 20 instance cho mùa sale trong khi thanh toán chỉ cần 3, và team thanh toán muốn release hằng ngày mà không chờ ai, lúc đó tách service mới đáng cái giá phải trả: mạng chập chờn, dữ liệu phân tán, cần tracing.
9.2 Giao tiếp đồng bộ và bất đồng bộ
| Tiêu chí | Sync (REST, gRPC) | Async (Kafka, RabbitMQ) |
|---|---|---|
| Phản hồi | Có kết quả ngay | Không chờ kết quả |
| Coupling thời gian | Cả hai phải cùng sống | Consumer chết thì message chờ trong broker |
| Nhất quán | Dễ lập luận hơn | Eventual consistency |
| Dùng khi | Truy vấn cần kết quả để trả cho user | Thông báo sự kiện, tác vụ nền, lan truyền trạng thái |
Chuỗi gọi đồng bộ dài là kẻ thù của availability: nếu mỗi service có availability 99,9% và một request đi qua 5 service nối tiếp, availability tổng chỉ còn khoảng 99,5%. Nguyên tắc của tôi: đọc dữ liệu cần ngay thì gọi sync; lan truyền thay đổi trạng thái thì dùng event.
Phép tính cụ thể: availability tổng của chuỗi nối tiếp là tích các availability, 0,999 × 0,999 × 0,999 × 0,999 × 0,999 ≈ 0,995. Đổi ra thời gian: 99,9% cho phép khoảng 43 phút ngừng mỗi tháng (30 × 24 × 60 × 0,001 ≈ 43), còn 99,5% là khoảng 216 phút, gấp 5 lần. Latency cũng cộng dồn: 5 service mỗi cái 40ms thì request mất ít nhất 200ms.
Ví dụ áp dụng nguyên tắc trên với luồng đặt hàng: kiểm tra giá và tồn kho để hiển thị cho user là sync (user cần câu trả lời ngay); sau khi đơn được tạo, việc gửi email, cộng điểm, cập nhật thống kê là async qua event OrderCreated (không ai cần chờ, và email service chết cũng không được làm hỏng việc đặt hàng).
9.3 API Gateway, service discovery, config tập trung
API Gateway (Spring Cloud Gateway, Kong, Nginx) là cửa vào duy nhất cho client: định tuyến, xác thực token, rate limit, CORS, gom log, đôi khi gộp response. Gateway không nên chứa logic nghiệp vụ, nếu không nó sẽ thành một monolith mới.
Service discovery giải quyết việc địa chỉ instance thay đổi liên tục. Có hai kiểu: client-side (Eureka, client tự hỏi registry rồi tự cân bằng tải) và server-side (Kubernetes Service: gọi http://inventory-service và kube-proxy/DNS lo phần còn lại). Khi chạy trên Kubernetes, gần như không cần Eureka nữa.
Config tập trung (Spring Cloud Config, Kubernetes ConfigMap và Secret, Vault) tách cấu hình khỏi artifact để cùng một image chạy được ở mọi môi trường. Secret không bao giờ nằm trong Git dạng plain text.
Ví dụ gateway: cấu hình Spring Cloud Gateway dưới đây định tuyến hai nhóm URL tới hai service và giới hạn tốc độ ở cửa vào.
spring:
cloud:
gateway:
routes:
- id: orders
uri: http://order-service # Kubernetes Service name, resolved by DNS
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter # token bucket backed by Redis
args:
redis-rate-limiter.replenishRate: 50 # 50 tokens added per second
redis-rate-limiter.burstCapacity: 100 # bucket holds at most 100
- id: catalog
uri: http://catalog-service
predicates:
- Path=/api/products/**Giải thích: request GET /api/orders/42 khớp route orders và được chuyển tới http://order-service/api/orders/42; trên Kubernetes, tên order-service được DNS phân giải ra các pod đang sống, đó là server-side discovery. Bộ lọc rate limit cho mỗi client (mặc định theo principal, thường cấu hình thêm key-resolver theo user hoặc IP) trung bình 50 request/giây, được "bùng" tới 100; vượt quá thì gateway trả 429 mà order-service không hề nhận request. Gateway chỉ định tuyến và kiểm soát, không có dòng logic nghiệp vụ nào.
9.4 Resilience: timeout, retry, circuit breaker, bulkhead, rate limit
Gọi qua mạng thì phải giả định sẽ lỗi. Các cơ chế sau bổ trợ nhau:
- Timeout: mọi lời gọi ra ngoài phải có connect timeout và read timeout. Không có timeout, một service chậm sẽ giữ hết thread của bạn và kéo sập bạn theo (cascading failure).
- Retry với exponential backoff và jitter: chỉ retry lỗi tạm thời (timeout, 503), không retry 400. Backoff tăng dần (200ms, 400ms, 800ms) và jitter ngẫu nhiên để hàng nghìn client không retry cùng một thời điểm. Chỉ retry thao tác idempotent hoặc có idempotency key.
- Circuit breaker: đếm tỷ lệ lỗi; vượt ngưỡng thì chuyển sang OPEN và fail nhanh không gọi nữa; sau một khoảng chờ chuyển HALF_OPEN cho vài request thử; thành công thì CLOSED trở lại.
- Bulkhead: giới hạn số lời gọi đồng thời tới từng dependency để một dependency chậm không chiếm hết tài nguyên dùng chung.
- Rate limit: bảo vệ chính mình khỏi quá tải (token bucket, sliding window), thường đặt ở gateway.
Timeout, retry, backoff và jitter bằng ví dụ số
Nhắc lại kiến thức
Chỉ retry được thao tác idempotent (gọi lặp lại cho cùng kết quả): GET, PUT, DELETE theo chuẩn là idempotent, POST thì không, trừ khi kèm Idempotency-Key (xem mục 7.2 và 7.4).
Timeout là thời gian tối đa bạn chịu chờ. Có hai loại: connect timeout (chờ mở kết nối TCP, thường 1–2 giây, vượt nghĩa là service đích không tới được) và read timeout (chờ response sau khi đã gửi request, đặt theo p99 của service đích cộng biên an toàn). Ví dụ inventory-service có p99 là 300ms thì read timeout khoảng 1 giây là hợp lý; để mặc định vô hạn là tự sát.
Vì sao thiếu timeout kéo sập cả service, với số cụ thể: Tomcat có 200 thread. Trang sản phẩm nhận 100 request/giây, mỗi request gọi inventory-service. Bình thường mỗi lời gọi mất 50ms, chỉ khoảng 5 thread bận. Inventory treo, mỗi lời gọi chờ 30 giây: sau 2 giây đã có 200 request đang chờ, hết thread, và mọi API khác của service (kể cả API không liên quan tới kho) cũng không phục vụ được. Với read timeout 1 giây, số thread bận tối đa khoảng 100 × 1 = 100, service vẫn sống.
Retry với backoff nghĩa là chờ tăng dần giữa các lần thử để service đích có thời gian hồi phục. Jitter là cộng hoặc thay thời gian chờ bằng một số ngẫu nhiên, để các client không thử lại cùng một lúc. Đoạn code dưới đây tính thời gian chờ theo cấp số nhân (gốc 200ms, trần 2 giây) và theo kiểu "full jitter" (ngẫu nhiên từ 0 tới giá trị cấp số nhân).
import java.util.Random;
public class BackoffDemo {
public static void main(String[] args) {
long base = 200, cap = 2_000;
Random random = new Random(42); // fixed seed so the output is repeatable
for (int attempt = 1; attempt <= 5; attempt++) {
long exp = Math.min(cap, base * (1L << (attempt - 1))); // 200, 400, 800, 1600, 2000
long jittered = random.nextLong(exp + 1); // full jitter: random in [0, exp]
System.out.println("retry " + attempt + ": backoff=" + exp + "ms, with jitter=" + jittered + "ms");
}
// prints:
// retry 1: backoff=200ms, with jitter=49ms
// retry 2: backoff=400ms, with jitter=87ms
// retry 3: backoff=800ms, with jitter=725ms
// retry 4: backoff=1600ms, with jitter=749ms
// retry 5: backoff=2000ms, with jitter=144ms
}
}Giải thích: không có jitter, 1.000 client cùng lỗi lúc 10:00:00.000 sẽ cùng retry đúng lúc 10:00:00.200, rồi cùng lúc 10:00:00.600, tạo ra những "đợt sóng" 1.000 request dồn vào service vừa mới hồi. Có jitter, lần retry thứ nhất của 1.000 client rải đều trong khoảng 0–200ms, tức khoảng 5 request mỗi mili giây thay vì 1.000 request trong cùng một mili giây. Trần 2 giây ngăn thời gian chờ tăng vô hạn. Seed cố định chỉ để output lặp lại được; code thật dùng ThreadLocalRandom.
Circuit breaker: bảng trạng thái
Circuit breaker giống cầu dao điện: khi thấy dòng lỗi quá lớn thì "ngắt" để bảo vệ phần còn lại, sau một lúc thử đóng lại xem đã ổn chưa.
| Trạng thái | Hành vi khi có lời gọi | Chuyển sang khi |
|---|---|---|
| CLOSED | Gọi thật, ghi nhận thành công/lỗi | OPEN nếu tỷ lệ lỗi trong cửa sổ vượt ngưỡng |
| OPEN | Không gọi, ném lỗi ngay (fail fast) | HALF_OPEN sau thời gian chờ (ví dụ 30 giây) |
| HALF_OPEN | Cho vài lời gọi thử đi qua | CLOSED nếu lời gọi thử ổn, OPEN nếu vẫn lỗi |
Ví dụ số với cấu hình ở dưới (cửa sổ 20 lời gọi, ngưỡng 50%, chờ 30 giây, 5 lời gọi thử):
| Thời điểm | Sự kiện | Trạng thái |
|---|---|---|
| 10:00:00 | 20 lời gọi gần nhất có 4 lỗi (20%) | CLOSED |
| 10:00:05 | Inventory quá tải, 20 lời gọi gần nhất có 11 lỗi (55%) | chuyển OPEN |
| 10:00:05–10:00:35 | 3.000 request bị từ chối ngay, trả fallback, không gọi mạng | OPEN |
| 10:00:35 | Hết 30 giây | HALF_OPEN |
| 10:00:36 | 5 lời gọi thử: 1 lỗi (20%, dưới ngưỡng) | chuyển CLOSED |
Đoạn code dưới đây là một circuit breaker tối giản (đếm 3 lỗi liên tiếp thay vì tỷ lệ trong cửa sổ) để thấy các bước chuyển trạng thái.
public class BreakerDemo {
enum State { CLOSED, OPEN, HALF_OPEN }
static State state = State.CLOSED;
static int failures = 0;
static long openedAt = 0;
static String call(long now, boolean backendOk) {
if (state == State.OPEN) {
if (now - openedAt < 30) return "rejected fast (OPEN)"; // no network call at all
state = State.HALF_OPEN; // wait is over: allow a trial call
}
if (backendOk) { state = State.CLOSED; failures = 0; return "ok (" + state + ")"; }
failures++;
if (state == State.HALF_OPEN || failures >= 3) { state = State.OPEN; openedAt = now; }
return "failed (" + state + ")";
}
public static void main(String[] args) {
System.out.println(call(0, false)); // prints: failed (CLOSED)
System.out.println(call(1, false)); // prints: failed (CLOSED)
System.out.println(call(2, false)); // prints: failed (OPEN)
System.out.println(call(10, true)); // prints: rejected fast (OPEN)
System.out.println(call(33, false)); // prints: failed (OPEN)
System.out.println(call(64, true)); // prints: ok (CLOSED)
}
}Giải thích: hai lỗi đầu vẫn CLOSED; lỗi thứ ba ở giây 2 chạm ngưỡng nên chuyển OPEN. Ở giây 10 backend thật ra đã ổn nhưng breaker vẫn từ chối ngay vì chưa đủ 30 giây, không tốn kết nối nào. Giây 33 đã quá 30 giây nên chuyển HALF_OPEN và cho một lời gọi thử; lời gọi này lỗi nên quay lại OPEN và đếm lại 30 giây từ giây 33. Giây 64 lời gọi thử thành công nên về CLOSED. Resilience4j làm cùng ý tưởng nhưng dùng tỷ lệ lỗi trong cửa sổ trượt, tính cả lời gọi chậm (slow-call-rate-threshold), và an toàn đa luồng.
Bulkhead bằng ví dụ số
Bulkhead lấy tên từ vách ngăn trên tàu: một khoang thủng nước thì các khoang khác vẫn khô. Ví dụ service có 200 thread và gọi 3 dependency. Không có bulkhead, dependency chậm có thể giữ cả 200 thread. Đặt bulkhead 30 lời gọi đồng thời cho inventory: khi inventory treo, tối đa 30 thread bị giữ, lời gọi thứ 31 bị từ chối ngay (BulkheadFullException) và nhận fallback, còn 170 thread tiếp tục phục vụ các API khác. Circuit breaker phản ứng sau khi đã thấy đủ lỗi; bulkhead giới hạn thiệt hại ngay từ đầu, nên hai cơ chế dùng cùng nhau.
Tình huống: trang chi tiết sản phẩm gọi inventory-service để lấy tồn kho. Khi service này chậm hoặc lỗi, trang sản phẩm không được sập theo. Đoạn code dưới đây dùng Resilience4j bọc lời gọi bằng retry và circuit breaker.
@Service
public class InventoryClient {
private final RestClient restClient;
public InventoryClient(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://inventory-service").build();
}
// Retry wraps CircuitBreaker; fallback sits on the outermost layer so Retry still sees the exception
@Retry(name = "inventory", fallbackMethod = "fallback")
@CircuitBreaker(name = "inventory") // every call (retries included) counts toward the failure rate
public StockDto getStock(String sku) {
return restClient.get().uri("/stocks/{sku}", sku).retrieve().body(StockDto.class);
}
// Same parameters as the original method, plus a trailing Throwable
private StockDto fallback(String sku, Throwable ex) {
return StockDto.unknown(sku); // show "updating" instead of a 500 error
}
}Giải thích: khi inventory-service trả 503 hoặc timeout, lời gọi được thử lại tối đa 3 lần; cả 3 lần đều lỗi thì fallback trả về StockDto.unknown(sku), người dùng thấy "đang cập nhật" thay vì trang lỗi. Khi tỷ lệ lỗi vượt ngưỡng, circuit breaker chuyển OPEN và ném ngay CallNotPermittedException mà không gọi mạng, nên service của bạn không phí thread chờ một dependency đang chết. Cơ chế: các annotation hoạt động qua Spring AOP proxy (cần resilience4j-spring-boot3 và spring-boot-starter-aop), vì vậy cũng dính bẫy self-invocation như @Transactional. Lưu ý vị trí fallbackMethod: nếu đặt fallback trên @CircuitBreaker (tầng trong), fallback nuốt exception ngay lần gọi đầu, @Retry ở tầng ngoài nhận về kết quả "thành công" và không bao giờ retry. Đặt fallback ở @Retry (tầng ngoài cùng) thì retry chạy đủ lượt rồi mới rơi vào fallback.
Các tham số tương ứng khai báo trong application.yml:
resilience4j:
circuitbreaker:
instances:
inventory:
sliding-window-size: 20 # failure rate over the last 20 calls
failure-rate-threshold: 50 # 50% failures or more -> OPEN
wait-duration-in-open-state: 30s # stay OPEN 30s, then HALF_OPEN
permitted-number-of-calls-in-half-open-state: 5 # allow 5 trial calls in HALF_OPEN
retry:
instances:
inventory:
max-attempts: 3 # 3 calls in total, including the first
wait-duration: 200ms # wait before the first retry
enable-exponential-backoff: true
exponential-backoff-multiplier: 2 # 200ms, then 400msGiải thích: với cấu hình này, nếu trong 20 lời gọi gần nhất có từ 10 lời gọi lỗi, circuit mở và mọi lời gọi trong 30 giây tiếp theo bị từ chối ngay. Hết 30 giây, circuit sang HALF_OPEN và cho đúng 5 lời gọi thật đi qua: nếu tỷ lệ lỗi của 5 lời gọi này dưới ngưỡng thì trở về CLOSED, ngược lại mở lại thêm 30 giây. Phía retry, một request lỗi được gọi lại sau 200ms, lỗi tiếp thì sau 400ms, tổng cộng 3 lần. Cần để ý hai điểm: max-attempts đã tính cả lần gọi đầu (không phải 3 lần retry), và nên cấu hình để retry bỏ qua CallNotPermittedException, vì retry khi circuit đang mở chỉ phí thời gian chờ.
Ghi chú
Hystrix đã ngừng phát triển từ 2018 và không hỗ trợ Spring Boot 3. Nếu tài liệu cũ nhắc Hystrix, hãy trả lời bằng Resilience4j. Jitter trong Resilience4j cấu hình qua IntervalFunction.ofExponentialRandomBackoff.
Bẫy thường gặp
Retry ở mọi tầng: gateway retry 3 lần, service A retry 3 lần, service B retry 3 lần, và một request lỗi biến thành 27 request đập vào service cuối đang quá tải. Chỉ retry ở một tầng và luôn có giới hạn.
9.5 Distributed transaction: 2PC và Saga
Nhắc lại kiến thức
ACID (mục 6.3) chỉ có trong một database: một transaction hoặc commit hết hoặc rollback hết. Khi nghiệp vụ trải trên nhiều DB, ta mất bảo đảm đó. Cách gửi event đáng tin cậy sau khi commit là Transactional Outbox, và consumer phải idempotent (mục 8.10, 8.11).
Khi một nghiệp vụ trải trên nhiều service, mỗi service có DB riêng, không còn một transaction ACID bao trọn.
2PC (Two-Phase Commit): coordinator hỏi mọi bên "prepare được không", tất cả đồng ý thì "commit". Nhược điểm: các bên giữ khóa suốt thời gian chờ, coordinator chết giữa chừng làm các bên bị treo (blocking), latency cao, và Kafka, nhiều NoSQL hay REST API đối tác không tham gia XA được. Vì vậy trong microservices hầu như không dùng.
Saga: chia nghiệp vụ thành chuỗi local transaction; mỗi bước commit ngay trong service của nó. Nếu một bước thất bại, chạy các compensating transaction để "hoàn tác theo nghĩa nghiệp vụ" các bước trước: hủy đơn, trả lại tồn kho, hoàn tiền. Compensation không phải rollback: dữ liệu trung gian đã được người khác nhìn thấy, và bản thân compensation có thể lỗi nên phải idempotent và retry được.
| Tiêu chí | Choreography | Orchestration |
|---|---|---|
| Cách chạy | Mỗi service nghe event và phát event tiếp | Một orchestrator ra lệnh từng bước |
| Ưu | Không có điểm trung tâm, coupling thấp | Luồng rõ ràng, dễ theo dõi, dễ thêm bước |
| Nhược | Khó thấy toàn cảnh, dễ vòng lặp event | Orchestrator dễ thành nơi chứa quá nhiều logic |
| Hợp khi | 2–4 bước đơn giản | Luồng dài, nhiều nhánh, cần timeout từng bước |
Ví dụ saga đặt hàng theo orchestration: tạo đơn PENDING → giữ tồn kho → thanh toán → xác nhận đơn CONFIRMED. Thanh toán thất bại thì orchestrator gọi nhả tồn kho rồi chuyển đơn sang CANCELLED. Trạng thái PENDING đóng vai trò "semantic lock": các nghiệp vụ khác biết đơn chưa chốt để không xử lý tiếp.
Kịch bản đặt hàng bằng 2PC, từng bước
Đơn 1001 mua 1 sản phẩm sku-9 giá 500.000đ. Coordinator là order-service, hai participant là inventory DB và payment DB.
- Phase 1 (prepare): coordinator gửi "prepare" tới inventory: inventory trừ tạm tồn kho
sku-9từ 5 xuống 4, ghi vào log, giữ khóa dòngsku-9, trả "yes". - Coordinator gửi "prepare" tới payment: payment ghi tạm giao dịch trừ 500.000đ, giữ khóa dòng tài khoản, trả "yes".
- Phase 2 (commit): cả hai đều "yes", coordinator ghi quyết định "commit" vào log của mình, gửi "commit" tới hai bên. Mỗi bên commit và nhả khóa.
- Nếu bước 2 trả "no" (không đủ tiền): coordinator gửi "rollback" tới cả hai, inventory hoàn tồn kho về 5 và nhả khóa.
Chỗ hỏng: coordinator chết ngay sau bước 2, trước khi gửi quyết định. Inventory và payment đã nói "yes" nên không được tự commit hay tự rollback; chúng giữ khóa dòng sku-9 và dòng tài khoản cho tới khi coordinator sống lại. Trong lúc đó mọi đơn khác mua sku-9 phải chờ khóa. Đây là tính chất blocking của 2PC, cộng với việc Kafka hay API đối tác không nói được "prepare", nên microservices tránh 2PC.
Kịch bản đặt hàng bằng Saga, kể cả bù trừ
Cùng đơn 1001, orchestration saga. Mỗi bước là một local transaction commit ngay trong service của nó.
| Bước | Service | Hành động | Bù trừ nếu bước sau lỗi |
|---|---|---|---|
| 1 | order | Tạo đơn 1001 PENDING | Chuyển đơn sang CANCELLED |
| 2 | inventory | Giữ 1 sku-9 (5 → 4) | Nhả hàng (4 → 5) |
| 3 | payment | Trừ 500.000đ | Hoàn 500.000đ |
| 4 | order | Chuyển đơn sang CONFIRMED | (bước cuối, không cần) |
Luồng thành công: 1 → 2 → 3 → 4, mỗi bước commit xong thì phát event (qua outbox) để orchestrator ra lệnh bước tiếp theo.
Luồng lỗi ở bước 3 (thẻ bị từ chối): payment trả PaymentFailed. Orchestrator chạy bù trừ theo thứ tự ngược: nhả hàng ở inventory (4 → 5), rồi chuyển đơn sang CANCELLED. Giữa bước 2 và lúc bù trừ, người khác đã có thể thấy tồn kho là 4; đó là lý do compensation khác rollback. Đoạn code dưới đây mô phỏng orchestrator chạy từng bước và bù trừ ngược khi gặp lỗi.
import java.util.*;
public class SagaDemo {
record Step(String name, Runnable action, Runnable compensation) {}
public static void main(String[] args) {
List<Step> steps = List.of(
new Step("createOrder", () -> log("order#1001 PENDING"), () -> log("order#1001 CANCELLED")),
new Step("reserveStock", () -> log("sku-9 stock 5 -> 4"), () -> log("sku-9 stock 4 -> 5")),
new Step("chargeCard", () -> { throw new IllegalStateException("card declined"); }, () -> {}));
Deque<Step> done = new ArrayDeque<>();
for (Step s : steps) {
try {
s.action().run();
done.push(s); // remember completed steps, newest on top
} catch (RuntimeException e) {
log(s.name() + " failed: " + e.getMessage());
while (!done.isEmpty()) done.pop().compensation().run(); // undo in reverse order
return;
}
}
log("order#1001 CONFIRMED");
}
static void log(String msg) { System.out.println(msg); }
}Giải thích: output lần lượt là order#1001 PENDING, sku-9 stock 5 -> 4, chargeCard failed: card declined, sku-9 stock 4 -> 5, order#1001 CANCELLED. Dùng Deque như stack nên bước làm sau được bù trước. Trong hệ thống thật, mỗi action và compensation là một lời gọi hoặc một event tới service khác, trạng thái saga (đang ở bước nào) phải lưu vào DB để orchestrator chết rồi sống lại vẫn chạy tiếp, và mỗi compensation phải idempotent vì có thể bị gửi lại.
9.6 CAP và eventual consistency
CAP nói rằng khi xảy ra network partition (các node không liên lạc được với nhau), hệ thống phân tán phải chọn giữa Consistency (mọi lần đọc thấy lần ghi mới nhất, hoặc báo lỗi) và Availability (mọi request đều nhận phản hồi, dù có thể là dữ liệu cũ). Partition là điều không tránh được trong mạng thật, nên câu hỏi thực tế là "khi mạng chia cắt, hệ thống của bạn từ chối phục vụ hay phục vụ dữ liệu có thể cũ?".
Ghi chú
Cách nói "chọn 2 trong 3" dễ gây hiểu lầm. Không có hệ thống phân tán nào "bỏ P" được. Khi không có partition, hệ thống vẫn có thể vừa nhất quán vừa sẵn sàng; lúc đó đánh đổi thực tế là giữa latency và consistency (mô hình PACELC).
Eventual consistency nghĩa là nếu không có ghi mới, sau một khoảng thời gian mọi bản sao sẽ hội tụ về cùng giá trị. Trong thực tế bạn phải thiết kế UI và nghiệp vụ chấp nhận điều đó: hiển thị "đơn đang xử lý", cho phép đọc từ primary ngay sau khi ghi (read-your-writes), và có job đối soát (reconciliation) phát hiện lệch.
Ví dụ mạng bị chia cắt
Hệ thống ví điện tử có hai node database, node HN và node HCM, đồng bộ dữ liệu cho nhau. Tài khoản của An có 1.000.000đ ở cả hai node. Lúc 10:00, đường cáp giữa hai node bị đứt (partition), nhưng cả hai node vẫn nhận request từ người dùng ở gần.
| Thời điểm | Node HN | Node HCM |
|---|---|---|
| 10:00 | Mất liên lạc với HCM | Mất liên lạc với HN |
| 10:01 | An rút 800.000đ qua HN | Vợ An rút 800.000đ qua HCM |
| Chọn CP | Từ chối (hoặc chỉ node giữ đa số phục vụ), báo "thử lại sau" | Từ chối |
| Chọn AP | Chấp nhận, số dư 200.000đ | Chấp nhận, số dư 200.000đ |
| 10:10 nối lại mạng | ||
| Kết quả AP | Tổng đã rút 1.600.000đ từ tài khoản 1.000.000đ, phải đối soát và đòi lại |
Giải thích: chọn C nghĩa là chấp nhận một số người không dùng được dịch vụ trong 10 phút để không bao giờ sai số dư; với tiền, đó là lựa chọn đúng. Chọn A nghĩa là ai cũng dùng được nhưng dữ liệu có thể xung đột và phải hòa giải sau. Với giỏ hàng hay số lượt like, AP hợp lý hơn: hai node cùng thêm hàng vào giỏ thì khi nối lại chỉ cần gộp hai giỏ, không ai mất tiền.
Ví dụ eventual consistency hằng ngày: user đổi tên hiển thị, order-service giữ bản sao tên (qua event) cập nhật chậm khoảng 1 giây. Trong 1 giây đó, trang lịch sử đơn vẫn hiện tên cũ; sau đó mọi nơi hiện tên mới. Nghiệp vụ chấp nhận được, nên không cần bất kỳ transaction phân tán nào.
9.7 Distributed tracing và database per service
Khi một request đi qua 6 service, log rời rạc là vô dụng nếu không có một id chung. Correlation id / trace id được sinh ở cửa vào, truyền qua header (chuẩn W3C traceparent) và qua header message Kafka, được ghi vào mọi dòng log qua MDC. Spring Boot 3 dùng Micrometer Tracing với OpenTelemetry bridge để tự động tạo trace, span và gắn traceId vào log; dữ liệu trace gửi về Jaeger, Zipkin hoặc Tempo để xem đồ thị thời gian từng span.
Giải thích các từ: trace là toàn bộ hành trình của một request qua mọi service; span là một đoạn công việc trong hành trình đó (một lời gọi HTTP, một câu SQL, xử lý một message), có thời điểm bắt đầu, thời lượng và span cha. Một trace gồm nhiều span, tất cả chung một traceId, mỗi span có spanId riêng. MDC (Mapped Diagnostic Context) là một map gắn với thread hiện tại mà logger tự in ra trong mỗi dòng log.
Ví dụ log thật của một request đặt hàng lỗi, sau khi gom từ 3 service vào hệ thống log tập trung và lọc theo traceId:
10:15:02.101 INFO [gateway,4bf92f3577b34da6a3ce929d0e0e4736,00f067aa0ba902b7] POST /api/orders userId=42
10:15:02.130 INFO [order-service,4bf92f3577b34da6a3ce929d0e0e4736,a2fb4a1d1a96d312] create order 1001 PENDING
10:15:02.135 INFO [order-service,4bf92f3577b34da6a3ce929d0e0e4736,a2fb4a1d1a96d312] call inventory reserve sku-9
10:15:03.140 WARN [inventory-service,4bf92f3577b34da6a3ce929d0e0e4736,5c1a7e9d2b3f4a10] slow query 1002ms: select ... for update
10:15:03.160 ERROR [order-service,4bf92f3577b34da6a3ce929d0e0e4736,a2fb4a1d1a96d312] inventory timeout after 1000msGiải thích: mỗi dòng in [tên service, traceId, spanId]. Cùng một traceId xuất hiện ở cả 3 service, nên chỉ cần một truy vấn traceId=4bf92f... là ghép được toàn bộ câu chuyện: gateway nhận request, order-service tạo đơn, gọi inventory, inventory chạy câu select ... for update mất 1 giây vì chờ khóa dòng, order-service timeout ở mốc 1 giây. Không có traceId, bạn phải đoán theo thời gian giữa hàng nghìn dòng log của các request khác. Để có định dạng này trong Spring Boot 3, thêm micrometer-tracing-bridge-otel và cấu hình pattern log:
spring:
application:
name: order-service
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" # read traceId/spanId from MDC
management:
tracing:
sampling:
probability: 0.1 # export 10% of traces to Jaeger/Tempo; logs still carry traceIdGiải thích: %X{traceId:-} đọc giá trị traceId từ MDC (rỗng nếu không có), Micrometer Tracing tự đặt giá trị này khi request vào và tự truyền header traceparent khi bạn gọi service khác qua RestClient/WebClient được tạo từ builder của Spring. probability: 0.1 chỉ gửi 10% trace về hệ thống tracing để tiết kiệm, nhưng mọi dòng log vẫn có traceId.
Ví dụ database per service: order-service cần hiển thị tên sản phẩm trong lịch sử đơn. Thay vì JOIN sang bảng product của catalog-service, order-service lưu product_name và unit_price vào dòng order_item lúc đặt hàng. Đây cũng là yêu cầu nghiệp vụ đúng: đơn cũ phải giữ giá tại thời điểm mua, không đổi theo giá hiện tại.
Database per service: mỗi service sở hữu schema của mình, service khác chỉ truy cập qua API hoặc event. Dùng chung DB thì đổi một cột có thể làm sập service khác, và bạn quay về distributed monolith. Cái giá là không JOIN xuyên service được: giải quyết bằng API composition, hoặc sao chép dữ liệu cần đọc qua event (mỗi service giữ một bản read model), hoặc CQRS.
9.8 Cách làm bài system design trong 30–45 phút
Người phỏng vấn không chấm "đáp án đúng", họ chấm cách bạn suy nghĩ. Khung tôi khuyên:
- Làm rõ yêu cầu (5 phút): chức năng chính, ngoài phạm vi, yêu cầu phi chức năng (latency, availability, nhất quán). Hỏi quy mô: bao nhiêu user, tỷ lệ đọc/ghi bao nhiêu.
- Ước lượng (3–5 phút): QPS trung bình và đỉnh, dung lượng lưu trữ, băng thông. Làm tròn mạnh tay, một ngày khoảng 100.000 giây (thực tế là 86.400).
- API (5 phút): vài endpoint chính với method, input, output.
- Data model (5 phút): bảng/collection chính, khóa, index, chọn SQL hay NoSQL và vì sao.
- Kiến trúc tổng thể (10 phút): vẽ client, gateway, service, DB, cache, queue; đi qua luồng chính.
- Điểm nghẽn và scale (5–10 phút): đâu sẽ vỡ đầu tiên, cache, replica, sharding, async, xử lý lỗi.
- Trade-off (còn lại): nói rõ đã đánh đổi gì và khi nào sẽ đổi thiết kế.
Công thức ước lượng nhanh
| Đại lượng | Công thức | Ví dụ |
|---|---|---|
| QPS trung bình | số thao tác mỗi ngày / 100.000 | 10 triệu đọc/ngày → 100 QPS |
| QPS đỉnh | QPS trung bình × 3 đến 10 | 100 × 5 = 500 QPS |
| Dung lượng | số bản ghi × kích thước × thời gian lưu | 1 triệu/ngày × 1 KB × 365 ≈ 365 GB/năm |
| Băng thông | QPS × kích thước response | 500 × 10 KB = 5 MB/s |
| Bộ nhớ cache | phần dữ liệu nóng (thường 20%) × kích thước | 20% × 365 GB ≈ 73 GB |
Một vài con số nên thuộc để so sánh: một instance PostgreSQL xử lý khoảng vài nghìn đến hơn chục nghìn truy vấn đơn giản mỗi giây; Redis khoảng 100.000 lệnh đơn giản mỗi giây trên một node; một service Spring Boot stateless vài trăm đến vài nghìn request/giây mỗi instance tùy logic. Không cần chính xác, chỉ cần đúng bậc độ lớn để quyết định "một DB là đủ" hay "phải có cache, phải shard".
Bẫy thường gặp
Nhảy thẳng vào vẽ Kafka, Redis, Kubernetes khi chưa hỏi hệ thống phục vụ bao nhiêu người. Thiết kế quá mức cho 1.000 user/ngày cũng bị trừ điểm như thiết kế thiếu.
9.9 Bài mẫu 1: URL shortener
Yêu cầu: tạo link rút gọn từ URL dài, redirect khi truy cập, tùy chọn hạn dùng. Phi chức năng: redirect nhanh (dưới 50ms phía server), availability cao, đọc nhiều hơn ghi khoảng 100 lần.
Ước lượng: 100 triệu link mới/tháng, khoảng 40 ghi/giây; đọc khoảng 4.000/giây, đỉnh gấp 3–5 lần. 5 năm là 6 tỷ bản ghi, mỗi bản ghi khoảng 500 byte, tổng khoảng 3 TB. Mã base62 dài 7 ký tự cho khoảng 3.500 tỷ tổ hợp, dư dùng.
Các phép tính trên, từng bước:
- Ghi: 100 triệu link/tháng ÷ 30 ngày ≈ 3,3 triệu link/ngày; ÷ 86.400 giây ≈ 38, làm tròn 40 ghi/giây.
- Đọc: tỷ lệ đọc/ghi 100 : 1 nên 40 × 100 = 4.000 đọc/giây trung bình; đỉnh gấp 5 là 20.000 đọc/giây.
- Số bản ghi: 100 triệu × 12 tháng × 5 năm = 6 tỷ bản ghi.
- Dung lượng: 6 tỷ × 500 byte = 3.000 tỷ byte ≈ 3 TB (chưa tính index và replica; nhân 2–3 khi nói về đĩa thật).
- Không gian mã: 62 ký tự cho mỗi vị trí, 7 vị trí: 62^7 ≈ 3,5 × 10^12, tức khoảng 3.500 tỷ mã, gấp khoảng 580 lần 6 tỷ. Mã 6 ký tự (62^6 ≈ 57 tỷ) cũng đủ nhưng biên an toàn nhỏ hơn.
- Cache: đọc mỗi ngày 4.000 × 86.400 ≈ 345 triệu lượt. Giả định 20% link được đọc nhiều nhất chiếm phần lớn lượt đọc; nếu mỗi ngày có khoảng 20 triệu link khác nhau được đọc, cache 20% của số đó là 4 triệu × 500 byte = 2 GB, một node Redis thừa sức.
- Băng thông: response redirect khoảng 500 byte, 20.000 × 500 byte = 10 MB/giây lúc đỉnh, không đáng lo.
Kết luận từ số liệu: ghi 40/giây thì một PostgreSQL thừa sức; đọc 20.000/giây lúc đỉnh vượt khả năng thoải mái của một DB nên cần Redis phía trước, với hit ratio 90% thì DB chỉ nhận khoảng 2.000 truy vấn/giây. 3 TB sau 5 năm là mức một cụm PostgreSQL vẫn chứa được, nên sharding là chuyện của năm thứ 3–4, không phải ngày đầu.
API: POST /api/links với { "url": "...", "expireAt": "..." } trả { "code": "aZ3kP9x" }; GET /{code} trả 302 kèm header Location.
Sinh mã: tôi chọn ID tăng dần rồi mã hóa base62, không đụng độ và không cần kiểm tra trùng. Để nhiều instance không tranh nhau một sequence, mỗi instance xin trước một dải ID (ví dụ 10.000 số) và cấp dần trong bộ nhớ. Nhược điểm là mã đoán được; nếu cần khó đoán thì trộn bit hoặc dùng random kèm unique constraint và retry khi trùng. Hash URL rồi cắt 7 ký tự cũng được nhưng phải xử lý đụng độ.
Data model: bảng short_link(code PK, long_url, created_at, expire_at, owner_id). Truy vấn chính là tìm theo khóa chính, nên PostgreSQL chạy tốt ở giai đoạn đầu; khi lớn thì shard theo code hoặc chuyển sang key-value store.
Kiến trúc: client → load balancer → link service (stateless, nhiều instance) → Redis cache-aside → DB. Link nóng nằm trong Redis với TTL; thống kê click đẩy vào Kafka để service analytics xử lý bất đồng bộ, không làm chậm redirect.
Trade-off: 301 được trình duyệt cache nên giảm tải nhưng mất số liệu click; 302 giữ được analytics nên tôi chọn 302. Cache null ngắn hạn cho mã không tồn tại để chống penetration.
9.10 Bài mẫu 2: Đặt hàng flash sale chống oversell
Nhắc lại kiến thức
Câu UPDATE ... WHERE available >= 1 là atomic update: kiểm tra và ghi trong một lệnh, row lock khiến các request tranh cùng dòng phải xếp hàng (mục 6.3, "Optimistic vs pessimistic lock"). Idempotency key chống double click được mô tả ở mục 7.4.
Yêu cầu: 1.000 sản phẩm giá sốc mở bán lúc 10:00, khoảng 100.000 người truy cập trong vài giây đầu, mỗi người mua tối đa 1 sản phẩm, tuyệt đối không bán quá số lượng. Giữ hàng 10 phút để thanh toán.
Ước lượng: đỉnh có thể tới vài chục nghìn request/giây trong vài giây đầu, trong khi chỉ 1.000 request thành công. Nhiệm vụ chính là chặn phần lớn traffic trước khi nó chạm DB.
Phép tính từng bước:
- Traffic mua: 100.000 người bấm "Mua" trong 5 giây đầu, trung bình 100.000 ÷ 5 = 20.000 request/giây. Người dùng bấm lặp khi thấy chậm, nhân 3 cho đỉnh: khoảng 60.000 request/giây.
- Traffic xem trang: mỗi người tải trang sản phẩm vài lần trước giờ G, giả sử 3 lần × 100.000 = 300.000 lượt trong 1 phút trước 10:00, tức 5.000 lượt/giây. Trang nặng 500 KB (ảnh, JS) thì 5.000 × 500 KB = 2,5 GB/giây: bắt buộc phải là trang tĩnh trên CDN, không để server của bạn phục vụ.
- Khả năng của từng tầng: một hot row trên DB chỉ chịu vài trăm đến vài nghìn UPDATE/giây vì các request phải xếp hàng chờ khóa dòng. Redis chạy Lua script đơn giản được vài chục nghìn đến khoảng 100.000 lần/giây trên một node. So với đỉnh 60.000/giây: DB không thể đứng trực tiếp, Redis chịu được nếu gateway cắt bớt phần bấm lặp.
- Rate limit: mỗi user tối đa 1 request mua mỗi 2 giây tại gateway, 60.000/giây giảm về khoảng 20.000/giây tới order service.
- Luồng thành công: chỉ 1.000 lần Lua script trả
1, tức 1.000 message vào Kafka và 1.000 câu UPDATE xuống DB trong vài giây, DB xử lý dễ dàng. 99% request còn lại dừng ở Redis với câu trả lời "hết hàng". - Dữ liệu: 1.000 đơn × khoảng 2 KB = 2 MB, không đáng kể. Bài toán này là bài toán đỉnh tải, không phải bài toán dung lượng.
Chống oversell ở tầng DB: câu lệnh cập nhật có điều kiện là nguyên tử, không cần đọc rồi kiểm tra trong Java. Câu dưới đây vừa kiểm tra còn hàng vừa trừ kho trong cùng một lệnh.
UPDATE inventory
SET available = available - 1, reserved = reserved + 1 -- move 1 unit into "reserved"
WHERE sku = :sku AND available >= 1; -- the in-stock check is part of the write itself
-- 0 affected rows means sold outGiải thích: giả sử chỉ còn available = 1 và hai request A, B chạy câu này cùng lúc. A lấy được row lock trước, trừ còn 0; B phải chờ lock của dòng đó. Khi A commit, B đánh giá lại điều kiện available >= 1 trên phiên bản mới nhất của dòng (PostgreSQL ở READ COMMITTED kiểm tra lại dòng đã bị cập nhật, InnoDB thì UPDATE luôn đọc bản mới nhất), thấy không thỏa nên cập nhật 0 dòng. Phía Java chỉ cần kiểm tra updatedRows == 0 để báo hết hàng. Khác biệt với cách "đọc stock ra rồi if (stock > 0) rồi save" là việc kiểm tra và việc ghi không bị tách thành hai bước để request khác chen vào giữa.
Cách này đúng nhưng mọi request tranh nhau khóa cùng một dòng (hot row), DB sẽ nghẽn ở vài nghìn request/giây. Optimistic lock theo version còn tệ hơn vì tỷ lệ xung đột rất cao dẫn đến retry liên tục.
Trừ trước trên Redis: nạp số lượng vào Redis trước giờ mở bán, dùng Lua script để kiểm tra và trừ một cách nguyên tử, đồng thời chặn một người mua hai lần.
-- KEYS[1] = sale:{42}:stock, KEYS[2] = sale:{42}:buyers, ARGV[1] = userId
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then return -2 end -- this user already bought
local stock = tonumber(redis.call("GET", KEYS[1]) or "0") -- missing key counts as 0
if stock <= 0 then return -1 end -- sold out
redis.call("DECR", KEYS[1]) -- take 1 unit
redis.call("SADD", KEYS[2], ARGV[1]) -- remember this buyer
return 1 -- reservation succeededGiải thích: giả sử stock = 1. User u1 gọi script: chưa có trong tập buyers, stock là 1 nên trừ còn 0, thêm u1 vào tập và trả 1. u1 bấm lần nữa: SISMEMBER trả 1 nên script trả -2 ngay. User u2 gọi: stock là 0 nên trả -1. Script đúng được là nhờ Redis chạy trọn một Lua script trên thread thực thi lệnh mà không xen lệnh của client khác vào giữa; nếu tách thành các lệnh GET rồi DECR gửi từ Java, hai request có thể cùng đọc thấy stock = 1 và cùng trừ, ra số âm. Hash tag {42} trong tên key giúp hai key nằm cùng slot khi chạy Redis Cluster (xem câu hỏi đào sâu cuối mục).
Luồng: user bấm mua → gateway rate limit theo user và IP → order service chạy Lua script; -1 trả "hết hàng" ngay, 1 thì publish message tạo đơn vào Kafka và trả 202 "đang xử lý" → consumer tạo đơn PENDING và chạy câu UPDATE có điều kiện ở trên làm chốt chặn cuối → hết 10 phút chưa thanh toán thì job nhả hàng (compensation) và cộng lại Redis.
Các chốt an toàn khác: idempotency key cho request đặt hàng để chống double click; unique constraint (campaign_id, user_id) trong DB; trang sản phẩm tĩnh đặt trên CDN; đối soát định kỳ giữa Redis và DB.
Trade-off: Redis và DB có thể lệch tạm thời (Redis trừ rồi nhưng tạo đơn lỗi), nên DB là nguồn sự thật và có job đối soát. Với traffic nhỏ, chỉ cần câu UPDATE có điều kiện là đủ; tôi sẽ nói rõ điều đó thay vì mặc định dựng Redis và Kafka.
Câu hỏi đào sâu
Chạy Lua script trên Redis Cluster với 2 key thì sao? Các key trong một script phải nằm cùng hash slot, nên đặt hash tag như sale:{42}:stock và sale:{42}:buyers để chúng cùng slot.
9.11 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Cơ bản): Khi nào bạn không chọn microservices?
Khi team nhỏ, nghiệp vụ còn thay đổi mạnh và ranh giới chưa rõ, hoặc chưa có nền tảng vận hành (CI/CD, log tập trung, tracing, monitoring). Khi đó tôi chọn modular monolith: một deploy, module tách theo nghiệp vụ, ranh giới được kiểm tra bằng test kiến trúc. Tách service khi có lý do cụ thể: một phần cần scale khác biệt, nhịp release khác, hoặc team riêng sở hữu.
Bẫy thường gặp
"Microservices giúp hệ thống nhanh hơn." Gọi qua mạng luôn chậm hơn gọi hàm; microservices tối ưu tổ chức và khả năng scale độc lập, không phải tốc độ từng request.
Câu hỏi (Thực chiến): Service B chậm làm service A chết theo. Bạn xử lý thế nào?
Đây là cascading failure. Tôi đặt timeout ngắn và hợp lý cho lời gọi sang B, circuit breaker để fail nhanh khi B lỗi nhiều, bulkhead để giới hạn số thread dành cho B, và fallback hợp lý (dữ liệu cache, giá trị mặc định, hoặc báo lỗi rõ ràng). Về lâu dài, xem lại xem lời gọi đó có cần đồng bộ không hay chuyển sang event.
Câu hỏi đào sâu
Thứ tự áp dụng mặc định trong Resilience4j? Retry bọc ngoài CircuitBreaker, CircuitBreaker bọc ngoài RateLimiter, TimeLimiter và Bulkhead. Nghĩa là mỗi lần retry đều đi qua circuit breaker và được tính vào tỷ lệ lỗi.
Câu hỏi (Hiểu sâu): Retry có an toàn không?
Chỉ an toàn khi thao tác idempotent. GET hay PUT đặt trạng thái thì retry thoải mái; POST /payments retry có thể trừ tiền hai lần. Cách làm là client gửi Idempotency-Key, server lưu key cùng kết quả và trả lại kết quả cũ nếu gặp lại key. Thêm backoff và jitter để không tạo retry storm.
Bẫy thường gặp
Bật retry cho mọi exception, kể cả lỗi 400 hoặc lỗi nghiệp vụ, khiến lỗi của người dùng được "thử lại" vô ích.
Câu hỏi (Hiểu sâu): Làm thế nào đảm bảo nhất quán khi đặt hàng liên quan tới order, inventory và payment?
Tôi không dùng 2PC vì blocking, giữ khóa lâu và nhiều thành phần không hỗ trợ. Tôi dùng Saga: mỗi service commit local transaction và phát event qua Transactional Outbox; bước sau lỗi thì chạy compensation cho các bước trước. Consumer idempotent vì event có thể đến trùng. Trạng thái trung gian như PENDING được hiển thị rõ cho người dùng.
Câu hỏi đào sâu
Compensation cũng lỗi thì sao? Retry có backoff vì compensation phải idempotent; quá số lần thì đưa vào DLQ, bắn alert và có quy trình xử lý thủ công. Không hệ thống nào tự động 100%.
Câu hỏi (Cơ bản): Giải thích định lý CAP.
Khi có network partition, hệ thống phải chọn trả lời nhất quán (từ chối hoặc chờ, chấp nhận không sẵn sàng) hoặc sẵn sàng (trả lời ngay, chấp nhận dữ liệu có thể cũ). Ví dụ số dư ngân hàng tôi nghiêng về C, còn số lượt like hay giỏ hàng thì nghiêng về A. Khi không có partition, đánh đổi còn lại là latency và consistency.
Bẫy thường gặp
Nói "MongoDB là CP, Cassandra là AP" như chân lý. Nhiều hệ thống cho phép chỉnh mức nhất quán theo từng truy vấn (read/write concern, quorum), nên nhãn cứng như vậy thiếu chính xác.
Câu hỏi (Thực chiến): Một request lỗi đi qua 5 service, bạn tìm nguyên nhân thế nào?
Lấy traceId từ response hoặc log gateway, tra trên hệ thống tracing (Jaeger, Tempo) để thấy span nào lỗi hoặc chậm, rồi lọc log tập trung theo traceId để đọc stacktrace. Điều kiện tiên quyết là trace id được truyền qua cả HTTP header lẫn message header, và mọi dòng log có traceId qua MDC.
Câu hỏi đào sâu
Trace id có tự đi qua Kafka không? Với Spring Kafka và Micrometer Tracing, cần bật observation cho KafkaTemplate và listener container thì header trace mới được gắn và đọc tự động.
Câu hỏi (Hiểu sâu): Vì sao mỗi service nên có database riêng? Cần dữ liệu của service khác thì làm sao?
Để service thực sự độc lập: đổi schema không làm vỡ service khác, chọn loại DB phù hợp, scale riêng. Cần dữ liệu của service khác thì gọi API của nó, hoặc lắng nghe event để giữ bản sao dữ liệu cần đọc (ví dụ order service giữ tên và giá sản phẩm tại thời điểm đặt). Báo cáo tổng hợp thì đẩy dữ liệu sang data warehouse.
Bẫy thường gặp
"Cho các service đọc chung một DB cho nhanh." Đó là con đường ngắn nhất tới distributed monolith.
Câu hỏi (Thực chiến): Làm sao chống oversell khi nhiều người cùng mua một sản phẩm?
Chốt chặn cuối cùng luôn ở DB: UPDATE ... SET available = available - 1 WHERE sku = ? AND available >= 1 và kiểm tra số dòng bị ảnh hưởng. Traffic thường thì như vậy là đủ. Với flash sale, tôi thêm lớp trừ trước bằng Lua script trên Redis và xử lý tạo đơn bất đồng bộ qua queue để chặn phần lớn request trước khi chạm DB, cộng với rate limit, idempotency key và job đối soát.
Bẫy thường gặp
Đọc stock ra Java, if (stock > 0) rồi save(stock - 1). Hai request đọc cùng lúc sẽ cùng thấy còn hàng. Đặt synchronized cũng không cứu được vì bạn chạy nhiều instance.
Câu hỏi (Cơ bản): Monolith, modular monolith và microservices khác nhau thế nào? Cho ví dụ.
Khác nhau ở số đơn vị deploy và cách các phần gọi nhau: monolith là một ứng dụng lẫn lộn, modular monolith là một ứng dụng chia module có ranh giới rõ, microservices là nhiều ứng dụng gọi nhau qua mạng. Ví dụ shop online: monolith là một shop.jar mà class OrderService đọc thẳng bảng inventory; modular monolith là một shop.jar nhưng module order chỉ gọi InventoryApi public, không đụng bảng của inventory; microservices là order-service và inventory-service chạy riêng, DB riêng, gọi nhau qua HTTP hoặc Kafka. Modular monolith giữ được transaction ACID và debug đơn giản, trong khi đã sẵn ranh giới để tách service về sau.
Câu hỏi (Cơ bản): API Gateway làm những gì và không nên làm gì?
Gateway là cửa vào chung: định tuyến, xác thực token, rate limit, CORS, log; nó không nên chứa logic nghiệp vụ. Ví dụ /api/orders/** đi tới order-service, /api/products/** tới catalog-service; token JWT hết hạn bị chặn ngay ở gateway; mỗi user tối đa 50 request/giây. Nếu nhét logic "đơn trên 10 triệu thì áp mã giảm giá" vào gateway, mỗi lần đổi nghiệp vụ phải deploy gateway, và gateway trở thành một monolith mới mà mọi team phải chờ nhau.
Câu hỏi (Cơ bản): Connect timeout và read timeout khác nhau thế nào? Đặt giá trị bao nhiêu?
Connect timeout là thời gian chờ mở kết nối TCP; read timeout là thời gian chờ response sau khi đã kết nối. Connect timeout ngắn (1–2 giây) vì mở kết nối trong cùng data center chỉ mất vài mili giây; vượt quá nghĩa là đích không tới được. Read timeout đặt theo p99 của service đích cộng biên: p99 là 300ms thì khoảng 1 giây. Thêm vào đó nên có tổng thời gian cho cả request (deadline) để các lần retry cộng lại không vượt timeout của client gọi bạn.
Bẫy thường gặp
Không đặt timeout vì "thư viện có mặc định". Nhiều HTTP client mặc định read timeout là vô hạn hoặc rất dài; một dependency treo sẽ giữ hết thread của bạn.
Câu hỏi (Cơ bản): Circuit breaker có mấy trạng thái, chuyển đổi thế nào?
Ba trạng thái: CLOSED gọi bình thường, OPEN từ chối ngay không gọi, HALF_OPEN cho vài lời gọi thử. Tỷ lệ lỗi trong cửa sổ vượt ngưỡng (ví dụ 50% trên 20 lời gọi) thì CLOSED sang OPEN; hết thời gian chờ (ví dụ 30 giây) thì OPEN sang HALF_OPEN; lời gọi thử ổn thì về CLOSED, vẫn lỗi thì về OPEN. Lợi ích: không phí thread và thời gian chờ một dependency đang chết, và cho dependency thời gian hồi phục thay vì bị dội thêm request.
Câu hỏi (Cơ bản): Saga là gì? Compensation khác rollback ở điểm nào?
Saga chia một nghiệp vụ phân tán thành chuỗi local transaction, mỗi bước commit ngay; bước sau lỗi thì chạy các compensation để hoàn tác theo nghĩa nghiệp vụ. Rollback xóa thay đổi trước khi ai khác thấy; compensation là một thao tác mới sau khi thay đổi đã commit và có thể đã bị người khác thấy. Ví dụ đã trừ tiền rồi thì compensation là tạo giao dịch hoàn tiền, lịch sử vẫn ghi hai dòng "trừ" và "hoàn", không phải xóa dòng "trừ".
Câu hỏi (Cơ bản): Eventual consistency là gì? Cho ví dụ bạn đã gặp.
Eventual consistency nghĩa là các bản sao có thể lệch nhau tạm thời, nhưng nếu không có ghi mới thì sau một khoảng thời gian chúng sẽ giống nhau. Ví dụ: user thanh toán xong, payment-service phát event PaymentCompleted, order-service nhận sau khoảng 500ms mới chuyển đơn sang PAID. Trong 500ms đó trang đơn hàng vẫn hiện "chờ thanh toán". Cách làm UX: sau khi thanh toán hiển thị "đang xác nhận" và polling, hoặc đọc trực tiếp trạng thái từ payment-service cho màn hình vừa thanh toán.
Câu hỏi (Cơ bản): traceId và spanId khác nhau thế nào?
traceId định danh cả hành trình của một request qua mọi service; spanId định danh một đoạn công việc trong hành trình đó. Một request đặt hàng có một traceId, và các span: gateway nhận request, order-service xử lý, lời gọi HTTP sang inventory, câu SQL trong inventory. Mỗi span ghi span cha của nó, nhờ vậy Jaeger vẽ được cây thời gian và bạn thấy ngay span nào chiếm 900ms trong tổng 1 giây.
Câu hỏi (Hiểu sâu): Vì sao retry cần jitter? Minh họa bằng số.
Không có jitter, các client lỗi cùng lúc sẽ retry cùng lúc, tạo ra những đợt tải dồn đúng vào lúc service vừa hồi phục. Ví dụ 2.000 client cùng timeout lúc T. Backoff cố định 200ms, 400ms, 800ms thì cả 2.000 request quay lại đúng T+200ms, rồi T+600ms: service nhận 2.000 request trong 1 mili giây, lại quá tải, lại lỗi. Full jitter (ngẫu nhiên 0–200ms) rải 2.000 request đó trong 200ms, khoảng 10 request mỗi mili giây. Code ví dụ và output ở mục 9.4.
Câu hỏi (Hiểu sâu): Vì sao 2PC bị gọi là blocking? Trình bày kịch bản coordinator chết.
Vì sau khi participant trả lời "yes" ở phase 1, nó không được tự quyết commit hay rollback, phải giữ khóa và chờ quyết định từ coordinator. Kịch bản: inventory và payment đều prepare thành công và giữ khóa dòng; coordinator chết trước khi gửi "commit". Cả hai bị treo với khóa đang giữ; mọi giao dịch khác cần dòng đó phải chờ, có thể hàng phút tới khi coordinator khôi phục từ log. Ngoài ra Kafka, Redis, API đối tác không tham gia XA được, nên 2PC gần như không dùng trong microservices.
Câu hỏi đào sâu
Saga có giải quyết được mọi thứ 2PC làm không? Không. Saga mất tính cô lập (isolation): trạng thái trung gian bị thấy. Bù lại bằng semantic lock (trạng thái PENDING), thiết kế thứ tự bước để bước khó bù trừ (trừ tiền, gửi hàng) nằm cuối cùng, và đọc lại giá trị trước khi làm (không tin dữ liệu cũ).
Câu hỏi (Hiểu sâu): Choreography và orchestration: khi nào chọn cái nào?
Choreography hợp với luồng ngắn 2–4 bước, ít nhánh; orchestration hợp với luồng dài, nhiều nhánh, cần timeout và theo dõi từng bước. Choreography: order phát OrderCreated, inventory nghe và phát StockReserved, payment nghe và phát PaymentCompleted, không ai điều phối. Dễ bắt đầu nhưng khi có 7 bước, muốn biết "đơn 1001 đang kẹt ở đâu" phải lần qua log của 7 service. Orchestration: một saga orchestrator lưu trạng thái từng bước trong DB, trả lời được ngay câu đó, dễ thêm timeout "giữ hàng quá 10 phút thì hủy". Nhược điểm là orchestrator có thể phình ra chứa logic của mọi service.
Câu hỏi (Hiểu sâu): Bulkhead và circuit breaker khác nhau thế nào? Có cần cả hai không?
Circuit breaker phản ứng theo tỷ lệ lỗi đã quan sát; bulkhead giới hạn số lời gọi đồng thời ngay từ đầu. Nên dùng cả hai. Khi dependency bắt đầu chậm (chưa lỗi), circuit breaker chưa mở vì chưa thấy lỗi, nhưng thread đã bị giữ dần; bulkhead 30 lời gọi đồng thời chặn thiệt hại ở 30 thread. Khi dependency lỗi hẳn, circuit breaker mở và các lời gọi không còn chờ timeout nữa. Hai cơ chế bổ sung nhau: một cái giới hạn tài nguyên, một cái ngắt khi đã chắc là hỏng.
Câu hỏi (Hiểu sâu): Nối kiến thức: thiết kế luồng đặt hàng qua order, inventory, payment sao cho không mất bước và không xử lý trùng.
Saga cho luồng nghiệp vụ, outbox cho việc phát event đáng tin cậy, idempotent consumer cho việc nhận event, Idempotency-Key cho request của client.
- Client gửi
POST /orderskèmIdempotency-Keyđể double click không tạo hai đơn (mục 7.4). - Order-service ghi đơn
PENDINGvà eventOrderCreatedvào outbox trong một transaction (mục 8.11), nên không có đơn nào bị "quên" gửi event. - Inventory-service nhận event, dùng
eventIdlàm khóa trong bảngprocessed_messagecùng transaction với việc giữ hàng (mục 8.10), nên event giao lại không trừ kho hai lần. Trừ kho bằng atomic update (mục 6.3). - Payment lỗi thì orchestrator phát lệnh bù trừ, các compensation cũng idempotent theo
sagaId.
Câu hỏi (Hiểu sâu): Distributed monolith là gì? Dấu hiệu nhận biết?
Distributed monolith là hệ thống chia thành nhiều service nhưng vẫn gắn chặt như một khối, nên mang nhược điểm của cả hai mô hình. Dấu hiệu:
- Sửa một tính năng phải deploy 3 service cùng lúc theo đúng thứ tự.
- Các service đọc ghi chung một database hoặc chung bảng.
- Một request đi qua chuỗi 5–6 lời gọi đồng bộ, một service chết là cả luồng chết.
- Dùng chung một thư viện "common" chứa entity, đổi một field phải build lại tất cả.
Cách chữa: gộp các service luôn đi cùng nhau, tách database, chuyển lan truyền trạng thái sang event.
Câu hỏi (Hiểu sâu): Chuỗi 5 service nối tiếp, mỗi service availability 99,9%. Availability tổng là bao nhiêu và bạn cải thiện thế nào?
Khoảng 99,5%, vì availability của chuỗi nối tiếp là tích: 0,999^5 ≈ 0,995; thời gian ngừng mỗi tháng tăng từ khoảng 43 phút lên khoảng 216 phút. Cải thiện: bỏ các lời gọi đồng bộ không cần thiết (dùng event, giữ bản sao dữ liệu cần đọc), gọi song song thay vì tuần tự khi các lời gọi độc lập, có fallback để một service phụ lỗi không làm hỏng cả request (ví dụ không lấy được gợi ý sản phẩm thì bỏ khối gợi ý), và cache kết quả của service ít thay đổi.
Câu hỏi (Thực chiến): Payment-gateway của đối tác có p99 tăng từ 200ms lên 8 giây. Service của bạn bắt đầu trả 503 cho cả những API không liên quan. Bạn xử lý thế nào?
Đây là thread pool cạn vì lời gọi chậm chiếm hết thread; chữa cháy bằng timeout ngắn và bulkhead, rồi xem lại thiết kế đồng bộ.
- Xác nhận: metric thread Tomcat bận 200/200, thread dump thấy hàng trăm thread đang chờ ở
PaymentClient, connection pool HTTP tới đối tác đầy. - Chữa cháy: giảm read timeout xuống khoảng 2 giây, bật bulkhead (ví dụ tối đa 40 lời gọi đồng thời tới đối tác), circuit breaker với
slow-call-rate-thresholdđể coi lời gọi chậm như lỗi. - Trải nghiệm: lời gọi bị từ chối trả "thanh toán đang được xử lý", đơn ở trạng thái chờ, có job hỏi lại trạng thái giao dịch với đối tác.
- Dài hạn: chuyển bước gọi đối tác sang bất đồng bộ (queue + worker riêng), tách thread pool cho đối tác, alert theo p99 của từng dependency.
Câu hỏi đào sâu
Vì sao không chỉ tăng số thread Tomcat lên 1.000? Vì đó chỉ kéo dài thời gian trước khi sập: 1.000 thread cũng bị lấp đầy sau vài giây nếu mỗi lời gọi chờ 8 giây, trong khi mỗi thread tốn bộ nhớ Stack và CPU chuyển ngữ cảnh. Virtual thread (Java 21) giảm chi phí mỗi thread nhưng không bỏ được nhu cầu timeout và bulkhead.
Câu hỏi (Thực chiến): Sau khi inventory-service được khởi động lại, nó lập tức sập lần nữa vì quá tải. Log cho thấy lượng request gấp 10 lần bình thường. Nguyên nhân và cách xử lý?
Đây là retry storm: các client đã tích lũy retry trong lúc service chết và cùng dội vào khi nó vừa sống lại, cộng thêm retry nhân lên qua nhiều tầng.
- Nguyên nhân thường gặp: retry ở mọi tầng (gateway 3, order 3, inventory client 3 lần), không có jitter, circuit breaker không có hoặc HALF_OPEN cho quá nhiều lời gọi thử.
- Xử lý ngay: rate limit ở gateway, tạm tắt retry ở tầng ngoài, khởi động lại dần từng instance (warm-up) thay vì cả cụm cùng lúc.
- Phòng ngừa: chỉ retry ở một tầng, có jitter, có "retry budget" (retry không vượt khoảng 10–20% số request), circuit breaker ở phía client để cho service thời gian hồi.
Câu hỏi (Thực chiến): Gọi API thanh toán bị timeout. Bạn không biết khách đã bị trừ tiền chưa. Hệ thống của bạn xử lý thế nào?
Timeout nghĩa là "không biết", không phải "thất bại"; đơn phải chuyển sang trạng thái UNKNOWN/PENDING rồi hỏi lại đối tác, không được tự hủy cũng không được tự trả lại tiền.
- Mỗi lần thanh toán gửi kèm mã giao dịch duy nhất do mình sinh (idempotency key với đối tác).
- Timeout: lưu đơn ở
PAYMENT_PENDING, không báo lỗi cho khách, hiển thị "đang xác nhận". - Job định kỳ gọi API tra cứu giao dịch theo mã đó: thành công thì chuyển
PAID, thất bại thì cho phép thanh toán lại, chưa có thông tin thì hỏi lại sau. - Nhận webhook từ đối tác nếu có, xử lý idempotent.
- Cuối ngày đối soát với file sao kê của đối tác.
Bẫy thường gặp
Retry POST /charge với một mã giao dịch mới sau timeout. Nếu lần đầu thật ra đã thành công, khách bị trừ tiền hai lần.
Câu hỏi (Thực chiến): Đơn hàng tạo thành công nhưng tồn kho không bao giờ bị trừ, xảy ra khoảng 1 đơn trên 5.000. Bạn điều tra thế nào?
Tỷ lệ nhỏ và ngẫu nhiên gợi ý event bị mất ở ranh giới giữa hai hệ thống: dual write hoặc consumer bỏ qua message.
- Kiểm tra cách phát event: nếu order-service commit DB rồi
kafkaTemplate.send()không có outbox, những lần Kafka lỗi hoặc pod bị kill giữa hai bước sẽ mất event. Sửa bằng outbox. - Kiểm tra consumer: có bắt exception rồi nuốt (log và return) không, có commit offset trước khi xử lý không, có message nào trong DLT không.
- Kiểm tra chống trùng: đánh dấu "đã xử lý" ở transaction riêng trước khi xử lý sẽ khiến message lỗi lần đầu bị bỏ qua mãi mãi.
- Tra theo traceId hoặc orderId của các đơn lỗi để thấy event dừng ở đâu. Thêm job đối soát "đơn
CONFIRMEDmà không có bản ghi giữ hàng" để phát hiện sớm.
Câu hỏi (Thực chiến): Thiết kế nhỏ: service gửi thông báo (email, SMS, push) cho 2 triệu user mỗi ngày.
Dùng queue để tách việc tạo thông báo khỏi việc gửi, worker riêng cho từng kênh, retry có backoff và chống gửi trùng.
- Ước lượng: 2 triệu ÷ 100.000 giây ≈ 20 thông báo/giây trung bình; chiến dịch marketing có thể gửi 1 triệu trong 10 phút, khoảng 1.700/giây.
- Luồng: các service phát event (
OrderShipped), notification-service nhận, chọn template và kênh theo cài đặt của user, ghi bản ghinotification(id, user_id, channel, status)rồi đẩy vào topic riêng từng kênh. - Worker mỗi kênh tôn trọng giới hạn của nhà cung cấp (ví dụ SMS 100 tin/giây) bằng rate limit, retry lỗi tạm thời, lỗi vĩnh viễn (số điện thoại sai) thì đánh dấu thất bại.
- Chống trùng bằng
notification.idduy nhất; SMS tốn tiền nên kiểm tra trạng thái trước khi gửi lại. - Tách topic ưu tiên: OTP không được chờ sau 1 triệu tin marketing.
Câu hỏi (Đọc code): Cấu hình Resilience4j dưới đây có retry 3 lần nhưng log chỉ thấy gọi 1 lần. Vì sao?
@Service
public class InventoryClient {
// ... fields and constructor omitted
@Retry(name = "inventory")
@CircuitBreaker(name = "inventory", fallbackMethod = "fallback")
public StockDto getStock(String sku) {
return restClient.get().uri("/stocks/{sku}", sku).retrieve().body(StockDto.class);
}
private StockDto fallback(String sku, Throwable ex) { return StockDto.unknown(sku); }
}Đáp án:
Fallback đặt ở @CircuitBreaker, là tầng bên trong @Retry, nên exception bị fallback nuốt ngay lần đầu và @Retry nhận về kết quả bình thường.
- Thứ tự mặc định của Resilience4j: Retry bọc ngoài CircuitBreaker. Lần gọi đầu lỗi, CircuitBreaker gọi
fallbackvà trảStockDto.unknown(sku); với Retry, đó là một lời gọi thành công nên không thử lại. - Sửa: chuyển
fallbackMethodsang@Retry(tầng ngoài cùng), như mẫu ở mục 9.4. Khi đó Retry thấy exception, thử đủ 3 lần, hết lượt mới rơi vào fallback.
Câu hỏi (Đọc code): Client dưới đây thiếu gì, và hậu quả trên production là gì?
@Configuration
public class ClientConfig {
@Bean
RestClient paymentClient(RestClient.Builder builder) {
return builder.baseUrl("https://partner-pay.example").build(); // no timeouts configured
}
}Đáp án:
Không đặt connect timeout và read timeout, nên khi đối tác treo, thread gọi sẽ chờ theo mặc định của HTTP client bên dưới, có thể rất lâu hoặc vô hạn.
- Hậu quả: như kịch bản ở mục 9.4, 200 thread Tomcat bị giữ trong vài giây, service trả 503 cho mọi API.
- Sửa: cấu hình request factory có timeout, ví dụ
SimpleClientHttpRequestFactoryvớisetConnectTimeout(Duration.ofSeconds(1))vàsetReadTimeout(Duration.ofSeconds(2)), rồibuilder.requestFactory(factory); kèm bulkhead và circuit breaker cho lời gọi này.
Câu hỏi (Đọc code): Compensation dưới đây có vấn đề gì khi message bị giao lại?
@Component
public class StockCompensation {
// ... fields and constructor omitted
@Transactional
@KafkaListener(topics = "release-stock", groupId = "inventory")
public void on(ReleaseStock cmd) {
Inventory inv = repo.findBySku(cmd.sku()).orElseThrow();
inv.setAvailable(inv.getAvailable() + cmd.qty()); // give the stock back
}
}Đáp án:
Compensation không idempotent: nếu lệnh release-stock bị giao lại (at-least-once), tồn kho được cộng hai lần và kho "đẻ" ra hàng không có thật.
- Ví dụ: giữ 1 hàng (5 → 4), thanh toán lỗi, nhả hàng (4 → 5), message bị giao lại sau rebalance, nhả lần nữa (5 → 6).
- Ngoài ra đọc rồi ghi (
getrồiset) bị lost update khi có giao dịch khác cùng lúc. - Sửa: lưu bản ghi giữ hàng
reservation(order_id, sku, qty, status); compensation chạyUPDATE reservation SET status = 'RELEASED' WHERE order_id = ? AND status = 'RESERVED', chỉ khi cập nhật được 1 dòng mới cộng lại kho bằng atomic updateSET available = available + ?.
Câu hỏi (Đọc code): Vòng retry dưới đây gọi API thanh toán. Hãy chỉ ra các lỗi.
@Service
public class ChargeService {
// ... fields and constructor omitted
public ChargeResult charge(ChargeRequest req) {
for (int i = 0; i < 5; i++) {
try {
return paymentApi.post("/charges", req); // POST without idempotency key
} catch (Exception e) {
// try again immediately
}
}
throw new IllegalStateException("payment failed");
}
}Đáp án:
Có bốn lỗi: retry một POST không idempotent, retry mọi exception, không backoff và jitter, và nuốt mất nguyên nhân lỗi.
POST /chargesbị timeout có thể đã trừ tiền; gọi lại tạo giao dịch mới, khách có thể bị trừ tới 5 lần. Phải gửi idempotency key (cùng key cho mọi lần thử) hoặc tra cứu trạng thái trước khi thử lại.catch (Exception e)retry cả lỗi 400 (thẻ sai, số tiền âm), vô ích.- Thử lại ngay lập tức 5 lần trong vài mili giây dội thêm tải vào đối tác đang có vấn đề.
- Exception cuối không kèm
cause, khi điều tra không biết lỗi thật là gì. - Sửa: dùng
@Retrycủa Resilience4j chỉ cho lỗi tạm thời, có backoff và jitter, idempotency key cố định cho một ý định thanh toán, và xử lý timeout như trạng thái "chưa rõ" (xem câu hỏi thực chiến về timeout thanh toán).
Câu hỏi (Đọc code): Đọc thiết kế: báo cáo doanh thu được làm như dưới đây. Rủi ro là gì?
report-service --(JDBC, read-only user)--> order DB : SELECT ... FROM orders JOIN order_items
report-service --(JDBC, read-only user)--> payment DB : SELECT ... FROM payments
report-service joins both results in memory, runs every 5 minutes on the primaryĐáp án:
Report-service đọc thẳng database của hai service khác, phá vỡ database per service, và chạy truy vấn nặng trên primary.
- Order-team đổi tên cột
totalthànhtotal_amountlà báo cáo vỡ, dù API của order-service không đổi. Hai team bị buộc chặt vào schema của nhau. - Truy vấn JOIN lớn mỗi 5 phút trên primary tranh tài nguyên với luồng đặt hàng, có thể làm chậm checkout.
- Sửa: order và payment phát event (hoặc CDC) sang một kho dữ liệu báo cáo riêng (data warehouse, hoặc read model của report-service); nếu tạm thời phải đọc DB thì đọc từ replica, qua view được hai bên thống nhất như một hợp đồng.
9.12 Tóm tắt trọng tâm
- Microservices tối ưu khả năng deploy và scale độc lập của nhiều team, không làm request nhanh hơn, vì mỗi lời gọi hàm trở thành một lời gọi mạng có thể lỗi.
- Team nhỏ, nghiệp vụ chưa rõ ranh giới thì chọn modular monolith, vì nó giữ được transaction ACID mà vẫn sẵn ranh giới để tách sau.
- Chuỗi gọi đồng bộ làm availability giảm theo phép nhân (5 service 99,9% còn khoảng 99,5%), nên lan truyền thay đổi trạng thái bằng event.
- Mọi lời gọi ra ngoài phải có timeout, vì không có timeout thì một dependency treo sẽ giữ hết thread và kéo sập cả service.
- Retry chỉ cho lỗi tạm thời và thao tác idempotent, có backoff và jitter, và chỉ ở một tầng, nếu không sẽ gây trừ tiền hai lần hoặc retry storm.
- Circuit breaker có ba trạng thái CLOSED, OPEN, HALF_OPEN; nó fail nhanh khi dependency đang hỏng để không phí tài nguyên và cho dependency hồi phục.
- Bulkhead giới hạn số lời gọi đồng thời tới từng dependency, nên một dependency chậm chỉ chiếm được phần tài nguyên đã chia cho nó.
- 2PC bị tránh vì blocking khi coordinator chết và nhiều thành phần không hỗ trợ; Saga thay bằng chuỗi local transaction cộng compensation.
- Compensation không phải rollback: nó chạy sau khi dữ liệu đã bị thấy, nên phải idempotent, retry được và có đường xử lý thủ công.
- CAP chỉ nói về lúc mạng chia cắt: chọn từ chối phục vụ (C) hay phục vụ dữ liệu có thể cũ (A); tiền bạc nghiêng về C, like và giỏ hàng nghiêng về A.
- Trace id sinh ở cửa vào, truyền qua HTTP header và message header, in vào mọi dòng log qua MDC, nhờ đó một truy vấn log ghép được cả hành trình request.
- Mỗi service sở hữu database riêng để đổi schema không làm vỡ service khác; cần dữ liệu thì gọi API hoặc giữ bản sao qua event.
- Bài system design được chấm theo cách suy nghĩ: làm rõ yêu cầu, ước lượng bằng số, rồi mới chọn công nghệ và nói rõ trade-off.
- Ước lượng dùng một ngày khoảng 100.000 giây, nhân 3–10 cho đỉnh, và so với khả năng từng tầng để quyết định có cần cache, queue, sharding hay không.