Skip to content

19.1 - Nguyên tắc: đo trước, đo sau ​

Tóm tắt: Quy tắc duy nhất của hiệu năng: đừng tin trực giác của bạn. Kỹ sư giàu kinh nghiệm đoán sai điểm nghẽn thường xuyên, vì nghẽn hầu như luôn nằm ở chỗ chờ — chờ database, chờ mạng, chờ khoá — chứ không phải ở chỗ code "trông chậm". Ba nguyên tắc chi phối cả module này. Một: đo trước khi sửa, đo lại sau khi sửa — không có số "sau", bạn không biết mình đã cải thiện hay làm tệ đi. Hai: p99, không phải trung bình — trung bình che giấu đúng nhóm người dùng đang khổ. Ba, và đây là thứ hay bị bỏ qua nhất: số đo chỉ có nghĩa khi môi trường đo giống production — benchmark trên laptop với database rỗng cho ra những con số đẹp và vô dụng.

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

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

  • Đặt SLO cụ thể thay vì mục tiêu "làm cho nhanh hơn".
  • Chọn đúng chỉ số cho từng loại vấn đề.
  • Tránh ba cái bẫy khiến số đo nói dối.
  • Dựng kịch bản tải đại diện cho hành vi thật.
  • Trình bày kết quả tối ưu bằng số liệu.

Nội dung bài học ​

19.1.1 — Vì sao trực giác sai ​

csharp
// Trông "chậm" — nhưng chỉ tốn ~2ms với 1000 phần tử
var result = items
    .Where(x => x.IsActive)
    .OrderByDescending(x => x.CreatedAt)
    .Select(x => new ItemDto(x.Id, x.Name))
    .ToList();

// Trông "bình thường" — nhưng là 1 + N truy vấn database, tốn 4 GIÂY
foreach (var order in orders)
    order.Customer = await _db.Customers.FindAsync(order.CustomerId);

Dòng LINQ trông phức tạp nên hay bị nghi ngờ; vòng foreach trông đơn giản nên được bỏ qua. Thực tế chênh nhau hai nghìn lần.

Thứ tự chi phí trong một ứng dụng backend điển hình:

Loại thao tácBậc độ lớn
Tính toán trong bộ nhớnano giây
Truy cập bộ nhớnano giây
Đọc cache Redis cùng vùngmili giây thấp
Một truy vấn databasevài tới vài chục mili giây
Gọi HTTP tới service kháchàng chục mili giây
Chờ khoá hoặc thread pool cạnhàng giây

Chênh lệch giữa hàng đầu và hàng cuối là chín bậc độ lớn. Tối ưu vòng lặp trong khi có một truy vấn N+1 là tối ưu nhầm chỗ hoàn toàn.

19.1.2 — Đặt SLO trước ​

"Làm cho nhanh hơn" không phải mục tiêu vì không ai biết khi nào xong. SLO cụ thể thì có.

SLO cho /api/leads/search:
  - p95 < 200ms
  - p99 < 500ms
  - tỷ lệ lỗi < 0,1%
  - ở mức tải 100 request/giây
  - với 2 triệu lead trong database

Năm thành phần đều cần: hai phân vị, tỷ lệ lỗi, mức tải, và kích thước dữ liệu. Thiếu hai cái cuối, con số không so sánh được với gì cả — 200ms với 1.000 bản ghi và 200ms với 2 triệu bản ghi là hai kết quả khác hẳn nhau.

Phân vị nào cho nghiệp vụ nào:

LoạiPhân vị quan tâmVì sao
API người dùng tương tácp95, p99Trải nghiệm trực tiếp
Job nềnThông lượng, không phải độ trễXong trong cửa sổ cho phép là đủ
Báo cáop50 cũng chấp nhận đượcNgười dùng biết là sẽ chậm
Thanh toán, tích hợpp99,9Đuôi phân phối là tiền thật

19.1.3 — Ba cái bẫy khiến số đo nói dối ​

Bẫy 1 — Môi trường khác production.

Laptop:      SSD NVMe, database rỗng, không có đồng thời
Production:  disk mạng, 2 triệu bản ghi, 200 kết nối đồng thời

Kết quả: truy vấn 5ms trên laptop -> 800ms trên production

Nguyên nhân không phải phần cứng, mà là kế hoạch truy vấn đổi khi dữ liệu lớn lên. SQL Server chọn table scan cho bảng nhỏ và index seek cho bảng lớn — hoặc ngược lại, khi thống kê lệch. Đo trên dữ liệu nhỏ là đo một kế hoạch khác hẳn.

Tối thiểu: đo trên bản sao dữ liệu có kích thước tương đương, kể cả khi phần cứng yếu hơn.

Bẫy 2 — Đo lần chạy đầu.

