18.5 — 4. Giao tiếp giữa các service
Câu hỏi đúng không phải "REST hay gRPC" mà "đồng bộ hay bất đồng bộ" — đó mới là lựa chọn kiến trúc; REST và gRPC chỉ là hai cách cài đặt của cùng một lựa chọn đồng bộ. Nguyên tắc chọn: gọi đồng bộ khi cần câu trả lời ngay để tiếp tục, gửi message khi chỉ cần thông báo việc đã xảy ra. Và có một con số phải nhớ: mỗi lời gọi đồng bộ nhân xác suất lỗi. Bốn service mỗi cái 99,9% uptime nối thành chuỗi cho ra 99,6% — tức gần 3 giờ downtime mỗi tháng, dù không service nào "có vấn đề". Chuỗi gọi đồng bộ càng dài, hệ thống càng giòn, và vòng gọi A → B → C → A là dấu hiệu ranh giới service đã sai ngay từ đầu.
Mục tiêu bài học
Sau bài này bạn có thể:
- Chọn đồng bộ hay bất đồng bộ theo nhu cầu nghiệp vụ.
- Tính xác suất lỗi tích luỹ của một chuỗi gọi.
- Cấu hình gRPC với versioning proto có kỷ luật.
- Nhận ra và phá vỡ chuỗi gọi đồng bộ dài.
- Đặt timeout và circuit breaker đúng chỗ.
Nội dung bài học
18.5.1 — Đồng bộ hay bất đồng bộ
| Câu hỏi | Nếu có | Nếu không |
|---|---|---|
| Cần kết quả để tiếp tục xử lý? | Đồng bộ | Bất đồng bộ |
| Người dùng đang chờ màn hình? | Đồng bộ | Bất đồng bộ |
| Chấp nhận dữ liệu trễ vài giây? | Bất đồng bộ | Đồng bộ |
| Bên nhận chết thì bên gửi có được phép thất bại? | Đồng bộ | Bất đồng bộ |
Hàng cuối là câu hỏi quyết định. "Kiểm tra hạn mức tín dụng trước khi tạo đơn" phải đồng bộ — không có câu trả lời thì không tạo đơn được. "Gửi email xác nhận sau khi tạo đơn" phải bất đồng bộ — SMTP chết không có nghĩa là đơn hàng phải hỏng theo.
// Đồng bộ: cần kết quả để quyết định
var creditCheck = await _billingClient.CheckCreditAsync(customerId, amount, ct);
if (!creditCheck.Approved)
return Result.Failure("Vượt hạn mức tín dụng");
var order = Order.Create(customerId, items);
_db.Orders.Add(order);
// Bất đồng bộ: chỉ thông báo việc đã xảy ra
_db.OutboxMessages.Add(OutboxMessage.From(new OrderPlacedEvent(order.Id)));
await _db.SaveChangesAsync(ct);
Một use case dùng cả hai là chuyện bình thường, không phải thiếu nhất quán.
18.5.2 — Lỗi tích luỹ
Chuoi: API -> Sales -> Billing -> Inventory
Moi service: 99,9% uptime
Uptime toan chuoi = 0,999^4 = 99,6%
Downtime = 0,4% x 30 ngay = ~2,9 gio moi thang
Không service nào "có vấn đề", nhưng hệ thống có. Và đó mới là con số người dùng cảm nhận.
Độ trễ cũng cộng dồn, và cộng dồn ở đuôi phân phối chứ không phải ở trung bình:
Moi service p99 = 100ms
Chuỗi 4 service: p99 KHÔNG phải 400ms mà tệ hơn nhiều,
vì xác suất chậm ít nhất một chặng tăng theo độ dài chuỗi.
Ba cách rút ngắn chuỗi, theo thứ tự nên ưu tiên:
1. Nhân bản dữ liệu. Thay vì gọi Billing mỗi lần, Sales giữ bản sao tối thiểu được cập nhật qua event (bài 18.3). Chuỗi từ 4 xuống 2.
2. Gọi song song thay vì tuần tự.
// SAI — tuan tu: do tre la TONG
var customer = await _customerClient.GetAsync(id, ct);
var invoices = await _billingClient.GetInvoicesAsync(id, ct);
var orders = await _orderClient.GetOrdersAsync(id, ct);
// DUNG — song song: do tre la MAX
var customerTask = _customerClient.GetAsync(id, ct);
var invoicesTask = _billingClient.GetInvoicesAsync(id, ct);
var ordersTask = _orderClient.GetOrdersAsync(id, ct);
await Task.WhenAll(customerTask, invoicesTask, ordersTask);
Chỉ áp dụng được khi các lời gọi không phụ thuộc nhau — nhưng đó là trường hợp phổ biến hơn người ta nghĩ.
3. Suy nghĩ lại ranh giới. Chuỗi dài thường là triệu chứng của ranh giới sai, không phải vấn đề kỹ thuật cần tối ưu.
18.5.3 — Vòng gọi: dấu hiệu ranh giới sai
Sales -> Billing -> Inventory -> Sales
Ba hậu quả, và cái thứ nhất rất khó chẩn đoán:
- Deadlock độ trễ. Sales giữ kết nối chờ Inventory, Inventory chờ Sales phản hồi. Dưới tải cao, cả hai cạn connection pool cùng lúc và hệ thống đứng mà không có log lỗi rõ ràng.
- Không test độc lập được. Muốn test Sales phải dựng cả ba.
- Không deploy độc lập được. Thay đổi ở bất kỳ đâu cũng có thể lan ngược về chính nó.
Hai cách phá vòng:
// Cách 1: đảo chiều — Inventory PHÁT event thay vì GỌI ngược lại Sales
public sealed class StockReservedConsumer(SalesDbContext db) : IConsumer<StockReservedEvent>
{
public async Task Consume(ConsumeContext<StockReservedEvent> context)
{
var order = await db.Orders.FindAsync(context.Message.OrderId);
order!.MarkStockReserved();
await db.SaveChangesAsync();
}
}
Cách 2: gộp service. Nếu ba service luôn gọi vòng nhau, có thể chúng vốn là một (bài 18.3).
18.5.4 — gRPC: khi nào và kỷ luật versioning
gRPC phù hợp cho giao tiếp nội bộ giữa các service: hợp đồng rõ ràng qua .proto, serialize nhị phân gọn hơn JSON, có streaming hai chiều, và sinh client tự động.
syntax = "proto3";
option csharp_namespace = "Crm.Billing.Grpc";
service BillingService {
rpc CheckCredit (CheckCreditRequest) returns (CheckCreditResponse);
}
message CheckCreditRequest {
string customer_id = 1;
int64 amount_minor = 2; // đơn vị nhỏ nhất, tránh số thực
}
message CheckCreditResponse {
bool approved = 1;
int64 available_limit_minor = 2;
}
Dùng int64 cho tiền theo đơn vị nhỏ nhất thay vì double: số thực nhị phân không biểu diễn chính xác giá trị thập phân, và sai số đó tích luỹ thành lệch sổ sách.
Kỷ luật versioning proto — ba quy tắc, vi phạm là gãy client đang chạy:
| Quy tắc | Vì sao |
|---|---|
| Không bao giờ đổi số field | Số field là định danh trên đường truyền, không phải tên |
Không xoá field, dùng reserved | Tránh số đó bị tái sử dụng cho mục đích khác |
| Chỉ thêm field mới với số mới | Client cũ bỏ qua field nó không biết |
message CheckCreditRequest {
reserved 3; // đã xoá 'legacy_code', KHÔNG dùng lại số này
reserved "legacy_code";
string customer_id = 1;
int64 amount_minor = 2;
string currency = 4; // them moi -> so moi
}
Hạn chế cần biết: gRPC-Web cần proxy để chạy từ trình duyệt, và message nhị phân khó debug hơn JSON — không có chuyện mở DevTools đọc thẳng. Với API công khai cho bên thứ ba, REST vẫn dễ tiếp cận hơn nhiều.
18.5.5 — Timeout và circuit breaker
Mọi lời gọi đồng bộ đều phải có timeout. Không có timeout nghĩa là chờ vô hạn, và chờ vô hạn dưới tải cao nghĩa là cạn thread pool rồi sập theo dây chuyền.
builder.Services.AddHttpClient<BillingClient>(c =>
{
c.BaseAddress = new Uri("http://billing:8080");
c.Timeout = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler(o =>
{
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3); // moi lan thu
o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10); // toan bo
o.Retry.MaxRetryAttempts = 2;
o.Retry.UseJitter = true;
o.CircuitBreaker.FailureRatio = 0.5;
o.CircuitBreaker.MinimumThroughput = 10;
});
Quan hệ giữa các mốc thời gian phải đúng thứ tự, nếu không cấu hình mâu thuẫn:
AttemptTimeout (3s) < TotalRequestTimeout (10s) <= HttpClient.Timeout (10s)
< Timeout cua ban goi
Và đây là thứ ít người làm nhưng đáng giá nhất: thiết kế sẵn hành vi khi service phía sau chết.
public async Task<CreditCheckResult> CheckCreditAsync(Guid customerId, decimal amount, CancellationToken ct)
{
try
{
return await _billingClient.CheckCreditAsync(customerId, amount, ct);
}
catch (BrokenCircuitException)
{
_logger.LogWarning("Billing không khả dụng, áp dụng chính sách dự phòng");
// Quyết định NGHIỆP VỤ: đơn nhỏ vẫn cho qua, đơn lớn thì từ chối
return amount <= 1_000_000m
? CreditCheckResult.ApprovedByFallback()
: CreditCheckResult.Unavailable();
}
}
Đây là quyết định nghiệp vụ, không phải kỹ thuật — và phải hỏi người phụ trách nghiệp vụ, không tự quyết. Nhưng nếu không có phương án dự phòng, Billing chết đồng nghĩa với toàn bộ luồng bán hàng dừng.
18.5.6 — Rà lại code của bạn
Danh sách rà soát giao tiếp giữa service
- •Mỗi lời gọi đồng bộ đều trả lời được vì sao không thể bất đồng bộ.
- •Mọi lời gọi HTTP hoặc gRPC đều có timeout rõ ràng.
- •Thứ tự các mốc timeout nhất quán từ trong ra ngoài.
- •Có circuit breaker cho mọi phụ thuộc đồng bộ.
- •Đã thiết kế hành vi dự phòng khi phụ thuộc chết, và nghiệp vụ đã duyệt.
- •Không có vòng gọi giữa các service.
- •Các lời gọi không phụ thuộc nhau được chạy song song.
- •File proto tuân thủ không đổi số field, không xoá field, chỉ thêm số mới.
- •Tiền được truyền theo đơn vị nhỏ nhất, không dùng số thực.
Bài tập áp dụng
Bài 1 — Tính uptime chuỗi
Vẽ chuỗi gọi dài nhất trong hệ thống của bạn và tính uptime tích luỹ với giả định mỗi service đạt 99,9%.
Tiêu chí hoàn thành: bạn tính được con số, và hiểu vì sao phép nhân này lạc quan so với thực tế.
Gợi ý và lời giải — Bài 1
Gợi ý. Nếu A cần B để phục vụ một request, A hoạt động được khi B chết không?
Lời giải — tìm chuỗi dài nhất:
# Từ trace — chuỗi có nhiều span nhất
curl -s "http://jaeger:16686/api/traces?service=crm-gateway&limit=100" \
| jq '[.data[] | {traceID, spans: (.spans | length)}] | sort_by(-.spans) | .[0:5]'
[
{"traceID": "4bf92f35...", "spans": 14},
{"traceID": "7a2c8e91...", "spans": 12}
]
Chuỗi dài nhất — POST /api/don-hang:
Gateway -> Order Service -> Inventory Service -> Product Service
\-> Pricing Service -> Promotion Service
\-> Customer Service -> Identity Service
Tính uptime tích luỹ:
Mỗi service: 99,9% (downtime 8,8 giờ/năm)
Chuỗi đồng bộ dài nhất: Gateway -> Order -> Inventory -> Product
= 0,999^4 = 0,9960 = 99,60%
Nhưng Order CŨNG cần Pricing và Customer đồng bộ:
Gateway, Order, Inventory, Product, Pricing, Promotion, Customer, Identity
= 0,999^8 = 0,9920 = 99,20%
| Số phụ thuộc đồng bộ | Uptime tích luỹ | Downtime/năm |
|---|---|---|
| 1 (monolith) | 99,90% | 8,8 giờ |
| 2 | 99,80% | 17,5 giờ |
| 4 | 99,60% | 35,0 giờ |
| 8 | 99,20% | 70,1 giờ |
| 16 | 98,41% | 139,3 giờ |
Tách thành 8 service làm downtime tăng gấp 8 lần so với monolith — nếu mọi lời gọi đều đồng bộ.
Vì sao phép nhân này LẠC QUAN so với thực tế — bốn lý do:
1. Sự cố tương quan, không độc lập.
Phép nhân giả định các sự cố ĐỘC LẬP.
Thực tế:
- Một sự cố mạng ảnh hưởng nhiều service cùng lúc
- Một node Kubernetes chết mang theo nhiều pod
- Một bản vá hệ điều hành lỗi ảnh hưởng cả cụm
- Một database dùng chung chết -> mọi service dùng nó chết
2. Không tính thời gian triển khai.
8 service × deploy 2 lần/tuần = 16 lần deploy/tuần
Mỗi lần có cửa sổ vài giây mà request có thể lỗi
-> với rolling update chưa đúng ([bài 15.13](../../stage-04-database-production/module-15-docker-deployment/15.13-quick-real-world-example.mdx)),
cửa sổ đó là vài chục request mỗi lần
3. Không tính suy giảm hiệu năng.
"Uptime 99,9%" thường nghĩa là "service phản hồi"
Nhưng một service phản hồi trong 30 giây thì với người dùng nó ĐÃ CHẾT
-> uptime thật (bao gồm cả độ trễ chấp nhận được) thấp hơn con số công bố
4. Lỗi cascade — và đây là lý do nghiêm trọng nhất.
Product Service chậm (chưa chết, chỉ chậm)
-> Inventory chờ, luồng của nó bị chiếm
-> Inventory chậm
-> Order chờ, luồng của nó bị chiếm
-> Order chậm
-> Gateway chờ
-> TOÀN BỘ hệ thống chậm vì MỘT service ở cuối chuỗi
Cascade không nằm trong phép nhân, và nó là cách mà hệ phân tán thường chết thật sự: không phải một service chết, mà một service chậm và kéo theo mọi thứ.
Tính lại với tương quan — ước lượng thực tế hơn:
Uptime tích luỹ (lý thuyết): 99,20%
Trừ thời gian deploy: -0,15%
Trừ suy giảm hiệu năng: -0,30%
Trừ lỗi cascade: -0,40%
-------
Uptime thực tế ước tính: 98,35% = 144 giờ/năm
Ba cách cải thiện, theo hiệu quả giảm dần:
Cách 1 — phá vỡ chuỗi bằng event (hiệu quả nhất):
// TRƯỚC — chuỗi 4 service đồng bộ
public async Task<Result> TaoDonHangAsync(TaoDonRequest req, CancellationToken ct)
{
var gia = await _pricing.TinhGiaAsync(req.Items, ct); // đồng bộ
var kho = await _inventory.KiemTraKhoAsync(req.Items, ct); // đồng bộ
var khach = await _customer.LayAsync(req.CustomerId, ct); // đồng bộ
var don = Order.Tao(khach.Id, req.Items, gia.Value);
_db.Orders.Add(don);
await _db.SaveChangesAsync(ct);
return Result.ThanhCong();
}
// SAU — chỉ giữ đồng bộ thứ THẬT SỰ cần trước khi tạo đơn
public async Task<Result> TaoDonHangAsync(TaoDonRequest req, CancellationToken ct)
{
// Giá: PHẢI đồng bộ — không tạo đơn mà không biết giá
var gia = await _pricing.TinhGiaAsync(req.Items, ct);
if (!gia.ThanhCong) return Result.Loi(gia.Loi);
// Khách hàng: dùng SNAPSHOT nhân bản qua event
var khach = await _db.CustomerSnapshots.FirstOrDefaultAsync(c => c.Id == req.CustomerId, ct);
if (khach is null) return Result.Loi("Không tìm thấy khách hàng");
var don = Order.Tao(khach.Id, req.Items, gia.Value);
_db.Orders.Add(don);
// Trừ kho: BẤT ĐỒNG BỘ qua event, có bù trừ nếu không đủ hàng
_db.Outbox.Add(TinNhanOutbox.Tao(new DonHangDaTaoV1(don.Id.Value, req.Items)));
await _db.SaveChangesAsync(ct);
return Result.ThanhCong();
}
Phụ thuộc đồng bộ: 4 -> 2 (Gateway, Order, Pricing)
Uptime: 99,20% -> 99,70%
Downtime: 70 giờ -> 26 giờ/năm
Cách 2 — phương án dự phòng khi service phụ chết:
var gia = await _pricing.TinhGiaAsync(req.Items, ct);
try
{
var gia = await _pricing.TinhGiaAsync(req.Items, ct);
return gia;
}
catch (BrokenCircuitException)
{
// Dùng bảng giá cache — có thể cũ, nhưng vẫn phục vụ được
_logger.LogWarning("Pricing chết — dùng bảng giá cache");
return await _cache.GetOrCreateAsync($"bang-gia", ...);
}
Phụ thuộc "cứng" -> phụ thuộc "mềm"
-> Pricing chết KHÔNG làm Order chết
-> và người dùng thấy giá cũ thay vì thấy lỗi
Cách 3 — bulkhead, ngăn cascade:
services.AddHttpClient<IPricingClient, PricingClient>()
.AddResilienceHandler("pricing", b =>
{
b.AddConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 20, // tối đa 20 lời gọi đồng thời
QueueLimit = 10,
});
b.AddTimeout(TimeSpan.FromSeconds(2));
b.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions { FailureRatio = 0.5 });
});
Pricing chậm 30 giây
-> chỉ 20 luồng của Order bị chiếm, không phải tất cả
-> lời gọi thứ 31 bị từ chối NGAY
-> các endpoint không dùng Pricing vẫn hoạt động bình thường
Bulkhead không ngăn Pricing chết, nhưng nó giới hạn thiệt hại — và đó là khác biệt giữa "một chức năng hỏng" và "toàn hệ thống chậm".
Vẽ bản đồ phụ thuộc và đánh dấu loại:
## Bản đồ phụ thuộc — POST /api/don-hang
| Từ | Tới | Loại | Nếu chết |
|---|---|---|---|
| Gateway | Order | **Cứng** | Endpoint chết |
| Order | Pricing | **Cứng** | Dùng bảng giá cache |
| Order | Customer | Mềm (snapshot) | Không ảnh hưởng |
| Order | Inventory | Mềm (event) | Trừ kho chậm vài giây |
| Inventory | Product | Mềm (snapshot) | Không ảnh hưởng |
**Phụ thuộc cứng: 2** -> uptime 99,70%
Và quy tắc đáng nhớ:
Mỗi phụ thuộc đồng bộ là một cách mới để hệ thống chết. Đếm chúng, và giảm chúng.
Cách đếm nhanh:
grep -rn "HttpClient\|GrpcClient\|_.*Client\." --include="*.cs" src/ \
| grep -v Tests | grep -oP "I[A-Z][a-zA-Z]*Client" | sort -u | wc -l
Con số này nên được theo dõi như một chỉ số kiến trúc — nếu nó tăng đều, uptime của bạn đang giảm đều.