Zarządzanie czasem życia DbContext w ASP.NET Core: Wydajność kontra bezpieczeństwo wątkowe w operacjach asynchronicznych

Kompleksowy przewodnik po zarządzaniu czasem życia DbContext w ASP.NET Core. Dowiedz się, kiedy stosować scoped vs transient, jak bezpiecznie obsługiwać operacje asynchroniczne oraz jak optymalizować wydajność za pomocą poolingu DbContext.

Wzorce czasu życia DbContext i bezpieczeństwa wątkowego w operacjach asynchronicznych ASP.NET Core

Zarządzanie czasem życia DbContext stanowi jedno z najczęstszych źródeł błędów w aplikacjach ASP.NET Core. Entity Framework Core DbContext nie jest bezpieczny wątkowo, a jego niewłaściwe użycie w operacjach asynchronicznych prowadzi do uszkodzenia stanu, wyścigów (race conditions) oraz wyjątków, które są trudne do odtworzenia.

DbContext nie jest bezpieczny wątkowo

Pojedyncza instancja DbContext nie może być używana równocześnie przez wiele wątków. Jeśli metoda asynchroniczna nie zostanie oczekiwana (awaited) przed rozpoczęciem kolejnej operacji na tym samym kontekście, stan wewnętrzny ulega uszkodzeniu. Ta zasada obowiązuje we wszystkich wersjach EF Core, włącznie z EF Core 9.

Dlaczego czas życia Scoped sprawdza się w żądaniach webowych

Wstrzykiwanie zależności ASP.NET Core domyślnie rejestruje DbContext z czasem życia scoped podczas używania AddDbContext<T>(). Każde żądanie HTTP otrzymuje własną instancję DbContext, która jest usuwana po zakończeniu żądania.

To podejście rozwiązuje dwa problemy: zapewnia, że każde żądanie ma izolowany stan bazy danych oraz dopasowuje czas życia kontekstu do wzorca Unit of Work, gdzie zmiany kumulują się podczas przetwarzania żądania i są zatwierdzane razem na końcu.

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

Czas życia scoped jest odpowiedni dla standardowych przepływów żądanie-odpowiedź. Akcja kontrolera lub handler Razor Page działa na pojedynczym wątku, a dopóki wszystkie wywołania asynchroniczne są prawidłowo oczekiwane, DbContext pozostaje w spójnym stanie przez całe żądanie.

OrderController.cscsharp
public class OrderController : ControllerBase
{
    private readonly AppDbContext _context;

    public OrderController(AppDbContext context)
    {
        _context = context;
    }

    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderDto dto)
    {
        var order = new Order { CustomerId = dto.CustomerId, Total = dto.Total };
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    }
}

Kluczowe ograniczenie: każda operacja asynchroniczna musi się zakończyć przed rozpoczęciem kolejnej. Powyższy kod jest poprawny, ponieważ SaveChangesAsync jest oczekiwane przed zwróceniem wyniku przez metodę.

Problem bezpieczeństwa wątkowego w operacjach równoległych

Problemy pojawiają się, gdy programiści próbują zrównoleglić operacje bazodanowe w ramach pojedynczego żądania. Rozważmy ten błędny przykład:

csharp
// BŁĘDNY KOD: Nie używać
public async Task<IActionResult> GetDashboard()
{
    var ordersTask = _context.Orders.CountAsync();
    var productsTask = _context.Products.CountAsync();
    var customersTask = _context.Customers.CountAsync();

    // Uruchomienie trzech zapytań na tym samym DbContext równocześnie
    await Task.WhenAll(ordersTask, productsTask, customersTask);

    return Ok(new { Orders = ordersTask.Result, Products = productsTask.Result, Customers = customersTask.Result });
}

Ten kod uruchamia trzy asynchroniczne zapytania bez indywidualnego oczekiwania na każde z nich. Wszystkie trzy operacje działają równocześnie na tej samej instancji DbContext, naruszając wymagania bezpieczeństwa wątkowego EF Core. Wynik jest nieprzewidywalny: czasem działa, czasem rzuca InvalidOperationException, a czasem zwraca nieprawidłowe dane.

Oficjalna dokumentacja Microsoft wyraźnie stwierdza, że metody asynchroniczne muszą być natychmiast oczekiwane. Wewnętrzny change tracker, stan połączenia i cache kompilacji zapytań nie są zaprojektowane do równoczesnego dostępu.

Gotowy na rozmowy o .NET?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Użycie IDbContextFactory do zapytań równoległych

Gdy równoległe operacje bazodanowe są rzeczywiście potrzebne, IDbContextFactory<T> dostarcza rozwiązanie. Ta fabryka tworzy nowe instancje DbContext na żądanie, każda z własnym izolowanym stanem.

Program.cscsharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

Po zarejestrowaniu fabryki należy ją wstrzykiwać zamiast bezpośrednio DbContext:

DashboardService.cscsharp
public class DashboardService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public DashboardService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task<DashboardStats> GetStatsAsync()
    {
        // Każde zadanie otrzymuje własną instancję DbContext
        var ordersTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Orders.CountAsync();
        });

        var productsTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Products.CountAsync();
        });

        var customersTask = Task.Run(async () =>
        {
            await using var context = _contextFactory.CreateDbContext();
            return await context.Customers.CountAsync();
        });

        await Task.WhenAll(ordersTask, productsTask, customersTask);

        return new DashboardStats
        {
            Orders = ordersTask.Result,
            Products = productsTask.Result,
            Customers = customersTask.Result
        };
    }
}

Każde zadanie tworzy, używa i usuwa własny kontekst. Instrukcja await using zapewnia prawidłowe czyszczenie nawet w przypadku wystąpienia wyjątku.

Pooling DbContext dla aplikacji o wysokiej przepustowości

Tworzenie nowego DbContext wiąże się z alokacją pamięci, inicjalizacją change trackera i konfiguracją wewnętrznych cache'ów. Dla aplikacji o wysokiej przepustowości przetwarzających tysiące żądań na sekundę, ten narzut staje się mierzalny.

Pooling DbContext rozwiązuje ten problem poprzez ponowne wykorzystanie instancji kontekstu. Gdy poolowany kontekst jest usuwany, EF Core resetuje jego stan i zwraca go do puli zamiast poddawać garbage collection.

Program.cscsharp
builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")),
    poolSize: 128);

Benchmarki Dave'a Callana pokazują, że pooling redukuje czas tworzenia kontekstu o ponad 90% w mikrobenchmarkach. Jednak rzeczywisty wpływ zależy od wzorców zapytań aplikacji. Jeśli zapytania do bazy dominują w czasie przetwarzania żądania, narzut tworzenia kontekstu jest w porównaniu znikomy.

Pooling wprowadza jedno ograniczenie: stan DbContext jest resetowany przy zwrocie do puli. Wszelkie niestandardowe pola lub właściwości dodane do klasy DbContext tracą swoje wartości. Change tracker jest czyszczony, więc niezatwierdzone zmiany znikają.

Łączenie poolingu z IDbContextFactory

Dla aplikacji wymagających zarówno wydajności poolingu, jak i wsparcia dla zapytań równoległych, należy użyć AddPooledDbContextFactory:

Program.cscsharp
builder.Services.AddPooledDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")),
    poolSize: 128);

Ta rejestracja dostarcza IDbContextFactory<AppDbContext>, gdzie konteksty pochodzą z puli. Każde wywołanie CreateDbContext() pobiera instancję z puli, a jej usunięcie zwraca instancję do puli.

BatchProcessor.cscsharp
public class BatchProcessor
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public BatchProcessor(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public async Task ProcessBatchAsync(IEnumerable<OrderUpdate> updates)
    {
        // Przetwarzanie równoległe w porcjach z poolowanymi kontekstami
        var chunks = updates.Chunk(100);
        var tasks = chunks.Select(async chunk =>
        {
            await using var context = _contextFactory.CreateDbContext();
            foreach (var update in chunk)
            {
                var order = await context.Orders.FindAsync(update.OrderId);
                if (order != null)
                {
                    order.Status = update.NewStatus;
                }
            }
            await context.SaveChangesAsync();
        });

        await Task.WhenAll(tasks);
    }
}

Analiza Milana Jovanovica dostarcza dodatkowych benchmarków pokazujących wydajność poolowanej fabryki w scenariuszach przetwarzania wsadowego.

Blazor Server: Przypadek szczególny

Aplikacje Blazor Server wymagają szczególnej uwagi przy zarządzaniu czasem życia DbContext. Obwód Blazor (circuit) utrzymuje się przez wiele interakcji użytkownika, w przeciwieństwie do żądań HTTP, które mają wyraźne granice.

Domyślny czas życia scoped staje się problematyczny: pojedyncza instancja DbContext żyje przez cały czas trwania obwodu, potencjalnie wiele godzin. Długożyjące konteksty akumulują śledzone encje, zwiększając zużycie pamięci i spowalniając operacje.

Program.cs dla Blazor Servercsharp
builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

W komponentach Blazor należy wstrzykiwać fabrykę i tworzyć krótkotrwałe konteksty:

OrderList.razor.cscsharp
public partial class OrderList : ComponentBase
{
    [Inject]
    private IDbContextFactory<AppDbContext> ContextFactory { get; set; } = default!;

    private List<Order> _orders = new();

    protected override async Task OnInitializedAsync()
    {
        await using var context = ContextFactory.CreateDbContext();
        _orders = await context.Orders
            .AsNoTracking()
            .OrderByDescending(o => o.CreatedAt)
            .Take(50)
            .ToListAsync();
    }
}

Wywołanie AsNoTracking() jest szczególnie ważne w Blazor Server. Bez niego każda załadowana encja pozostaje w change trackerze, zużywając pamięć aż do zakończenia obwodu.

Usługi działające w tle i Hosted Services

Usługi działające w tle zarejestrowane jako singletony nie mogą wstrzykiwać scoped DbContext bezpośrednio. Usługa przeżywa każdy scope, tworząc błędy niezgodności czasu życia.

OrderProcessingService.cscsharp
public class OrderProcessingService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public OrderProcessingService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            var pendingOrders = await context.Orders
                .Where(o => o.Status == OrderStatus.Pending)
                .Take(10)
                .ToListAsync(stoppingToken);

            foreach (var order in pendingOrders)
            {
                order.Status = OrderStatus.Processing;
            }

            await context.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

IServiceScopeFactory tworzy nowy scope dla każdej iteracji. Scope i DbContext wewnątrz niego są usuwane na końcu każdej iteracji pętli.

Alternatywnie można użyć IDbContextFactory dla bardziej szczegółowej kontroli:

OrderProcessingService.cs (wersja z fabryką)csharp
public class OrderProcessingService : BackgroundService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public OrderProcessingService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var context = _contextFactory.CreateDbContext();

            var pendingOrders = await context.Orders
                .Where(o => o.Status == OrderStatus.Pending)
                .Take(10)
                .ToListAsync(stoppingToken);

            foreach (var order in pendingOrders)
            {
                order.Status = OrderStatus.Processing;
            }

            await context.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

Częste błędy powodujące naruszenia bezpieczeństwa wątkowego

Kilka wzorców konsekwentnie powoduje problemy z bezpieczeństwem wątkowym DbContext w kodzie produkcyjnym:

Przechowywanie DbContext w polach statycznych lub singletonach: DbContext nigdy nie powinien przeżywać swojego zamierzonego zakresu. Statyczne referencje umożliwiają dostęp do tej samej instancji z wielu wątków.

Wywołania asynchroniczne typu fire-and-forget: Rozpoczęcie operacji asynchronicznej bez oczekiwania na nią podczas kontynuowania używania kontekstu na bieżącym wątku tworzy równoczesny dostęp.

csharp
// BŁĘDNY KOD: Nie używać
public void UpdateAndNotify(int orderId)
{
    var order = _context.Orders.Find(orderId);
    order.Status = OrderStatus.Shipped;
    _context.SaveChangesAsync(); // Nie oczekiwane!
    _notificationService.SendAsync(order.CustomerId); // Kontekst może nadal zapisywać
}

Wstrzykiwanie DbContext do usług singletonowych: Kontener DI rzuca wyjątek w środowisku deweloperskim, ale niektóre konfiguracje maskują ten błąd.

Używanie DbContext przez wiele await bez zrozumienia przepływu wykonania: Każde await to punkt zawieszenia. Jeśli wznowiony kod działa na innym wątku niż oczekiwano, może wystąpić równoczesny dostęp z innymi ścieżkami kodu.

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Przewodnik decyzyjny dla konfiguracji czasu życia DbContext

Wybór właściwej konfiguracji DbContext zależy od typu aplikacji i wymagań wydajnościowych:

  • Standardowe web API lub aplikacja MVC: Użyj AddDbContext<T>() z domyślnym czasem życia scoped. To pokrywa większość scenariuszy prawidłowo.
  • API o wysokiej przepustowości (tysiące żądań/sekundę): Użyj AddDbContextPool<T>() aby zredukować narzut alokacji.
  • Aplikacja wymagająca równoległych zapytań do bazy: Użyj AddDbContextFactory<T>() lub AddPooledDbContextFactory<T>().
  • Aplikacja Blazor Server: Użyj AddDbContextFactory<T>() z krótkotrwałymi kontekstami tworzonymi per operacja.
  • Usługi działające w tle: Użyj IServiceScopeFactory lub IDbContextFactory<T>() do tworzenia kontekstów wewnątrz usługi.
  • Przetwarzanie wsadowe z równoległością: Użyj AddPooledDbContextFactory<T>() dla optymalnej przepustowości.

Dla głębszego omówienia wzorców asynchronicznych w ASP.NET Core, moduł programowania asynchronicznego obejmuje powiązane koncepcje. Zaawansowany moduł EF Core rozwija optymalizację zapytań i zachowanie change trackera omówione tutaj.

Wyzwanie dnia

Znajdziesz błąd w .NET?

Prawdziwy fragment kodu, ukryty błąd, jedna próba dziennie. Bez konta, żeby spróbować.

Anthony Fillion-Maillet

Autor:

Anthony Fillion-Maillet

Założyciel SharpSkill

Programista fullstack od ponad 10 lat. Prowadzi SharpSkill i odpowiada za wszystko, co się tu ukazuje.

Zaktualizowano 7 września 2026

Tagi

#dotnet
#entity-framework
#aspnet-core
#async
#performance

Udostępnij

Powiązane artykuły