csharp
// SAI — lần đầu bao gồm JIT, khởi tạo EF model, mở connection pool
var sw = Stopwatch.StartNew();
var result = await _service.SearchAsync(query);
Console.WriteLine(sw.ElapsedMilliseconds);      // 2400ms — vo nghia

// ĐÚNG — làm nóng trước, đo nhiều lần, lấy phân vị
for (int i = 0; i < 10; i++) await _service.SearchAsync(query);

var timings = new List<long>();
for (int i = 0; i < 100; i++)
{
    var sw2 = Stopwatch.StartNew();
    await _service.SearchAsync(query);
    timings.Add(sw2.ElapsedMilliseconds);
}

timings.Sort();
Console.WriteLine($"p50={timings[50]}ms p95={timings[95]}ms p99={timings[99]}ms");

Lần chạy đầu của một ứng dụng .NET bao gồm JIT compile, dựng EF Core model, và mở connection pool — thường chậm hơn 10–50 lần so với trạng thái ổn định. Đó là con số cold start, hữu ích riêng cho việc khác, nhưng không dùng để so sánh tối ưu.

Bẫy 3 — Tải không đại diện.

javascript
// SAI — cùng một query, cache ăn hết
export default function () {
  http.get('https://api.example.com/leads/search?q=nguyen');
}

// ĐÚNG — phân bố giống thực tế
export default function () {
  const queries = ['nguyen', 'tran', 'le', 'pham', 'hoang'];
  const q = queries[Math.floor(Math.random() * queries.length)];
  const page = Math.floor(Math.random() * 20) + 1;

  http.get(`https://api.example.com/leads/search?q=${q}&page=${page}`);
}

Cùng một truy vấn lặp lại sẽ nằm trong cache sau lần đầu, và bạn đang đo tốc độ của cache chứ không phải của hệ thống. Tải thật có phân bố: một số truy vấn phổ biến, phần lớn thì không — và chính phần "không" quyết định p99.

19.1.4 — Quy trình bốn bước ​

1. Đo và ghi lại baseline.

/api/leads/search, 2 triệu bản ghi, 100 rps:
  p50 = 180ms   p95 = 1200ms   p99 = 3400ms
  CPU = 45%     alloc = 120 MB/s

Ghi đủ ngữ cảnh: kích thước dữ liệu, mức tải, cấu hình. Sáu tháng sau bạn sẽ cần so sánh và không nhớ được.

2. Tìm điểm nghẽn bằng profiler, không bằng phỏng đoán (bài 19.3).

3. Sửa một thứ. Sửa ba thứ cùng lúc thì không biết cái nào có tác dụng — và một trong ba có thể đang làm chậm đi mà bị che bởi hai cái kia.

4. Đo lại đúng điều kiện cũ.

Sau khi thêm index trên (TenantId, CreatedAt):
  p50 = 45ms    p95 = 120ms    p99 = 280ms
  CPU = 20%     alloc = 118 MB/s

=> p99 giảm 12 lần. alloc không đổi (đúng, vì thay đổi nằm ở database).

Nhận xét cuối quan trọng: khi một chỉ số không đổi đúng như dự đoán, đó là bằng chứng bạn hiểu đúng nguyên nhân. Nếu alloc cũng giảm mạnh mà không giải thích được, có thể bạn vừa đo sai.

19.1.5 — Công cụ tạo tải ​

Công cụƯuNhược
k6Kịch bản JavaScript, nhẹ, dễ đưa vào CITách khỏi codebase .NET
NBomberViết bằng C#, tái dùng client có sẵnCộng đồng nhỏ hơn
JMeterGiao diện đồ hoạ, nhiều tính năngNặng, cấu hình XML
bombardierMột lệnh, kiểm tra nhanhKhông có kịch bản phức tạp
javascript
// k6 — tăng tải dần để tìm điểm gãy
export const options = {
  stages: [
    { duration: '2m', target: 50 },    // tăng dần
    { duration: '5m', target: 50 },    // giữ ổn định
    { duration: '2m', target: 200 },   // tăng mạnh
    { duration: '5m', target: 200 },
    { duration: '2m', target: 0 },     // giảm dần
  ],
  thresholds: {
    http_req_duration: ['p(95)<200', 'p(99)<500'],
    http_req_failed: ['rate<0.001'],
  },
};

thresholds biến bài test tải thành một bài test pass/fail, chạy được trong CI. Đây là cách ngăn hồi quy hiệu năng lọt vào production mà không ai biết.

Tăng tải theo bậc thang (thay vì đặt thẳng mức cao) cho bạn thấy điểm gãy: hệ thống ổn tới 150 rps rồi p99 vọt lên — thông tin đó hữu ích hơn nhiều so với một con số đơn lẻ.

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

Danh sách rà soát trước khi tối ưu

  • Có SLO cụ thể với phân vị, tỷ lệ lỗi, mức tải và kích thước dữ liệu.
  • Đã ghi baseline đầy đủ ngữ cảnh trước khi sửa gì.
  • Môi trường đo có kích thước dữ liệu tương đương production.
  • Đã làm nóng trước khi đo, không lấy số lần chạy đầu.
  • Đo nhiều lần và lấy phân vị, không lấy một lần duy nhất.
  • Kịch bản tải có phân bố giống hành vi thật, không lặp một truy vấn.
  • Chỉ sửa một thứ giữa hai lần đo.
  • Có ngưỡng hiệu năng chạy trong CI để chặn hồi quy.
  • Điểm nghẽn được xác định bằng profiler, không bằng phỏng đoán.

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

Bài 1 — Đặt SLO ​

Chọn ba endpoint quan trọng nhất và viết SLO đủ năm thành phần cho từng cái.

Tiêu chí hoàn thành: mỗi SLO có đủ hai phân vị, tỷ lệ lỗi, mức tải và kích thước dữ liệu — và bạn lấy con số từ dữ liệu hiện tại, không tự nghĩ ra.

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

Gợi ý. SLO đặt quá chặt thì cảnh báo kêu suốt và bị tắt. Đặt quá lỏng thì nó không bảo vệ gì. Lấy con số từ hiện trạng rồi siết dần.

Lời giải — bắt đầu bằng đo hiện trạng:

promql
# p95 và p99 của 30 ngày qua, theo endpoint
histogram_quantile(0.95,
  sum by (le, route) (rate(http_server_request_duration_seconds_bucket[30d])))

histogram_quantile(0.99,
  sum by (le, route) (rate(http_server_request_duration_seconds_bucket[30d])))

# Tỷ lệ lỗi
sum by (route) (rate(http_server_requests_total{status=~"5.."}[30d]))
  / sum by (route) (rate(http_server_requests_total[30d]))

# Mức tải đỉnh
max by (route) (rate(http_server_requests_total[5m]))
text
route                       p95      p99   loi     rps_dinh
/api/leads/search         840ms   2100ms  0,04%      112
/api/leads/{id}           120ms    310ms  0,01%      340
/api/bao-cao/tong-hop     6400ms  11200ms 1,20%        3

Và kích thước dữ liệu — thành phần hay bị bỏ quên nhất:

sql
SELECT 'Leads' AS bang, COUNT(*) AS so_dong FROM Leads
UNION ALL SELECT 'KhachHang', COUNT(*) FROM KhachHang
UNION ALL SELECT 'HoaDon',    COUNT(*) FROM HoaDon;
text
 bang      | so_dong
-----------+----------
 Leads     | 2.140.882
 KhachHang |   418.204
 HoaDon    | 1.902.331

Ba SLO, đủ năm thành phần:

markdown
## SLO — API CRM, hiệu lực từ 2026-10-01, xem lại 2027-01-01

### 1. GET /api/leads/search  (tìm kiếm — người dùng gõ và chờ)
- p95 < 400 ms
- p99 < 900 ms
- Tỷ lệ lỗi < 0,1%
- Ở mức tải 150 request/giây
- Với 2,5 triệu lead trong database

Hiện tại: p95 840 ms, p99 2100 ms -> CHƯA ĐẠT, cần cải thiện ~2,3 lần

### 2. GET /api/leads/{id}  (xem chi tiết — nằm trên mọi luồng nghiệp vụ)
- p95 < 150 ms
- p99 < 400 ms
- Tỷ lệ lỗi < 0,05%
- Ở mức tải 400 request/giây
- Với 2,5 triệu lead

Hiện tại: p95 120 ms, p99 310 ms -> ĐẠT, SLO này để GIỮ, không để cải thiện

### 3. GET /api/bao-cao/tong-hop  (báo cáo — người dùng biết là sẽ chờ)
- p95 < 8 giây
- p99 < 15 giây
- Tỷ lệ lỗi < 1%
- Ở mức tải 5 request/giây
- Với 1,9 triệu hoá đơn, khoảng thời gian tối đa 12 tháng

Hiện tại: p95 6400 ms -> ĐẠT. SLO lỏng là CỐ Ý, xem giải thích bên dưới.

Bốn điều đáng chú ý trong ba SLO này:

1. SLO của báo cáo lỏng hơn nhiều, và đó là quyết định có chủ đích.

text
Người dùng bấm "Xuất báo cáo" và biết là sẽ chờ.
Đặt SLO 500 ms cho nó nghĩa là cam kết một việc không ai cần
-> và nó sẽ nuốt thời gian kỹ sư đáng lẽ dành cho /api/leads/search

