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

19.12 — Mini case study

Tóm tắt

Một tình huống thật và điển hình: dashboard CRM chạy ổn hai năm, rồi sau khi onboard một khách hàng lớn thì p99 nhảy từ 400ms lên 9 giây — trong khi CPU server chỉ 25% và "không ai đổi gì". Bài này đi qua toàn bộ quá trình chẩn đoán, từ số liệu đầu tiên tới nguyên nhân gốc, và cho thấy điều quan trọng nhất: có bốn vấn đề chồng lên nhau, không phải một. Sửa một cái mà không sửa ba cái còn lại gần như không thay đổi gì — đó là lý do phải đo lại sau mỗi thay đổi thay vì sửa hàng loạt rồi hy vọng.

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

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

  • Đi từ triệu chứng tới nguyên nhân gốc theo trình tự có kỷ luật.
  • Nhận ra khi nhiều vấn đề chồng lên nhau.
  • Ước lượng tác động của từng thay đổi trước khi làm.
  • Trình bày kết quả tối ưu bằng số liệu.

Nội dung bài học​

19.12.1 — Đề bài​

Hệ thống: CRM multi-tenant, ASP.NET Core 9, SQL Server, 3 instance API sau load balancer.

Endpoint có vấn đề: GET /api/dashboard — trả về 5 khối: số lead theo trạng thái, 10 deal lớn nhất, doanh thu 12 tháng, hoạt động gần đây, và danh sách nhân viên cùng chỉ tiêu.

Triệu chứng:

Trước khi onboard tenant lớn:
p50 = 120ms p95 = 310ms p99 = 400ms

Sau khi onboard "MegaCorp" (1,8 triệu lead, 240 nhân viên):
p50 = 890ms p95 = 4200ms p99 = 9100ms

CPU server: 25% <-- thap
CPU database: 85% <-- cao
Không có thay đổi code nào trong 3 tuần

Ràng buộc: không được đổi giao diện, phải xong trong một tuần, và các tenant khác không được bị ảnh hưởng trong lúc sửa.

Trước khi đọc tiếp, hãy tự trả lời: bạn sẽ đo cái gì đầu tiên?

19.12.2 — Bước 1: khoanh vùng​

CPU server thấp, CPU database cao → nghẽn nằm ở tầng dữ liệu, không phải ở code C# (bài 19.4).

Bước tiếp theo là xác định khối nào trong 5 khối gây chậm. Thêm đo thời gian từng khối:

using var activity = _activitySource.StartActivity("dashboard.top_deals");
var topDeals = await _dealService.GetTopDealsAsync(tenantId, ct);

Kết quả trên trace của một request chậm:

dashboard.lead_counts       120ms
dashboard.top_deals 7800ms <-- thu pham chinh
dashboard.revenue_chart 890ms
dashboard.recent_activity 95ms
dashboard.staff_targets 210ms
------
tong (tuan tu) 9115ms

Hai thông tin ngay lập tức: top_deals chiếm 85% thời gian, và tổng bằng tổng các phần — nghĩa là 5 khối đang chạy tuần tự.

19.12.3 — Bước 2: đào vào top_deals​

// Code hien tai
public async Task<List<DealDto>> GetTopDealsAsync(Guid tenantId, CancellationToken ct)
{
var deals = await _db.Deals
.Include(d => d.Customer)
.Include(d => d.Owner)
.Include(d => d.Activities)
.Where(d => d.TenantId == tenantId && d.Status == DealStatus.Open)
.OrderByDescending(d => d.Value)
.Take(10)
.ToListAsync(ct);

return deals.Select(MapToDto).ToList();
}

Chạy với SET STATISTICS IO ON:

Table 'Deals'.      Scan count 1, logical reads 284,193
Table 'Activities'. Scan count 10, logical reads 91,204
CPU time = 6203 ms, elapsed time = 7810 ms

Execution plan cho thấy Clustered Index Scan trên Deals với 1,8 triệu hàng, rồi Sort toàn bộ kết quả trước khi lấy 10 hàng đầu.

Bốn vấn đề lộ ra, không phải một:

