Skip to content

19.12 - Ví dụ thực tế nhanh ​

Tóm tắt: Một ví dụ ngắn nhưng đắt giá: endpoint export danh sách lead ra Excel chạy tốt suốt một năm, rồi một ngày làm container bị OOM kill — và điều tệ nhất là nó không chỉ làm hỏng request export, mà làm mọi request đang chạy trên instance đó chết theo. Nguyên nhân: toàn bộ 200.000 bản ghi được nạp vào List, rồi dựng thành một file Excel trong MemoryStream, rồi copy sang mảng byte để trả về — ba bản sao của cùng dữ liệu tồn tại cùng lúc. Cách sửa là streaming, và nó cũng dạy một bài học chung: trong backend, thứ nguy hiểm không phải code chậm mà là code có mức tiêu thụ bộ nhớ tỷ lệ với dữ liệu đầu vào.

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

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

  • Nhận ra code có bộ nhớ tỷ lệ với dữ liệu đầu vào.
  • Chuyển một endpoint export sang streaming.
  • Giải thích vì sao OOM kill nguy hiểm hơn một lỗi thường.
  • Tự kiểm tra rủi ro này trong dự án chỉ với vài phút.

Nội dung bài học ​

19.12.1 — Ví dụ: export Excel ​

csharp
// Code that — chay tot voi 5.000 lead, GIET container voi 200.000
[HttpGet("export")]
public async Task<IActionResult> ExportLeads(CancellationToken ct)
{
    // Bản sao 1: toàn bộ entity trong bộ nhớ
    var leads = await _db.Leads.Where(l => l.TenantId == _tenant.Id).ToListAsync(ct);

    using var workbook = new XLWorkbook();
    var sheet = workbook.Worksheets.Add("Leads");

    for (int i = 0; i < leads.Count; i++)
    {
        sheet.Cell(i + 2, 1).Value = leads[i].Name;
        sheet.Cell(i + 2, 2).Value = leads[i].Email;
        sheet.Cell(i + 2, 3).Value = leads[i].Value;
    }

    // Bản sao 2: cả file Excel trong bộ nhớ
    using var stream = new MemoryStream();
    workbook.SaveAs(stream);

    // Bản sao 3: ToArray() COPY toàn bộ stream sang mảng mới
    return File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "leads.xlsx");
}

Ước lượng bộ nhớ với 200.000 lead:

Bản sao 1: 200.000 entity × ~800 B          = ~160 MB
Bản sao 2: workbook trong MemoryStream      = ~180 MB
Bản sao 3: ToArray() sao chép stream        = ~180 MB
Object trung gian của thư viện Excel        = ~200 MB
                                            ----------
Đỉnh bộ nhớ cho MỘT request                 = ~720 MB

Container có memory limit: 512Mi → OOM kill.

19.12.2 — Vì sao OOM kill tệ hơn một lỗi thường ​

Request export bắt đầu
Bộ nhớ tăng dần: 200MB... 400MB... 512MB
Kernel OOM killer GIẾT tiến trình — KHÔNG có exception, KHÔNG có log

Hậu quả:
  - 40 request khác đang chạy trên instance đó: MẤT HẾT
  - Không có stack trace để điều tra
  - Kubernetes restart pod, vài giây không phục vụ được
  - Lần sau vẫn vậy

Đây là điểm quan trọng nhất: một lỗi thường chỉ làm một request thất bại và để lại stack trace. OOM kill giết cả tiến trình, không để lại gì trong log ứng dụng, và kéo theo mọi request đang chạy (bài 15.9).

Dấu hiệu nhận ra trong Kubernetes:

bash
kubectl describe pod crm-api-7d8f9

# Last State:  Terminated
#   Reason:    OOMKilled          <-- day
#   Exit Code: 137                <-- 128 + 9 (SIGKILL)

Exit Code 137 là chữ ký của OOM kill. Nếu thấy nó trong lịch sử pod, bạn có vấn đề bộ nhớ chứ không phải bug logic.

19.12.3 — Sửa bằng streaming ​

