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
// 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ác | Bậ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ùng | mili giây thấp |
| Một truy vấn database | vài tới vài chục mili giây |
| Gọi HTTP tới service khác | hàng chục mili giây |
| Chờ khoá hoặc thread pool cạn | hà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 databaseNă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ại | Phân vị quan tâm | Vì sao |
|---|---|---|
| API người dùng tương tác | p95, p99 | Trải nghiệm trực tiếp |
| Job nền | Thông lượng, không phải độ trễ | Xong trong cửa sổ cho phép là đủ |
| Báo cáo | p50 cũng chấp nhận được | Người dùng biết là sẽ chậm |
| Thanh toán, tích hợp | p99,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 productionNguyê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.
// 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.
// 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/sGhi đủ 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ụ | Ưu | Nhược |
|---|---|---|
| k6 | Kịch bản JavaScript, nhẹ, dễ đưa vào CI | Tách khỏi codebase .NET |
| NBomber | Viết bằng C#, tái dùng client có sẵn | Cộng đồng nhỏ hơn |
| JMeter | Giao diện đồ hoạ, nhiều tính năng | Nặng, cấu hình XML |
| bombardier | Một lệnh, kiểm tra nhanh | Không có kịch bản phức tạp |
// 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:
# 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]))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% 3Và kích thước dữ liệu — thành phần hay bị bỏ quên nhất:
SELECT 'Leads' AS bang, COUNT(*) AS so_dong FROM Leads
UNION ALL SELECT 'KhachHang', COUNT(*) FROM KhachHang
UNION ALL SELECT 'HoaDon', COUNT(*) FROM HoaDon; bang | so_dong
-----------+----------
Leads | 2.140.882
KhachHang | 418.204
HoaDon | 1.902.331Ba SLO, đủ năm thành phần:
## 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.
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/search2. SLO số 2 đã đạt — và vẫn cần viết ra.
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.
Đỉ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:
# 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'],
},
};# 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.
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ớiNgâ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.
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.
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:
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 index | Có index | Chênh | |
|---|---|---|---|
| 1.000 dòng | 0,070 ms | 0,022 ms | 3,2× |
| 1.000.000 dòng | 76,629 ms | 0,023 ms | 3.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.
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ẵnDò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.
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.
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.
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:
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;-- 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.
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ãiBa cách dựng dữ liệu đủ lớn để đo, theo thứ tự nên thử:
| Cách | Ưu | Nhượ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ật | Cần quy trình che dữ liệu |
| Sinh dữ liệu giả cùng kích thước | Nhanh, làm được ngay | Phân bố có thể sai — xem bên dưới |
| Sao chép chỉ thống kê sang database rỗng | Rẻ, thấy đúng kế hoạch | Không đo được thời gian thật |
Cạm bẫy của dữ liệu sinh ngẫu nhiên đều:
// 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:
-- 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:
// 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:
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:
# .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:
// 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);
} ✗ 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.
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ụngBốn cách xử lý, theo thứ tự hiệu quả:
1. Đặt ngưỡng CI lỏng hơn SLO thật.
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ố.
# 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.3p95: main 412ms -> PR 1847ms = 4,48x VUOT NGUONG 1,3xCá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.
// Đế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;
}
}[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.
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ào | Chạy gì | Thời gian |
|---|---|---|
| Mỗi pull request | Test đếm truy vấn, ngưỡng CI lỏng | dưới 1 phút |
| Hằng đêm trên nhánh chính | Test tải đầy đủ, ngưỡng SLO thật | 15 phút |
| Trước mỗi lần phát hành | Test tải trên staging giống production | 1 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:
- 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.
- 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.
- 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
- Bài trước: Không có (bài mở đầu module).
- Bài tiếp theo: 19.2 — BenchmarkDotNet
- Về module: Trang mục lục