2. SLO số 2 đã đạt — và vẫn cần viết ra.

text
SLO không chỉ để cải thiện. Nó còn để GIỮ.
Không có SLO cho endpoint đang khoẻ
-> nó xuống cấp dần qua từng lần deploy
-> và không ai nhận ra cho tới khi nó đã tệ

3. Mức tải trong SLO cao hơn tải đỉnh hiện tại.

text
Đỉnh hiện tại 112 rps -> SLO viết 150 rps

Vì SLO là cam kết cho TƯƠNG LAI GẦN, không phải mô tả hiện tại.
Đặt bằng đúng đỉnh hiện tại nghĩa là SLO hỏng ngay khi có thêm khách hàng.

4. Kích thước dữ liệu ghi 2,5 triệu, trong khi hiện có 2,14 triệu.

Cùng lý do: dữ liệu chỉ tăng. Đo ở 2,14 triệu rồi cam kết ở 2,14 triệu nghĩa là SLO hết hiệu lực sau vài tháng, đúng lúc kế hoạch truy vấn bắt đầu đổi.

Biến SLO thành thứ chạy được, thay vì một trang wiki:

yaml
# k6 — ngưỡng lấy thẳng từ SLO
export const options = {
  scenarios: {
    tim_kiem: {
      executor: 'constant-arrival-rate',
      rate: 150, timeUnit: '1s',
      duration: '10m',
      preAllocatedVUs: 100,
    },
  },
  thresholds: {
    'http_req_duration{route:search}': ['p(95)<400', 'p(99)<900'],
    'http_req_failed{route:search}':   ['rate<0.001'],
  },
};
yaml
# Cảnh báo — cùng con số, để SLO và cảnh báo không trôi khỏi nhau
groups:
  - name: slo-crm
    rules:
      - alert: TimKiemLeadViPhamSLO
        expr: |
          histogram_quantile(0.95,
            sum by (le) (rate(http_server_request_duration_seconds_bucket{route="/api/leads/search"}[5m]))
          ) > 0.4
        for: 10m
        labels: { severity: warning }
        annotations:
          summary: "p95 tìm kiếm lead vượt SLO 400 ms"

Và một khái niệm làm SLO dùng được trong thực tế: ngân sách lỗi.

text
SLO tỷ lệ lỗi < 0,1% trong 30 ngày
Lưu lượng: 112 rps × 30 ngày ≈ 290 triệu request
-> ngân sách lỗi = 290.000 request được phép hỏng

Tuần 1 hỏng 250.000 request (một sự cố 40 phút)
-> đã tiêu 86% ngân sách trong tuần đầu
-> ba tuần còn lại: ưu tiên ổn định, hoãn tính năng mới

Ngân sách lỗi biến "có nên dừng làm tính năng để sửa ổn định không" từ một cuộc tranh luận thành một phép trừ — và đó là giá trị lớn nhất của việc viết SLO ra bằng số.

Sai lầm thường gặp: đặt SLO cho p50.

text
p50 < 200 ms nghe hợp lý
Nhưng: một nửa số request CHẬM HƠN thế, và SLO không nói gì về chúng.

Người dùng khó chịu không phải người gặp p50.
Họ là người gặp p99 — và họ gặp nó nhiều lần mỗi ngày.

Với 20 lời gọi API cho một lần tải trang, xác suất một người dùng gặp ít nhất một request ở mức p99 là 1 − 0,99²⁰ ≈ 18%. Gần một phần năm số lần tải trang chạm vào cái đuôi đó.


Bài 2 — Chứng minh bẫy môi trường ​

Đo một truy vấn trên database 1.000 bản ghi và trên 1 triệu bản ghi. So sánh execution plan của hai lần.

Tiêu chí hoàn thành: bạn có hai kế hoạch truy vấn khác nhau kèm số đo, và giải thích được vì sao đo trên dữ liệu nhỏ không chỉ kém chính xác mà còn có thể dẫn tới kết luận ngược.

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

Gợi ý. Với bảng nhỏ, quét toàn bộ bảng có khi còn nhanh hơn dùng index — và bạn sẽ kết luận rằng index không cần thiết.

Lời giải — bốn tổ hợp: hai kích thước × có/không index.

csharp
static void Chay(int soDong, bool coIndex)
{
    // ... tạo bảng, chèn soDong dòng, tuỳ chọn tạo index, rồi ANALYZE ...
    const string sql = "SELECT Id,HoTen FROM Leads WHERE TenantId=7 ORDER BY NgayTao DESC LIMIT 20";

    using (var k = c.CreateCommand())
    {
        k.CommandText = "EXPLAIN QUERY PLAN " + sql;     // SQLite; SQL Server dùng SET SHOWPLAN_ALL ON
        using var r = k.ExecuteReader();
        while (r.Read()) Console.Write(r.GetString(3) + " | ");
    }

    for (int i = 0; i < 5; i++) DocHet(sql);             // làm nóng
    var ms = new List<double>();
    for (int i = 0; i < 30; i++)
    {
        var sw = Stopwatch.StartNew();
        DocHet(sql);
        ms.Add(sw.Elapsed.TotalMilliseconds);
    }
    ms.Sort();
    Console.WriteLine($"p50={ms[15]:F3} ms");
}

