Levelwise
فارسی
سیستم‌های توزیع‌شده

تکرار امن درخواست‌ها (Idempotency)

در سیستم توزیع‌شده، هر درخواست و هر پیام ممکن است دو بار برسد. یک کار Idempotent است اگر انجام دادن آن چند بار، همان نتیجه یک بار را بدهد. با کلید تکرار و کلید یکتا در دیتابیس، کارهای خطرناک مثل پرداخت را امن کن.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال پرداخت در فروشگاه اینترنتیکد C# و ASP.NET Core در .NET 10

نویسنده: bezzad

مشکل: «جواب نگرفتم» یعنی «انجام نشد»؟

مشتری در اپ فروشگاه دکمه پرداخت را می‌زند. اپ درخواست را به سرویس پرداخت می‌فرستد. سرویس پرداخت از بانک پول را برمی‌دارد. ولی جواب در راه برگشت گم می‌شود. مثلاً اینترنت موبایل مشتری یک لحظه قطع می‌شود.

اپ موبایلسرویس پرداختبانکپرداخت ۵۰۰ هزار تومانبرداشت اولجواب گم شدزمان انتظار تمام شددوباره: پرداخت ۵۰۰ هزار تومانبرداشت دومیک خرید، دو برداشت
اپ جواب نگرفت. ولی پرداخت انجام شده بود. تلاش دوباره، یک پرداخت جدید ساخت.

اپ نمی‌داند چه شد. فقط می‌داند جواب نگرفته است. سه حالت ممکن است:

  1. درخواست اصلاً به سرور نرسیده است.
  2. درخواست رسیده، ولی سرور وسط کار افتاده است.
  3. کار کامل انجام شده، ولی جواب گم شده است.

از طرف اپ، این سه حالت هیچ فرقی ندارند. پس اپ دوباره تلاش می‌کند (Retry). اگر سرور نتواند تشخیص دهد که این همان درخواست قبلی است، پول دو بار کم می‌شود.

جمله کلیدی: در شبکه، «جواب نگرفتم» یعنی «نمی‌دانم». نه «انجام نشد».

تعریف Idempotency

یک کار Idempotent است اگر انجام دادنش دو بار، سه بار یا صد بار، همان نتیجه یک بار را بدهد.

Idempotentوضعیت را تنظیم کن

  • «وضعیت سفارش ۴۲ را پرداخت‌شده کن.»
  • «آدرس مشتری را این آدرس کن.»
  • «سفارش ۴۲ را حذف کن.»

دو بار انجام دادن، نتیجه را عوض نمی‌کند.

غیر Idempotentچیزی اضافه کن

  • «۵۰۰ هزار تومان از حساب کم کن.»
  • «یک سفارش جدید بساز.»
  • «یک پیامک بفرست.»

هر بار انجام دادن، یک اثر جدید دارد.

چرا این موضوع برای Senior مهم است؟ چون در سیستم توزیع‌شده، تکرار عادی است، نه استثنا:

  1. کاربر دو بار کلیک می‌کند. یا صفحه را دوباره بارگذاری می‌کند.
  2. کلاینت یا Polly دوباره تلاش می‌کند. بعد از هر خطای زمان انتظار.
  3. کافکا و RabbitMQ پیام را دوباره تحویل می‌دهند. تحویل آن‌ها «حداقل یک بار» است.
  4. الگوی Outbox پیام را دوباره می‌فرستد. اگر بعد از ارسال و قبل از علامت زدن بیفتد.

پس سؤال این نیست که «آیا تکرار پیش می‌آید؟». سؤال این است که «وقتی پیش آمد، چه می‌شود؟».

روش‌های HTTP

استاندارد HTTP می‌گوید کدام روش‌ها باید Idempotent باشند:

روش Idempotent؟ مثال در فروشگاه
GET بله خواندن سفارش
PUT بله جایگزین کردن کامل آدرس
DELETE بله حذف کالا از سبد
POST نه ساختن سفارش جدید، پرداخت
PATCH لزوماً نه تغییر بخشی از سفارش

این فقط یک قرارداد است. اگر کد تو برای PUT یک ردیف جدید بسازد، Idempotent نیست. مشکل اصلی هم معمولاً POST است. برای امن کردن POST به کلید تکرار نیاز داریم.

کلید تکرار (Idempotency Key)

ایده ساده است:

  1. کلاینت یک شناسه یکتا می‌سازد. مثلاً یک Guid، وقتی کاربر صفحه پرداخت را باز می‌کند. یک شناسه برای یک «قصد» کاربر.
  2. همین شناسه را در همه تلاش‌ها می‌فرستد. معمولاً در هدری به اسم Idempotency-Key.
  3. سرور قبل از انجام کار، کلید را چک می‌کند. اگر کلید جدید است، کار را انجام می‌دهد و جواب را کنار کلید ذخیره می‌کند. اگر قبلاً دیده شده، همان جواب ذخیره‌شده را برمی‌گرداند.
درخواست با کلیدIdempotency-Key: 7f3aاین کلید را دیده‌ایم؟جستجو در جدول کلیدهانه: کلید را ثبت کن و کار را بکنجواب را کنار کلید ذخیره کنبله و تمام شدههمان جواب ذخیره‌شده را برگردانبله، ولی هنوز در حال انجامبگو بعداً دوباره بپرسکلاینت در همه تلاش‌ها همین کلید را می‌فرستد
سرور برای هر کلید فقط یک بار کار را انجام می‌دهد.
کلید مال یک قصد است، نه یک درخواست: اگر اپ برای هر تلاش یک کلید جدید بسازد، هیچ فایده‌ای ندارد. کلید باید قبل از اولین تلاش ساخته شود و تا پایان همان پرداخت ثابت بماند.

مثال زنده

یک بار بدون کلید و یک بار با کلید اجرا کن. شمارنده برداشت از حساب را ببین:

مثال زنده: پرداخت ۵۰۰ هزار تومان
۰درخواست به سرور
۰برداشت از حساب
-وضعیت کلید
گزینه را انتخاب کن و یک سناریو را اجرا کن.

    دام اصلی: دو درخواست در یک لحظه

    راه ساده‌ای که به ذهن می‌رسد این است: «اول چک کن کلید هست یا نه. اگر نبود، کار را انجام بده. آخر کار، کلید و جواب را ذخیره کن.» این روش یک مشکل همزمانی (Race Condition) دارد:

    بد: اول چک، بعد کار، آخر ثبتدرخواست ۱درخواست ۲چک: نیستچک: نیستپرداختپرداختثبت نتیجهثبت نتیجههر دو پرداخت کردندخوب: اول ثبت کلید با کلید یکتادرخواست ۱درخواست ۲ثبت کلید: موفقثبت کلید: تکراریپرداختکد ۴۰۹ثبت نتیجهدیتابیس فقط یکی را قبول کردزمان از بالا به پایین
    در روش بد، هر دو درخواست قبل از اینکه دیگری چیزی ذخیره کند، چک می‌کنند.
    1. درخواست اول چک می‌کند: کلید نیست.
    2. دو میلی‌ثانیه بعد، درخواست دوم چک می‌کند: باز هم کلید نیست. چون درخواست اول هنوز چیزی ذخیره نکرده است.
    3. هر دو پرداخت را انجام می‌دهند.

    راه درست: اول کلید را ثبت کن، بعد کار کن. جدول کلیدها یک کلید یکتا (Unique Constraint) روی شناسه مشتری و کلید دارد. دیتابیس تضمین می‌کند فقط یک درخواست بتواند آن ردیف را بسازد. حتی اگر دو درخواست روی دو Pod مختلف باشند.

    دستور lock کافی نیست: دستور lock در C# فقط داخل یک Process کار می‌کند. سرویس تو روی چند Pod اجرا می‌شود. پس قفل باید در جایی مشترک باشد، مثل دیتابیس.

    کد

    جدول کلیدها

    public enum KeyStatus { InProgress, Completed }
    
    public sealed class IdempotencyRecord
    {
        public required Guid CustomerId { get; init; }   // key part 1
        public required string Key { get; init; }        // key part 2
        public required string RequestHash { get; init; }
        public Guid PaymentId { get; init; } = Guid.NewGuid();
        public KeyStatus Status { get; set; } = KeyStatus.InProgress;
        public int? ResponseStatus { get; set; }
        public string? ResponseBody { get; set; }
        public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
    }
    
    // In OnModelCreating: one row per customer + key.
    modelBuilder.Entity<IdempotencyRecord>().HasKey(r => new { r.CustomerId, r.Key });

    اندپوینت پرداخت

    app.MapPost("/payments", async (
        [FromHeader(Name = "Idempotency-Key")] string? key,
        PayRequest request, ShopDbContext db, IBankGateway bank, CancellationToken ct) =>
    {
        if (string.IsNullOrWhiteSpace(key))
            return Results.BadRequest("Idempotency-Key header is required.");
    
        var hash = Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(request)));
        var record = new IdempotencyRecord { CustomerId = request.CustomerId, Key = key, RequestHash = hash };
    
        // 1. Claim the key first. The database lets only one request win.
        db.IdempotencyRecords.Add(record);
        try
        {
            await db.SaveChangesAsync(ct);
        }
        catch (DbUpdateException ex) when (IsDuplicateKey(ex))
        {
            db.ChangeTracker.Clear();
            var old = await db.IdempotencyRecords.SingleAsync(
                r => r.CustomerId == request.CustomerId && r.Key == key, ct);
    
            if (old.RequestHash != hash)
                return Results.UnprocessableEntity("Same key was used for a different request.");
            if (old.Status == KeyStatus.InProgress)
                return Results.Conflict("This payment is still in progress.");
    
            // 2. Same request again: return the saved response.
            return Results.Content(old.ResponseBody, "application/json", statusCode: old.ResponseStatus);
        }
    
        // 3. Only one request reaches this line. PaymentId is the same in every retry to the bank.
        var receipt = await bank.ChargeAsync(record.PaymentId, request.Amount, ct);
    
        record.Status = KeyStatus.Completed;
        record.ResponseStatus = StatusCodes.Status201Created;
        record.ResponseBody = JsonSerializer.Serialize(receipt);
        await db.SaveChangesAsync(ct);
    
        return Results.Created($"/payments/{record.PaymentId}", receipt);
    });
    
    // PostgreSQL with Npgsql. Other databases use other error codes.
    static bool IsDuplicateKey(DbUpdateException ex) =>
        ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };

    چند نکته در این کد:

    1. هش بدنه درخواست. اگر همان کلید با مبلغ دیگری بیاید، یعنی کلاینت باگ دارد. کار را انجام نمی‌دهیم و خطای ۴۲۲ برمی‌گردانیم.
    2. شناسه پرداخت ثابت. همان شناسه را به بانک می‌فرستیم. بیشتر درگاه‌های پرداخت با شناسه تکراری، پرداخت جدید نمی‌سازند. پس حتی بین ما و بانک هم تکرار امن است.
    3. کد ۴۰۹ برای «در حال انجام». کلاینت کمی صبر می‌کند و دوباره با همان کلید می‌پرسد.
    اگر بانک جواب نداد؟ در این کد، اگر صدا زدن بانک خطای زمان انتظار بدهد، ردیف در وضعیت «در حال انجام» می‌ماند. این عمداً است. ما نمی‌دانیم پول کم شده یا نه. پس پرداخت را «ناموفق» ثبت نمی‌کنیم. یک کار پس‌زمینه بعداً با همان شناسه پرداخت از بانک وضعیت را می‌پرسد (Reconciliation) و ردیف را کامل می‌کند.

    مصرف‌کننده پیام

    برای پیام‌ها هم همین ایده کار می‌کند. هر پیام یک شناسه دارد. گیرنده شناسه را همراه با کارش در یک تراکنش ثبت می‌کند. اگر پیام دوباره رسید، ثبت شناسه شکست می‌خورد و کار تکرار نمی‌شود. به این روش Inbox می‌گویند و در درس Outbox و Inbox کامل آمده است.

    راه ساده‌تر این است که خود کار را ذاتاً Idempotent بنویسی:

    // Not idempotent: a duplicate message adds stock twice.
    product.Stock += message.Quantity;
    
    // Idempotent: a duplicate message changes nothing.
    if (order.Status != OrderStatus.Paid)
        order.MarkPaid(message.PaymentId);

    قانون‌های مهم

    1. هر کار با اثر جانبی را برای تکرار طراحی کن. پرداخت، ساختن سفارش، فرستادن پیامک، کم کردن موجودی.
    2. کلید را کلاینت می‌سازد، یک بار برای هر قصد. نه برای هر تلاش.
    3. اول کلید را ثبت کن، بعد کار کن. با کلید یکتا در دیتابیس، نه با چک در حافظه.
    4. جواب را ذخیره کن و همان را برگردان. درخواست تکراری باید همان جوابی را بگیرد که درخواست اول گرفت.
    5. کلید را به شناسه مشتری گره بزن. وگرنه کلید یک مشتری می‌تواند جواب مشتری دیگر را برگرداند.
    6. «نمی‌دانم» را با «نه» یکی نکن. بعد از خطای زمان انتظار، اول وضعیت را بپرس، بعد دوباره تلاش کن.
    7. کلیدها را بعد از مدتی پاک کن. مثلاً بعد از ۲۴ ساعت. این مدت باید از بیشترین زمان ممکن برای تلاش دوباره کلاینت بیشتر باشد.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    ساختن کلید جدید در هر تلاش هر تلاش یک پرداخت جدید است. یک کلید برای کل یک قصد کاربر.
    اول چک، بعد کار، آخر ثبت کلید دو درخواست همزمان هر دو انجام می‌شوند. اول ثبت کلید با کلید یکتا.
    استفاده از دستور lock یا قفل در حافظه روی چند Pod کار نمی‌کند. کلید یکتا در دیتابیس.
    ثبت «ناموفق» بعد از خطای زمان انتظار پول کم شده، ولی سفارش ناموفق است. وضعیت نامعلوم و استعلام از بانک.
    کلید بدون شناسه مشتری جواب یک مشتری به مشتری دیگر می‌رسد. کلید یکتا روی شناسه مشتری و کلید.
    نادیده گرفتن بدنه متفاوت با کلید تکراری باگ کلاینت پنهان می‌ماند. ذخیره هش بدنه و خطای ۴۲۲.

    چه وقت کلید تکرار لازم است؟

    لازم است

    • پرداخت، انتقال پول و هر کار مالی.
    • ساختن سفارش یا هر چیزی که تکرارش یک ردیف اضافه می‌سازد.
    • هر درخواستی که کلاینت یا Polly ممکن است دوباره بفرستد.

    لازم نیست

    • خواندن داده با GET.
    • کارهایی که ذاتاً Idempotent هستند، مثل «وضعیت را این کن».
    • کارهایی که تکرارشان هیچ هزینه‌ای ندارد. ولی این را با دقت تصمیم بگیر.

    خلاصه در شش خط

    1. در شبکه، هر درخواست و هر پیام ممکن است دو بار برسد. این عادی است.
    2. کار Idempotent یعنی چند بار انجام دادن، همان نتیجه یک بار را بدهد.
    3. برای POST و کارهای مالی، کلاینت یک کلید تکرار برای هر قصد می‌سازد.
    4. سرور اول کلید را با کلید یکتا در دیتابیس ثبت می‌کند، بعد کار را انجام می‌دهد.
    5. درخواست تکراری همان جواب ذخیره‌شده را می‌گیرد، یا کد ۴۰۹ اگر هنوز در حال انجام است.
    6. خطای زمان انتظار یعنی «نمی‌دانم». اول استعلام کن، بعد دوباره تلاش کن.