6.10 - Case study: đồng bộ 500 khách hàng với song song có giới hạn
Tóm tắt: Một bài toán tối ưu async điển hình: endpoint đồng bộ hoá 500 khách hàng với hệ thống kế toán bên ngoài, chạy 8 phút và thường xuyên timeout. Code hoàn toàn dùng
async/awaitđúng cú pháp — vấn đề là nóawaittrong vòng lặp, nên 500 lời gọi mạng chạy tuần tự. Song song tối đa trên giấy chỉ còn khoảng 1 giây, nhưng hỏng ngay trên môi trường thật vì ba cái bẫy: API bên ngoài trả 429 vì bị gọi 500 lần cùng lúc,DbContextném exception vì bị dùng đa luồng, và một lỗi duy nhất làm hỏng cả lô. Giải pháp là song song có giới hạn (10 lời gọi cùng lúc, khoảng 48 giây), và vì 48 giây vẫn quá dài cho một request HTTP, cuối cùng chuyển thành job nền.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nhận ra
awaittrong vòng lặp làm mất tính song song. - Giới hạn mức song song bằng
SemaphoreSlimhoặcParallel.ForEachAsync. - Xử lý lỗi từng phần mà không hỏng cả lô.
- Tránh dùng
DbContexttrong code chạy song song.
Cần biết trước: bài 6.5 (Task.WhenAll, Parallel.ForEachAsync, SemaphoreSlim, DbContext không thread-safe) và bài 6.7 (List<T> ghi song song). Bài này áp dụng chúng vào một tình huống; khái niệm đã dạy ở đó chỉ được nhắc lại ngắn. HTTP 429 (Too Many Requests) là mã lỗi API trả về khi bạn gọi vượt giới hạn tần suất của họ.
Nội dung bài học
6.10.1 — Code ban đầu
public async Task<SyncResult> SyncCustomersAsync(CancellationToken ct)
{
var customers = await _db.Customers.Where(c => c.NeedsSync).ToListAsync(ct);
foreach (var customer in customers) // 500 khách hàng
{
var result = await _accountingClient.SyncAsync(customer, ct); // ~950ms mỗi lần
customer.LastSyncedUtc = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct);
return SyncResult.Success(customers.Count);
}500 khách x 950ms = 475 giây = ~8 phút
Timeout của gateway: 300 giây -> thất bại trước khi xongCode này dùng async/await đúng cú pháp. Vấn đề không phải ở cú pháp mà ở cấu trúc: await bên trong foreach nghĩa là mỗi vòng lặp chờ xong rồi mới bắt đầu vòng sau.
async giúp thread được trả về pool trong lúc chờ I/O — nên server không bị chiếm thread. Nhưng nó không tự làm các lời gọi chạy song song (bài 6.2).
6.10.2 — Thử 1: song song tối đa
var tasks = customers.Select(c => _accountingClient.SyncAsync(c, ct));
var results = await Task.WhenAll(tasks); // 500 lời gọi CÙNG LÚCThời gian (trên giấy): 8 phút -> khoảng 1 giây, bằng lời gọi chậm nhấtNhưng chạy trên môi trường thật thì hỏng ngay, theo ba cách.
Bẫy 1 — API bên ngoài trả 429:
HTTP 429 Too Many Requests
Retry-After: 60Hệ thống kế toán cho tối đa 20 request đồng thời. 500 request cùng lúc bị chặn gần hết.
Bẫy 2 — DbContext không an toàn đa luồng:
// Nếu trong SyncAsync có truy vấn database
InvalidOperationException: A second operation was started on this context
instance before a previous operation completed.DbContext được thiết kế cho một luồng tại một thời điểm. Dùng chung cho 500 task song song là lỗi chắc chắn (bài 13.9).
Bẫy 3 — một lỗi làm hỏng tất cả:
await Task.WhenAll(tasks);
// Nếu 1 task ném exception -> WhenAll ném exception
// -> 499 kết quả thành công bị mất, không biết cái nào đã xongTệ hơn: await Task.WhenAll chỉ ném một exception dù nhiều task cùng lỗi (bài 6.5), nên bạn còn không biết có bao nhiêu cái thất bại.
6.10.3 — Giải pháp: song song có giới hạn
public async Task<SyncResult> SyncCustomersAsync(CancellationToken ct)
{
var customers = await _db.Customers
.Where(c => c.NeedsSync)
.AsNoTracking()
.ToListAsync(ct);
var succeeded = new ConcurrentBag<Guid>();
var failed = new ConcurrentBag<(Guid Id, string Reason)>();
await Parallel.ForEachAsync(
customers,
new ParallelOptions
{
MaxDegreeOfParallelism = 10, // tối đa 10 lúc một lần
CancellationToken = ct
},
async (customer, token) =>
{
try
{
await _accountingClient.SyncAsync(customer, token);
succeeded.Add(customer.Id);
}
catch (Exception ex)
{
// MỘT lỗi KHÔNG làm hỏng các cái khác
failed.Add((customer.Id, ex.Message));
_logger.LogWarning(ex, "Đồng bộ thất bại cho khách {Id}", customer.Id);
}
});
// Cập nhật database SAU khi xong, trên MỘT luồng
await UpdateSyncStatusAsync(succeeded, ct);
return new SyncResult(succeeded.Count, failed.ToList());
}Thời gian: 500 / 10 x 950ms = ~48 giâyBốn thay đổi giải quyết cả ba cái bẫy:
| Thay đổi | Giải quyết |
|---|---|
MaxDegreeOfParallelism = 10 | Không vượt giới hạn của API bên ngoài |
try/catch trong từng task | Một lỗi không làm hỏng cả lô |
ConcurrentBag | An toàn khi nhiều luồng cùng ghi |
| Cập nhật database sau, một luồng | DbContext không bị dùng đa luồng |
ConcurrentBag là chi tiết dễ bỏ sót: List<T> không an toàn đa luồng, và ghi vào nó từ nhiều task song song có thể làm hỏng cấu trúc nội bộ — không phải lúc nào cũng ném exception, đôi khi chỉ mất dữ liệu im lặng.
6.10.4 — Cách khác: SemaphoreSlim
Khi cần kiểm soát chi tiết hơn Parallel.ForEachAsync:
var semaphore = new SemaphoreSlim(10); // tối đa 10 đồng thời
var tasks = customers.Select(async customer =>
{
await semaphore.WaitAsync(ct);
try
{
return await _accountingClient.SyncAsync(customer, ct);
}
finally
{
semaphore.Release(); // PHẢI ở finally
}
});
var results = await Task.WhenAll(tasks);Release() trong finally là bắt buộc: nếu một exception thoát ra mà không Release, semaphore mất dần số lượt cho phép và cuối cùng mọi task đều treo vĩnh viễn.
Parallel.ForEachAsync | SemaphoreSlim | |
|---|---|---|
| Cú pháp | Gọn hơn | Dài hơn |
| Kiểm soát | Cơ bản | Chi tiết |
| Thu kết quả | Tự làm bằng collection | Task.WhenAll trả về mảng |
| Phù hợp | Phần lớn trường hợp | Khi cần logic đặc biệt |
Dùng Parallel.ForEachAsync làm mặc định.
6.10.5 — Chọn mức song song
Không có con số đúng cho mọi trường hợp. Cách tìm:
1. Bắt đầu từ 4
2. Đo thông lượng và tỷ lệ lỗi
3. Tăng gấp đôi, đo lại
4. Dừng khi: tỷ lệ lỗi tăng, HOẶC thông lượng không tăng nữaBa giới hạn cần cân nhắc cùng lúc:
| Giới hạn | Cách biết |
|---|---|
| API bên ngoài | Đọc tài liệu, hoặc tăng tới khi nhận 429 |
| Connection pool database | Mặc định 100; chia cho số instance |
| Bộ nhớ | Mỗi task đang chạy giữ dữ liệu của nó |
Với công việc thiên về I/O như ca này, mức song song có thể cao hơn số core rất nhiều — vì thread đang chờ mạng không dùng CPU. Với công việc thiên về CPU, đặt bằng Environment.ProcessorCount là hợp lý.
6.10.6 — Và có lẽ nó không nên là endpoint HTTP
Sau khi tối ưu, 48 giây vẫn là quá lâu cho một request HTTP: người dùng chờ, gateway có timeout riêng, và nếu kết nối đứt thì không biết đã xong tới đâu.
// Trả về ngay, chạy nền
[HttpPost("sync")]
public IActionResult StartSync()
{
var jobId = _jobs.Enqueue<ISyncService>(s => s.SyncCustomersAsync(CancellationToken.None));
return Accepted(new { jobId }); // 202 Accepted
}
[HttpGet("sync/{jobId}")]
public async Task<IActionResult> GetStatus(string jobId)
=> Ok(await _jobs.GetStatusAsync(jobId));Người dùng nhận phản hồi tức thì kèm mã theo dõi, và công việc dài chạy trong job nền với retry sẵn có (bài 14.6).
Quy tắc: bất kỳ thao tác nào vượt quá vài giây đều nên là job nền, không phải request đồng bộ.
6.10.7 — Rà lại code của bạn
Danh sách rà soát async song song
- Không await lời gọi mạng độc lập bên trong vòng lặp.
- Mức song song có giới hạn, không dùng Task.WhenAll không kiểm soát.
- Mỗi task có try catch riêng để một lỗi không hỏng cả lô.
- Thu kết quả bằng collection an toàn đa luồng.
- Không dùng chung DbContext giữa các task song song.
- SemaphoreSlim luôn Release trong finally.
- Mức song song được chọn bằng đo đạc, không đoán.
- Thao tác dài hơn vài giây chạy trong job nền.
Bài tập áp dụng
Bài 1 — Đo ba phiên bản
Cài cả ba cách — tuần tự, song song tối đa, song song có giới hạn — và đo thời gian cùng tỷ lệ lỗi.
Tiêu chí hoàn thành: bạn có số đo cho cả ba, và nhận ra vì sao bài thử này chưa đủ để chọn mức song song cho hệ thống thật.
Gợi ý và lời giải — Bài 1
Gợi ý. Đo thêm một chỉ số nữa ngoài thời gian: đỉnh số việc chạy đồng thời. Nó cho thấy mỗi cách thật sự làm gì.
Lời giải:
using System.Diagnostics;
const int ItemCount = 200;
int running = 0, peak = 0;
async Task ProcessAsync(int i, CancellationToken ct = default)
{
var n = Interlocked.Increment(ref running);
InterlockedMax(ref peak, n);
await Task.Delay(50, ct); // mô phỏng một lời gọi I/O
Interlocked.Decrement(ref running);
}
static void InterlockedMax(ref int target, int value)
{
int current;
do { current = Volatile.Read(ref target); if (value <= current) return; }
while (Interlocked.CompareExchange(ref target, value, current) != current);
}
async Task Measure(string name, Func<Task> run)
{
peak = 0;
var sw = Stopwatch.StartNew();
await run();
Console.WriteLine($" {name,-20}: {sw.ElapsedMilliseconds,6} ms | đỉnh đồng thời {peak,3}");
}
await Measure("tuần tự", async () => { for (int i = 0; i < ItemCount; i++) await ProcessAsync(i); });
await Measure("song song tối đa", () => Task.WhenAll(Enumerable.Range(0, ItemCount).Select(i => ProcessAsync(i))));
await Measure("có giới hạn MDOP=8", () => Parallel.ForEachAsync(Enumerable.Range(0, ItemCount),
new ParallelOptions { MaxDegreeOfParallelism = 8 }, async (i, ct) => await ProcessAsync(i, ct)));
foreach (var mdop in new[] { 2, 4, 8, 16, 32, 64 })
{
var sw = Stopwatch.StartNew();
await Parallel.ForEachAsync(Enumerable.Range(0, ItemCount),
new ParallelOptions { MaxDegreeOfParallelism = mdop }, async (i, ct) => await ProcessAsync(i, ct));
Console.WriteLine($" MDOP={mdop,2} : {sw.ElapsedMilliseconds,6} ms | thông lượng {ItemCount / sw.Elapsed.TotalSeconds,5:F0}/s");
}InterlockedMax ghi lại giá trị lớn nhất một cách an toàn khi nhiều task cùng ghi: đọc giá trị hiện tại, và chỉ ghi đè nếu không ai đổi nó trong lúc đó (CompareExchange), nếu bị đổi thì thử lại.
Kết quả chạy trên .NET 10, dotnet run -c Release, 200 mục, mỗi mục Task.Delay(50):
tuần tự : 12273 ms | đỉnh đồng thời 1
song song tối đa : 57 ms | đỉnh đồng thời 200
có giới hạn MDOP=8 : 1556 ms | đỉnh đồng thời 8
MDOP= 2 : 6114 ms | thông lượng 33/s
MDOP= 4 : 3074 ms | thông lượng 65/s
MDOP= 8 : 1551 ms | thông lượng 129/s
MDOP=16 : 790 ms | thông lượng 253/s
MDOP=32 : 434 ms | thông lượng 460/s
MDOP=64 : 242 ms | thông lượng 824/sBản tuần tự mất 12,3 giây chứ không phải 200 × 50 = 10 giây vì trên Windows mỗi Task.Delay(50) thực tế kéo dài khoảng 61 ms (bộ hẹn giờ của hệ điều hành làm tròn lên).
| Thời gian | Đỉnh đồng thời | So với tuần tự | |
|---|---|---|---|
| Tuần tự | 12.273 ms | 1 | 1× |
| Song song tối đa | 57 ms | 200 | khoảng 215× |
| Có giới hạn (8) | 1.556 ms | 8 | khoảng 8× |
Song song tối đa nhanh nhất — nhưng đó chính là vấn đề.
200 lời gọi cùng lúc tới một hệ thống bên ngoài:
- API bên thứ ba: gần như chắc chắn bị giới hạn tần suất (429)
- database: 200 kết nối -> vượt pool -> hàng đợi -> timeout
- chính hệ thống của bạn: 200 luồng hoặc 200 kết nối bị chiếmĐây là điều mục 6.10.2 nói: nhanh nhất trên máy đo không có nghĩa là dùng được.
Và đây là phần quan trọng hơn — vì sao bài thử này CHƯA ĐỦ:
Công việc ở đây là Task.Delay(50) — nó không tranh chấp gì cả.
-> tăng MDOP bao nhiêu cũng nhanh hơn, tuyến tính, mãi mãiSố liệu ở trên chứng minh điều đó: từ MDOP=2 tới MDOP=64, thông lượng đi 33, 65, 129, 253, 460, 824 mục mỗi giây.
Thông lượng tăng gần gấp đôi mỗi lần MDOP gấp đôi.
Không có điểm bão hoà, vì không có tài nguyên nào để bão hoà.Với công việc thật, đường cong này có hình khác hẳn:
Gọi database thật:
MDOP tăng -> thông lượng tăng -> tới khi database bão hoà
-> thông lượng ĐỨNG LẠI, rồi GIẢM vì tranh chấp khoá và chuyển ngữ cảnh
Gọi API có giới hạn tần suất:
MDOP vượt ngưỡng -> bắt đầu nhận 429 -> tỷ lệ lỗi tăng
-> thông lượng THỰC (không tính lỗi) giảmNên kết luận đúng của bài này là về cơ chế, không phải về con số:
Học được: song song có giới hạn giữ đúng số việc đồng thời (đỉnh = MDOP)
KHÔNG học được: MDOP tối ưu là bao nhiêu
Con số đó chỉ tìm được bằng cách đo trên phụ thuộc THẬT — bài 3 dưới đây.Thêm cột tỷ lệ lỗi để bài thử sát thực tế hơn:
int succeeded = 0, failed = 0;
async Task ProcessAsync(int i, CancellationToken ct = default)
{
try
{
using var response = await _http.GetAsync($"{Url}/{i}", ct);
if (response.IsSuccessStatusCode) Interlocked.Increment(ref succeeded);
else Interlocked.Increment(ref failed); // 429, 503...
}
catch (Exception) { Interlocked.Increment(ref failed); }
}Thông lượng THỰC = succeeded / thời gian
Với MDOP quá cao, thông lượng thô vẫn tăng nhưng thông lượng thực giảm
-> và đó mới là con số cần tối ưu.Bài 2 — Tái hiện lỗi DbContext
Chạy 10 truy vấn song song dùng chung một DbContext và ghi lại exception. Thử trên bảng nhỏ và bảng lớn.
Tiêu chí hoàn thành: bạn có thông điệp lỗi chính xác, và giải thích được vì sao lỗi này phụ thuộc thời điểm, điều làm nó khó tìm.
Gợi ý và lời giải — Bài 2
Gợi ý. EF Core có một bộ phát hiện dùng đồng thời: khi một thao tác bắt đầu mà thao tác trước trên cùng context chưa xong, nó ném lỗi. Lỗi chỉ xảy ra khi hai thao tác chồng lấn về thời gian.
Lời giải:
await using var db = await factory.CreateDbContextAsync();
await db.Leads.AnyAsync(); // làm nóng context: lần dùng đầu tiên EF Core còn phải cấu hình nó
var tasks = Enumerable.Range(0, 10)
.Select(t => Task.Run(() => db.Leads.AsNoTracking()
.Where(l => l.TenantId == t).ToListAsync()))
.ToArray();
try { Task.WaitAll(tasks); Console.WriteLine("không lỗi"); }
catch (Exception ex)
{
var first = ex is AggregateException ae ? ae.InnerExceptions[0] : ex;
Console.WriteLine($"{tasks.Count(x => x.IsFaulted)}/10 task lỗi. {first.GetType().Name}: {first.Message}");
}Kết quả trên .NET 10, EF Core 10, PostgreSQL 16, chạy ba lần trên bảng 200.000 dòng và ba lần trên bảng 5.000 dòng, lần nào cũng như nhau:
9/10 task lỗi. InvalidOperationException: A second operation was started on this context instance
before a previous operation completed. This is usually caused by different threads concurrently
using the same instance of DbContext. For more information on how to avoid threading issues with
DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.Bỏ dòng làm nóng thì thông điệp khác (An attempt was made to use the context instance while it is being configured...), nhưng vẫn là cùng một lỗi: hai thao tác chạm vào một context cùng lúc.
Vì sao nó vẫn khó tìm. Trong thử nghiệm trên, 10 task khởi động cùng lúc nên luôn chồng lấn. Trong code thật, các thao tác trên context dùng chung thường rời rạc hơn: một thao tác ngắn, xong trước khi thao tác kia bắt đầu, thì không có gì để phát hiện.
Máy phát triển: dữ liệu nhỏ, ít người dùng, truy vấn vài mili-giây
-> các thao tác hiếm khi chồng lấn -> code sai vẫn chạy "đúng"
Production: dữ liệu lớn, database bận, truy vấn kéo dài
-> chồng lấn thường xuyên hơn -> lỗi xuất hiện KHÔNG ĐỀU
-> "thỉnh thoảng lỗi, F5 lại thì được"Và có tình huống còn tệ hơn cả việc ném lỗi:
DbContext không an toàn cho truy cập đồng thời.
Bộ phát hiện của EF Core bắt được PHẦN LỚN trường hợp, không phải MỌI trường hợp.
Khi nó không bắt được: change tracker hỏng trạng thái
-> dữ liệu sai, hoặc ngoại lệ ở một chỗ hoàn toàn khác, muộn hơn nhiềuCách sửa — mỗi tác vụ song song một context:
builder.Services.AddDbContextFactory<CrmDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Crm")));SQL Server
builder.Services.AddDbContextFactory<CrmDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Crm")));var tasks = tenants.Select(async t =>
{
await using var db = await _factory.CreateDbContextAsync(ct);
return await db.Leads.AsNoTracking().Where(l => l.TenantId == t).ToListAsync(ct);
});
var results = await Task.WhenAll(tasks);Lưu ý: mỗi context là một KẾT NỐI riêng.
-> 10 tác vụ song song = 10 kết nối
-> nên vẫn cần giới hạn số tác vụ đồng thời (bài 1 ở trên)Kết hợp cả hai:
await Parallel.ForEachAsync(tenants,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (t, ct2) =>
{
await using var db = await _factory.CreateDbContextAsync(ct2);
var leads = await db.Leads.AsNoTracking().Where(l => l.TenantId == t).ToListAsync(ct2);
await ProcessLeadsAsync(leads, ct2);
});Ba biến thể của cùng lỗi, khó thấy hơn:
1. Task.WhenAll trên các phương thức của cùng một service.
await Task.WhenAll(
_leadService.CountByStatusAsync(ct),
_dealService.GetTopAsync(ct),
_reportService.GetRevenueAsync(ct));Ba service khác nhau, nhưng DbContext đăng ký Scoped
-> cả ba nhận CÙNG MỘT instance trong một request -> vẫn là lỗi2. Duyệt IAsyncEnumerable rồi truy vấn bên trong vòng lặp.
await foreach (var lead in db.Leads.AsAsyncEnumerable())
{
var customer = await db.Customers.FindAsync(lead.CustomerId); // reader kia còn mở
}Đây không phải chạy song song, nhưng connection vẫn đang bận đọc dở kết quả của truy vấn ngoài. Đã thử trên .NET 10, PostgreSQL ném NpgsqlOperationInProgressException: A command is already in progress: SELECT .... Cách sửa: nạp danh sách ngoài vào bộ nhớ trước (ToListAsync), hoặc gộp thành một truy vấn có Include/Join.
SQL Server
Không bật MARS, SQL Server ném InvalidOperationException: There is already an open DataReader associated with this Connection which must be closed first. (đã thử trên SQL Server 2022). Bật MultipleActiveResultSets=True trong chuỗi kết nối thì chạy được, nhưng mỗi vòng lặp vẫn là một truy vấn riêng.
3. BackgroundService nhận DbContext qua constructor.
// SAI — Scoped bị giữ bởi Singleton
public class SyncService(CrmDbContext db) : BackgroundService { }
// ĐÚNG — tạo scope mỗi lượt
public class SyncService(IServiceScopeFactory factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
await DoWorkAsync(db, ct);
await Task.Delay(TimeSpan.FromMinutes(5), ct);
}
}
}Biến thể thứ ba có công cụ phát hiện tự động, và nên bật ở mọi dự án:
builder.Host.UseDefaultServiceProvider((_, o) =>
{
o.ValidateScopes = true; // ném lỗi ngay lúc khởi động
o.ValidateOnBuild = true;
});Hai dòng này biến một lỗi phụ thuộc thời gian, khó tái hiện
thành một lỗi khởi động không thể bỏ qua.Bài 3 — Tìm mức tối ưu
Tăng dần MaxDegreeOfParallelism từ 2 tới 64 và vẽ đồ thị thông lượng.
Tiêu chí hoàn thành: bạn tìm được điểm bão hoà trên một phụ thuộc thật, và biết vì sao con số đó không chuyển được sang môi trường khác.
Gợi ý và lời giải — Bài 3
Gợi ý. Đo trên chính phụ thuộc thật. Với công việc giả lập, đường cong không bao giờ bão hoà — như đã thấy ở bài 1.
Lời giải — khung đo:
foreach (var mdop in new[] { 1, 2, 4, 8, 16, 32, 64 })
{
int succeeded = 0, failed = 0;
var sw = Stopwatch.StartNew();
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = mdop, CancellationToken = ct },
async (item, ct2) =>
{
try
{
await CallRealDependencyAsync(item, ct2);
Interlocked.Increment(ref succeeded);
}
catch { Interlocked.Increment(ref failed); }
});
sw.Stop();
var realThroughput = succeeded / sw.Elapsed.TotalSeconds;
Console.WriteLine($"MDOP={mdop,2} | {sw.Elapsed.TotalSeconds,6:F1}s | " +
$"OK {succeeded,4} | lỗi {failed,3} | thông lượng thực {realThroughput,6:F0}/s");
}Bảng dưới đây là số liệu minh hoạ, không phải số đo: bài thử này cần một API hoặc database thật có giới hạn, còn máy viết bài chỉ có công việc giả lập (và nó không bao giờ bão hoà, như bài 1). Hình dạng đường cong là thứ bạn sẽ gặp trên phụ thuộc thật:
MDOP= 1 | 40,0s | OK 2000 | lỗi 0 | thông lượng thực 50/s
MDOP= 2 | 20,1s | OK 2000 | lỗi 0 | thông lượng thực 99/s
MDOP= 4 | 10,2s | OK 2000 | lỗi 0 | thông lượng thực 196/s
MDOP= 8 | 5,4s | OK 2000 | lỗi 0 | thông lượng thực 370/s
MDOP=16 | 4,1s | OK 2000 | lỗi 0 | thông lượng thực 488/s <- bắt đầu chững
MDOP=32 | 3,9s | OK 1981 | lỗi 19 | thông lượng thực 508/s <- đỉnh
MDOP=64 | 4,6s | OK 1802 | lỗi 198 | thông lượng thực 392/s <- GIẢMBa vùng trên đường cong, và mỗi vùng nói một điều:
MDOP 1–8 : thông lượng tăng gần TUYẾN TÍNH
-> phụ thuộc chưa bão hoà, cứ tăng tiếp
MDOP 16–32 : tăng CHẬM DẦN rồi đứng lại
-> phụ thuộc đã bão hoà, đây là trần thật
MDOP 64 : thông lượng GIẢM và lỗi tăng vọt
-> quá tải: tranh chấp, hàng đợi, timeout, 429Điểm nên chọn không phải đỉnh, mà là chỗ ngay trước khi lỗi xuất hiện:
MDOP=32 cho thông lượng cao nhất (508/s) nhưng đã có 19 lỗi
MDOP=16 cho 488/s và KHÔNG lỗi
Chênh 4% thông lượng, đổi lấy 0 lỗi -> chọn 16.Lý do: 19 lỗi ở MDOP=32 là 19 lần phải thử lại
-> tải thật cao hơn con số đo được
-> và ở mức tải cao hơn một chút, nó trượt sang vùng 64Và đây là điều quan trọng nhất — con số này KHÔNG chuyển được:
MDOP tối ưu phụ thuộc:
- số nhân và bộ nhớ của phụ thuộc
- có bao nhiêu instance của CHÍNH ứng dụng bạn đang chạy
- có ai khác đang dùng phụ thuộc đó không
- giới hạn tần suất của bên thứ ba
- độ trễ mạng giữa hai bênĐo trên staging với 1 instance -> MDOP=16
Chạy production với 6 instance -> tổng 96 việc đồng thời tới cùng database
-> vượt xa điểm bão hoà -> sự cố// Vì vậy MDOP nên là cấu hình, không phải hằng số (Options pattern, bài 7.7)
var mdop = _options.Value.MaxDegreeOfParallelism; // mặc định 8// Và tốt hơn nữa: chia cho số instance nếu biết được
var mdop = Math.Max(1, _options.Value.TotalParallelism / _options.Value.InstanceCount);Cách bền hơn việc chỉnh một con số: để phụ thuộc tự nói. Phần này là đọc thêm, dùng thư viện Polly v8 (miễn phí, giấy phép BSD) và được học kỹ ở phần resilience sau này. Cần cài hai gói Polly.Extensions (đăng ký pipeline vào DI) và Polly.RateLimiting (cho AddConcurrencyLimiter). Vì pipeline xử lý kết quả HttpResponseMessage (thử lại khi nhận 429), cả pipeline lẫn options phải là bản generic <HttpResponseMessage>:
using System.Net;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
builder.Services.AddResiliencePipeline<string, HttpResponseMessage>("external-api", b => b
.AddConcurrencyLimiter(permitLimit: 16, queueLimit: 8) // tối đa 16 lời gọi cùng lúc
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // thêm ngẫu nhiên để các lần thử không dồn cùng lúc
MaxRetryAttempts = 3,
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
.Handle<HttpRequestException>(),
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 0.3, // 30% lỗi thì tạm ngừng gọi
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => (int)r.StatusCode >= 500)
.Handle<HttpRequestException>(),
}));Đã biên dịch và chạy thử trên .NET 10 với Polly 8.8.0: lấy pipeline qua ResiliencePipelineProvider<string>, giả lập hai lần trả 429 rồi một lần 200, kết quả OK sau 3 lần gọi.
Giới hạn đồng thời + thử lại có jitter + ngắt mạch
-> hệ thống tự điều chỉnh khi phụ thuộc chậm đi
-> thay vì phụ thuộc vào một con số đo được sáu tháng trướcVà theo dõi để biết khi nào con số cần chỉnh lại:
# Tỷ lệ lỗi theo mức song song — nếu tăng, MDOP đang quá cao cho tải hiện tại
rate(jobs_failed_total[5m]) / rate(jobs_total[5m]) > 0.01Nối lại ba bài: bài 1 cho thấy cơ chế, bài 2 cho thấy cái bẫy kỹ thuật đi kèm khi song song hoá, bài 3 cho thấy con số tối ưu là một thứ đo được nhưng không mang đi được. Mẫu số chung: song song hoá là một quyết định về tài nguyên của bên kia, không phải về tốc độ của bên mình.
Tự kiểm tra
Vì sao await trong vòng lặp làm mất tính song song?
Vì mỗi vòng lặp chờ xong rồi mới bắt đầu vòng sau. async giúp thread được trả về pool trong lúc chờ I/O nên server không bị chiếm thread, nhưng nó không tự làm các lời gọi chạy song song.
Ba cái bẫy khi chuyển sang Task.WhenAll không giới hạn là gì?
API bên ngoài trả 429 vì bị gọi quá nhiều cùng lúc, DbContext ném lỗi vì không an toàn đa luồng, và một task lỗi làm WhenAll ném exception khiến mọi kết quả thành công bị mất.
Vì sao cần collection an toàn đa luồng để thu kết quả?
Vì List không an toàn đa luồng, và ghi vào nó từ nhiều task song song có thể làm hỏng cấu trúc nội bộ. Nó không phải lúc nào cũng ném exception, đôi khi chỉ mất dữ liệu một cách im lặng.
Vì sao Release của SemaphoreSlim phải nằm trong finally?
Vì nếu một exception thoát ra mà không Release, semaphore mất dần số lượt cho phép, và cuối cùng mọi task đều treo vĩnh viễn chờ một lượt không bao giờ được trả lại.
Chọn mức song song thế nào?
Bắt đầu từ 4, đo thông lượng và tỷ lệ lỗi, tăng gấp đôi rồi đo lại, dừng khi tỷ lệ lỗi tăng hoặc thông lượng không tăng nữa. Ba giới hạn cần cân nhắc là API bên ngoài, connection pool và bộ nhớ.
Vì sao 48 giây vẫn quá lâu cho một request HTTP?
Vì người dùng phải chờ, gateway có timeout riêng, và nếu kết nối đứt thì không biết công việc đã xong tới đâu. Thao tác dài hơn vài giây nên chạy trong job nền và trả về mã theo dõi ngay.
Kết luận
Ba điều đáng nhớ nhất:
asynckhông tự làm code chạy song song.awaittrong vòng lặp vẫn là tuần tự.- Song song có giới hạn, không phải song song tối đa — ba cái bẫy đều đến từ việc bỏ giới hạn.
- Thao tác dài hơn vài giây thuộc về job nền.
Tham khảo
Điều hướng
- Bài trước: 6.9 — Đọc thêm: TimeProvider, PeriodicTimer và đo ValueTask
- Bài tiếp theo: 6.11 — Sự cố thực tế: một dòng .Result làm treo cả API
- Về module: Trang mục lục