Kết quả đo trên .NET 9.0.203 với Microsoft.Data.Sqlite 9.0.3:

text
    1.000 dòng | index=không | p50=   0,070 ms | SCAN Leads | USE TEMP B-TREE FOR ORDER BY
    1.000 dòng | index=có    | p50=   0,022 ms | SEARCH Leads USING INDEX IX_Leads_Tenant_Ngay (TenantId=?)
1.000.000 dòng | index=không | p50=  76,629 ms | SCAN Leads | USE TEMP B-TREE FOR ORDER BY
1.000.000 dòng | index=có    | p50=   0,023 ms | SEARCH Leads USING INDEX IX_Leads_Tenant_Ngay (TenantId=?)
Không indexCó indexChênh
1.000 dòng0,070 ms0,022 ms3,2×
1.000.000 dòng76,629 ms0,023 ms3.332×

Ba điều bảng này nói ra, và điều thứ ba là điều quan trọng nhất:

1. Kế hoạch truy vấn khác nhau — SCAN so với SEARCH.

text
SCAN Leads                          -> đọc TOÀN BỘ bảng
USE TEMP B-TREE FOR ORDER BY        -> và còn phải sắp xếp tạm

SEARCH Leads USING INDEX ...        -> nhảy thẳng tới đúng chỗ
                                    -> không có dòng ORDER BY, vì index đã sắp sẵn

Dòng USE TEMP B-TREE FOR ORDER BY biến mất khi có index là chi tiết đáng chú ý: index không chỉ giúp lọc, nó còn xoá luôn bước sắp xếp.

2. Thời gian của bản có index gần như KHÔNG đổi: 0,022 ms so với 0,023 ms.

text
Dữ liệu tăng 1.000 lần -> thời gian tăng 4%

Đó là chữ ký của tra cứu theo cây B: O(log n).
Bản không index: 0,070 -> 76,629 ms, tăng 1.095 lần -> O(n).

3. Trên 1.000 dòng, thiếu index chỉ tốn 0,048 ms — và con số đó ẩn nó đi hoàn toàn.

text
Trên máy phát triển:
    "Truy vấn mất 0,07 ms. Nhanh mà. Index này thừa."
    -> và kết luận đó ĐÚNG với dữ liệu đang có

Trên production:
    cùng một truy vấn, cùng một dòng code -> 76,6 ms
    × 340 request/giây -> database bão hoà

Đây là điểm mấu chốt của bài này: đo trên dữ liệu nhỏ không cho ra con số kém chính xác hơn, nó cho ra kết luận ngược. Bạn không đo sai một chút — bạn quyết định ngược.

Trường hợp còn rõ hơn: bảng rất nhỏ, quét bảng THẮNG index.

text
Bảng 50 dòng nằm gọn trong một trang dữ liệu
-> quét toàn bộ: một lần đọc trang
-> dùng index: đọc trang index + đọc trang dữ liệu = hai lần

Bộ tối ưu hoá của database BIẾT điều này và cố tình chọn quét bảng.

Nên nếu bạn benchmark trên 50 dòng và thấy index không giúp gì, bạn đã đo đúng — chỉ là đo một câu hỏi khác với câu hỏi cần trả lời.

Với SQL Server, cùng thí nghiệm nhưng công cụ khác:

sql
SET STATISTICS IO, TIME ON;
SET SHOWPLAN_XML ON;

SELECT Id, HoTen FROM Leads WHERE TenantId = 7 ORDER BY NgayTao DESC OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;
text
-- 1.000 dòng:
Table 'Leads'. Scan count 1, logical reads 8
<TableScan> — và bộ tối ưu hoá cho biết chi phí ước tính rất thấp

-- 2.000.000 dòng, không index:
Table 'Leads'. Scan count 1, logical reads 24.918
<TableScan> + <Sort> — cảnh báo SpillLevel1 nếu bộ nhớ không đủ

-- 2.000.000 dòng, có index:
Table 'Leads'. Scan count 1, logical reads 6
<IndexSeek>

logical reads là chỉ số nên dùng, không phải thời gian.

text
Thời gian phụ thuộc: cache, tải hiện tại, phần cứng
    -> thay đổi giữa các lần chạy

