کار پسزمینه با Background Service
بعضی کارها نباید منتظر یک درخواست HTTP بمانند، مثل پاک کردن سبدهای قدیمی یا ارسال ایمیل. برای این کارها یک Background Service مینویسیم. سه چیز در آن مهم است. یک Scope جدا برای هر دور کار، مدیریت خطا و خاموش شدن امن.
نویسنده: bezzad
مشکل: کارهایی که درخواست ندارند
فروشگاه ما چند کار دارد که هیچ کاربری منتظرش نیست:
- پاک کردن سبدهای منقضی. هر چند دقیقه یک بار.
- ارسال ایمیل تأیید سفارش. کاربر نباید سه ثانیه منتظر سرور ایمیل بماند.
- فرستادن پیامهای Outbox. هر چند ثانیه، پیامهای ذخیرهشده را به صف پیام بفرست.
این کارها را داخل endpoint نمیگذاریم. یک سرویس میسازیم که همراه برنامه شروع میشود، در پسزمینه کار میکند و همراه برنامه خاموش میشود. به آن Background Service میگوییم.
ایده: یک سرویس با شروع و پایان
در .NET، میزبان (Host) برنامه را اجرا میکند. هر سرویسی که رابط IHostedService را پیاده کند، دو متد دارد:
- متد StartAsync: وقتی برنامه بالا میآید، صدا زده میشود.
- متد StopAsync: وقتی برنامه خاموش میشود، صدا زده میشود.
کلاس آماده BackgroundService این کار را سادهتر میکند. فقط یک متد را مینویسی: ExecuteAsync. این متد یک توکن به اسم stoppingToken میگیرد. وقتی برنامه میخواهد خاموش شود، این توکن لغو میشود.
کد: پاک کردن سبدهای منقضی
public sealed class ExpiredCartCleaner(
IServiceScopeFactory scopeFactory,
ILogger<ExpiredCartCleaner> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
// A new scope (and a new DbContext) for every round.
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ShopDb>();
var deleted = await db.Carts
.Where(c => c.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(stoppingToken);
logger.LogInformation("Deleted {Count} expired carts", deleted);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// One bad round must not kill the service. Try again next tick.
logger.LogError(ex, "Cart cleanup failed");
}
}
}
}
// Program.cs
builder.Services.AddHostedService<ExpiredCartCleaner>();
سه نکته در این کد است. هر کدام یک بخش جدا در ادامه دارد:
- برای هر دور، یک Scope تازه.
- خطا داخل حلقه گرفته میشود.
- توکن stoppingToken همه جا پاس داده میشود.
نکته اول: Scope برای هر دور
سرویس پسزمینه یک بار ساخته میشود و تا آخر عمر برنامه زنده است. پس رفتارش مثل Singleton است. ولی DbContext از نوع Scoped است. اگر DbContext را مستقیم در سازنده بگیری، در محیط Development برنامه هنگام شروع خطا میدهد. در Production این چک به طور پیشفرض خاموش است، پس خطایی نمیبینی و همان مشکل پایین پیش میآید. اگر یک Scope را در اول متد بسازی و تا آخر نگه داری، مشکل بدتری داری:
- یک DbContext برای همیشه زنده میماند.
- هر چیزی که با آن خوانده شود، در Change Tracker آن میماند. حتی بعد از ذخیره.
- حافظه آهسته بالا میرود. بعد از چند روز، pod با خطای کمبود حافظه ریاستارت میشود.
- سرعت هم کم میشود. هر بار ذخیره، همه آن چیزها را دوباره چک میکند.
نکته دوم: خطا سرویس را نکشد
اگر خطایی از متد ExecuteAsync بیرون بیاید، به طور پیشفرض کل برنامه متوقف میشود (از .NET 6 این رفتار پیشفرض است). این خوب است، چون خطا پنهان نمیماند. ولی یعنی یک قطعی کوتاه دیتابیس، کل سرویس را خاموش میکند.
پس:
- خطای هر دور را داخل حلقه بگیر. لاگ کن و دور بعد دوباره تلاش کن.
- خطای لغو را نگیر. خطای OperationCanceledException یعنی برنامه دارد خاموش میشود. بگذار حلقه تمام شود.
- اگر خطا مدام تکرار شد، هشدار بساز. یک سرویس پسزمینه که بیصدا هر بار شکست میخورد، از سرویس خاموش بدتر است.
صف داخل حافظه: ارسال ایمیل
برای ایمیل تأیید سفارش، endpoint نباید منتظر سرور ایمیل بماند. پس endpoint فقط کار را در یک صف میگذارد و فوراً جواب میدهد. یک سرویس پسزمینه از صف برمیدارد و ایمیل را میفرستد. کلاس Channel برای همین کار ساخته شده است.
public sealed class EmailQueue
{
// Bounded: if the queue is full, writers wait instead of using all memory.
private readonly Channel<OrderEmail> _channel = Channel.CreateBounded<OrderEmail>(1_000);
public ValueTask EnqueueAsync(OrderEmail email, CancellationToken ct) =>
_channel.Writer.WriteAsync(email, ct);
public IAsyncEnumerable<OrderEmail> ReadAllAsync(CancellationToken ct) =>
_channel.Reader.ReadAllAsync(ct);
}
public sealed class EmailSender(
EmailQueue queue, IServiceScopeFactory scopeFactory, ILogger<EmailSender> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var email in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IEmailClient>();
await client.SendAsync(email, stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Sending email for order {OrderId} failed", email.OrderId);
}
}
}
}
// Program.cs
builder.Services.AddSingleton<EmailQueue>();
builder.Services.AddHostedService<EmailSender>();
نکته سوم: خاموش شدن امن
در Kubernetes، هر deploy یعنی pod های قدیمی خاموش میشوند. ترتیب کار این است:
- سیگنال SIGTERM میرسد. میزبان .NET شروع به خاموش شدن میکند.
- درخواست جدید قبول نمیشود. درخواستهای در حال اجرا فرصت دارند تمام شوند.
- توکن stoppingToken لغو میشود. حلقه سرویس پسزمینه باید این را ببیند و تمام شود.
- میزبان تا یک سقف زمانی صبر میکند. این سقف با تنظیم ShutdownTimeout در HostOptions مشخص میشود. مقدار پیشفرض آن ۳۰ ثانیه است.
- اگر Kubernetes بیشتر صبر نکند، SIGKILL میفرستد. مهلت Kubernetes هم به طور پیشفرض ۳۰ ثانیه است. بعد از آن، process فوراً کشته میشود.
builder.Services.Configure<HostOptions>(options =>
{
// Must be shorter than the Kubernetes grace period.
options.ShutdownTimeout = TimeSpan.FromSeconds(20);
});
حتی با همه اینها، pod گاهی ناگهانی میمیرد. پس هر کار پسزمینه باید Idempotent باشد. یعنی اگر دو بار اجرا شد، نتیجه خراب نشود.
چند pod، چند نسخه از همان کار
یک گزارش مالی هر شب ساعت ۲ ساخته و برای مشتریها ایمیل میشود. برای تحمل بار، تعداد pod ها را از ۱ به ۳ میرسانیم. حالا چه میشود؟
- هر pod یک نسخه کامل از برنامه است. پس هر pod سرویس پسزمینه خودش را دارد.
- هر سه، ساعت ۲ بیدار میشوند. هیچ کدام از بقیه خبر ندارند.
- هر مشتری سه ایمیل یکسان میگیرد.
راهها، از ساده به پیچیده:
- کار را از سرویس جدا کن. یک CronJob در Kubernetes که هر شب یک pod کوتاهعمر اجرا میکند. دیگر چند نسخه نداریم.
- ثبت اجرا در دیتابیس با Unique Constraint. هر pod قبل از شروع، یک ردیف «گزارش امروز» اضافه میکند. فقط اولین نوشتن موفق میشود.
- قفل توزیعشده. با قفل دیتابیس یا Redis. یا ابزار آمادهای مثل Hangfire یا Quartz.NET در حالت cluster.
قانونهای مهم
- برای هر واحد کار، یک Scope تازه بساز. با متد CreateAsyncScope روی IServiceScopeFactory.
- توکن stoppingToken را همه جا پاس بده. به دیتابیس، HttpClient و تأخیرها.
- خطای هر دور را بگیر و لاگ کن. ولی خطای لغو را نگیر.
- سقف خاموش شدن را کمتر از مهلت Kubernetes بگذار.
- کار را Idempotent بنویس. ریاستارت و اجرای دوباره همیشه ممکن است.
- به تعداد pod ها فکر کن. کار زمانبندیشده روی چند pod، چند بار اجرا میشود.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| یک Scope و یک DbContext برای کل عمر سرویس | حافظه مدام بالا میرود و سرعت کم میشود. | یک Scope تازه برای هر دور. |
| نگرفتن خطا داخل حلقه | یک خطای موقت، کل برنامه را متوقف میکند. | گرفتن خطا، لاگ و تلاش در دور بعد. |
| حلقه با Task.Delay و بدون توکن | برنامه دیر خاموش میشود و کار وسط راه قطع میشود. | پاس دادن stoppingToken. |
| صف حافظه برای کار مهم | با ریاستارت، کارها گم میشوند. | صف ماندگار یا Outbox. |
| زمانبند داخلی روی چند pod | کار چند بار اجرا میشود. | CronJob، Unique Constraint یا قفل. |
| کار سنگین CPU در سرویس وب | درخواستهای کاربران کند میشوند. | سرویس Worker جدا. |
چه وقت Background Service؟
مناسب
- کارهای کوتاه و دورهای، مثل پاکسازی.
- خواندن از صف پیام (Kafka یا RabbitMQ).
- فرستادن پیامهای Outbox.
- کار سبکی که نباید کاربر را منتظر بگذارد.
نامناسب
- کار شبانهای که فقط یک بار باید اجرا شود و چند pod داریم. یک CronJob سادهتر است.
- کار طولانی و مهمی که نباید با ریاستارت گم شود، ولی فقط در حافظه است.
- کار سنگینی که منابع سرویس وب را میگیرد. آن را به یک سرویس جدا ببر.
خلاصه در شش خط
- سرویس پسزمینه همراه برنامه شروع و خاموش میشود. متد ExecuteAsync کار اصلی است.
- سرویس پسزمینه مثل Singleton است. برای هر دور کار، یک Scope تازه بساز.
- خطای هر دور را بگیر. وگرنه به طور پیشفرض کل برنامه متوقف میشود.
- توکن توقف را همه جا پاس بده تا خاموش شدن امن باشد.
- صف داخل حافظه با ریاستارت پاک میشود. برای کار مهم صف ماندگار لازم است.
- روی چند pod، هر pod کار را جدا اجرا میکند. هماهنگی و Idempotency لازم است.