csharp
[HttpGet("export")]
public async Task ExportLeads(CancellationToken ct)
{
    Response.ContentType = "text/csv";
    Response.Headers.ContentDisposition = "attachment; filename=leads.csv";

    await using var writer = new StreamWriter(Response.Body);
    await writer.WriteLineAsync("Name,Email,Value");

    // AsAsyncEnumerable: đọc TỪNG hàng, không nạp hết vào bộ nhớ
    var query = _db.Leads
        .AsNoTracking()
        .Where(l => l.TenantId == _tenant.Id)
        .Select(l => new { l.Name, l.Email, l.Value })
        .AsAsyncEnumerable();

    await foreach (var lead in query.WithCancellation(ct))
    {
        await writer.WriteLineAsync($"{Escape(lead.Name)},{lead.Email},{lead.Value}");
    }
}

Bộ nhớ giờ là hằng số — khoảng vài MB bất kể 5.000 hay 5 triệu bản ghi.

Bốn thay đổi tạo nên khác biệt:

Thay đổiTác dụng
AsAsyncEnumerableĐọc từng hàng, không nạp hết
Select projectionChỉ lấy 3 cột cần
AsNoTrackingKhông giữ snapshot change tracking
Ghi thẳng Response.BodyKhông có bản sao trung gian nào

Đổi từ Excel sang CSV cũng là một quyết định có chủ đích: định dạng Excel buộc phải dựng toàn bộ workbook trong bộ nhớ trước khi ghi ra. Nếu nghiệp vụ bắt buộc .xlsx, dùng thư viện hỗ trợ streaming (ví dụ chế độ SpreadsheetWriter của OpenXML SDK), hoặc chuyển export sang job nền và gửi link tải về sau (bài 14.6).

19.12.4 — Lỗi phổ biến nhất khi sửa ​

csharp
// VẪN SAI — ToListAsync vẫn nạp hết rồi mới ghi từng dòng
var leads = await query.ToListAsync(ct);
foreach (var lead in leads)
    await writer.WriteLineAsync(...);

Ghi ra từng dòng không giải quyết gì nếu bạn đã nạp hết vào bộ nhớ trước đó. Phần tốn bộ nhớ nằm ở ToListAsync, không phải ở vòng lặp ghi.

Một biến thể tinh vi hơn:

csharp
// CŨNG SAI — phân trang nhưng vẫn gom lại
var all = new List<Lead>();
for (int page = 0; ; page++)
{
    var batch = await query.Skip(page * 1000).Take(1000).ToListAsync(ct);
    if (batch.Count == 0) break;
    all.AddRange(batch);            // vẫn gom hết vào bộ nhớ!
}

Phân trang chỉ giúp nếu bạn xử lý xong rồi bỏ mỗi lô, không giữ lại.

19.12.5 — Tự kiểm tra trong 5 phút ​

Ba câu hỏi rà nhanh toàn dự án:

1. Tìm mọi ToListAsync không có Take:

bash
grep -rn "ToListAsync" --include=*.cs src/ | grep -v "Take(" | grep -v "FirstOrDefault"

Mỗi kết quả là một chỗ có thể nạp không giới hạn. Với truy vấn theo TenantId, số bản ghi phụ thuộc tenant — và một tenant lớn có thể làm sập hệ thống.

2. Tìm MemoryStream kèm ToArray:

bash
grep -rn "\.ToArray()" --include=*.cs src/ | grep -i "stream"

ToArray() trên MemoryStream nhân đôi bộ nhớ ngay tại thời điểm đó. Dùng GetBuffer() hoặc ghi thẳng vào Response.Body.

3. Kiểm tra lịch sử OOM:

bash
kubectl get pods -o json | jq -r '
  .items[] | select(.status.containerStatuses[]?.lastState.terminated.reason == "OOMKilled")
  | .metadata.name'

Nếu có kết quả, đã có OOM kill xảy ra mà có thể chưa ai để ý — vì nó không xuất hiện trong log ứng dụng.

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

Danh sách rà soát bộ nhớ theo dữ liệu đầu vào

  • Không có ToListAsync nào trên truy vấn không giới hạn số hàng.
  • Endpoint export dùng streaming, không dựng cả file trong bộ nhớ.
  • Không gọi ToArray trên MemoryStream lớn.
  • Truy vấn export dùng projection và AsNoTracking.
  • Export định dạng bắt buộc dựng toàn bộ đã chuyển sang job nền.
  • Đã kiểm tra lịch sử pod có OOMKilled hay không.
  • Container có memory limit và có cảnh báo khi dùng gần ngưỡng.
  • Đã kiểm thử export với lượng dữ liệu của tenant lớn nhất.

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

Bài 1 — Tái hiện OOM ​