logical reads = số trang 8 KB phải đọc
    -> ổn định, so sánh được giữa các môi trường
    -> 24.918 so với 6 là một con số không thể tranh cãi

Ba cách dựng dữ liệu đủ lớn để đo, theo thứ tự nên thử:

CáchƯuNhược
Khôi phục bản sao production (đã che dữ liệu nhạy cảm)Phân bố thật, thống kê thậtCần quy trình che dữ liệu
Sinh dữ liệu giả cùng kích thướcNhanh, làm được ngayPhân bố có thể sai — xem bên dưới
Sao chép chỉ thống kê sang database rỗngRẻ, thấy đúng kế hoạchKhông đo được thời gian thật

Cạm bẫy của dữ liệu sinh ngẫu nhiên đều:

csharp
// SAI — mọi tenant có số lead bằng nhau
pt.Value = rnd.Next(1, 51);        // 50 tenant, mỗi tenant ~2% dữ liệu

// Thực tế thường lệch rất mạnh:
//   tenant lớn nhất giữ 40% dữ liệu
//   20 tenant nhỏ nhất cộng lại chưa tới 1%

Bộ tối ưu hoá dùng thống kê phân bố để chọn kế hoạch. Với dữ liệu đều, nó thấy mọi giá trị TenantId có độ chọn lọc như nhau và chọn một kế hoạch. Với dữ liệu lệch, nó có thể chọn kế hoạch khác nhau cho tenant lớn và tenant nhỏ — và đó đúng là tình huống gây sự cố trên production, khi khách hàng lớn nhất gặp một kế hoạch mà không ai từng đo.

Cách thứ ba trong bảng trên xử lý được việc này mà không cần dữ liệu thật:

sql
-- Xuất thống kê từ production rồi nạp vào database rỗng
-- SQL Server: script sinh ra bởi Generate Scripts với tuỳ chọn Script Statistics
UPDATE STATISTICS Leads WITH ROWCOUNT = 2140882, PAGECOUNT = 24918;

Kế hoạch truy vấn sinh ra sẽ giống production, dù bảng đang rỗng. Không đo được thời gian, nhưng trả lời được câu hỏi "truy vấn này có dùng index không khi dữ liệu lớn" — và đó thường là câu hỏi cần trả lời trước.


Bài 3 — Dựng bài test tải trong CI ​

Viết kịch bản k6 có thresholds, chạy trong pipeline và xác nhận nó chặn được một thay đổi làm chậm endpoint.

Tiêu chí hoàn thành: pipeline của bạn thất bại khi bạn cố tình làm chậm endpoint, và bạn xử lý được vấn đề khó nhất của test tải trong CI: nhiễu.

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

Gợi ý. Một bài test tải không chặn được gì thì chỉ là một biểu đồ đẹp. Phần giá trị nằm ở chỗ nó thất bại.

Lời giải — kịch bản lấy ngưỡng từ SLO:

javascript
// tai-leads.js
import http from 'k6/http';
import { check } from 'k6';
import { Trend } from 'k6/metrics';

const doTreTimKiem = new Trend('do_tre_tim_kiem', true);

export const options = {
  scenarios: {
    tim_kiem: {
      executor: 'constant-arrival-rate',   // giữ ĐÚNG mức rps, không phụ thuộc thời gian phản hồi
      rate: 150,
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 100,
      maxVUs: 400,
    },
  },
  thresholds: {
    'do_tre_tim_kiem':   ['p(95)<400', 'p(99)<900'],
    'http_req_failed':   ['rate<0.001'],
    'checks':            ['rate>0.999'],
  },
};

const TU_KHOA = ['nguyen', 'tran', 'le', 'pham', 'hoang', 'vu', 'dang', 'bui'];

export default function () {
  // Phân bố giống thực tế: 20% từ khoá phổ biến, 80% ngẫu nhiên
  const tuKhoa = Math.random() < 0.2
    ? 'nguyen'
    : TU_KHOA[Math.floor(Math.random() * TU_KHOA.length)] + Math.floor(Math.random() * 500);

  const trang = Math.floor(Math.random() * 20) + 1;

  const res = http.get(
    `${__ENV.BASE_URL}/api/leads/search?q=${tuKhoa}&page=${trang}`,
    { headers: { Authorization: `Bearer ${__ENV.TOKEN}` }, tags: { route: 'search' } });

  doTreTimKiem.add(res.timings.duration);
  check(res, {
    'status 200': r => r.status === 200,
    'co ket qua': r => r.json('items') !== undefined,
  });
}

constant-arrival-rate là lựa chọn quan trọng nhất trong kịch bản này:

text
constant-vus:           100 VU, mỗi VU gửi xong mới gửi tiếp
    -> endpoint chậm đi  ->  rps GIẢM  ->  tải nhẹ đi
    -> hệ thống chậm được "thưởng" bằng ít việc hơn -> che mất vấn đề