#Vấn đềBằng chứng
1Thiếu index cho (TenantId, Status, Value)Clustered Index Scan + Sort
2Include(Activities) kéo hàng nghìn hàng chỉ để hiển thị số đếm91.204 logical reads trên Activities
3Lấy entity đầy đủ rồi mới map sang DTOTruyền dư cột qua mạng
45 khối chạy tuần tựTổng = tổng các phần

19.12.4 — Bước 3: sửa từng cái, đo từng lần​

Thay đổi 1 — thêm index.

CREATE NONCLUSTERED INDEX IX_Deals_Tenant_Status_Value
ON Deals (TenantId, Status, Value DESC)
INCLUDE (CustomerId, OwnerId, Title, ExpectedCloseDate);
top_deals: 7800ms -> 1240ms
p99 toan endpoint: 9100ms -> 2600ms

Cải thiện lớn nhưng chưa đủ. Include(Activities) vẫn còn.

Thay đổi 2 — bỏ Include, dùng projection.

var deals = await _db.Deals
.AsNoTracking()
.Where(d => d.TenantId == tenantId && d.Status == DealStatus.Open)
.OrderByDescending(d => d.Value)
.Take(10)
.Select(d => new DealDto(
d.Id,
d.Title,
d.Value,
d.Customer.Name,
d.Owner.FullName,
d.Activities.Count())) // COUNT trong SQL, không kéo về
.ToListAsync(ct);

d.Activities.Count() được EF Core dịch thành subquery COUNT — database trả về một số, không phải hàng nghìn hàng (bài 19.5).

top_deals: 1240ms -> 95ms
p99 toan endpoint: 2600ms -> 1400ms

Thay đổi 3 — chạy 5 khối song song.

// Mỗi khối một DbContext riêng — DbContext KHÔNG an toàn đa luồng
await using var scope1 = _scopeFactory.CreateAsyncScope();
await using var scope2 = _scopeFactory.CreateAsyncScope();
// ...

var leadCountsTask = GetLeadCountsAsync(scope1, tenantId, ct);
var topDealsTask = GetTopDealsAsync(scope2, tenantId, ct);
var revenueTask = GetRevenueChartAsync(scope3, tenantId, ct);
var activityTask = GetRecentActivityAsync(scope4, tenantId, ct);
var staffTask = GetStaffTargetsAsync(scope5, tenantId, ct);

await Task.WhenAll(leadCountsTask, topDealsTask, revenueTask, activityTask, staffTask);

Cảnh báo quan trọng: DbContext không an toàn đa luồng. Dùng chung một instance cho 5 task song song sẽ ném InvalidOperationException: A second operation was started on this context — hoặc tệ hơn, làm hỏng change tracker một cách khó đoán. Phải tạo scope riêng cho mỗi task.

p99 toàn endpoint: 1400ms -> 560ms      (bằng khối chậm nhất, không phải tổng)

Thay đổi 4 — cache biểu đồ doanh thu.

Doanh thu 12 tháng là dữ liệu tổng hợp nặng nhưng chỉ đổi khi có deal đóng — chịu cũ 10 phút được (bài 19.6).

var revenue = await _hybridCache.GetOrCreateAsync(
$"dashboard:revenue:{tenantId}",
async token => await ComputeRevenueChartAsync(tenantId, token),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10) },
cancellationToken: ct);

Dùng HybridCache thay vì tự cache để chống stampede — nếu không, 10 phút một lần sẽ có một đợt request cùng miss cùng chạy truy vấn tổng hợp nặng.

p99 toan endpoint: 560ms -> 280ms

19.12.5 — Kết quả và bài học​

Bướcp99Cải thiện tích luỹ
Ban đầu9100ms—
Thêm index2600ms3,5×
Projection thay Include1400ms6,5×
Chạy song song560ms16×
Cache biểu đồ280ms32×

Và chỉ số ít ai nhìn nhưng quan trọng: CPU database từ 85% xuống 12% — nghĩa là mọi tenant khác cũng nhanh lên, không riêng MegaCorp.

Bốn bài học từ ca này:

1. Vấn đề ẩn cho tới khi dữ liệu đủ lớn. Cả bốn vấn đề tồn tại từ ngày đầu. Với tenant 5.000 lead, thiếu index chỉ tốn 20ms nên không ai để ý. Đó là lý do phải kiểm thử hiệu năng trên dữ liệu cỡ production (bài 19.2).

2. Nhiều vấn đề chồng nhau. Chỉ thêm index thì p99 vẫn 2,6 giây — vẫn tệ. Nếu dừng ở đó và kết luận "đã tối ưu rồi", vấn đề vẫn còn nguyên.

3. Đo sau mỗi thay đổi. Nhờ đo từng bước mới biết projection có tác dụng lớn hơn cả chạy song song. Sửa cả bốn cùng lúc thì không học được gì và không biết cái nào thực sự cần.

4. Một tenant lớn phơi bày mọi điểm yếu. Đây là lập luận cho việc kiểm thử với phân bố dữ liệu lệch, không chỉ với dữ liệu lớn đều (bài 19.7).

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

Danh sách rà soát rút ra từ ca này

  • •Endpoint tổng hợp nhiều khối đã chạy song song, mỗi khối một scope riêng.
  • •Không dùng chung DbContext giữa các task chạy song song.
  • •Không Include collection chỉ để lấy số đếm.
  • •Truy vấn danh sách có index phủ cả cột lọc lẫn cột sắp xếp.
  • •Có đo thời gian từng khối, không chỉ tổng thời gian endpoint.
  • •Đã kiểm thử hiệu năng với tenant lớn nhất, không chỉ tenant trung bình.
  • •Dữ liệu tổng hợp nặng được cache với cơ chế chống stampede.
  • •Đo lại sau từng thay đổi, không gộp nhiều thay đổi một lần.

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

Bài 1 — Tự chẩn đoán​

Dựng lại tình huống với 1 triệu bản ghi, đo baseline, rồi áp dụng bốn thay đổi theo thứ tự và ghi p99 sau mỗi bước.

Tiêu chí hoàn thành: bạn có bảng p99 sau từng bước, và phát hiện ra điều mà việc sửa cả bốn cùng lúc sẽ giấu đi.

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

Gợi ý. Ghi lại p99 sau mỗi bước, kể cả khi bước đó có vẻ hiển nhiên là đúng.

Lời giải — dựng dữ liệu lệch, không phải dữ liệu đều:

// 100 tenant nhỏ + 1 tenant lớn — mô phỏng đúng tình huống MegaCorp
for (int t = 1; t <= 100; t++) TaoLead(tenantId: t, so: 2_000);
TaoLead(tenantId: 999, so: 1_000_000); // MegaCorp
// Đo p99 của endpoint, không đo từng truy vấn
async Task<double> DoP99Async(int tenantId, int soLan = 200)
{
for (int i = 0; i < 20; i++) await GoiDashboardAsync(tenantId); // làm nóng
var ms = new List<double>(soLan);
for (int i = 0; i < soLan; i++)
{
var sw = Stopwatch.StartNew();
await GoiDashboardAsync(tenantId);
ms.Add(sw.Elapsed.TotalMilliseconds);
}
ms.Sort();
return ms[(int)(soLan * 0.99)];
}

Bảng kết quả — ghi lại sau MỖI bước, không chỉ ở cuối:

## Chẩn đoán dashboard — dữ liệu 1 triệu dòng cho tenant 999

| Bước | Thay đổi | p99 (tenant 999) | p99 (tenant nhỏ) | Cải thiện |
|---|---|---:|---:|---:|
| 0 | Baseline | 9.100 ms | 340 ms | — |
| 1 | Index (TenantId, GiaTri DESC) | 2.600 ms | 320 ms | 3,5× |
| 2 | Projection thay Include | 1.400 ms | 300 ms | 6,5× |
| 3 | 5 khối chạy song song | 560 ms | 180 ms | 16× |
| 4 | Cache biểu đồ doanh thu | 280 ms | 120 ms | **32×** |

Cột "p99 tenant nhỏ" là cột mà đề bài không yêu cầu, nhưng nó trả lời một câu hỏi quan trọng:

Tenant nhỏ cũng nhanh lên: 340 ms -> 120 ms

-> bốn thay đổi này không phải "chữa riêng cho MegaCorp"
-> chúng sửa những vấn đề vốn có, chỉ là MegaCorp làm chúng lộ ra

Đây là bằng chứng cho bài học thứ tư trong mục 19.12.5: một tenant lớn phơi bày mọi điểm yếu, và sửa chúng có lợi cho tất cả.

Điều mà sửa cả bốn cùng lúc sẽ giấu đi:

Bước 3 (chạy song song) cải thiện từ 1.400 xuống 560 ms — 2,5 lần
Nhưng NẾU làm bước 3 TRƯỚC bước 1 và 2:
9.100 ms -> khoảng 7.800 ms (bằng khối chậm nhất)
-> chỉ 1,2 lần, và trông như một thay đổi "không đáng"
Chạy song song có tác dụng TỈ LỆ THUẬN với mức độ cân bằng giữa các khối.
Khi một khối chiếm 85% thời gian, song song hoá gần như vô ích.
Sau khi khối đó được sửa, nó mới phát huy.

Nếu sửa cả bốn cùng lúc, bạn sẽ không biết điều này — và lần sau gặp vấn đề tương tự, bạn có thể bắt đầu bằng song song hoá và kết luận rằng nó không hiệu quả.

Ba điều nữa chỉ lộ ra khi đo từng bước:

1. Thứ tự đúng là thứ tự chi phí tăng dần, và nó trùng với thứ tự hiệu quả ở đây.

Bước 1 — thêm index:       10 phút,  cải thiện 3,5×
Bước 2 — projection: 2 giờ, thêm 1,9×
Bước 3 — song song hoá: 4 giờ, thêm 2,5×
Bước 4 — cache: 1 ngày, thêm 2,0×

2. Có thể dừng sớm.

Nếu SLO là p99 < 1 giây:
dừng sau bước 3 (560 ms) -> tiết kiệm một ngày công
và không phải gánh thêm một tầng cache để bảo trì

3. Một bước có thể làm chậm đi, và chỉ đo mới phát hiện.

Ví dụ: thêm index thứ hai cho cùng bảng
-> truy vấn dashboard nhanh hơn 5%
-> nhưng endpoint tạo lead chậm hơn 15%, vì mỗi INSERT phải cập nhật thêm index

Đo p99 của DASHBOARD sẽ thấy cải thiện.
Chỉ khi đo cả endpoint ghi mới thấy cái giá.

Nên bảng đo nên có thêm một cột cho một endpoint ghi, không chỉ các endpoint đọc — ít nhất là sau những bước thêm index.


Bài 2 — Tái hiện lỗi DbContext​

Chạy 5 truy vấn song song dùng chung một DbContext và ghi lại exception nhận được.

Tiêu chí hoàn thành: bạn có thông điệp lỗi chính xác, và hiểu vì sao lỗi này đôi khi không xảy ra — điều làm nó khó tìm.

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

Gợi ý. Thử với bảng nhỏ trước, rồi thử với bảng lớn. Kết quả khác nhau, và đó là phần đáng học nhất.

Lời giải:

using var db = new Db(cs);

var tasks = Enumerable.Range(0, 5)
.Select(t => Task.Run(() => db.Leads.AsNoTracking()
.Where(l => l.TenantId == t).ToListAsync()))
.ToArray();

try { Task.WaitAll(tasks); }
catch (Exception ex)
{
var g = ex is AggregateException ae ? ae.InnerExceptions[0] : ex;
Console.WriteLine($"{g.GetType().Name}: {g.Message}");
}

Kết quả trên .NET 9.0.203, EF Core 9.0.0, bảng 200.000 dòng:

InvalidOperationException: A second operation was started on this context instance
before a previous operation completed. This is usually caused by different threads
concurrently using the same instance of DbContext. For more information on how to
avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913

Và đây là phần quan trọng: với bảng 5.000 dòng, cùng đoạn code đó KHÔNG ném lỗi.