Tạo 200.000 bản ghi, chạy endpoint export kiểu cũ trong container giới hạn 512Mi, và xác nhận exit code 137.

Tiêu chí hoàn thành: bạn thấy tiến trình chết vì hết bộ nhớ, và phân biệt được hai cách chết khác nhau — và vì sao một trong hai tệ hơn nhiều.

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

Gợi ý. Không có container cũng tái hiện được: DOTNET_GCHeapHardLimit cho runtime một trần bộ nhớ giống như giới hạn của container.

Lời giải — hai chế độ, cùng dữ liệu:

csharp
if (che == "gom")
{
    // Kiểu cũ: gom hết vào bộ nhớ rồi mới ghi
    var ds = new List<string>(soDong);
    for (int i = 0; i < soDong; i++)
        ds.Add($"LEAD-{i};Người dùng số {i};nguoi{i}@congty.vn;...");

    var sb = new StringBuilder();
    foreach (var d in ds) sb.AppendLine(d);
}
else
{
    // Streaming: ghi từng dòng, không giữ lại
    await using var w = new StreamWriter(Stream.Null);
    for (int i = 0; i < soDong; i++)
        await w.WriteLineAsync($"LEAD-{i};Người dùng số {i};nguoi{i}@congty.vn;...");
}

Lần đo 1 — 200.000 dòng, không giới hạn bộ nhớ:

text
--- gom ---
  dòng      0   heap    1,6 MB | RSS   28,7 MB
  dòng 50.000   heap   11,5 MB | RSS   43,4 MB
  dòng 100.000  heap   21,4 MB | RSS   54,7 MB
  dòng 150.000  heap   31,4 MB | RSS   65,0 MB
  xong gom      heap   76,6 MB | RSS  110,9 MB
  RSS đỉnh: 110,9 MB | 266 ms

--- streaming ---
  xong stream   heap    4,2 MB | RSS   51,3 MB
  RSS đỉnh:  51,3 MB | 136 ms
Gom hếtStreaming
Heap cuối76,6 MB4,2 MB
RSS đỉnh110,9 MB51,3 MB
Thời gian266 ms136 ms

Chú ý bước nhảy ở dòng xong gom: heap từ 31,4 lên 76,6 MB chỉ trong phần cuối. Đó là lúc StringBuilder dựng bản sao thứ hai của toàn bộ dữ liệu — danh sách và chuỗi kết quả cùng tồn tại.

Lần đo 2 — 2 triệu dòng, trần heap 128 MB (DOTNET_GCHeapHardLimit=8000000):

text
--- gom ---
  dòng 400.000  heap   95,2 MB | RSS  120,9 MB
  dòng 450.000  heap  105,7 MB | RSS  135,6 MB
  dòng 500.000  heap  114,5 MB | RSS  144,6 MB
  OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.
  RSS đỉnh trước khi lỗi: 144,6 MB

--- streaming ---
  dòng 1.950.000 heap    2,3 MB | RSS   44,4 MB
  xong stream    heap    2,6 MB | RSS   44,4 MB
  RSS đỉnh:  44,4 MB | 822 ms

Bản gom chết ở dòng 500.000 trên 2 triệu. Bản streaming chạy hết 2 triệu dòng với heap giữ nguyên 2,3 MB.

text
Heap của bản streaming: 2,3 MB ở dòng 1.950.000
                        4,2 MB ở 200.000 dòng (lần đo 1)

-> bộ nhớ là HẰNG SỐ, không phụ thuộc số dòng
-> đúng như mục 19.13.3 nói, và giờ có số để chứng minh

Hai cách chết khác nhau, và một trong hai tệ hơn nhiều:

OutOfMemoryExceptionOOM kill của kernel
Khi nàoRuntime biết trần heap của mìnhRuntime tưởng có nhiều bộ nhớ hơn thực tế
Ai giếtRuntime .NETKernel
Có ngoại lệ?Có — bắt được, log đượcKhông
Trong log ứng dụngCó stack traceTrống hoàn toàn
Request đang chạy khácCó thể vẫn xongChết hết cùng lúc
Dấu vếtLog ứng dụngexit code 137, reason: OOMKilled
bash
kubectl get pod crm-api-7d4b9c5f8-2xk4p -o json \
  | jq '.status.containerStatuses[0].lastState.terminated | {reason, exitCode}'
text
{
  "reason": "OOMKilled",
  "exitCode": 137
}