constant-arrival-rate:  luôn 150 rps bất kể thời gian phản hồi
    -> endpoint chậm đi  ->  request dồn lại  ->  giống production

Đây là lỗi phổ biến nhất khi viết test tải, và nó làm kết quả lạc quan một cách có hệ thống.

Đưa vào pipeline:

yaml
# .github/workflows/kiem-tra-hieu-nang.yml
name: Kiểm tra hiệu năng

on:
  pull_request:
    paths: ['src/**']

jobs:
  test-tai:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: test }
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-retries 10

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '9.0.x' }

      # Nạp dữ liệu CÙNG KÍCH THƯỚC với production — nếu không, test vô nghĩa
      - name: Nạp dữ liệu mẫu 2 triệu dòng
        run: |
          psql -h localhost -U postgres -f ci/schema.sql
          psql -h localhost -U postgres -c "\copy Leads FROM 'ci/leads-2m.csv' CSV"
          psql -h localhost -U postgres -c "ANALYZE;"

      - name: Chạy API
        run: |
          dotnet run -c Release --project src/Crm.Api &
          timeout 120 bash -c 'until curl -sf http://localhost:5000/health/ready; do sleep 2; done'

      - name: Làm nóng
        run: k6 run --duration 60s --vus 10 ci/tai-leads.js
        env: { BASE_URL: 'http://localhost:5000' }

      - name: Test tải
        run: k6 run --out json=ket-qua.json ci/tai-leads.js
        env: { BASE_URL: 'http://localhost:5000', TOKEN: '${{ secrets.CI_TOKEN }}' }

      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: ket-qua-k6, path: ket-qua.json }

k6 trả mã thoát 99 khi một threshold không đạt, nên bước này tự làm hỏng pipeline — không cần script kiểm tra thêm.

Xác nhận nó thật sự chặn được — cố tình làm chậm:

csharp
// Thay đổi "vô hại" trong một pull request
public async Task<IEnumerable<LeadDto>> SearchAsync(string q, int page, CancellationToken ct)
{
    var leads = await _db.Leads
        .Where(l => l.HoTen.Contains(q))
        .OrderByDescending(l => l.NgayTao)
        .Skip((page - 1) * 20).Take(20)
        .ToListAsync(ct);

    // Thêm "cho tiện": lấy số hoạt động gần nhất của mỗi lead
    foreach (var lead in leads)
        lead.SoHoatDong = await _db.HoatDong.CountAsync(h => h.LeadId == lead.Id, ct);   // N+1

    return leads.Select(ToDto);
}
text
     ✗ do_tre_tim_kiem..... p(95)=1847.32ms  p(99)=4102.88ms

     ERRO[0312] thresholds on metrics 'do_tre_tim_kiem' have been crossed

Error: Process completed with exit code 99.

Pull request bị chặn, và thông báo chỉ thẳng vào số liệu. Không cần ai phải review ra được N+1 bằng mắt.

Vấn đề khó nhất của test tải trong CI: nhiễu.

text
Máy chạy CI dùng chung, CPU bị chia sẻ
-> cùng một commit chạy hai lần cho hai kết quả khác nhau
-> ngưỡng p95 < 400ms thỉnh thoảng thất bại mà không có lỗi nào
-> đội mất niềm tin -> thêm `continue-on-error: true` -> test thành vô dụng

Bốn cách xử lý, theo thứ tự hiệu quả:

1. Đặt ngưỡng CI lỏng hơn SLO thật.

javascript
const NGUONG = __ENV.CI === 'true'
  ? { p95: 800, p99: 1800 }    // CI: lỏng gấp đôi, chỉ bắt hồi quy LỚN
  : { p95: 400, p99: 900 };    // Staging trên phần cứng giống production: SLO thật

export const options = {
  thresholds: {
    'do_tre_tim_kiem': [`p(95)<${NGUONG.p95}`, `p(99)<${NGUONG.p99}`],
  },
};

CI bắt hồi quy cỡ 2–5 lần, là loại hồi quy do lỗi cấu trúc như N+1. Hồi quy 10% cần môi trường ổn định hơn, và đó là việc của staging.

2. So sánh với baseline của chính nhánh chính, không với hằng số.

bash
# Chạy cùng kịch bản trên main và trên nhánh PR, rồi so sánh
k6 run --out json=main.json ci/tai-leads.js
git checkout $PR_BRANCH && dotnet build -c Release
k6 run --out json=pr.json ci/tai-leads.js

python3 ci/so-sanh.py main.json pr.json --nguong-chenh 1.3
text
p95: main 412ms -> PR 1847ms  = 4,48x   VUOT NGUONG 1,3x