Bảng 5.000 dòng  -> mỗi truy vấn xong trước khi truy vấn kế tiếp kịp bắt đầu
-> bộ phát hiện đồng thời của EF Core không thấy chồng lấn
-> chạy trơn tru, không lỗi

Bảng 200.000 dòng -> truy vấn đủ lâu để chồng lấn -> ném lỗi ngay

Vì sao điều này làm lỗi khó tìm:

Máy phát triển: database nhỏ, truy vấn xong trong vài mili giây
-> code sai vẫn chạy đúng, tháng này qua tháng khác

Production: dữ liệu lớn, database bận, truy vấn lâu hơn
-> lỗi xuất hiện, và xuất hiện KHÔNG ĐỀU
-> "thỉnh thoảng lỗi, thử lại thì được"

Lỗi phụ thuộc thời gian là loại lỗi tệ nhất để chẩn đoán: nó không tái hiện được theo yêu cầu, và nó thường xuất hiện nhiều nhất đúng lúc hệ thống đang bận nhất.

Và có một tình huống còn tệ hơn cả việc ném lỗi:

DbContext không an toàn cho truy cập đồng thời. Bộ phát hiện của EF Core
bắt được PHẦN LỚN trường hợp, không phải MỌI trường hợp.

Khi nó không bắt được: change tracker bị hỏng trạng thái
-> dữ liệu sai, hoặc ngoại lệ ở một chỗ hoàn toàn khác, muộn hơn nhiều

Cách sửa — mỗi tác vụ song song một context:

// SAI — một context cho 5 tác vụ
using var db = new Db(cs);
var tasks = tenants.Select(t => db.Leads.Where(l => l.TenantId == t).ToListAsync());

// ĐÚNG — mỗi tác vụ một context, tạo từ factory
builder.Services.AddDbContextFactory<CrmDbContext>(o => o.UseSqlServer(cs));

var tasks = tenants.Select(async t =>
{
await using var db = await _factory.CreateDbContextAsync(ct);
return await db.Leads.AsNoTracking().Where(l => l.TenantId == t).ToListAsync(ct);
});
var ketQua = await Task.WhenAll(tasks);

Đây chính là cách bước 3 của case study (chạy 5 khối song song) phải được cài đặt — và là lỗi hay gặp nhất khi người ta song song hoá một handler đang chạy tuần tự.

Ba biến thể của cùng một lỗi, khó thấy hơn:

1. Task.WhenAll trên các phương thức của cùng một service.

// Ba phương thức này đều dùng _db — cùng một instance
await Task.WhenAll(
_leadService.DemTheoTrangThaiAsync(ct),
_dealService.LayTopAsync(ct),
_baoCaoService.DoanhThuAsync(ct));
Ba service khác nhau, nhưng DI đăng ký DbContext là Scoped
-> cả ba nhận CÙNG MỘT instance trong một request
-> vẫn là lỗi, chỉ khó thấy hơn

2. IAsyncEnumerable chưa duyệt xong đã chạy truy vấn khác.

await foreach (var lead in db.Leads.AsAsyncEnumerable())
{
// Truy vấn này chạy TRONG LÚC reader kia còn mở
var kh = await db.KhachHangs.FindAsync(lead.KhachHangId);
}

3. BackgroundService dùng context của scope đã đóng.

// SAI — DbContext Scoped bị giữ bởi một Singleton
public class DongBoService(CrmDbContext db) : BackgroundService { }

// ĐÚNG — tạo scope riêng cho mỗi lượt
public class DongBoService(IServiceScopeFactory factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
await LamViecAsync(db, ct);
await Task.Delay(TimeSpan.FromMinutes(5), ct);
}
}
}

Biến thể thứ ba có công cụ phát hiện tự động:

builder.Host.UseDefaultServiceProvider((_, o) =>
{
o.ValidateScopes = true; // ném lỗi ngay lúc khởi động
o.ValidateOnBuild = true;
});
ValidateScopes phát hiện Singleton giữ Scoped NGAY LÚC KHỞI ĐỘNG
-> lỗi xuất hiện ở máy phát triển, không phải ở production lúc 2 giờ sáng

