Skip to content

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ó await trong 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, DbContext né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 await trong vòng lặp làm mất tính song song.
  • Giới hạn mức song song bằng SemaphoreSlim hoặc Parallel.ForEachAsync.
  • Xử lý lỗi từng phần mà không hỏng cả lô.
  • Tránh dùng DbContext trong 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 ​

csharp
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 xong

Code 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 ​

csharp
var tasks = customers.Select(c => _accountingClient.SyncAsync(c, ct));
var results = await Task.WhenAll(tasks);        // 500 lời gọi CÙNG LÚC
Thời gian (trên giấy): 8 phút -> khoảng 1 giây, bằng lời gọi chậm nhất

Như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: 60

Hệ 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:

csharp
// 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ả:

csharp
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 đã xong

Tệ 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 ​

csharp
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ây

Bốn thay đổi giải quyết cả ba cái bẫy:

Thay đổiGiải quyết
MaxDegreeOfParallelism = 10Không vượt giới hạn của API bên ngoài
try/catch trong từng taskMột lỗi không làm hỏng cả lô
ConcurrentBagAn toàn khi nhiều luồng cùng ghi
Cập nhật database sau, một luồngDbContext 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:

csharp
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.ForEachAsyncSemaphoreSlim
Cú phápGọn hơnDài hơn
Kiểm soátCơ bảnChi tiết
Thu kết quảTự làm bằng collectionTask.WhenAll trả về mảng
Phù hợpPhần lớn trường hợpKhi 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ữa

Ba giới hạn cần cân nhắc cùng lúc:

Giới hạnCá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 databaseMặ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.

csharp
// 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:

csharp
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):

text
  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/s

Bả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ờiSo với tuần tự
Tuần tự12.273 ms11×
Song song tối đa57 ms200khoảng 215×
Có giới hạn (8)1.556 ms8khoảng 8×

Song song tối đa nhanh nhất — nhưng đó chính là vấn đề.

text
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 ĐỦ:

text
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ãi

Số 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.

text
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:

text
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ảm

Nên kết luận đúng của bài này là về cơ chế, không phải về con số:

text
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:

csharp
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); }
}
text
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:

csharp
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:

text
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.

text
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:

text
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ều

Cách sửa — mỗi tác vụ song song một context:

csharp
builder.Services.AddDbContextFactory<CrmDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Crm")));
SQL Server
csharp
builder.Services.AddDbContextFactory<CrmDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Crm")));
csharp
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);
text
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:

csharp
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.

csharp
await Task.WhenAll(
    _leadService.CountByStatusAsync(ct),
    _dealService.GetTopAsync(ct),
    _reportService.GetRevenueAsync(ct));
text
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ỗi

2. Duyệt IAsyncEnumerable rồi truy vấn bên trong vòng lặp.

csharp
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.

csharp
// 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:

csharp
builder.Host.UseDefaultServiceProvider((_, o) =>
{
    o.ValidateScopes = true;       // ném lỗi ngay lúc khởi động
    o.ValidateOnBuild = true;
});
text
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:

csharp
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:

text
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ẢM

Ba vùng trên đường cong, và mỗi vùng nói một điều:

text
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:

text
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.
text
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 64

Và đây là điều quan trọng nhất — con số này KHÔNG chuyển được:

text
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
text
Đ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ố
csharp
// 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
csharp
// 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>:

csharp
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.

text
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ước

Và theo dõi để biết khi nào con số cần chỉnh lại:

promql
# 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.01

Nố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:

  1. async không tự làm code chạy song song. await trong vòng lặp vẫn là tuần tự.
  2. 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.
  3. Thao tác dài hơn vài giây thuộc về job nền.

Tham khảo ​

Điều hướng ​

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