Cách này miễn nhiễm với tốc độ máy CI, vì cả hai lần đo chịu cùng điều kiện. Đổi lại, thời gian chạy gấp đôi.

3. Dùng chỉ số không phụ thuộc thời gian.

csharp
// Đếm số truy vấn thay vì đo thời gian — hoàn toàn ổn định
public class DemTruyVanInterceptor : DbCommandInterceptor
{
    public static int SoTruyVan;
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        Interlocked.Increment(ref SoTruyVan);
        return result;
    }
}
csharp
[Fact]
public async Task Tim_kiem_lead_chi_dung_mot_truy_van()
{
    DemTruyVanInterceptor.SoTruyVan = 0;

    await _client.GetAsync("/api/leads/search?q=nguyen&page=1");

    DemTruyVanInterceptor.SoTruyVan.Should().Be(1,
        "thêm truy vấn cho mỗi lead là N+1");
}

Test này chạy trong vài mili giây, không nhiễu, và bắt được đúng loại hồi quy phổ biến nhất. Với nhiều đội, nó đáng giá hơn cả bài test tải — và nên có trước khi dựng test tải.

4. Chạy test tải theo lịch, không theo mỗi pull request.

yaml
on:
  schedule:
    - cron: '0 2 * * *'      # mỗi đêm, trên máy runner riêng
  workflow_dispatch:

Test tải đầy đủ mất 10–20 phút. Chạy nó cho mỗi pull request làm chậm mọi người; chạy hằng đêm trên phần cứng ổn định cho kết quả đáng tin hơn, và hồi quy vẫn được phát hiện trong vòng một ngày.

Kết hợp hợp lý cho phần lớn đội:

Khi nàoChạy gìThời gian
Mỗi pull requestTest đếm truy vấn, ngưỡng CI lỏngdưới 1 phút
Hằng đêm trên nhánh chínhTest tải đầy đủ, ngưỡng SLO thật15 phút
Trước mỗi lần phát hànhTest tải trên staging giống production1 giờ

Nối lại với quy trình bốn bước ở mục 19.1.4: bài 1 định nghĩa "đủ nhanh", bài 2 đảm bảo số đo có ý nghĩa, bài 3 giữ cho kết quả không trôi mất sau vài tháng. Thiếu bài 3, mọi cải thiện đo được ở hai bài kia sẽ mất dần qua từng lần deploy mà không ai nhận ra.

Tự kiểm tra ​

Vì sao trực giác về hiệu năng thường sai?

Vì nghẽn hầu như luôn nằm ở chỗ chờ, tức chờ database, chờ mạng hay chờ khoá, chứ không phải ở đoạn code trông phức tạp. Chênh lệch giữa tính toán trong bộ nhớ và chờ khoá lên tới chín bậc độ lớn, nên tối ưu nhầm chỗ gần như không thay đổi gì.

Một SLO đầy đủ gồm những gì?

Hai phân vị như p95 và p99, tỷ lệ lỗi, mức tải tính bằng request mỗi giây, và kích thước dữ liệu. Thiếu hai thành phần cuối thì con số không so sánh được, vì 200ms với một nghìn bản ghi khác hẳn 200ms với hai triệu bản ghi.

Vì sao đo trên laptop với database rỗng lại sai?

Vì kế hoạch truy vấn thay đổi khi dữ liệu lớn lên. Database chọn table scan cho bảng nhỏ và index seek cho bảng lớn hoặc ngược lại, nên bạn đang đo một kế hoạch hoàn toàn khác với production.

Vì sao không được lấy số của lần chạy đầu?

Vì lần đầu bao gồm JIT compile, dựng EF Core model và mở connection pool, thường chậm hơn trạng thái ổn định mười tới năm mươi lần. Đó là số cold start, hữu ích cho mục đích khác nhưng không dùng để so sánh tối ưu.

Vì sao kịch bản tải lặp một truy vấn là sai?

Vì sau lần đầu nó nằm trong cache, nên bạn đang đo tốc độ cache chứ không phải của hệ thống. Tải thật có phân bố với một số truy vấn phổ biến và phần lớn thì không, và chính phần không phổ biến quyết định p99.

Vì sao chỉ nên sửa một thứ giữa hai lần đo?

Vì sửa nhiều thứ cùng lúc thì không biết cái nào có tác dụng, và một trong số đó có thể đang làm chậm đi nhưng bị che bởi những cái còn lại.

Kết luận ​

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

  1. Không đo thì không biết. Trực giác về hiệu năng sai thường xuyên hơn đúng.
  2. Môi trường đo phải giống production về kích thước dữ liệu, nếu không số đo vô nghĩa.
  3. Ngưỡng hiệu năng trong CI là cách duy nhất ngăn hồi quy lọt qua.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.