Hai dòng cấu hình này nên có trong mọi dự án — chúng biến một lớp lỗi khó chẩn đoán thành một lỗi khởi động không thể bỏ qua.


Bài 3 — Đo trên phân bố lệch​

Tạo 100 tenant nhỏ và 1 tenant chiếm 90% dữ liệu, chạy tải đều trên mọi tenant và so sánh p99 giữa hai nhóm.

Tiêu chí hoàn thành: bạn thấy p99 tổng thể che mất vấn đề, và biết cách đo để nó không bị che.

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

Gợi ý. Nếu 1% request thuộc về tenant lớn, và p99 là phân vị thứ 99, thì con số nào che con số nào?

Lời giải — dựng và đo:

for (int t = 1; t <= 100; t++) TaoLead(tenantId: t, so: 2_000);   // tổng 200.000
TaoLead(tenantId: 999, so: 1_800_000); // 90% dữ liệu
// k6 — tải ĐỀU trên mọi tenant: mỗi tenant có cùng số request
export default function () {
const tenants = [...Array(100).keys()].map(i => i + 1).concat([999]);
const t = tenants[Math.floor(Math.random() * tenants.length)];
http.get(`${__ENV.BASE_URL}/api/dashboard?tenant=${t}`, { tags: { tenant: String(t) } });
}

Hình dạng kết quả (mức điển hình — cần một API và database thật để chạy đủ tải; máy viết bài không có):

p99 TỔNG THỂ                     620 ms      <- trông chấp nhận được

Tách theo nhóm:
p99 của 100 tenant nhỏ 210 ms
p99 của tenant 999 8.900 ms <- hỏng hoàn toàn

Vì sao p99 tổng thể che mất vấn đề — một phép tính:

101 tenant, tải đều -> tenant 999 chiếm 1/101 ≈ 0,99% số request

p99 = phân vị thứ 99 = bỏ qua 1% chậm nhất
-> gần như ĐÚNG BẰNG phần của tenant 999
-> nó rơi ra ngoài p99, và không xuất hiện trong con số đó
Muốn thấy nó trong số tổng thể, phải nhìn p99,9:
p99,9 = 9.100 ms

Và trong thực tế còn bị che nặng hơn nữa:

Tải thật KHÔNG đều — tenant lớn thường có nhiều người dùng hơn.
Nhưng cũng có trường hợp ngược lại: một tenant lớn về DỮ LIỆU
nhưng ít người dùng (ví dụ dữ liệu lịch sử di cư sang).

-> khi đó nó chiếm dưới 0,1% lưu lượng
-> không xuất hiện trong cả p99 lẫn p99,9
-> chỉ xuất hiện trong phiếu hỗ trợ của khách hàng đó

Cách đo để nó không bị che — ba mức:

1. Gắn nhãn tenant vào mọi chỉ số.

builder.Services.AddOpenTelemetry().WithMetrics(m => m
.AddAspNetCoreInstrumentation(o => o.EnrichWithHttpRequest = (a, r) =>
a.SetTag("tenant", r.HttpContext.User.FindFirst("tenant_id")?.Value ?? "?")));
# p99 theo từng tenant, chỉ hiện những tenant vượt ngưỡng
topk(10, histogram_quantile(0.99,
sum by (le, tenant) (rate(http_server_request_duration_seconds_bucket[5m])))) > 1
Cảnh báo: "tenant 999 có p99 = 8,9 giây"
thay vì: "p99 tổng thể = 620 ms, mọi thứ ổn"

Lưu ý về số lượng nhãn: với hàng nghìn tenant, gắn nhãn tenant vào histogram sinh ra rất nhiều chuỗi thời gian. Cách thường dùng là chỉ gắn nhãn cho nhóm tenant lớn và gộp phần còn lại thành other.

2. Đặt SLO theo nhóm, không chỉ theo tổng thể.

## SLO dashboard
- p99 tổng thể < 800 ms
- **p99 của MỌI tenant có trên 100.000 lead < 2.000 ms**
- p99,9 tổng thể < 3.000 ms

