معماری

وقتی Distributed Lock دروغ می‌گه

قصه‌ی TTL، Fencing Token و اون ۵ ثانیه‌ی لعنتی

صحنه‌ی جرم

فرض کنید یه distributed lock دارید (مثلاً روی Redis) با TTL = 10s. یه درخواست انتقال وجه بین دو کیف‌پول می‌رسه، قفل گرفته می‌شه، پردازش شروع می‌شه... و به هر دلیلی (GC pause، یه I/O کند، لود بالا) پردازش ۱۵ ثانیه طول می‌کشه.

توی این ۵ ثانیه‌ی اضافه، یه اتفاق ساده و بی‌سروصدا افتاده: TTL قفل تموم شده. Redis که خبر نداره Node1 هنوز وسط کاره، قفل رو آزاد کرده. یه درخواست جدید (یا retry همون درخواست) می‌رسه، می‌ره روی Node2، قفل آزاده، Node2 هم می‌گیرتش.

نتیجه؟ دو تا process، هم‌زمان، دارن روی یک انتقال وجه کار می‌کنن. و چون این عملیات atomic نیست (کسر از یه ولت، افزایش توی ولت دیگه، دو تا مرحله‌ی جدا)، این‌جا دیگه بحث سر کندی نیست؛ بحث سر درستی داده‌ست.

سوالی که باید جدی جوابش رو بدید: چطور جلوی این رو بگیریم؟

اول یه چیزی رو بپذیریم: لاک، ذاتاً دروغ می‌گه

قبل از رفتن سراغ راه‌حل، یه واقعیت ناخوشایند رو باید قبول کنیم: هیچ distributed lockـی که بر پایه‌ی timeout کار می‌کنه، نمی‌تونه mutual exclusion رو صد در صد تضمین کنه. این چیزی نیست که من اختراع کرده باشم؛ Martin Kleppmann توی مقاله‌ی معروفش («How to do distributed locking») دقیقاً همین رو اثبات می‌کنه. هر چقدر TTL رو بزرگ‌تر کنید، هر چقدر watchdog و lock-renewal بذارید، فقط احتمال وقوع این سناریو رو کم می‌کنید، نه اینکه از بین ببرید. یه GC pause یا یه network partition بدشانس، همیشه می‌تونه از زیر دستتون در بره.

این یعنی طرز فکرمون باید عوض بشه. لاک رو دیگه نباید به چشم "قفل امنیتی نهایی" ببینیم؛ باید بهش به چشم یه بهینه‌سازی برای کاهش کار تکراری نگاه کنیم. تضمین درستی داده باید از یه جای دیگه بیاد: از خود دیتابیس.

سه لایه‌ای که باید از هم جداشون کنید

اینجا جایی‌ست که خیلی‌ها (و اولین باری که خودم این بحث رو نوشتم، حتی خودم!) سه تا مفهوم رو قاطی می‌کنن. بذارید کاملاً مجزا نگهشون داریم، چون هرکدوم یه مشکل متفاوت رو حل می‌کنن:

لایه مشکلی که حل می‌کنه
Lock جلوی هم‌زمانیِ معمول رو می‌گیره
Fencing Token جلوی نوشتنِ قدیمی روی جدید رو می‌گیره
Idempotency Key جلوی اجرای تکراریِ همون عملیات رو می‌گیره

Fencing Token: همون Optimistic Locking که بلدید، فقط یه‌جای دیگه

اگه قبلاً با optimistic concurrency روی دیتابیس کار کرده باشید (همون الگوی آشنای WHERE Version = @expectedVersion)، خبر خوب اینه که Fencing Token دقیقاً همون ایده‌ست، فقط جابه‌جا شده.

Row Version چی می‌گه؟

"من این ردیف رو وقتی Version=5 بود خوندم. اگه الان دیگه Version=5 نیست، یعنی یکی زودتر از من نوشته — من رد می‌شم."

اینجا نسخه داره تغییرات خودِ داده رو دنبال می‌کنه.

Fencing Token چی می‌گه؟

"من قفل رو با شماره‌ی ۱۲ گرفتم. اگه یه عملیات با شماره‌ی بزرگ‌تر (مثلاً ۱۳) زودتر از من به مقصد رسیده، من از اون قدیمی‌ترم — رد می‌شم."

اینجا شماره داره ترتیب گرفتن قفل‌ها رو دنبال می‌کنه، نه تغییرات داده. سرویس قفل (Redis) هر بار که کسی قفل رو می‌گیره، یه عدد صعودی و یکتا صادر می‌کنه، و منبع نهاییِ داده (دیتابیس) موظفه فقط توکن‌های بزرگ‌تر از آخرین توکنی که دیده رو قبول کنه.

نتیجه؟ حتی اگه Node1 و Node2 هر دو فکر کنن مالک قفل‌ان، وقتی نوبت نوشتن روی دیتابیس می‌رسه، فقط اونی که آخرین (تازه‌ترین) توکن رو داره برنده می‌شه.

چرا فقط Fencing Token هم کافی نیست؟

چون Fencing Token فقط جلوی "قدیمی روی جدید بنویسه" رو می‌گیره. یه سناریوی دیگه رو تصور کنید: کاربر دکمه‌ی «انتقال» رو دو بار زده (یا صف پیام، پیام رو دوبار deliver کرده). این دو تا، دو عملیات جدا با دو fencing token متفاوت هستن — و از نظر منطق fencing، هر دو کاملاً معتبرن، چون هر دو صعودی‌ان! اینجا فنسینگ هیچ کمکی نمی‌کنه.

این‌جاست که Idempotency Key وارد می‌شه: یه شناسه‌ی ثابت برای کل عملیات منطقی (نه برای هر تلاش قفل) که به دیتابیس می‌گه: «این عملیات خاص رو، صرف‌نظر از این‌که چندبار بهم رسیده، فقط یک‌بار اعمال کن.»

حالا با هم ببینیمش: Sequence Diagram

اینجا دقیقاً همون سناریوی اول رو، این بار با fencing token و idempotency key، قدم‌به‌قدم می‌بینید:

sequenceDiagram
    participant C as Client
    participant N1 as Node 1
    participant N2 as Node 2
    participant R as Redis (Lock)
    participant DB as Database

    C->>N1: درخواست انتقال (OperationId=X)
    N1->>R: SET lock NX PX 10000
    R-->>N1: OK, fence = 12
    Note over N1: شروع پردازش... (طول می‌کشه تا ۱۵ ثانیه)

    Note over R: t=10s → TTL تموم شد، قفل خودکار آزاد شد

    C->>N2: retry همون درخواست (OperationId=X)
    N2->>R: SET lock NX PX 10000
    R-->>N2: OK, fence = 13
    Note over N2: شروع پردازش

    N2->>DB: INSERT ProcessedTransfers (OperationId=X)
    DB-->>N2: موفق (اولین بار بود)
    N2->>DB: UPDATE Wallets ... WHERE LastAppliedFence < 13
    DB-->>N2: موفق، LastAppliedFence = 13
    N2->>R: Release Lock

    Note over N1: بعد از ۱۵ ثانیه بالاخره کارش تموم شد
    N1->>DB: INSERT ProcessedTransfers (OperationId=X)
    DB-->>N1: رد شد (قبلاً وجود داره - idempotency)
    Note over N1: خروجی امن: AlreadyProcessed

دو نقطه‌ی حیاتی این دیاگرام:

  1. حتی اگه Node1 هم به مرحله‌ی نوشتن روی دیتابیس می‌رسید، fencing token=12 در برابر LastAppliedFence=13 رد می‌شد.
  2. idempotency key (OperationId) قبل از اون حتی، جلوی هرگونه تلاش دوباره رو می‌گیره — این دو تا با هم backup همدیگه‌ان.

منطق تصمیم‌گیری داخل دیتابیس: Flowchart

این همون چیزیه که واقعاً داخل تراکنش دیتابیس اتفاق می‌افته — جایی که تصمیم نهایی گرفته می‌شه، نه توی لاک:

flowchart TD
    A[شروع تراکنش] --> B{OperationId قبلاً<br/>در ProcessedTransfers ثبت شده؟}
    B -- بله --> C[Rollback<br/>خروجی: AlreadyProcessed]
    B -- خیر --> D[INSERT در ProcessedTransfers]
    D --> E{FencingToken بزرگ‌تر از<br/>LastAppliedFence روی ولت هست؟}
    E -- خیر --> F[Rollback<br/>خروجی: Rejected - توکن قدیمی]
    E -- بله --> G{موجودی کافیه؟}
    G -- خیر --> H[Rollback<br/>خروجی: Insufficient Balance]
    G -- بله --> I[کسر از ولت مبدا<br/>+ آپدیت LastAppliedFence]
    I --> J[افزایش در ولت مقصد<br/>+ آپدیت LastAppliedFence]
    J --> K[Commit<br/>خروجی: Success]

می‌بینید که همه‌چیز، نهایتاً، به یه سری شرط ساده روی خود دیتابیس ختم می‌شه — نه به این‌که لاک درست کار کرده باشه یا نه.

پیاده‌سازی (سی‌شارپ)

گرفتن قفل + صدور Fencing Token (اتمیک، با Lua Script روی Redis)

public sealed class LockHandle
{
    public string Resource { get; init; } = default!;
    public long FencingToken { get; init; }
    public string LockValue { get; init; } = default!;
}

public class FencingRedisLock
{
    private readonly IDatabase _db;
    public FencingRedisLock(IDatabase db) => _db = db;

    // یک اسکریپت، دو کار اتمیک: صدور fencing token صعودی + گرفتن قفل NX
    private const string AcquireScript = @"
        local fenceKey = KEYS[1] .. ':fence'
        local lockKey  = KEYS[1] .. ':lock'
        local token = redis.call('INCR', fenceKey)
        local ok = redis.call('SET', lockKey, ARGV[1], 'NX', 'PX', ARGV[2])
        if ok then return token else return -1 end";

    private const string ReleaseScript = @"
        local lockKey = KEYS[1] .. ':lock'
        if redis.call('GET', lockKey) == ARGV[1] then
            return redis.call('DEL', lockKey)
        end
        return 0";

    public async Task<LockHandle?> AcquireAsync(string resource, TimeSpan ttl)
    {
        var lockValue = Guid.NewGuid().ToString("N");
        var result = (long)await _db.ScriptEvaluateAsync(
            AcquireScript,
            new RedisKey[] { resource },
            new RedisValue[] { lockValue, (long)ttl.TotalMilliseconds });

        return result == -1 ? null : new LockHandle
        {
            Resource = resource, FencingToken = result, LockValue = lockValue
        };
    }

    public Task ReleaseAsync(LockHandle handle) =>
        _db.ScriptEvaluateAsync(ReleaseScript,
            new RedisKey[] { handle.Resource },
            new RedisValue[] { handle.LockValue });
}

دفاع نهایی: idempotency + fencing، داخل تراکنش دیتابیس

public class WalletTransferService
{
    private readonly FencingRedisLock _lockService;
    private readonly string _connectionString;

    public WalletTransferService(FencingRedisLock lockService, string connectionString)
    {
        _lockService = lockService;
        _connectionString = connectionString;
    }

    public async Task<TransferResult> TransferAsync(
        Guid operationId, Guid fromWallet, Guid toWallet, decimal amount)
    {
        var lockKey = $"wallet-lock:{fromWallet}";
        var handle = await _lockService.AcquireAsync(lockKey, TimeSpan.FromSeconds(10));

        if (handle == null)
            return TransferResult.Failed("لاک گرفته نشد، بعداً دوباره تلاش کن");

        try
        {
            // حتی اگه لاک زودتر expire بشه و یه Node دیگه هم موازی وارد این متد بشه،
            // خط دفاع نهایی پایین (idempotency + fencing در DB) جلوی دبل-اپلای رو می‌گیره
            return await ApplyTransferAsync(operationId, handle.FencingToken, fromWallet, toWallet, amount);
        }
        finally
        {
            await _lockService.ReleaseAsync(handle);
        }
    }

    private async Task<TransferResult> ApplyTransferAsync(
        Guid operationId, long fencingToken, Guid from, Guid to, decimal amount)
    {
        using var conn = new SqlConnection(_connectionString);
        await conn.OpenAsync();
        using var tx = conn.BeginTransaction(IsolationLevel.Serializable);

        try
        {
            // ۱) Idempotency: اگه یه instance دیگه قبلاً این OperationId رو ثبت کرده، insert صفر برمی‌گرده
            var inserted = await conn.ExecuteAsync(@"
                INSERT INTO ProcessedTransfers (OperationId, FencingToken, FromWallet, ToWallet, Amount)
                SELECT @OperationId, @FencingToken, @From, @To, @Amount
                WHERE NOT EXISTS (SELECT 1 FROM ProcessedTransfers WHERE OperationId = @OperationId)",
                new { OperationId = operationId, FencingToken = fencingToken, From = from, To = to, Amount = amount },
                tx);

            if (inserted == 0)
            {
                tx.Rollback();
                return TransferResult.AlreadyProcessed(operationId);
            }

            // ۲) Fencing: فقط اگه توکن ما از آخرین توکن اعمال‌شده روی این ولت جدیدتر باشه
            var debited = await conn.ExecuteAsync(@"
                UPDATE Wallets
                SET Balance = Balance - @Amount, LastAppliedFence = @FencingToken
                WHERE Id = @From AND Balance >= @Amount AND LastAppliedFence < @FencingToken",
                new { From = from, Amount = amount, FencingToken = fencingToken }, tx);

            if (debited == 0)
            {
                tx.Rollback();
                return TransferResult.Failed("موجودی ناکافی یا fencing token قدیمی‌تر از عملیات دیگه");
            }

            // ۳) واریز به مقصد
            await conn.ExecuteAsync(@"
                UPDATE Wallets
                SET Balance = Balance + @Amount, LastAppliedFence = @FencingToken
                WHERE Id = @To AND LastAppliedFence < @FencingToken",
                new { To = to, FencingToken = fencingToken, Amount = amount }, tx);

            tx.Commit();
            return TransferResult.Success(operationId);
        }
        catch
        {
            tx.Rollback();
            throw;
        }
    }
}