137 = 128 + 9 — tiến trình nhận SIGKILL. Không có cơ hội chạy finally, không có cơ hội ghi log, không có cơ hội hoàn tất request nào khác.

Vì sao OOM kill tệ hơn — ba lý do cụ thể:

1. Nó giết cả những request không liên quan.

text
Một người dùng bấm "Xuất dữ liệu" trên 2 triệu bản ghi
-> pod bị giết
-> 50 request khác đang chạy trên pod đó cũng chết
-> và không ai trong số 50 người đó làm gì sai

2. Không có dấu vết trong log ứng dụng.

text
Log cuối cùng: một request bình thường
Rồi: im lặng, pod khởi động lại

-> chẩn đoán phải dựa vào kubectl describe, không phải log
-> và nếu không ai nghĩ tới việc kiểm tra lastState, sự cố bị ghi nhận
   nhầm thành "pod tự khởi động lại, chưa rõ nguyên nhân"

3. Nó có thể thành vòng lặp.

text
Pod bị giết -> khởi động lại -> người dùng thử lại -> bị giết
-> CrashLoopBackOff, đúng mẫu ở bài 18.12

Cấu hình để biến OOM kill thành OutOfMemoryException:

yaml
env:
  - name: DOTNET_GCHeapHardLimitPercent
    value: "4B"          # 0x4B = 75% của memory limit
resources:
  limits: { memory: "512Mi" }
text
Không có nó: runtime tưởng có 8 GB -> để heap lớn dần -> kernel giết ở 512Mi
Có nó:       runtime biết trần 384 MB -> ném OutOfMemoryException
             -> request đó hỏng, ghi được log, các request khác vẫn sống

Nói cách khác: nó không sửa được lỗi, nhưng nó biến một sự cố toàn pod thành một lỗi của một request — và cho bạn một thông báo lỗi để lần ra nguyên nhân.


Bài 2 — Đo bộ nhớ hai cách ​

Dùng dotnet-counters theo dõi GC Heap Size trong lúc chạy cả hai phiên bản export.

Tiêu chí hoàn thành: bạn thấy hình dạng của hai đồ thị khác nhau, và biết chỉ số nào nói lên điều gì.

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

Gợi ý. Con số đỉnh quan trọng, nhưng hình dạng theo thời gian còn nói nhiều hơn.

Lời giải:

bash
dotnet-counters monitor --process-id $(pgrep -f Crm.Api) \
  --counters System.Runtime[gc-heap-size,gen-0-gc-count,gen-2-gc-count,alloc-rate,working-set] \
  --refresh-interval 1
bash
# Ở cửa sổ khác
curl -o /dev/null "http://localhost:5000/api/leads/export?tenant=999"

Phiên bản cũ — hình dạng "dốc lên rồi rơi":

text
t(s)  GC Heap (MB)  Gen-2 count  Alloc Rate (MB/s)  Working Set (MB)
----  ------------  -----------  -----------------  ----------------
   0          12,4            3                2,1              48,2
   2          31,4            3               84,0              65,0
   4          58,7            4              102,4              92,1
   6          76,6            5               96,8             110,9
   8         118,2            7              110,2             152,4
  10         144,6            9               98,1             181,0   <- đỉnh
  12          14,1           10                1,8              92,3   <- sau khi xong

Phiên bản streaming — hình dạng "phẳng":

text
t(s)  GC Heap (MB)  Gen-2 count  Alloc Rate (MB/s)  Working Set (MB)
----  ------------  -----------  -----------------  ----------------
   0          12,1            3                2,0              47,8
   2           3,8            3               41,2              44,1
   4           2,3            3               40,8              44,4
   6           2,6            3               41,0              44,4
   8           2,4            3               40,9              44,4
  10           2,3            3               41,1              44,4
  12           2,6            3                1,9              44,2

Ba khác biệt trong hình dạng, và mỗi cái nói một điều:

1. GC Heap Size — dốc lên so với phẳng.

text
Cũ:        12 -> 145 MB, tăng tuyến tính theo số dòng đã đọc
Streaming: dao động quanh 2–4 MB, KHÔNG phụ thuộc số dòng

-> đây là chỉ số trả lời "bộ nhớ có tỉ lệ với dữ liệu đầu vào không"
-> và đó đúng là câu hỏi quyết định endpoint này có an toàn hay không

2. Gen-2 GC Count — từ 3 lên 10 so với đứng yên ở 3.