Dòng thứ hai là dòng khiến vấn đề không thể trốn: nó áp cho mọi tenant thoả điều kiện, không cho phép trung bình hoá.

3. Đưa phân bố lệch vào dữ liệu test.

// SAI — mọi tenant bằng nhau
for (int t = 1; t <= 100; t++) TaoLead(t, 10_000);

// ĐÚNG — theo phân bố thật, thường gần với luật luỹ thừa
TaoLead(1, 1_800_000); // tenant lớn nhất
TaoLead(2, 400_000);
TaoLead(3, 150_000);
for (int t = 4; t <= 100; t++) TaoLead(t, 2_000); // phần đuôi dài
Dữ liệu test đều -> mọi truy vấn có cùng kế hoạch, cùng độ chọn lọc
-> bộ tối ưu hoá của database thấy một thế giới không có thật
-> và parameter sniffing (bài 19.4) không bao giờ lộ ra

Và một cách kiểm tra rẻ hơn nhiều so với chạy tải: so sánh số dòng phải quét.

-- Endpoint này quét bao nhiêu dòng cho tenant lớn nhất so với tenant trung vị?
SELECT TenantId, COUNT(*) AS so_dong
FROM Leads
GROUP BY TenantId
ORDER BY so_dong DESC;
Tenant lớn nhất : 1.800.000 dòng
Tenant trung vị : 2.000 dòng
Tỉ lệ : 900×

-> nếu truy vấn nào đó là O(n) theo số dòng của tenant,
tenant lớn nhất sẽ chậm hơn 900 lần
-> biết điều này trước khi chạy bất kỳ bài test tải nào

Phép chia này mất một phút và thường chỉ ra ngay tenant nào sẽ gặp vấn đề — hữu ích nhất trước khi onboard một khách hàng lớn, đúng tình huống mở đầu của case study này.

Tự kiểm tra​

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

Vì sao CPU server thấp nhưng CPU database cao lại là thông tin quan trọng?

Nó khoanh vùng ngay nghẽn nằm ở tầng dữ liệu chứ không phải ở code C#, nên bạn không mất thời gian profile CPU ứng dụng. Đây là bước đầu tiên đúng trong quy trình chẩn đoán.

Làm sao biết năm khối đang chạy tuần tự?

Vì tổng thời gian endpoint bằng đúng tổng thời gian các khối. Nếu chạy song song thì tổng sẽ xấp xỉ khối chậm nhất chứ không phải tổng của tất cả.

Vì sao Include collection chỉ để lấy số đếm lại tốn kém?

Vì EF Core kéo toàn bộ hàng của collection về bộ nhớ rồi mới đếm. Dùng projection với Count trong biểu thức Select thì database trả về một số duy nhất, giảm hàng chục nghìn logical reads.

Vì sao mỗi task song song cần một DbContext riêng?

Vì DbContext không an toàn đa luồng. Dùng chung sẽ ném lỗi về second operation on this context, hoặc tệ hơn là làm hỏng change tracker theo cách khó đoán. Phải tạo scope riêng cho mỗi task.

Vì sao bốn vấn đề chỉ lộ ra khi có tenant lớn?

Vì với tenant năm nghìn bản ghi, thiếu index chỉ tốn hai mươi mili giây nên không ai để ý. Cùng một đoạn code với 1,8 triệu bản ghi thì tốn gần tám giây. Đó là lý do phải kiểm thử trên dữ liệu cỡ production.

Bài học về việc đo sau mỗi thay đổi là gì?

Nhờ đo từng bước mới biết projection có tác dụng lớn hơn cả việc chạy song song. Sửa bốn thứ cùng lúc thì không biết cái nào thực sự cần, và một thay đổi vô ích có thể được giữ lại mãi mãi.

Kết luận​

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

  1. Điểm nghẽn hiệu năng thường là nhiều vấn đề chồng nhau, không phải một.
  2. Đo từng khối, không chỉ tổng thời gian — nếu không bạn không biết đào vào đâu.
  3. Kiểm thử trên dữ liệu cỡ production và phân bố lệch, vì đó là nơi vấn đề lộ ra.

Tham khảo​

Điều hướng​