Search

Background Service Trong .NET: Xử Lý Job Không Cần Hangfire

Background Service Trong .NET: Xử Lý Job Không Cần Hangfire

Mỗi lần nhắc đến background job trong .NET, phản xạ đầu tiên của hầu hết anh em là kéo Hangfire vào project. Nó đã trở thành lựa chọn mặc định đến mức nhiều người quên mất rằng bản thân .NET đã cung cấp sẵn một bộ công cụ khá mạnh cho việc này — không cần NuGet, không cần Redis, không cần dashboard.

Bài viết này dành cho những ai muốn hiểu rõ mình đang có gì trong tay trước khi quyết định lôi thêm dependency vào.

Vấn đề thực tế mà ai cũng gặp

Bạn có một API nhận request tạo đơn hàng. Sau khi lưu DB xong, bạn cần gửi email xác nhận, đẩy notification lên mobile, sync dữ liệu sang hệ thống kế toán. Nếu nhét tất cả vào trong một request thì response time sẽ tăng vọt — người dùng phải chờ 3-5 giây cho một thao tác lẽ ra chỉ mất vài trăm millisecond.

Giải pháp hiển nhiên: đẩy những việc không cần response ngay ra background.

Câu hỏi là dùng cái gì để đẩy.

IHostedService — điểm xuất phát

IHostedService là một interface có từ .NET Core 2.1, chỉ gồm hai method:

public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

Khi bạn đăng ký một class implement interface này vào DI container bằng AddHostedService<T>(), .NET host sẽ tự động gọi StartAsync khi application khởi động và StopAsync khi application shutdown. Đơn giản vậy thôi.

Tuy nhiên, dùng trực tiếp IHostedService cho long-running task hơi rườm rà vì bạn phải tự quản lý thread, tự handle exception, tự đảm bảo graceful shutdown. Nên Microsoft đã cung cấp thêm một lớp abstract tiện hơn.

BackgroundService — cái mà bạn sẽ dùng nhiều nhất

BackgroundService kế thừa IHostedService và bọc sẵn boilerplate cho bạn. Bạn chỉ cần override đúng một method:

public class OrderSyncWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderSyncWorker> _logger;

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

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("OrderSyncWorker started.");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                var pendingOrders = await db.Orders
                    .Where(o => o.SyncStatus == SyncStatus.Pending)
                    .Take(50)
                    .ToListAsync(stoppingToken);

                foreach (var order in pendingOrders)
                {
                    // gọi API hệ thống kế toán
                    await SyncToAccountingSystem(order);
                    order.SyncStatus = SyncStatus.Completed;
                }

                await db.SaveChangesAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Sync failed. Will retry next cycle.");
            }

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

Đăng ký trong Program.cs:

builder.Services.AddHostedService<OrderSyncWorker>();

Xong. Không cần cài gì thêm.

Một điểm dễ sai mà mình muốn nhấn mạnh: BackgroundService được đăng ký dưới dạng Singleton, nhưng DbContext thường là Scoped. Nếu bạn inject DbContext thẳng vào constructor, nó sẽ bị giữ suốt lifetime của application — leak connection, data cũ, đủ thứ lỗi khó debug. Luôn dùng IServiceScopeFactory để tạo scope mới cho mỗi chu kỳ xử lý như ví dụ trên.

Khi polling không đủ: dùng Channel<T>

Pattern phía trên hoạt động tốt cho kiểu "cứ mỗi 30 giây quét DB một lần". Nhưng nếu bạn cần xử lý ngay khi có request — kiểu fire-and-forget từ controller — thì polling sẽ gây delay không cần thiết.

Đây là lúc System.Threading.Channels phát huy tác dụng. Nghĩ về nó như một in-memory queue — thread-safe, hỗ trợ async, bounded hoặc unbounded tùy bạn chọn.

Đầu tiên, tạo một abstraction đơn giản:

public class BackgroundTaskQueue
{
    private readonly Channel<Func<IServiceScopeFactory, CancellationToken, Task>> _channel;

    public BackgroundTaskQueue(int capacity = 100)
    {
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait
        };
        _channel = Channel.CreateBounded<Func<IServiceScopeFactory, CancellationToken, Task>>(options);
    }

    public async ValueTask EnqueueAsync(
        Func<IServiceScopeFactory, CancellationToken, Task> workItem)
    {
        await _channel.Writer.WriteAsync(workItem);
    }

    public async ValueTask<Func<IServiceScopeFactory, CancellationToken, Task>> DequeueAsync(
        CancellationToken cancellationToken)
    {
        return await _channel.Reader.ReadAsync(cancellationToken);
    }
}

Worker đọc từ channel:

public class QueuedWorker : BackgroundService
{
    private readonly BackgroundTaskQueue _queue;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<QueuedWorker> _logger;

    public QueuedWorker(
        BackgroundTaskQueue queue,
        IServiceScopeFactory scopeFactory,
        ILogger<QueuedWorker> logger)
    {
        _queue = queue;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var workItem = await _queue.DequeueAsync(stoppingToken);

            try
            {
                await workItem(_scopeFactory, stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing queued work item.");
            }
        }
    }
}

