Chuyển tới nội dung chính

18.10 — Liên hệ CRM

Tóm tắt

Không phải phần nào của CRM cũng đáng tách. Bài này chỉ ra bốn ứng viên tốt — Notification, Identity, Reporting, Integration — và quan trọng không kém là ba phần nên giữ nguyên trong lõi: Lead, Deal và Customer. Lý do cho cả hai danh sách đều giống nhau: ứng viên tốt là phần có ranh giới nghiệp vụ rõ, ít giao dịch chung với lõi, và nhu cầu scale khác biệt. Lead, Deal và Customer thì ngược lại — chúng thay đổi cùng nhau trong cùng một transaction, nên tách chúng biến một SaveChanges thành một saga phân tán, đổi lấy không lợi ích nào.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Chọn đúng phần của CRM để tách và phần nên giữ.
  • Định tuyến theo tenant và phiên bản API ở gateway.
  • Xử lý dữ liệu người dùng dùng chung giữa các service.
  • Cấu hình health check phản ánh đúng phụ thuộc.

Nội dung bài học​

18.10.1 — Bốn ứng viên tốt​

ServiceVì sao tách đượcNhu cầu scale
NotificationChỉ cần biết gửi cho ai, nội dung gìĐỉnh tải theo giờ, rất khác lõi
IdentityRanh giới rõ, chuẩn công nghiệpĐọc rất nhiều, ghi ít
ReportingChỉ đọc, chịu được dữ liệu trễTruy vấn nặng, chạy theo đợt
IntegrationĐóng gói mọi thứ liên quan bên thứ baPhụ thuộc vào nhịp của đối tác

Notification là ứng viên số một trong hầu hết dự án: ít phụ thuộc, scale khác hẳn, và hỏng thì hậu quả nhẹ (bài 18.12).

Integration Service đáng nói riêng. Nó gom mọi tích hợp bên thứ ba — cổng thanh toán, hoá đơn điện tử, chữ ký số, API đối tác — vào một chỗ. Lợi ích cụ thể: khi một đối tác đổi API hoặc down, chỉ một service bị ảnh hưởng, và mọi logic retry/mapping đặc thù của đối tác không lan vào lõi (anti-corruption layer).

Reporting tách được vì nó chỉ đọc. Không có transaction chung với lõi, nên không có saga nào cần thiết kế — đây là kiểu tách rẻ nhất.

18.10.2 — Ba phần nên giữ nguyên​

// Một use case CRM điển hình: chuyển đổi lead
public async Task<Result> ConvertLeadAsync(LeadId id, CancellationToken ct)
{
var lead = await _db.Leads.FindAsync(id, ct);
var customer = lead.Convert(DateTime.UtcNow); // tao Customer
_db.Customers.Add(customer);

var deal = Deal.CreateFrom(lead, customer); // tao Deal
_db.Deals.Add(deal);

await _db.SaveChangesAsync(ct); // MOT transaction
return Result.Success();
}

Lead, Customer và Deal thay đổi cùng nhau. Nếu tách thành ba service:

Sales Service:     cập nhật Lead
Customer Service: tạo Customer <- có thể thất bại
Deal Service: tạo Deal <- có thể thất bại

=> Cần saga với bước bù trừ cho mỗi bước
=> Trạng thái trung gian: Lead "Won" nhưng chưa có Customer
=> Người dùng thấy dữ liệu mâu thuẫn trong vài giây

Đổi một SaveChangesAsync lấy một saga ba bước có bù trừ, để nhận lại không lợi ích nào — ba phần này không có nhu cầu scale khác nhau và cùng một đội sở hữu.

Quy tắc kiểm tra: nếu hai thực thể thường xuyên thay đổi trong cùng một transaction, chúng thuộc cùng một service (bài 18.3).

18.10.3 — Định tuyến ở gateway​

CRM multi-tenant cần hai chiều định tuyến, và cả hai đều thuộc về gateway.

Theo tenant — nhận diện từ subdomain hoặc header:

app.Use(async (context, next) =>
{
// acme.crm.example.com -> tenant "acme"
var host = context.Request.Host.Host;
var tenant = host.Split('.')[0];

context.Request.Headers["X-Tenant-Id"] = tenant;
await next();
});

Cảnh báo bảo mật: header này chỉ dùng để định tuyến, không bao giờ để phân quyền. Quyền truy cập tenant phải lấy từ claim đã ký trong token, vì header có thể bị giả mạo nếu ai đó gọi thẳng service (bài 18.7).

Theo phiên bản API:

{
"Routes": {
"sales-v1": {
"ClusterId": "sales-v1",
"Match": { "Path": "/api/v1/sales/{**catch-all}" }
},
"sales-v2": {
"ClusterId": "sales-v2",
"Match": { "Path": "/api/v2/sales/{**catch-all}" }
}
}
}

Hai phiên bản chạy song song cho phép client cũ tiếp tục hoạt động trong lúc client mới chuyển dần (bài 9.3).

18.10.4 — Dữ liệu người dùng dùng chung​

Mọi service đều cần biết tên và email người dùng để hiển thị. Ba cách, và cách thứ hai thường đúng nhất:

CáchƯuNhược
Gọi Identity mỗi lầnLuôn mớiThêm một chặng vào mọi request
Nhân bản tối thiểu qua eventNhanh, không coupling thời gianTrễ vài giây
Nhồi vào JWTKhông gọi gì cảToken phình, chỉ đúng với người đang đăng nhập

Cách thứ ba có một hạn chế hay bị bỏ sót: token chỉ chứa thông tin của người đang gọi API, nhưng màn hình thường cần hiển thị tên của người khác — chủ sở hữu lead, người tạo ghi chú. Token không giúp gì ở đó.

// Bản sao tối thiểu trong mỗi service — chỉ những trường CẦN để hiển thị
public sealed class UserSnapshot
{
public Guid UserId { get; set; }
public string DisplayName { get; set; } = null!;
public string Email { get; set; } = null!;
public DateTime UpdatedAtUtc { get; set; }
}

public sealed class UserUpdatedConsumer(SalesDbContext db) : IConsumer<UserUpdatedEvent>
{
public async Task Consume(ConsumeContext<UserUpdatedEvent> context)
{
var snapshot = await db.UserSnapshots.FindAsync(context.Message.UserId)
?? db.UserSnapshots.Add(new UserSnapshot { UserId = context.Message.UserId }).Entity;

snapshot.DisplayName = context.Message.DisplayName;
snapshot.Email = context.Message.Email;
snapshot.UpdatedAtUtc = DateTime.UtcNow;

await db.SaveChangesAsync();
}
}

Chỉ giữ những trường cần để hiển thị. Không sao chép vai trò, quyền hay trạng thái tài khoản — những thứ đó phải hỏi Identity vì chúng ảnh hưởng tới bảo mật và không được phép cũ.

18.10.5 — Health check phản ánh đúng phụ thuộc​

builder.Services.AddHealthChecks()
.AddSqlServer(connectionString, tags: ["ready"])
.AddRabbitMQ(rabbitConnection, tags: ["ready"])
.AddRedis(redisConnection, tags: ["ready"])
// Identity là phụ thuộc BẮT BUỘC của Sales -> tính vào readiness
.AddUrlGroup(new Uri("http://identity:8080/health/live"), tags: ["ready"]);

Câu hỏi quyết định cho mỗi phụ thuộc: "thiếu nó thì service này còn phục vụ được không?"

  • Database chết → không phục vụ được → tính vào readiness.
  • Broker chết → không publish được event → tuỳ nghiệp vụ, thường có.
  • Notification Service chết → Sales vẫn tạo lead được → không tính vào readiness.

Tính nhầm một phụ thuộc không bắt buộc vào readiness sẽ khiến service của bạn bị gỡ khỏi load balancer vì một lý do không liên quan — lỗi lan ngược, đúng thứ việc tách service muốn tránh (bài 18.13).

18.10.6 — Rà lại code của bạn​

Danh sách rà soát microservices trong CRM

  • •Lead, Customer và Deal nằm cùng một service.
  • •Không có thực thể nào thay đổi cùng transaction lại bị tách service.
  • •Notification và Reporting là ứng viên tách trước lõi nghiệp vụ.
  • •Mọi tích hợp bên thứ ba gom vào một chỗ, không rải vào lõi.
  • •Header tenant chỉ dùng để định tuyến, phân quyền lấy từ claim đã ký.
  • •Mỗi service tự áp lọc theo tenant, không tin gateway đã lọc.
  • •Dữ liệu người dùng nhân bản chỉ gồm trường cần hiển thị.
  • •Vai trò và quyền không được nhân bản, luôn hỏi Identity.
  • •readiness chỉ gồm phụ thuộc bắt buộc, không gồm phụ thuộc tuỳ chọn.

Bài tập áp dụng​

Bài 1 — Chấm điểm ứng viên​

Liệt kê các module trong CRM của bạn và chấm theo ba tiêu chí: ranh giới rõ, ít giao dịch chung, nhu cầu scale khác biệt.

Tiêu chí hoàn thành: mỗi điểm số của bạn dựa trên một câu lệnh đo được, không dựa trên cảm nhận, và bạn xếp hạng được các module theo thứ tự nên tách.

Gợi ý và lời giải — Bài 1

Gợi ý. "Ranh giới rõ" là một cảm nhận. "Số lời gọi từ module khác vào bên trong module này" là một con số.

Lời giải — biến ba tiêu chí thành ba câu lệnh:

#!/usr/bin/env bash
# cham-diem-module.sh

for m in Leads KhachHang HoaDon BaoCao ThongBao NguoiDung; do
echo "=== $m ==="

# Tiêu chí 1: ranh giới rõ — có bao nhiêu chỗ bên ngoài chạm vào kiểu bên trong?
ro=$(grep -rn "Crm\.$m\.\(Domain\|Infrastructure\)" --include="*.cs" src/ \
| grep -v "src/Crm.$m/" | wc -l)
echo " Rò rỉ ranh giới: $ro chỗ"

# Tiêu chí 2: giao dịch chung — SaveChanges nào chạm vào entity của module khác?
chung=$(grep -rln "SaveChangesAsync" --include="*.cs" src/Crm.$m/ \
| xargs grep -l "Crm\.\(Leads\|KhachHang\|HoaDon\|BaoCao\|ThongBao\|NguoiDung\)" \
| xargs grep -L "Crm\.$m\." 2>/dev/null | wc -l)
echo " Handler ghi xuyên module: $chung"

# Tiêu chí 3: nhu cầu scale — lấy từ metric, không đoán
echo " (CPU và lưu lượng: lấy từ dashboard)"
done
=== Leads ===
Rò rỉ ranh giới: 34 chỗ
Handler ghi xuyên module: 7
=== KhachHang ===
Rò rỉ ranh giới: 41 chỗ
Handler ghi xuyên module: 9
=== HoaDon ===
Rò rỉ ranh giới: 12 chỗ
Handler ghi xuyên module: 4
=== BaoCao ===
Rò rỉ ranh giới: 3 chỗ
Handler ghi xuyên module: 0
=== ThongBao ===
Rò rỉ ranh giới: 2 chỗ
Handler ghi xuyên module: 0
=== NguoiDung ===
Rò rỉ ranh giới: 28 chỗ
Handler ghi xuyên module: 2

Còn tiêu chí thứ ba lấy từ metric thật:

-- Prometheus / bảng thống kê request
SELECT
module,
SUM(so_request) AS request_ngay,
ROUND(AVG(cpu_phan_tram), 1) AS cpu_tb,
ROUND(MAX(bo_nho_mb)) AS bo_nho_dinh
FROM thong_ke_endpoint
WHERE ngay >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY module
ORDER BY cpu_tb DESC;
 module     | request_ngay | cpu_tb | bo_nho_dinh
------------+--------------+--------+-------------
BaoCao | 1204 | 78.0 | 2148
Leads | 842104 | 12.4 | 180
KhachHang | 412008 | 8.1 | 140
HoaDon | 98221 | 6.2 | 120
NguoiDung | 904332 | 4.8 | 96
ThongBao | 44190 | 2.1 | 64

Bảng chấm điểm (mỗi tiêu chí 0–3, cao hơn là ứng viên tốt hơn):

ModuleRanh giới rõÍt giao dịch chungScale khác biệtTổng
BaoCao3 (3 rò rỉ)3 (0 handler)3 (78% CPU / 0,1% lưu lượng)9
ThongBao3 (2 rò rỉ)3 (0 handler)1 (2,1% CPU)7
HoaDon2 (12 rò rỉ)2 (4 handler)04
NguoiDung1 (28 rò rỉ)2 (2 handler)03
Leads0 (34 rò rỉ)0 (7 handler)00
KhachHang0 (41 rò rỉ)0 (9 handler)00

Bốn điều bảng này nói ra:

1. BaoCao là ứng viên rõ ràng nhất, và vì một lý do đặc biệt: nó chỉ đọc.

0 handler ghi xuyên module — vì nó không ghi gì cả
-> không có transaction phải tách
-> không có saga phải thiết kế
-> dữ liệu trễ vài giây là chấp nhận được với báo cáo

2. ThongBao đứng thứ hai, nhưng không có lý do scale.

Ranh giới rất rõ (2 rò rỉ), 0 giao dịch chung
Nhưng 2,1% CPU — tách nó không giải quyết vấn đề nào cả

-> ứng viên tốt về KỸ THUẬT, nhưng không có LÝ DO

Đây là một phân biệt quan trọng: tách được không có nghĩa là nên tách.

3. Leads và KhachHang đạt 0 — và đó là tín hiệu mạnh nhất trong bảng.

34 và 41 chỗ rò rỉ, 7 và 9 handler ghi xuyên module

Tách chúng nghĩa là:
- 16 handler phải chuyển thành saga
- 75 chỗ gọi phải chuyển thành lời gọi mạng
-> và kết quả gần như chắc chắn là distributed monolith (bài 18.7 ở trên)

4. Rò rỉ ranh giới là thứ sửa được MÀ KHÔNG cần tách service.

41 chỗ rò rỉ của KhachHang
-> thêm kiến trúc test chặn (bài 16.13)
-> sửa dần trong 2–3 sprint
-> và sau đó module này mới có thể được xem xét lại

Xếp hạng cuối cùng:

## Ứng viên tách service — CRM, 2026-09-25

1. **BaoCao (9/9)** — tách trong quý tới
Lý do: 78% CPU cho 0,1% lưu lượng, chỉ đọc, 0 transaction chung

2. **ThongBao (7/9)** — KHÔNG tách
Lý do: tách được nhưng không có vấn đề nào cần giải quyết.
Xem lại nếu khối lượng gửi tăng 10 lần.

3. **HoaDon (4/9)** — chưa
Việc cần làm trước: giảm 12 chỗ rò rỉ, xem lại 4 handler ghi xuyên module

4. **NguoiDung (3/9)** — chưa
28 chỗ rò rỉ. Nhưng lưu ý: identity thường là ứng viên tốt vì
lý do khác (bài 18.6 ở trên) — cần đánh giá riêng.

5–6. **Leads, KhachHang (0/9)** — không tách trong tương lai gần
Đây là lõi nghiệp vụ, gắn chặt nhau. Giữ chung một service.

Và một cách đọc khác cho cùng bảng đó:

Nếu chỉ BaoCao đạt điểm cao, và nó chỉ chiếm 0,1% lưu lượng
-> thì 99,9% hệ thống KHÔNG có ứng viên tách service

Kết luận không phải "chúng ta chưa sẵn sàng"
mà là "modular monolith + 1 service báo cáo tách riêng"
là kiến trúc đúng cho hệ thống này, không phải một bước trung gian.

Bài 2 — Tìm transaction xuyên ranh giới​

Tìm mọi use case ghi vào nhiều thực thể trong một SaveChanges và kiểm tra chúng có bị tách service không.

Tiêu chí hoàn thành: bạn có danh sách cụ thể, và với mỗi mục biết được chi phí quy đổi thành saga nếu ranh giới đi qua nó.

Gợi ý và lời giải — Bài 2

Gợi ý. Một SaveChanges ghi hai entity thuộc hai module là một transaction chung. Nếu ranh giới service đi qua giữa chúng, nó trở thành một saga.

Lời giải — tìm bằng chính EF Core, chính xác hơn grep:

public sealed class TimTransactionXuyenRanhGioi : ISaveChangesInterceptor
{
private readonly ILogger<TimTransactionXuyenRanhGioi> _log;

public TimTransactionXuyenRanhGioi(ILogger<TimTransactionXuyenRanhGioi> log)
=> _log = log;

public ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context!;

var cacModule = context.ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added
or EntityState.Modified
or EntityState.Deleted)
.Select(e => ModuleCua(e.Entity.GetType()))
.Distinct()
.OrderBy(m => m)
.ToArray();

if (cacModule.Length > 1)
{
_log.LogWarning(
"Transaction xuyên ranh giới: {Modules} | {UseCase}",
string.Join(" + ", cacModule),
Activity.Current?.GetTagItem("use_case") ?? "không rõ");
}

return ValueTask.FromResult(result);
}

private static string ModuleCua(Type kieu)
=> kieu.Namespace?.Split('.') is [_, var m, ..] ? m : "?";
}

Chạy toàn bộ bộ test tích hợp với interceptor này bật lên, rồi gom log:

dotnet test 2>&1 | grep "Transaction xuyên ranh giới" \
| sed 's/.*giới: //' | sort | uniq -c | sort -rn
  14 Leads + KhachHang | TaoLeadTuKhachHangMoi
9 HoaDon + KhachHang | ChotDonVaCapNhatHanMuc
7 Leads + NguoiDung | GanLeadChoNhanVien
6 Leads + KhachHang + HoaDon | ChuyenLeadThanhKhachHang
3 KhachHang + ThongBao | DangKyNhanTin

Bảng đánh giá:

Use caseModuleSố lầnRanh giới có đi qua?Chi phí nếu tách
ChuyenLeadThanhKhachHangLeads + KhachHang + HoaDon6Không (cùng service)—
TaoLeadTuKhachHangMoiLeads + KhachHang14Không—
ChotDonVaCapNhatHanMucHoaDon + KhachHang9Không—
GanLeadChoNhanVienLeads + NguoiDung7Có, nếu tách NguoiDungSaga 2 bước
DangKyNhanTinKhachHang + ThongBao3Có, nếu tách ThongBaoSự kiện, không cần saga

Ba kết luận:

1. Ba use case nặng nhất đều nằm gọn trong nhóm Leads + KhachHang + HoaDon.

Đây là bằng chứng độc lập cho kết luận ở bài 1 ở trên: ba module này thuộc cùng một ranh giới giao dịch. Bảng chấm điểm và bảng transaction nói cùng một điều bằng hai cách đo khác nhau.

2. DangKyNhanTin không cần saga — và đó là sự khác biệt quan trọng.

Ghi KhachHang + ghi ThongBao trong một SaveChanges

Nhưng: nếu gửi tin thất bại, khách hàng vẫn đăng ký hợp lệ
-> KHÔNG cần rollback
-> chỉ cần: ghi KhachHang + outbox event, ThongBao nghe event

Chuyển từ transaction chung sang sự kiện ở đây không mất gì, vì nghiệp vụ vốn không đòi hỏi tính nguyên tử.

3. GanLeadChoNhanVien thì khác.

Ghi Leads.NguoiPhuTrachId + ghi NguoiDung.SoLeadDangGiu

Nếu tách NguoiDung ra:
-> phải kiểm tra hạn mức lead của nhân viên qua mạng
-> rồi ghi ở hai nơi
-> nếu bước 2 hỏng: lead đã gán nhưng bộ đếm chưa tăng
-> cần saga với bước bù trừ

Cách phân biệt: hỏi "nếu bước hai hỏng, dữ liệu có sai về NGHIỆP VỤ không?"

DangKyNhanTin hỏng bước 2   -> khách chưa nhận tin, gửi lại sau. Không sai.
GanLeadChoNhanVien hỏng b.2 -> bộ đếm sai -> nhân viên nhận quá hạn mức. SAI.

Câu hỏi này quyết định giữa một sự kiện đơn giản và một saga phức tạp — và trả lời sai theo hướng "dùng sự kiện cho mọi thứ" là nguồn gốc của nhiều lỗi dữ liệu âm thầm.

Cách xử lý GanLeadChoNhanVien mà không cần saga: đổi mô hình dữ liệu.

// Thay vì lưu bộ đếm trong NguoiDung (module khác),
// tính nó từ chính bảng Leads (cùng module).
public async Task<int> DemLeadDangGiuAsync(Guid nhanVienId, CancellationToken ct)
=> await _db.Leads.CountAsync(
l => l.NguoiPhuTrachId == nhanVienId && l.TrangThai == TrangThaiLead.DangXuLy,
ct);
Trước: ghi 2 module, cần saga nếu tách
Sau: ghi 1 module, đọc 1 module -> không còn transaction xuyên ranh giới

Nếu COUNT quá chậm, dùng bảng đếm trong cùng module Leads, cập nhật trong cùng SaveChanges — vẫn là một transaction, vẫn nằm trong một ranh giới.

Đây là mẫu chung đáng nhớ: phần lớn transaction xuyên ranh giới biến mất khi bạn đặt dữ liệu vào đúng chỗ, chứ không cần saga.

Và một điều cần kiểm tra trước khi kết luận:

Interceptor chỉ thấy những gì bộ test tích hợp chạm tới.
Độ phủ test 64% -> có thể còn transaction xuyên ranh giới chưa lộ.

-> bật interceptor này trong môi trường staging một tuần
-> nó sẽ tìm ra phần còn lại, từ lưu lượng thật

Bài 3 — Rà health check​

Với mỗi phụ thuộc trong readiness, trả lời "thiếu nó thì service còn phục vụ được không" và bỏ những cái trả lời là có.

Tiêu chí hoàn thành: readiness của bạn chỉ còn các phụ thuộc thật sự chặn, và bạn giải thích được vì sao mỗi lần bỏ một phụ thuộc là một lần giảm rủi ro sự cố lan truyền.

Gợi ý và lời giải — Bài 3

Gợi ý. Readiness thất bại nghĩa là bộ cân bằng tải ngừng gửi request. Câu hỏi luôn là: ngừng gửi request có tốt hơn là gửi không?

Lời giải — liệt kê rồi chất vấn từng cái:

// TRƯỚC — mọi thứ đều nằm trong readiness
builder.Services.AddHealthChecks()
.AddNpgSql(chuoiKetNoi, name: "postgres", tags: ["ready"])
.AddRedis(redisConnection, name: "redis", tags: ["ready"])
.AddRabbitMQ(rabbitConnection, name: "rabbitmq", tags: ["ready"])
.AddUrlGroup(new Uri(billingUrl), name: "billing-api", tags: ["ready"])
.AddUrlGroup(new Uri(identityUrl), name: "identity-api", tags: ["ready"])
.AddElasticsearch(esUrl, name: "elasticsearch", tags: ["ready"])
.AddAzureBlobStorage(blobConn, name: "blob", tags: ["ready"]);

Chất vấn từng phụ thuộc:

Phụ thuộcThiếu nó thì còn phục vụ được không?Kết luận
PostgresKhông — mọi endpoint đều đọc/ghiGiữ trong readiness
RedisCó — cache miss, chậm hơn nhưng vẫn đúngBỏ
RabbitMQCó — nhờ outbox, message chờ trong DBBỏ
billing-apiCó — chỉ 2/37 endpoint cầnBỏ
identity-apiCó — token JWT tự kiểm chứng đượcBỏ
ElasticsearchCó — tìm kiếm hỏng, phần còn lại chạyBỏ
Blob storageCó — chỉ dùng khi tải file đính kèmBỏ
// SAU — readiness chỉ còn thứ thật sự chặn
builder.Services.AddHealthChecks()
// CHẶN: không có database thì không endpoint nào phục vụ được
.AddNpgSql(chuoiKetNoi, name: "postgres", tags: ["ready"])

// KHÔNG CHẶN: hiển thị trên bảng theo dõi, không ảnh hưởng định tuyến
.AddRedis(redisConnection, name: "redis", tags: ["quan-sat"])
.AddRabbitMQ(rabbitConnection, name: "rabbitmq", tags: ["quan-sat"])
.AddUrlGroup(new Uri(billingUrl), name: "billing-api", tags: ["quan-sat"])
.AddUrlGroup(new Uri(identityUrl), name: "identity-api", tags: ["quan-sat"])
.AddElasticsearch(esUrl, name: "elasticsearch", tags: ["quan-sat"])
.AddAzureBlobStorage(blobConn, name: "blob", tags: ["quan-sat"]);

app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });
app.MapHealthChecks("/health/chi-tiet", new()
{
Predicate = _ => true,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
});

Vì sao mỗi lần bỏ một phụ thuộc là một lần giảm rủi ro sự cố lan truyền:

1. Readiness quá rộng biến một sự cố nhỏ thành sự cố toàn hệ thống.

Elasticsearch chậm 30 giây
-> readiness của Leads thất bại
-> Kubernetes rút MỌI pod Leads khỏi service
-> 100% lưu lượng Leads hỏng

Trong khi thực tế: chỉ endpoint tìm kiếm bị ảnh hưởng — 1/37 endpoint

2. Readiness gọi service khác tạo ra dây chuyền.

identity-api chậm
-> readiness của Leads hỏng -> Leads bị rút
-> readiness của Gateway kiểm tra Leads -> Gateway bị rút
-> toàn hệ thống ngừng, vì MỘT service chậm

Đây là cơ chế đằng sau nhiều sự cố toàn hệ thống: health check truyền sự cố nhanh hơn cả lưu lượng thật.

3. Và tệ nhất: nó có thể tạo vòng lặp không thoát được.

A kiểm tra B trong readiness, B kiểm tra A
-> cả hai cùng khởi động lại sau sự cố
-> A chờ B sẵn sàng, B chờ A sẵn sàng
-> không bao giờ sẵn sàng, cần can thiệp tay

Quy tắc: readiness không bao giờ gọi service khác. Không có ngoại lệ đáng giá.

Xử lý phần phụ thuộc đã bỏ: đưa xuống tầng endpoint.

// Thay vì để readiness quyết định cho cả service,
// endpoint nào cần thì tự xử lý
app.MapGet("/api/leads/tim-kiem", async (
string tuKhoa, ITimKiemLead timKiem, ILeadRepository repo, CancellationToken ct) =>
{
try
{
return Results.Ok(await timKiem.TimAsync(tuKhoa, ct));
}
catch (ElasticsearchException ex)
{
// Phương án dự phòng: tìm bằng SQL, chậm hơn nhưng vẫn trả kết quả
Log.Warning(ex, "Elasticsearch hỏng, dùng SQL LIKE thay thế");
return Results.Ok(await repo.TimTheoTenAsync(tuKhoa, gioiHan: 50, ct));
}
});
Elasticsearch hỏng:
Trước: 37/37 endpoint hỏng (readiness thất bại)
Sau: 0/37 endpoint hỏng, 1 endpoint chậm hơn

Kiểm chứng bằng test, để nó không quay lại:

[Theory]
[InlineData("redis")]
[InlineData("rabbitmq")]
[InlineData("elasticsearch")]
[InlineData("billing-api")]
public async Task Readiness_van_xanh_khi_phu_thuoc_khong_bat_buoc_hong(string ten)
{
await fixture.NgatAsync(ten);

var phanHoi = await client.GetAsync("/health/ready");

phanHoi.StatusCode.Should().Be(HttpStatusCode.OK,
"{0} hỏng không được làm service bị rút khỏi bộ cân bằng tải", ten);
}

[Fact]
public async Task Readiness_do_khi_database_hong()
{
await fixture.NgatAsync("postgres");

var phanHoi = await client.GetAsync("/health/ready");

phanHoi.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
}

[Fact]
public async Task Readiness_khong_goi_service_khac()
{
var nguon = File.ReadAllText(TimFile("Program.cs"));
var dongReady = nguon.Split('\n')
.Where(d => d.Contains("\"ready\"") && d.Contains("AddUrlGroup"));

dongReady.Should().BeEmpty(
"readiness gọi service khác tạo ra sự cố lan truyền và có thể gây khoá chéo");
}

Còn liveness thì sao?

app.MapHealthChecks("/health/live", new() { Predicate = _ => false });

Predicate = _ => false nghĩa là không kiểm tra gì cả — chỉ trả 200 nếu tiến trình còn phản hồi được HTTP. Đó chính là điều liveness cần trả lời: tiến trình này có cần khởi động lại không?

Liveness kiểm tra database -> database chậm -> pod bị GIẾT và khởi động lại
-> khởi động lại cũng không sửa được database
-> và pod mới lại chết -> vòng lặp CrashLoopBackOff giữa lúc đang có sự cố

Liveness kiểm tra phụ thuộc bên ngoài là một trong những cấu hình gây hại nhất trong vận hành: nó biến một sự cố phụ thuộc thành một sự cố phụ thuộc cộng với việc mất toàn bộ pod.

Nối lại với phần trước của bài này: ba bài tập ở đây đo ba thứ khác nhau — ranh giới code, ranh giới giao dịch, ranh giới sự cố — nhưng nếu cả ba chỉ ra cùng một nhóm module thì đó là ranh giới service thật sự. Còn khi chúng chỉ ra ba nhóm khác nhau, hệ thống chưa sẵn sàng để tách theo bất kỳ nhóm nào.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao Notification là ứng viên tách tốt nhất?

Vì nó ít phụ thuộc, chỉ cần biết gửi cho ai và nội dung gì, có nhu cầu scale khác hẳn phần còn lại do đỉnh tải theo giờ, và nếu hỏng thì email trễ vài phút chứ không hỏng dữ liệu.

Vì sao Lead, Customer và Deal nên nằm cùng một service?

Vì chúng thay đổi cùng nhau trong cùng một transaction. Tách ra thì một SaveChanges thành một saga ba bước có bù trừ, sinh trạng thái trung gian mâu thuẫn, mà không nhận lại lợi ích nào về scale hay quyền sở hữu.

Quy tắc kiểm tra ranh giới service là gì?

Nếu hai thực thể thường xuyên thay đổi trong cùng một transaction thì chúng thuộc cùng một service. Tách chúng là biến một cam kết nguyên tử thành một quy trình phân tán.

Vì sao header tenant không được dùng để phân quyền?

Vì header có thể bị giả mạo nếu ai đó gọi thẳng service mà không qua gateway. Nó chỉ dùng để định tuyến, còn quyền truy cập tenant phải lấy từ claim đã ký trong token.

Hạn chế của việc nhồi thông tin người dùng vào JWT là gì?

Token chỉ chứa thông tin của người đang gọi API, nhưng màn hình thường cần hiển thị tên của người khác như chủ sở hữu lead hay người tạo ghi chú. Token không giúp gì trong những trường hợp đó.

Vì sao không nên nhân bản vai trò và quyền?

Vì chúng ảnh hưởng tới bảo mật và không được phép cũ. Một người bị thu hồi quyền mà bản sao vẫn còn quyền cũ là lỗ hổng thật. Chỉ nhân bản những trường dùng để hiển thị.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Tách Notification, Identity, Reporting và Integration — giữ Lead, Deal, Customer ở lõi.
  2. Thực thể thay đổi cùng transaction thuộc cùng service. Đây là quy tắc kiểm tra nhanh nhất.
  3. readiness chỉ gồm phụ thuộc bắt buộc, nếu không bạn tự tạo đường lan lỗi.

Tham khảo​

Điều hướng​