text
Cũ: 7 lần gen-2 GC trong 10 giây
    -> mỗi lần gen-2 blocking đều DỪNG mọi luồng
    -> p99 của MỌI endpoint khác tăng trong khoảng đó

Streaming: 0 lần gen-2 thêm
    -> object chết ngay trong gen-0, không bao giờ lên tới gen-2

Đây là chỉ số cho biết endpoint này có làm phiền phần còn lại của hệ thống hay không — và nó không xuất hiện trong bất kỳ biểu đồ độ trễ nào của chính endpoint đó.

3. Alloc Rate — 100 MB/s so với 41 MB/s.

text
Cả hai đều cấp phát, nhưng:
    Cũ:        cấp phát rồi GIỮ LẠI -> lên gen-1, gen-2
    Streaming: cấp phát rồi BỎ NGAY -> chết trong gen-0, rất rẻ

-> alloc rate cao KHÔNG tự động là vấn đề
-> vấn đề là object SỐNG SÓT qua gen-0

Đây là điểm hay bị hiểu sai: gen-0 GC rất rẻ vì nó chỉ đi qua những object còn sống. Cấp phát nhiều object chết ngay gần như không tốn gì. Cấp phát nhiều object sống mới đắt.

4. Và Working Set tụt chậm hơn GC Heap.

text
t=12: GC Heap về 14,1 MB nhưng Working Set vẫn 92,3 MB

-> GC đã giải phóng, nhưng bộ nhớ chưa được TRẢ VỀ hệ điều hành
-> .NET giữ lại để dùng cho lần sau
-> nên RSS cao sau một đợt tải KHÔNG nhất thiết là rò rỉ

Phân biệt với rò rỉ thật: ở bài 19.3, heap không giảm sau GC.Collect đầy đủ. Ở đây heap giảm từ 144,6 xuống 14,1 MB — bộ nhớ đã được thu hồi đúng, chỉ là RSS chưa trả lại hệ điều hành.

Đưa việc này thành test, để không phải nhớ đo bằng tay:

csharp
[Fact]
public async Task Export_khong_tang_bo_nho_theo_so_dong()
{
    async Task<long> DoAsync(int soDong)
    {
        await _fixture.TaoLeadAsync(soDong);
        GC.Collect(2, GCCollectionMode.Forced, true, true);
        var truoc = GC.GetTotalAllocatedBytes();

        using var res = await _client.GetAsync("/api/leads/export", HttpCompletionOption.ResponseHeadersRead);
        await res.Content.CopyToAsync(Stream.Null);

        return GC.GetTotalAllocatedBytes() - truoc;
    }

    var nho  = await DoAsync(1_000);
    var lon  = await DoAsync(100_000);

    // Cấp phát tăng theo số dòng là bình thường; điều KHÔNG bình thường là
    // đỉnh bộ nhớ tăng theo. Ở đây kiểm tra tỉ lệ cấp phát không vượt quá tuyến tính.
    (lon / (double)nho).Should().BeLessThan(150,
        "gấp 100 lần dữ liệu không được làm cấp phát tăng hơn 150 lần");
}

Với streaming, tỉ lệ bám sát 100 lần (tuyến tính theo dữ liệu, đúng như mong đợi). Với bản gom hết, nó vượt lên vì các bản sao trung gian — và đó là thứ test này bắt được.


Bài 3 — Rà dự án ​

Chạy ba lệnh kiểm tra ở mục 19.12.5 trên dự án thật và đếm số vị trí cần xem lại.

Tiêu chí hoàn thành: bạn có con số cụ thể, đã phân loại kết quả, và biết phân biệt kết quả đáng lo với kết quả vô hại.

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

Gợi ý. Ba lệnh grep sẽ cho nhiều kết quả. Phần việc thật nằm ở bước phân loại.

Lời giải — chạy và phân loại:

bash
#!/usr/bin/env bash
# ra-bo-nho.sh

echo "=== 1. ToListAsync không có Take ==="
grep -rn "ToListAsync" --include=*.cs src/ \
  | grep -v "Take(" | grep -v "FirstOrDefault" | grep -v "/Tests/" \
  | tee /tmp/tolist.txt | wc -l

echo "=== 2. ToArray trên stream ==="
grep -rn "\.ToArray()" --include=*.cs src/ | grep -i "stream" | wc -l