Trong controller, đẩy job vào queue:

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly BackgroundTaskQueue _queue;

    public OrdersController(AppDbContext db, BackgroundTaskQueue queue)
    {
        _db = db;
        _queue = queue;
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderRequest request)
    {
        var order = new Order { /* map từ request */ };
        _db.Orders.Add(order);
        await _db.SaveChangesAsync();

        // fire-and-forget: đẩy việc gửi email ra background
        await _queue.EnqueueAsync(async (scopeFactory, ct) =>
        {
            using var scope = scopeFactory.CreateScope();
            var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
            await emailService.SendOrderConfirmation(order.Id);
        });

        return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
    }
}

Đăng ký:

builder.Services.AddSingleton<BackgroundTaskQueue>();
builder.Services.AddHostedService<QueuedWorker>();

Response trả về ngay lập tức, email được xử lý phía sau. Không Hangfire, không Redis, không database polling.

Chạy nhiều worker song song

Một QueuedWorker đọc tuần tự từng job. Nếu bạn cần throughput cao hơn, đơn giản là đăng ký nhiều instance:

// 3 worker chạy song song, đọc từ cùng một channel
for (int i = 0; i < 3; i++)
{
    builder.Services.AddHostedService<QueuedWorker>();
}

Channel<T> đã thread-safe sẵn nên không cần lo race condition. Mỗi job chỉ được một worker duy nhất dequeue.

Timed Background Service — cron job đơn giản

Cho những job cần chạy theo lịch — dọn file tạm lúc 2h sáng, gửi report hàng ngày — bạn có thể dùng PeriodicTimer (từ .NET 6):

public class DailyCleanupService : BackgroundService
{
    private readonly PeriodicTimer _timer = new(TimeSpan.FromHours(1));
    private readonly ILogger<DailyCleanupService> _logger;

    public DailyCleanupService(ILogger<DailyCleanupService> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (await _timer.WaitForNextTickAsync(stoppingToken))
        {
            var now = DateTime.UtcNow;

            if (now.Hour == 2) // chỉ chạy lúc 2h sáng UTC
            {
                _logger.LogInformation("Running daily cleanup...");
                // xóa file tạm, purge old logs, v.v.
            }
        }
    }
}

Không mạnh bằng cron expression của Hangfire, nhưng cho 80% use case thì đủ dùng rồi.

Khi nào thì nên dùng Hangfire (hoặc thư viện khác)?

Nói đi cũng phải nói lại. Background Service built-in có giới hạn rõ ràng, và bạn nên hiểu ranh giới đó:

Dùng built-in khi:

  • Job chỉ cần chạy trong process, mất thì chấp nhận retry ở chu kỳ tiếp theo
  • Không cần dashboard theo dõi trạng thái từng job
  • Hệ thống chạy single instance hoặc bạn tự xử lý distributed lock
  • Bạn muốn giữ project gọn nhẹ, ít dependency

Cân nhắc Hangfire/Quartz khi:

  • Cần job persistence — app restart mà job không được mất
  • Cần retry policy phức tạp (exponential backoff, max retry count)
  • Cần cron expression chính xác kiểu 0 */6 * * *
  • Chạy nhiều instance và cần đảm bảo mỗi job chỉ chạy một lần
  • Cần UI để monitor, cancel, re-enqueue job

Nói cách khác, nếu bạn đang xây microservice nhỏ, xử lý vài nghìn job/ngày, background service built-in thừa sức. Còn nếu bạn đang vận hành hệ thống lớn với hàng trăm nghìn job, cần observability rõ ràng — thì Hangfire xứng đáng từng đồng license fee.

Lưu ý khi deploy

Một vài điểm mình thấy hay bị bỏ qua:

Graceful shutdown. Khi deploy lên IIS hoặc container, hãy đảm bảo SIGTERM được handle đúng. BackgroundService sẽ nhận CancellationToken bị cancel khi host shutdown, nhưng nếu bạn đang giữa chừng một batch xử lý nặng, hãy kiểm tra token thường xuyên:

foreach (var item in items)
{
    if (stoppingToken.IsCancellationRequested) break;
    await ProcessItem(item);
}

IIS Idle Timeout. Nếu host trên IIS, application pool sẽ bị recycle sau 20 phút không có request. Background service chết theo. Giải pháp: set Idle Timeout = 0, hoặc dùng AlwaysRunning preload, hoặc chuyển sang chạy như Windows Service / container.

Exception handling. Nếu ExecuteAsync throw exception mà không được catch, từ .NET 6 trở đi host sẽ crash. Luôn wrap toàn bộ logic trong try-catch và log lỗi thay vì để nó bay ra ngoài.

Tổng kết

.NET đã cho bạn đủ đồ chơi để xử lý background job ở mức cơ bản đến trung bình. BackgroundService cho polling, Channel<T> cho queue-based, PeriodicTimer cho scheduled task. Tất cả đều có sẵn, không tốn thêm dependency nào.

Trước khi kéo Hangfire vào csproj, hãy tự hỏi: mình thực sự cần những gì? Dashboard? Job persistence? Distributed lock? Nếu câu trả lời là không, thì có lẽ bạn đang over-engineer.

Đôi khi giải pháp tốt nhất là giải pháp đã nằm sẵn trong framework.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy