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

19.11 — 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.11.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 GCServer GC
Số heapMộtMột trên mỗi core
Thông lượngThấp hơnCao hơn
Bộ nhớ dùngÍt hơnNhiều hơn đáng kể
Mặc địnhỨng dụng desktopASP.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.11.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.11.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>
JITNative AOT
Cold startHàng trăm msVài chục ms
Bộ nhớCao hơnThấp hơn
Thông lượng ổn địnhCao hơn (JIT tối ưu theo runtime)Hơi thấp hơn
ReflectionĐầy đủHạn chế nhiều
EF CoreHỗ 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.11.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 StringBuilder

Chỉ đá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.11.5 — Những chủ đề khác đáng biết​

Chủ đềGiải quyếtKhi nào đào
ChannelsProducer-consumer trong tiến trìnhCần hàng đợi nội bộ có backpressure
IAsyncEnumerableStream dữ liệu không nạp hếtExport, xử lý lô lớn
Source generatorsBỏ reflection lúc chạyJSON serialize trong hot path
FrozenDictionaryTra cứu nhanh hơn cho dữ liệu bất biếnBảng tra cứu đọc rất nhiều
HTTP/3 và QUICGiảm độ trễ kết nốiClient mạng chậm hoặc hay mất gói
Connection multiplexingGiảm số kết nối databaseRấ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.11.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:

  1. Sửa N+1 — thường cho cải thiện hàng chục lần (bài 19.5).
  2. Thêm index còn thiếu — hàng trăm lần, bằng một câu lệnh.
  3. Bỏ sync-over-async — sửa thread pool starvation (bài 19.4).
  4. Projection thay vì entity đầy đủ.
  5. Cache dữ liệu tổng hợp nặng (bài 19.6).
  6. 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.11.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
WorkstationServerChênh
Heap quản lý97,8 MB97,8 MBbằng nhau
RSS134,0 MB136,6 MB+2,6 MB
Gen-0 collections3227ít hơn 16%
Gen-1 collections85í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ơn

Trê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ỗi

Khi đặ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 = 75
DOTNET_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 đổi

Và 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ốngChế độ
API server, từ 2 nhân trở lênServer GC
Container 1 nhânWorkstation — Server GC không giúp gì
Công cụ dòng lệnh, job ngắnWorkstation — 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ụng
Nhậ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ăng

Ba 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/finally

2. 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ảm

3. 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ác

Khi 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.11.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ần

2. 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ý doMức độ quan trọng
Tương thích Native AOT và trimmingQuyết định — reflection không hoạt động sau khi trim
Khởi động nhanh hơnCao — không phải dựng metadata lúc chạy lần đầu
Serialize nhanh hơnTrung bình — 26–36%
Deserialize nhanh hơnThấp, và chưa khẳng định được ở bài thử này
Lỗi phát hiện lúc biên dịchCao — 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ầm

Cấ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 nhau

3. 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 được

Quyết định:

Tình huốngDùng source generator?
Native AOT hoặc PublishTrimmedBắt buộc
Ứng dụng serverless, quan tâm cold startNên
API thường, tuần tự hoá không phải điểm nghẽnKhông cần vội
Có converter tuỳ biến phức tạpCâ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.3.4 cảnh báo — đo được trên benchmark, không đo được trên hệ thống.

Tự kiểm tra​

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

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:

  1. 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.
  2. Workstation GC đáng thử với container nhỏ — đây là mục dễ áp dụng nhất trong bài.
  3. Tối ưu vi mô là bước thứ mười, không phải bước đầu tiên.

Tham khảo​

Điều hướng​