echo "=== 3. Pod từng bị OOMKilled ==="
kubectl get pods -o json | jq -r '
  .items[] | select(.status.containerStatuses[]?.lastState.terminated.reason == "OOMKilled")
  | .metadata.name' | wc -l
text
=== 1. ToListAsync không có Take ===
34
=== 2. ToArray trên stream ===
6
=== 3. Pod từng bị OOMKilled ===
2

Phân loại 34 kết quả của lệnh 1 — đây là phần việc thật:

NhómSốĐánh giáHành động
Bảng tham chiếu nhỏ, cố định (dưới 500 dòng)12Vô hạiKhông làm gì
Đã lọc theo khoá chính hoặc khoá ngoại9Vô hạiKhông làm gì
Lọc theo TenantId, không giới hạn7Nguy hiểmThêm phân trang
Lọc theo khoảng thời gian do người dùng chọn4Nguy hiểmGiới hạn khoảng
Job nền, xử lý toàn bộ bảng2Cần xemChuyển sang lô
csharp
// Vô hại — bảng tham chiếu, 47 dòng, không bao giờ tăng
var tinhThanh = await _db.TinhThanh.ToListAsync(ct);

// Vô hại — đã lọc theo một lead cụ thể
var hoatDong = await _db.HoatDong.Where(h => h.LeadId == leadId).ToListAsync(ct);

// NGUY HIỂM — số dòng phụ thuộc kích thước tenant
var leads = await _db.Leads.Where(l => l.TenantId == tenantId).ToListAsync(ct);

// NGUY HIỂM — khoảng thời gian do người dùng chọn, không có trần
var hoaDon = await _db.HoaDon.Where(h => h.Ngay >= tu && h.Ngay <= den).ToListAsync(ct);

Câu hỏi phân loại, gói trong một dòng:

text
"Số dòng trả về có phụ thuộc vào dữ liệu do NGƯỜI DÙNG kiểm soát không?"

Không -> vô hại
Có    -> cần giới hạn

Bảng tham chiếu: không ai thêm được tỉnh thành mới. An toàn. Lọc theo TenantId: một khách hàng lớn có 1,8 triệu lead. Không an toàn.

Sửa 11 chỗ nguy hiểm — ba mẫu:

csharp
// 1. Thêm phân trang bắt buộc, có trần cứng
public async Task<IReadOnlyList<LeadDto>> LayAsync(int trang, int coTrang, CancellationToken ct)
{
    coTrang = Math.Clamp(coTrang, 1, 100);           // trần cứng, không tin client
    return await _db.Leads.AsNoTracking()
        .Where(l => l.TenantId == _tenant.Id)
        .OrderBy(l => l.NgayTao).ThenBy(l => l.Id)
        .Skip((trang - 1) * coTrang).Take(coTrang)
        .Select(l => new LeadDto(l.Id, l.HoTen, l.Email))
        .ToListAsync(ct);
}

// 2. Giới hạn khoảng thời gian
if ((den - tu).TotalDays > 366)
    return Results.BadRequest("Khoảng thời gian tối đa 12 tháng");

// 3. Job nền: xử lý theo lô, không gom
await foreach (var lead in _db.Leads.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
    await XuLyAsync(lead, ct);        // xử lý xong là bỏ, không giữ lại
}

Về 6 kết quả của lệnh 2 (ToArray trên stream):

csharp
// SAI — ToArray() nhân đôi bộ nhớ NGAY tại thời điểm gọi
var bytes = memoryStream.ToArray();
return File(bytes, "application/pdf");

// ĐÚNG — trả thẳng stream, không có bản sao
memoryStream.Position = 0;
return File(memoryStream, "application/pdf");
text
Tệp PDF 40 MB:
    ToArray()   -> 40 MB (MemoryStream) + 40 MB (mảng) = 80 MB
    trả stream  -> 40 MB

Và 40 MB đó vào Large Object Heap -> gom cùng gen-2, không được nén

Về 2 pod từng bị OOMKilled — đây là kết quả quan trọng nhất trong ba lệnh:

bash
kubectl get pods -o json | jq -r '
  .items[] | select(.status.containerStatuses[]?.lastState.terminated.reason == "OOMKilled")
  | "\(.metadata.name) restarts=\(.status.containerStatuses[0].restartCount)"'
text
crm-api-7d4b9c5f8-2xk4p restarts=3
crm-api-7d4b9c5f8-9mn7q restarts=1
text
4 lần OOM kill đã xảy ra, và không có mục nào trong log ứng dụng.
-> có thể chúng được ghi nhận là "pod tự khởi động lại"
-> hoặc không ai để ý, vì hệ thống tự phục hồi

