19.10 - Mở rộng và đào sâu
Tóm tắt: Bài này liệt kê những chủ đề hiệu năng nằm ngoài phạm vi module — không phải vì chúng không quan trọng, mà vì chúng chỉ đáng làm sau khi bạn đã xử lý xong N+1, index, caching và async. Mỗi mục dưới đây đều có một điều kiện kích hoạt cụ thể: con số hoặc triệu chứng cho biết khi nào nên đào vào. Làm chúng sớm hơn là tối ưu vi mô trên một hệ thống còn nghẽn ở chỗ khác — công sức bỏ ra nhiều, kết quả không đo được. Thông điệp chung: những thứ trong bài này gần như luôn là bước thứ mười, không phải bước đầu tiên.
Mục tiêu bài học
Sau bài này bạn có thể:
- Biết những chủ đề hiệu năng sâu hơn tồn tại và giải quyết vấn đề gì.
- Nhận ra điều kiện kích hoạt cho từng chủ đề.
- Tránh tối ưu vi mô khi chưa cần.
- Biết đọc tiếp ở đâu.
Nội dung bài học
19.10.1 — GC tuning
Điều kiện kích hoạt: Gen 2 GC trên 1 lần/giây, hoặc % Time in GC trên 10%.
.NET có hai chế độ GC, và chọn sai gây hậu quả rõ rệt trong container:
| Workstation GC | Server GC | |
|---|---|---|
| Số heap | Một | Một trên mỗi core |
| Thông lượng | Thấp hơn | Cao hơn |
| Bộ nhớ dùng | Ít hơn | Nhiều hơn đáng kể |
| Mặc định | Ứng dụng desktop | ASP.NET Core |
<PropertyGroup>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>Điều đáng biết với container nhỏ: Server GC cấp phát heap theo số core nhìn thấy được, nên một container 512 MB trên máy 32 core có thể dùng bộ nhớ cao hơn nhiều so với dự kiến. Với container dưới 1 GB và tải vừa phải, Workstation GC thường giảm bộ nhớ đáng kể mà thông lượng không giảm nhiều.
Đọc thêm: Workstation và Server GC.
Large Object Heap (LOH): object trên 85.000 byte vào thẳng LOH, và LOH không được nén mặc định. Mảng byte lớn cấp phát liên tục gây phân mảnh — biểu hiện là OutOfMemoryException dù tổng bộ nhớ còn trống.
19.10.2 — Span và ArrayPool
Điều kiện kích hoạt: profiler cho thấy allocation rate cao trong một hot path chạy hàng triệu lần.
// Cấp phát: mỗi lần Split tạo mảng mới + các chuỗi con
var parts = input.Split(',');
var id = int.Parse(parts[0]);
// Không cấp phát: Span trỏ vào bộ nhớ sẵn có
ReadOnlySpan<char> span = input;
var comma = span.IndexOf(',');
var id = int.Parse(span[..comma]);// ArrayPool — mượn mảng thay vì cấp phát mới
var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
var read = await stream.ReadAsync(buffer, ct);
Process(buffer.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // BẮT BUỘC trả lại
}Hai cảnh báo thực tế: Rent có thể trả về mảng lớn hơn kích thước yêu cầu, nên luôn dùng độ dài thật chứ không dùng buffer.Length. Và quên Return biến tối ưu thành rò rỉ.
Đây là công cụ cho thư viện và hot path thật sự nóng, không phải cho code nghiệp vụ thông thường. Trong code CRM điển hình, nó làm code khó đọc mà không đổi gì đo được.
19.10.3 — Native AOT
Điều kiện kích hoạt: cold start quan trọng (serverless, scale-to-zero), hoặc cần giảm kích thước image.
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>| JIT | Native AOT | |
|---|---|---|
| Cold start | Hàng trăm ms | Vài chục ms |
| Bộ nhớ | Cao hơn | Thấp hơn |
| Thông lượng ổn định | Cao hơn (JIT tối ưu theo runtime) | Hơi thấp hơn |
| Reflection | Đầy đủ | Hạn chế nhiều |
| EF Core | Hỗ trợ | Hạn chế |
Hàng áp chót là rào cản thật: nhiều thư viện phổ biến dựa vào reflection và không chạy dưới AOT. Với Minimal API đơn giản thì khả thi; với ứng dụng CRM đầy đủ dùng EF Core, AutoMapper và MediatR thì thường không.
Đọc thêm: Native AOT deployment.
19.10.4 — Object pooling
Điều kiện kích hoạt: cấp phát lặp lại cùng một loại object đắt, đã thấy trên profiler.
builder.Services.AddSingleton<ObjectPool<StringBuilder>>(sp =>
{
var provider = sp.GetRequiredService<ObjectPoolProvider>();
return provider.CreateStringBuilderPool();
});
// Su dung
var sb = _pool.Get();
try { /* dung sb */ }
finally { _pool.Return(sb); } // Return TỰ ĐỘNG clear StringBuilderChỉ đáng dùng cho object đắt để tạo và dùng rất thường xuyên. Với object nhỏ ngắn hạn, Gen 0 GC đã rất rẻ — pooling chỉ thêm độ phức tạp và rủi ro trạng thái sót lại giữa các lần dùng.
19.10.5 — Những chủ đề khác đáng biết
| Chủ đề | Giải quyết | Khi nào đào |
|---|---|---|
| Channels | Producer-consumer trong tiến trình | Cần hàng đợi nội bộ có backpressure |
IAsyncEnumerable | Stream dữ liệu không nạp hết | Export, xử lý lô lớn |
| Source generators | Bỏ reflection lúc chạy | JSON serialize trong hot path |
FrozenDictionary | Tra cứu nhanh hơn cho dữ liệu bất biến | Bảng tra cứu đọc rất nhiều |
| HTTP/3 và QUIC | Giảm độ trễ kết nối | Client mạng chậm hoặc hay mất gói |
| Connection multiplexing | Giảm số kết nối database | Rất nhiều instance nhỏ |
System.Text.Json source generator là mục dễ áp dụng nhất trong bảng này: thêm một JsonSerializerContext và bạn bỏ được reflection khi serialize, có lợi cho cả thông lượng lẫn khả năng tương thích AOT.
19.10.6 — Thứ tự ưu tiên
Trước khi đụng tới bất kỳ mục nào ở trên, hãy chắc chắn đã làm xong:
- Sửa N+1 — thường cho cải thiện hàng chục lần (bài 19.4).
- Thêm index còn thiếu — hàng trăm lần, bằng một câu lệnh.
- Bỏ sync-over-async — sửa thread pool starvation (bài 19.3).
- Projection thay vì entity đầy đủ.
- Cache dữ liệu tổng hợp nặng (bài 19.5).
- Chạy song song các lời gọi độc lập.
Năm mục trong bài này nằm ở vị trí thứ bảy trở đi. Làm chúng trước là tối ưu 1% trong khi 90% vấn đề vẫn còn nguyên.
19.10.7 — Rà lại code của bạn
Danh sách rà soát trước khi tối ưu sâu
- Đã sửa hết N+1 được phát hiện bằng test đếm truy vấn.
- Đã thêm index cho mọi truy vấn chậm đã đo.
- Không còn sync-over-async trong đường xử lý request.
- Đã dùng projection cho truy vấn chỉ đọc.
- Đã cache dữ liệu tổng hợp nặng.
- Các lời gọi độc lập đã chạy song song.
- Có số liệu profiler chứng minh hot path cần tối ưu sâu.
- Nếu chạy container nhỏ, đã cân nhắc Workstation GC.
Bài tập áp dụng
Bài 1 — So sánh hai chế độ GC
Chạy cùng bài test tải với ServerGarbageCollection bật và tắt trong container 512Mi, so sánh bộ nhớ và thông lượng.
Tiêu chí hoàn thành: bạn đo được cả hai chế độ, và nhận ra số nhân mà runtime nhìn thấy quan trọng hơn cả việc chọn chế độ.
Gợi ý và lời giải — Bài 1
Gợi ý. Server GC tạo một heap và một luồng thu gom cho mỗi nhân. Trong container giới hạn CPU, "mỗi nhân" có thể không phải con số bạn nghĩ.
Lời giải — đo cùng một khối lượng công việc ở hai chế độ:
dotnet run -c Release # Workstation (mặc định cho console)
DOTNET_gcServer=1 dotnet run -c Release # Server GC
DOTNET_GCHeapHardLimit=10000000 dotnet run -c Release # giới hạn heap 256 MB (hex)var info = GC.GetGCMemoryInfo();
Console.WriteLine($"ServerGC : {System.Runtime.GCSettings.IsServerGC}");
Console.WriteLine($"ProcessorCount : {Environment.ProcessorCount}");
Console.WriteLine($"TotalAvailableMemory : {info.TotalAvailableMemoryBytes / 1048576} MB");
Console.WriteLine($"GC.GetTotalMemory : {GC.GetTotalMemory(false) / 1048576.0:F1} MB");
Console.WriteLine($"WorkingSet (RSS) : {Process.GetCurrentProcess().WorkingSet64 / 1048576.0:F1} MB");
Console.WriteLine($"Gen0/1/2 collections : {GC.CollectionCount(0)}/{GC.CollectionCount(1)}/{GC.CollectionCount(2)}");Kết quả đo trên .NET 9.0.203, máy 4 nhân, cùng khối lượng cấp phát:
--- Workstation GC (mặc định) ---
ProcessorCount : 4
TotalAvailableMemory : 7937 MB
GC.GetTotalMemory : 97,8 MB
WorkingSet (RSS) : 134,0 MB
Gen0/1/2 collections : 32/8/3
--- Server GC (DOTNET_gcServer=1) ---
ProcessorCount : 4
TotalAvailableMemory : 7937 MB
GC.GetTotalMemory : 97,8 MB
WorkingSet (RSS) : 136,6 MB
Gen0/1/2 collections : 27/5/3
--- Workstation + DOTNET_GCHeapHardLimit=0x10000000 ---
TotalAvailableMemory : 256 MB <- runtime biết trần của nó
GC.GetTotalMemory : 97,8 MB
WorkingSet (RSS) : 133,7 MB
Gen0/1/2 collections : 32/8/3| Workstation | Server | Chênh | |
|---|---|---|---|
| Heap quản lý | 97,8 MB | 97,8 MB | bằng nhau |
| RSS | 134,0 MB | 136,6 MB | +2,6 MB |
| Gen-0 collections | 32 | 27 | ít hơn 16% |
| Gen-1 collections | 8 | 5 | ít hơn |
Ba kết luận từ ba lần đo:
1. Server GC gom ít lần hơn (27 so với 32) nhưng tốn thêm bộ nhớ (+2,6 MB).
Server GC: heap riêng cho mỗi nhân -> ngưỡng gom cao hơn -> ít lần gom hơn
-> ít lần tạm dừng hơn -> thông lượng tốt hơn khi có nhiều nhân
Đổi lại: mỗi heap có phần dự phòng riêng -> RSS cao hơnTrên máy 4 nhân, chênh lệch nhỏ. Với 16 hoặc 32 nhân, khoảng cách này lớn hơn rõ rệt theo cả hai chiều.
2. Khoảng cách giữa RSS và heap quản lý là 36,2 MB — và nó không phải rác.
RSS 134,0 MB − heap 97,8 MB = 36,2 MB
Phần đó gồm: mã đã JIT, stack của các luồng, bộ đệm native,
metadata của runtime, và phân mảnh
-> khi đặt memory limit cho container, phải cộng phần này vào
-> heap 400 MB KHÔNG đồng nghĩa container 400 MB là đủ3. Và đây là phần quan trọng nhất: TotalAvailableMemoryBytes mặc định là 7937 MB — toàn bộ RAM của máy.
Trong container giới hạn 512Mi mà runtime KHÔNG nhận biết giới hạn:
-> GC tưởng có 8 GB
-> nó để heap lớn dần, gom muộn
-> chạm 512Mi -> container bị OOM kill (exit code 137)
-> và KHÔNG có OutOfMemoryException nào trong log, vì tiến trình bị
kernel giết, không phải runtime ném lỗiKhi đặt DOTNET_GCHeapHardLimit, TotalAvailableMemoryBytes đổi từ 7937 MB xuống 256 MB — runtime biết trần của nó và gom sớm hơn thay vì để container bị giết.
Cấu hình đúng cho container:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>resources:
limits: { memory: "512Mi", cpu: "1000m" }
requests: { memory: "256Mi", cpu: "500m" }
env:
# Dùng 75% giới hạn container làm trần heap
- name: DOTNET_GCHeapHardLimitPercent
value: "4B" # 0x4B = 75DOTNET_GCHeapHardLimitPercent tốt hơn DOTNET_GCHeapHardLimit:
- tự động theo giới hạn container
- không phải sửa khi đổi memory limit
- và nó tính theo cgroup, nên đúng cả khi giới hạn thay đổiVà chi tiết ảnh hưởng lớn nhất trong container, lớn hơn cả việc chọn chế độ GC:
cpu limit 1000m -> Environment.ProcessorCount = 1
-> Server GC chỉ tạo MỘT heap
-> gần như không khác Workstation, nhưng vẫn tốn phần dự phòng
cpu limit 4000m -> ProcessorCount = 4 -> Server GC phát huy tác dụng// Kiểm tra runtime đang nhìn thấy gì — nên log lúc khởi động
_log.LogInformation("GC: Server={Server}, Cores={Cores}, HeapLimit={Limit} MB",
GCSettings.IsServerGC,
Environment.ProcessorCount,
GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1048576);Dòng log này mất một giây để thêm và trả lời ngay câu hỏi "vì sao pod cứ bị OOM kill" — thường vì runtime đang nghĩ nó có 8 GB trong một container 512Mi.
Quy tắc chọn:
| Tình huống | Chế độ |
|---|---|
| API server, từ 2 nhân trở lên | Server GC |
| Container 1 nhân | Workstation — Server GC không giúp gì |
| Công cụ dòng lệnh, job ngắn | Workstation — khởi động nhanh hơn |
| Bộ nhớ rất chặt (dưới 256Mi) | Workstation + GCHeapHardLimitPercent |
Bài 2 — Đo tác dụng của Span
Benchmark một hàm parse dùng Split và một hàm dùng ReadOnlySpan<char>, so sánh cột Allocated.
Tiêu chí hoàn thành: bạn đạt 0 byte cấp phát, và biết vì sao con số đó quan trọng hơn con số thời gian ở bối cảnh này.
Gợi ý và lời giải — Bài 2
Gợi ý. Bài này đã được làm ở bài 19.2 — ở đây hãy nối nó với phần GC vừa đo ở bài 1.
Lời giải — nhắc lại kết quả đo trên .NET 9.0.203:
| Method | Mean | Gen0 | Allocated | Alloc Ratio |
|------------- |----------:|-------:|----------:|------------:|
| string.Split | 284,69 ns | 0.0205 | 392 B | 1.00 |
| ReadOnlySpan | 92,10 ns | - | 0 B | 0.00 |Phần mới của bài này là nối hai con số đó với những gì vừa đo về GC:
Bài 1 đo được: 32 lần gen-0 GC cho khối lượng cấp phát của bài thử đó
Mỗi gen-0 GC đều TẠM DỪNG mọi luồng của ứng dụngNhập CSV 500.000 dòng:
string.Split : 500.000 × 392 B = 196 MB đi qua gen-0
ReadOnlySpan : 0 B
Gen-0 mặc định khoảng vài MB -> 196 MB tạo ra hàng chục lần gen-0 GC
-> mỗi lần đều dừng MỌI request đang chạy, không riêng việc nhập CSVĐây là lý do Allocated quan trọng hơn Mean trong bối cảnh server:
Mean: ảnh hưởng ĐẾN CHÍNH nó — endpoint nhập CSV chậm hơn 96 ms
Allocated: ảnh hưởng ĐẾN MỌI THỨ — mọi request khác bị dừng theo mỗi lần GC
Và ảnh hưởng thứ hai KHÔNG hiện ra trong biểu đồ độ trễ của endpoint nhập CSV.
Nó hiện ra ở p99 của những endpoint hoàn toàn không liên quan.Chính là chữ ký đã thấy ở bài 3 của bài 19.9: một tác vụ nặng làm chậm những thứ không liên quan tới nó.
ArrayPool — cùng ý tưởng, cho mảng lớn:
// TRƯỚC — mỗi lần gọi cấp phát một mảng 64 KB
public async Task<int> DocAsync(Stream s, CancellationToken ct)
{
var bo = new byte[65536]; // 64 KB mỗi lần -> vào LOH
return await s.ReadAsync(bo, ct);
}
// SAU — mượn từ pool, trả lại khi xong
public async Task<int> DocAsync(Stream s, CancellationToken ct)
{
var bo = ArrayPool<byte>.Shared.Rent(65536);
try { return await s.ReadAsync(bo.AsMemory(0, 65536), ct); }
finally { ArrayPool<byte>.Shared.Return(bo); }
}Mảng từ 85 KB trở lên vào Large Object Heap
-> LOH chỉ được gom cùng gen-2, và mặc định KHÔNG được nén
-> cấp phát mảng lớn lặp đi lặp lại -> phân mảnh LOH
-> RSS tăng dần dù heap quản lý không tăngBa cái bẫy của ArrayPool, và cái thứ hai gây lỗi khó tìm nhất:
1. Quên Return — pool cạn dần, và nó im lặng.
Không trả lại -> pool cấp phát mảng mới -> không có lỗi nào
-> chỉ là lợi ích biến mất, âm thầm
-> luôn dùng try/finally2. Rent(n) trả về mảng có thể dài hơn n.
var bo = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine(bo.Length); // có thể là 1024, không phải 1000
// SAI — xử lý cả phần thừa, và phần thừa chứa DỮ LIỆU CŨ
XuLy(bo);
// ĐÚNG — luôn giới hạn theo số phần tử thật
XuLy(bo.AsSpan(0, 1000));Phần thừa chứa dữ liệu của người mượn TRƯỚC
-> nếu ghi ra ngoài, đó là rò rỉ dữ liệu giữa các request
-> dùng Rent(n, clearArray: true) khi dữ liệu nhạy cảm3. Giữ tham chiếu sau khi đã Return.
ArrayPool<byte>.Shared.Return(bo);
var x = bo[0]; // SAI — mảng đã thuộc về người khácKhi nào KHÔNG cần tới span và pool:
Mã chạy vài lần mỗi request, xử lý chuỗi ngắn
-> 392 B là không đáng kể so với vài KB của chính request
-> và code khó đọc hơn là cái giá thật
Dùng chúng ở: vòng lặp nóng, xử lý tệp lớn, tuần tự hoá,
và các đường đi chạy hàng nghìn lần mỗi giây.Thứ tự đúng vẫn là thứ tự ở mục 19.10.6: sửa N+1 và index trước, cache tiếp theo, rồi mới tới span và pool. Tối ưu cấp phát khi còn một truy vấn N+1 là đổi 96 ms lấy một chỗ mà 200 ms đang bị mất.
Bài 3 — Thử source generator
Thêm JsonSerializerContext cho một DTO và benchmark serialize trước/sau.
Tiêu chí hoàn thành: bạn có số đo cho cả serialize và deserialize, và nhận ra lý do chính để dùng source generator không phải tốc độ.
Gợi ý và lời giải — Bài 3
Gợi ý. Đo cả hai chiều. Chúng cho kết quả khác nhau, và sự khác nhau đó nói lên điều quan trọng.
Lời giải:
public sealed record LeadDto(
int Id, string HoTen, string Email, string DienThoai,
string TrangThai, DateTime NgayTao, decimal GiaTri);
[JsonSerializable(typeof(LeadDto))]
[JsonSerializable(typeof(List<LeadDto>))]
public partial class CrmJsonContext : JsonSerializerContext { }[Benchmark(Baseline = true, Description = "Serialize - reflection")]
public string SerReflection() => JsonSerializer.Serialize(_ds);
[Benchmark(Description = "Serialize - source generator")]
public string SerSourceGen() => JsonSerializer.Serialize(_ds, CrmJsonContext.Default.ListLeadDto);
[Benchmark(Description = "Deserialize - reflection")]
public List<LeadDto>? DeReflection() => JsonSerializer.Deserialize<List<LeadDto>>(_json);
[Benchmark(Description = "Deserialize - source generator")]
public List<LeadDto>? DeSourceGen() => JsonSerializer.Deserialize(_json, CrmJsonContext.Default.ListLeadDto);Kết quả đo trên .NET 9.0.203, Xeon 8272CL, warmupCount: 3, iterationCount: 5:
| Method | Count | Mean | Error | Ratio | Allocated |
|--------------------------------- |------ |------------:|------------:|------:|----------:|
| 'Serialize - reflection' | 1 | 973,8 ns | 297,0 ns | 1.00 | 624 B |
| 'Serialize - source generator' | 1 | 621,1 ns | 168,5 ns | 0.64 | 312 B |
| 'Deserialize - reflection' | 1 | 1.747,7 ns | 410,3 ns | 1.80 | 944 B |
| 'Deserialize - source generator' | 1 | 1.735,4 ns | 425,1 ns | 1.79 | 944 B |
| | | | | | |
| 'Serialize - reflection' | 100 | 65.190,7 ns | 19.346,2 ns | 1.00 | 29.480 B |
| 'Serialize - source generator' | 100 | 48.222,0 ns | 15.449,6 ns | 0.74 | 29.168 B |
| 'Deserialize - reflection' | 100 | 168.681,4 ns | 15.133,0 ns | 2.60 | 41.064 B |
| 'Deserialize - source generator' | 100 | 142.238,2 ns | 40.744,1 ns | 2.19 | 41.064 B |Ba điều số đo này nói ra, và điều thứ ba là điều quan trọng nhất:
1. Serialize nhanh hơn rõ: 0,64× với một đối tượng, 0,74× với 100.
Và với một đối tượng, cấp phát giảm một nửa: 624 B -> 312 B
-> phần tiết kiệm là metadata mà reflection phải dựng mỗi lần2. Deserialize gần như không khác: 1.747,7 so với 1.735,4 ns.
Chênh 0,7%, trong khi Error là 410 ns (23% của Mean)
-> KHÔNG kết luận được gì
Ở Count=100 có vẻ khá hơn (2,60 so với 2,19) nhưng Error của bản
source generator là 40.744 ns — 29% của Mean.
-> vẫn chưa đủ để khẳng định.Muốn kết luận chắc chắn, phải tăng iterationCount — đây đúng là tình huống đã nêu ở bài 19.2: khi hai khoảng sai số chồng nhau, cách đúng là đo thêm, không phải chọn con số mình thích.
3. Và vì vậy: lý do chính để dùng source generator KHÔNG phải tốc độ.
| Lý do | Mức độ quan trọng |
|---|---|
| Tương thích Native AOT và trimming | Quyết định — reflection không hoạt động sau khi trim |
| Khởi động nhanh hơn | Cao — không phải dựng metadata lúc chạy lần đầu |
| Serialize nhanh hơn | Trung bình — 26–36% |
| Deserialize nhanh hơn | Thấp, và chưa khẳng định được ở bài thử này |
| Lỗi phát hiện lúc biên dịch | Cao — kiểu không hỗ trợ bị báo ngay |
Không có source generator + PublishTrimmed:
-> trimmer xoá thuộc tính mà nó không thấy ai dùng
-> chạy lên, JSON thiếu trường, KHÔNG có lỗi nào
-> đây là loại lỗi tệ nhất: sai âm thầmCấu hình để dùng cho toàn ứng dụng:
builder.Services.ConfigureHttpJsonOptions(o =>
{
o.SerializerOptions.TypeInfoResolverChain.Insert(0, CrmJsonContext.Default);
});[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(LeadDto))]
[JsonSerializable(typeof(List<LeadDto>))]
[JsonSerializable(typeof(PhanHoiLoi))]
public partial class CrmJsonContext : JsonSerializerContext { }Ba giới hạn cần biết trước khi chuyển:
1. Phải khai báo TRƯỚC mọi kiểu sẽ tuần tự hoá.
Quên một kiểu -> lỗi lúc chạy: "JsonTypeInfo metadata for type ... was not provided"
-> với ứng dụng có nhiều DTO, đây là một danh sách dài phải bảo trì2. Không hỗ trợ một số tính năng động.
Không dùng được: JsonConverter tuỳ biến có logic phụ thuộc kiểu lúc chạy,
đa hình không khai báo trước, một số kiểu tổng quát lồng nhau3. Thời gian biên dịch tăng.
Mỗi kiểu khai báo sinh ra một lượng code
-> với hàng trăm DTO, thời gian build tăng thấy đượcQuyết định:
| Tình huống | Dùng source generator? |
|---|---|
Native AOT hoặc PublishTrimmed | Bắt buộc |
| Ứng dụng serverless, quan tâm cold start | Nên |
| API thường, tuần tự hoá không phải điểm nghẽn | Không cần vội |
| Có converter tuỳ biến phức tạp | Cân nhắc — có thể không dùng được |
Hàng thứ ba đáng nhấn: với một API mất 200 ms mỗi request, tiết kiệm 17 µs khi serialize 100 đối tượng là 0,008%. Đây chính là loại tối ưu mà mục 19.2.4 cảnh báo — đo được trên benchmark, không đo được trên hệ thống.
Tự kiểm tra
Vì sao Server GC có thể gây vấn đề trong container nhỏ?
Vì nó cấp phát heap theo số core nhìn thấy được, nên một container 512 MB chạy trên máy nhiều core có thể dùng bộ nhớ cao hơn dự kiến. Với container nhỏ và tải vừa phải, Workstation GC thường giảm bộ nhớ đáng kể mà thông lượng không giảm nhiều.
Large Object Heap gây vấn đề gì?
Object trên 85.000 byte vào thẳng LOH và LOH không được nén mặc định, nên cấp phát mảng lớn liên tục gây phân mảnh. Biểu hiện là OutOfMemoryException dù tổng bộ nhớ vẫn còn trống.
Hai cảnh báo khi dùng ArrayPool là gì?
Rent có thể trả về mảng lớn hơn kích thước yêu cầu nên phải dùng độ dài thật chứ không dùng buffer.Length. Và quên gọi Return biến tối ưu thành rò rỉ bộ nhớ.
Rào cản lớn nhất của Native AOT là gì?
Hạn chế về reflection. Nhiều thư viện phổ biến dựa vào reflection và không chạy được dưới AOT, trong đó EF Core bị hạn chế đáng kể. Minimal API đơn giản thì khả thi, ứng dụng CRM đầy đủ thì thường không.
Khi nào object pooling đáng dùng?
Chỉ khi object đắt để tạo và được dùng rất thường xuyên, và đã có số liệu profiler chứng minh. Với object nhỏ ngắn hạn thì Gen 0 GC đã rất rẻ, nên pooling chỉ thêm độ phức tạp và rủi ro trạng thái sót lại.
Vì sao các chủ đề trong bài này là bước thứ bảy trở đi?
Vì sáu bước trước gồm sửa N+1, thêm index, bỏ sync-over-async, projection, cache và chạy song song thường cho cải thiện hàng chục tới hàng trăm lần. Tối ưu vi mô khi hệ thống còn nghẽn ở những chỗ đó là tối ưu 1 phần trăm trong khi 90 phần trăm vấn đề vẫn nguyên.
Kết luận
Ba điều đáng nhớ nhất:
- Mỗi kỹ thuật ở đây có điều kiện kích hoạt cụ thể. Không có số liệu thì chưa tới lúc.
- Workstation GC đáng thử với container nhỏ — đây là mục dễ áp dụng nhất trong bài.
- Tối ưu vi mô là bước thứ mười, không phải bước đầu tiên.
Tham khảo
- Workstation và Server GC
- Native AOT deployment
- ASP.NET Core performance best practices
- BenchmarkDotNet overview
Điều hướng
- Bài trước: 19.9 — Liên hệ CRM
- Bài tiếp theo: 19.11 — Mini case study
- Về module: Trang mục lục