قفلگذاری توزیعشده (Distributed Locking) در NET.:وقتی شما اپلیکیشنهایی میسازید که روی چندین سرور یا فرآیند اجرا میشوند، در نهایت با مشکل دسترسی همزمان مواجه میشوید. چندین worker سعی میکنند یک منبع یکسان را در یک زمان بهروز کنند، و شما با شرایط رقابتی (race conditions)، کار تکراری، یا دادههای خراب مواجه میشوید. 💥
هماهنگسازی کار در چندین نمونه (Instance) 🔐
داتنت اصول اولیه کنترل همزمانی (concurrency control primitives) عالی برای سناریوهای تک-فرآیندی، مانند lock، SemaphoreSlim، و Mutex فراهم میکند. اما وقتی اپلیکیشن شما در چندین نمونه مقیاسبندی (scale out) میشود، این اصول اولیه دیگر کار نمیکنند.
اینجاست که قفلگذاری توزیعشده وارد میشود. ✨
قفلگذاری توزیعشده با تضمین اینکه در هر لحظه فقط یک گره (node) (نمونه اپلیکیشن) میتواند به یک بخش بحرانی (critical section) دسترسی داشته باشد، راهحلی ارائه میدهد، که از شرایط رقابتی جلوگیری کرده و سازگاری داده را در سراسر سیستم توزیعشده شما حفظ میکند.
چرا و چه زمانی به قفلگذاری توزیعشده نیاز دارید؟ 🤔
در یک اپلیکیشن تک-فرآیندی، شما فقط میتوانید از lock یا کلاس جدید Lock در NET 10. استفاده کنید. اما هنگامی که مقیاسبندی میکنید، این کافی نیست، زیرا هر فرآیند فضای حافظه خود را دارد.
چند مورد استفاده رایج که در آنها قفلهای توزیعشده ارزشمند هستند:
🔹 Background jobs:
تضمین اینکه در هر لحظه فقط یک worker یک job یا منبع خاص را پردازش میکند.
🔹 Leader election (انتخاب رهبر):
انتخاب یک فرآیند واحد برای انجام کارهای دورهای (مانند اعمال async database projections).
🔹 جلوگیری از اجرای دوباره: تضمین اینکه تسکهای زمانبندیشده هنگام دیپلوی در چندین نمونه، چندین بار اجرا نشوند.
🔹 هماهنگسازی منابع مشترک: به عنوان مثال، فقط یک نمونه سرویس در هر لحظه یک مایگریشن یا پاکسازی را انجام دهد.
🔹 جلوگیری از Cache stampede: تضمین اینکه هنگام منقضی شدن یک کلید کش مشخص، فقط یک نمونه کش را رفرش کند.
ارزش کلیدی 🔑: سازگاری و ایمنی در سراسر محیطهای توزیعشده.
بدون این، شما با ریسک عملیات تکراری، وضعیت خراب، یا بار غیرضروری مواجه میشوید.
حالا شما میدانید چرا قفلگذاری توزیعشده مهم است.
بیایید به چند گزینه پیادهسازی نگاه کنیم.
قفلگذاری توزیعشده DIY با Advisory Locks در PostgreSQL 🛠
بیایید ساده شروع کنیم. PostgreSQL قابلیتی به نام advisory locks دارد که برای قفلگذاری توزیعشده عالی است. برخلاف قفلهای جدول، اینها با دادههای شما تداخلی ندارند - آنها صرفاً برای هماهنگی هستند.
در اینجا یک مثال آمده است: 👇
public class NightlyReportService(NpgsqlDataSource dataSource)
{
public async Task ProcessNightlyReport()
{
await using var connection = dataSource.OpenConnection();
var key = HashKey("nightly-report");
var acquired = await connection.ExecuteScalarAsync<bool>(
"SELECT pg_try_advisory_lock(@key)",
new { key });
if (!acquired)
{
throw new ConflictException("Another instance is already processing the nightly report");
}
try
{
await DoWork();
}
finally
{
await connection.ExecuteAsync(
"SELECT pg_advisory_unlock(@key)",
new { key });
}
}
private static long HashKey(string key) =>
BitConverter.ToInt64(SHA256.HashData(Encoding.UTF8.GetBytes(key)), 0);
private static Task DoWork() => Task.Delay(5000); // Your actual work here
}
در اینجا آنچه در پشت صحنه اتفاق میافتد، آمده است. ⚙️
ابتدا، ما نام قفل خود را به یک عدد تبدیل میکنیم. advisory lockهای PostgreSQL به کلیدهای عددی نیاز دارند، بنابراین ما nightly-report را به یک عدد صحیح ۶۴ بیتی هش میکنیم. هر گره (نمونه اپلیکیشن) باید برای رشته یکسان، عدد یکسانی تولید کند، در غیر این صورت این کار نخواهد کرد.
سپس، ()pg_try_advisory_lock تلاش میکند تا یک قفل انحصاری (exclusive lock) روی آن عدد بگیرد. اگر موفقیتآمیز باشد true برمیگرداند، و اگر اتصال دیگری از قبل آن را در اختیار داشته باشد false برمیگرداند. این فراخوانی مسدود (block) نمیشود - بلافاصله به شما میگوید که آیا قفل را به دست آوردهاید یا نه.
اگر قفل را به دست آوریم، کار خود را انجام میدهیم. اگر نه، یک پاسخ تداخل (conflict) برمیگردانیم و اجازه میدهیم نمونه دیگر آن را مدیریت کند.