public record TransferResult(bool IsSuccess, string Message)
{
    public static TransferResult Success(Guid id) => new(true, $"عملیات {id} با موفقیت انجام شد");
    public static TransferResult AlreadyProcessed(Guid id) => new(true, $"عملیات {id} قبلاً پردازش شده (idempotent skip)");
    public static TransferResult Failed(string msg) => new(false, msg);
}

اسکیمای موردنیاز، برای این‌که کد بالا معنی پیدا کنه:

CREATE TABLE ProcessedTransfers (
    OperationId    UNIQUEIDENTIFIER PRIMARY KEY,
    FencingToken   BIGINT NOT NULL,
    FromWallet     UNIQUEIDENTIFIER NOT NULL,
    ToWallet       UNIQUEIDENTIFIER NOT NULL,
    Amount         DECIMAL(18,2) NOT NULL,
    ProcessedAtUtc DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);

CREATE TABLE Wallets (
    Id               UNIQUEIDENTIFIER PRIMARY KEY,
    Balance          DECIMAL(18,2) NOT NULL,
    LastAppliedFence BIGINT NOT NULL DEFAULT 0
);

پس Watchdog / Lock Renewal اصلاً بی‌فایده‌ست؟

نه. صرفاً باید جایگاهش رو درست بشناسید. یه watchdog که هر ۴۰٪ از TTL، قفل رو تمدید می‌کنه، احتمال بروز این سناریو رو به شدت کم می‌کنه (چون دیگه صرفاً یه delay ساده کافی نیست که قفل expire بشه). ولی هیچ‌وقت این احتمال رو صفر نمی‌کنه — چون خودِ عملیات renew هم می‌تونه دیر برسه یا fail بشه (مثلاً یه partition شبکه‌ی کوتاه بین Node و Redis). پس بهش نگاه کنید به چشم کاهش‌دهنده‌ی احتمال، نه راه‌حل قطعی.

جمع‌بندی نهایی

اگه فقط یه چیز از این پست قراره یادتون بمونه، این باشه:

لاک = بهینه‌سازی برای کاهش کار تکراری. تراکنش دیتابیس + idempotency + fencing = منبع واقعیِ صحت داده.

و اگه از دنیای optimistic locking اومدید، همین الان یه چیز جدید یاد نگرفتید — یه الگوی آشنا رو یه‌جای جدید دیدید. Fencing Token چیزی نیست جز همون WHERE Version = @expected که بلدید، فقط این بار به‌جای این‌که نسخه‌ی داده رو بشماره، نسخه‌ی مجوز ورود رو می‌شماره.


این پست بخشی از یه سری مطالعه‌ست که در حال حاضر دارم پیش می‌برم. یه نکته هم راجع به هوش مصنوعی بگم: قبل از این‌که ابزارهایی مثل Claude بیان، توسعه‌ی نرم‌افزار (خصوصاً بک‌اند) چیزی حدود ۸۰٪ فکر کردن بود و ۲۰٪ کدنویسی. حالا هم دقیقاً همین نسبت باید حفظ بشه — فقط اون ۲۰٪ کدنویسی رو می‌تونین به Claude و امثالش بسپرین. خواهشاً جای این دو رو عوض نکنین؛ فکر کردن، درک عمیقِ چرایی مشکل، وظیفه‌ی خودمونه.

نوشته‌شده توسط محمد نظری من اینجا هستم تا دانشم رو با شما به اشتراک بذارم.

دیدگاه‌ها 0

برای ارسال دیدگاه یا پسند، وارد شوید.

ورود / ثبت‌نام

هنوز دیدگاهی نیست — اولین نفر باشید.