Как реализовать фоновую службу (Background Service) в ASP.NET Core для периодического изменения данных в базе данных?

Ответ

В ASP.NET Core для выполнения фоновых задач используется интерфейс IHostedService или его удобная абстракция BackgroundService. Вот пошаговая реализация службы, которая раз в минуту обновляет записи в БД.

1. Создаем класс фоновой службы:

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public class DatabaseCleanupService : BackgroundService
{
    private readonly ILogger<DatabaseCleanupService> _logger;
    private readonly IServiceScopeFactory _scopeFactory; // Для создания скоупов
    private readonly TimeSpan _period = TimeSpan.FromMinutes(1); // Период выполнения

    public DatabaseCleanupService(
        ILogger<DatabaseCleanupService> logger,
        IServiceScopeFactory scopeFactory)
    {
        _logger = logger;
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new PeriodicTimer(_period);

        while (!stoppingToken.IsCancellationRequested &&
               await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // Создаем новый scope для каждой итерации, чтобы корректно
                // получать scoped сервисы (как DbContext).
                using (var scope = _scopeFactory.CreateScope())
                {
                    var dbContext = scope.ServiceProvider
                        .GetRequiredService<ApplicationDbContext>();

                    await PerformCleanupAsync(dbContext, stoppingToken);
                }

                _logger.LogInformation("Фоновая задача выполнена успешно.");
            }
            catch (Exception ex)
            {
                // Важно логировать ошибки, чтобы служба не "молча" падала
                _logger.LogError(ex, "Ошибка при выполнении фоновой задачи.");
            }
        }
    }

    private async Task PerformCleanupAsync(
        ApplicationDbContext context,
        CancellationToken ct)
    {
        // Пример: архивация старых записей
        var cutoffDate = DateTime.UtcNow.AddDays(-30);
        var oldRecords = await context.LogEntries
            .Where(e => e.CreatedAt < cutoffDate && !e.IsArchived)
            .ToListAsync(ct);

        foreach (var record in oldRecords)
        {
            record.IsArchived = true;
        }

        // Пример: обновление агрегированных данных
        var today = DateTime.UtcNow.Date;
        var dailyStats = await context.Orders
            .Where(o => o.Date == today)
            .GroupBy(o => 1)
            .Select(g => new { Total = g.Sum(o => o.Amount), Count = g.Count() })
            .FirstOrDefaultAsync(ct);

        // Сохранение статистики в отдельную таблицу...

        await context.SaveChangesAsync(ct);
    }
}

2. Регистрируем службу в Program.cs:

// Регистрируем DbContext (обычно уже есть)
builder.Services.AddDbContext<ApplicationDbContext>(options => 
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// Регистрируем фоновую службу
builder.Services.AddHostedService<DatabaseCleanupService>();

Критически важные моменты:

  • Внедрение зависимостей: DbContext имеет scoped lifetime, поэтому его нельзя напрямую внедрять в singleton BackgroundService. Используйте IServiceScopeFactory для создания scope в каждом цикле.
  • Обработка ошибок: Всегда оборачивайте логику в try-catch. Непойманное исключение приведет к падению всей фоновой задачи.
  • Отмена операций: Всегда передавайте CancellationToken в асинхронные методы EF Core и другие операции, чтобы задача могла корректно завершиться при остановке приложения.
  • Распределенные блокировки: Для сценариев, где запущено несколько экземпляров приложения, используйте механизмы распределенных блокировок (например, через Redis или БД), чтобы задача выполнялась только на одной ноде.
  • Конфигурация: Выносите период выполнения (_period) в appsettings.json для гибкости.

Ответ 18+ 🔞

А, ну это же классика, блядь! Фоновые задачи в ASP.NET Core — это как та собака Муму: вроде и нужна, но если неправильно сделать, она тебе всю квартиру засрет, а потом ещё и утопить её придётся, самому же хуже будет.

Смотри, вот тебе нормальный человеческий вариант, без этих заумных формулировок, которые в документации пишут люди, никогда в продакшене не работавшие.

1. Делаем службу, которая будет тихонечко в фоне работать, пока основное приложение водку пьёт:

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public class DatabaseCleanupService : BackgroundService
{
    private readonly ILogger<DatabaseCleanupService> _logger;
    private readonly IServiceScopeFactory _scopeFactory; // Вот это — ключ к успеху, запомни!
    private readonly TimeSpan _period = TimeSpan.FromMinutes(1); // Раз в минуту дёргаемся

    public DatabaseCleanupService(
        ILogger<DatabaseCleanupService> logger,
        IServiceScopeFactory scopeFactory)
    {
        _logger = logger;
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Новый модный таймер, не надо этих Thread.Sleep, которые всю систему тормозят
        using PeriodicTimer timer = new PeriodicTimer(_period);

        // Крутимся, пока нас не попросят нахуй
        while (!stoppingToken.IsCancellationRequested &&
               await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // Вот тут самое важное, блядь! DbContext — он как одноразовый стаканчик.
                // Нельзя один стаканчик на всё приложение использовать, иначе получишь гонки данных и исключения.
                // Поэтому создаём новый scope для каждой итерации.
                using (var scope = _scopeFactory.CreateScope())
                {
                    var dbContext = scope.ServiceProvider
                        .GetRequiredService<ApplicationDbContext>();

                    // Делаем полезную работу
                    await PerformCleanupAsync(dbContext, stoppingToken);
                }

                _logger.LogInformation("Фоновая задача отработала, всё чисто.");
            }
            catch (Exception ex)
            {
                // А вот это, дружок, обязательно! Если ошибку проглотить, служба просто тихо сдохнет,
                // и ты будешь месяц гадать, почему статистика не обновляется.
                _logger.LogError(ex, "Ой, бля, что-то пошло не так в фоновой задаче.");
            }
        }
    }

    private async Task PerformCleanupAsync(
        ApplicationDbContext context,
        CancellationToken ct)
    {
        // Например, архивируем старые логи, которым больше месяца
        var cutoffDate = DateTime.UtcNow.AddDays(-30);
        var oldRecords = await context.LogEntries
            .Where(e => e.CreatedAt < cutoffDate && !e.IsArchived)
            .ToListAsync(ct);

        foreach (var record in oldRecords)
        {
            record.IsArchived = true;
        }

        // Или считаем, сколько денег сегодня срубили
        var today = DateTime.UtcNow.Date;
        var dailyStats = await context.Orders
            .Where(o => o.Date == today)
            .GroupBy(o => 1)
            .Select(g => new { Total = g.Sum(o => o.Amount), Count = g.Count() })
            .FirstOrDefaultAsync(ct);

        // Тут можешь сохранить эту статистику куда надо...

        await context.SaveChangesAsync(ct);
    }
}

2. Прописываем службу в Program.cs, чтобы система о ней узнала:

// DbContext обычно уже есть, но на всякий случай
builder.Services.AddDbContext<ApplicationDbContext>(options => 
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// А вот это — наша рабочая лошадка
builder.Services.AddHostedService<DatabaseCleanupService>();

Теперь про подводные камни, о которые все расшибаются:

  • IServiceScopeFactory — это твой лучший друг. Без него DbContext будет жить как синглтон, и ты получишь такие гонки данных и исключения, что мало не покажется. Это как одну зубную щётку на всю семью использовать — гигиенический пиздец.
  • Ошибки ловить надо, ёпта! Без try-catch любая мелочь (потеря соединения с БД, deadlock) убьёт твою службу нахуй, и она больше не запустится, пока приложение не перезагрузишь.
  • CancellationToken — не просто для галочки. Когда приложение останавливается, оно вежливо просит задачи завершиться. Если его игнорировать, то при остановке будет долгий и мучительный timeout, пока система не прибьёт процесс молотком.
  • Если у тебя несколько серверов (нод), то эта служба запустится на каждом из них и будет делать одну и ту же работу. Это как нанять пять уборщиц убирать одну комнату — деньги на ветер. Для таких случаев нужны распределённые блокировки (через Redis или хотя бы блокировку в самой БД).
  • Период выполнения (_period) лучше выноси в настройки (appsettings.json). Сегодня раз в минуту, а завтра заказчик скажет "делай раз в 5 секунд", и не придётся пересобирать весь проект.

Вот и вся магия. Главное — не переусложняй, и всё будет работать как швейцарские часы. Ну, или как минимум как старые советские, которые тикают, но время показывают.