Tìm thời điểm để đối chiếu với lưu lượng:

bash
kubectl get events --field-selector reason=OOMKilling -o json \
  | jq -r '.items[] | "\(.lastTimestamp) \(.involvedObject.name)"'
text
2026-09-22T14:32:11Z  crm-api-7d4b9c5f8-2xk4p
2026-09-23T09:15:44Z  crm-api-7d4b9c5f8-2xk4p
2026-09-24T14:28:02Z  crm-api-7d4b9c5f8-9mn7q
2026-09-25T14:31:55Z  crm-api-7d4b9c5f8-2xk4p

Ba trong bốn lần rơi vào khoảng 14:30 — đủ đều để không phải ngẫu nhiên. Bước tiếp theo là tìm xem có việc gì chạy vào giờ đó: một báo cáo định kỳ, một lần xuất dữ liệu quen thuộc, hoặc một job đồng bộ.

Đưa ba lệnh này vào việc định kỳ, thay vì chạy một lần rồi quên:

yaml
# .github/workflows/ra-bo-nho.yml
on:
  schedule: [{ cron: '0 3 * * 1' }]     # mỗi thứ hai
jobs:
  ra-soat:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Đếm ToListAsync không giới hạn
        run: |
          so=$(grep -rn "ToListAsync" --include=*.cs src/ \
               | grep -v "Take(" | grep -v "FirstOrDefault" | grep -v "/Tests/" | wc -l)
          echo "Hiện có $so vị trí"
          if [ "$so" -gt 34 ]; then
            echo "::warning::Tăng so với mốc 34 — có chỗ mới cần rà"
          fi

So với một mốc đã rà thay vì so với 0 là cách làm thực tế: nó không bắt bạn sửa hết 34 chỗ ngay, nhưng nó đảm bảo con số không tăng mà không ai biết.

Tự kiểm tra ​

Vì sao ba bản sao dữ liệu cùng tồn tại trong ví dụ ban đầu?

Bản một là toàn bộ entity nạp bằng ToListAsync, bản hai là file Excel dựng trong MemoryStream, bản ba là mảng byte do ToArray copy ra từ stream. Cộng thêm object trung gian của thư viện Excel, đỉnh bộ nhớ cho một request vượt xa giới hạn container.

Vì sao OOM kill nguy hiểm hơn một lỗi thường?

Vì nó giết cả tiến trình chứ không chỉ một request, nên mọi request đang chạy trên instance đó mất hết. Nó cũng không để lại exception hay stack trace trong log ứng dụng, nên rất khó điều tra.

Dấu hiệu nào nhận ra OOM kill trong Kubernetes?

Trạng thái terminated với lý do OOMKilled và exit code 137, tức 128 cộng 9 của SIGKILL. Thấy nó trong lịch sử pod nghĩa là có vấn đề bộ nhớ chứ không phải bug logic.

Vì sao ghi ra từng dòng chưa chắc giải quyết vấn đề?

Vì nếu vẫn gọi ToListAsync trước đó thì dữ liệu đã nằm hết trong bộ nhớ rồi. Phần tốn bộ nhớ nằm ở việc nạp dữ liệu, không nằm ở vòng lặp ghi.

Phân trang có giải quyết được vấn đề không?

Chỉ khi bạn xử lý xong rồi bỏ mỗi lô. Nếu gom mọi lô vào một danh sách chung thì kết quả y hệt việc nạp hết một lần, chỉ chậm hơn.

Nếu nghiệp vụ bắt buộc định dạng xlsx thì sao?

Dùng thư viện hỗ trợ ghi theo dòng thay vì dựng toàn bộ workbook trong bộ nhớ, hoặc chuyển export sang job nền rồi gửi cho người dùng một đường dẫn tải về sau khi file đã sẵn sàng.

Kết luận ​

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

  1. Code có bộ nhớ tỷ lệ với dữ liệu đầu vào là quả bom hẹn giờ — nó chỉ chờ một tenant đủ lớn.
  2. OOM kill giết cả tiến trình và không để lại log. Exit code 137 là chữ ký của nó.
  3. Streaming giữ bộ nhớ ở mức hằng số, bất kể dữ liệu lớn tới đâu.

Tham khảo ​

Điều hướng ​

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