تکرار امن درخواستها (Idempotency)
در سیستم توزیعشده، هر درخواست و هر پیام ممکن است دو بار برسد. یک کار Idempotent است اگر انجام دادن آن چند بار، همان نتیجه یک بار را بدهد. با کلید تکرار و کلید یکتا در دیتابیس، کارهای خطرناک مثل پرداخت را امن کن.
نویسنده: bezzad
مشکل: «جواب نگرفتم» یعنی «انجام نشد»؟
مشتری در اپ فروشگاه دکمه پرداخت را میزند. اپ درخواست را به سرویس پرداخت میفرستد. سرویس پرداخت از بانک پول را برمیدارد. ولی جواب در راه برگشت گم میشود. مثلاً اینترنت موبایل مشتری یک لحظه قطع میشود.
اپ نمیداند چه شد. فقط میداند جواب نگرفته است. سه حالت ممکن است:
- درخواست اصلاً به سرور نرسیده است.
- درخواست رسیده، ولی سرور وسط کار افتاده است.
- کار کامل انجام شده، ولی جواب گم شده است.
از طرف اپ، این سه حالت هیچ فرقی ندارند. پس اپ دوباره تلاش میکند (Retry). اگر سرور نتواند تشخیص دهد که این همان درخواست قبلی است، پول دو بار کم میشود.
تعریف Idempotency
یک کار Idempotent است اگر انجام دادنش دو بار، سه بار یا صد بار، همان نتیجه یک بار را بدهد.
Idempotentوضعیت را تنظیم کن
- «وضعیت سفارش ۴۲ را پرداختشده کن.»
- «آدرس مشتری را این آدرس کن.»
- «سفارش ۴۲ را حذف کن.»
دو بار انجام دادن، نتیجه را عوض نمیکند.
غیر Idempotentچیزی اضافه کن
- «۵۰۰ هزار تومان از حساب کم کن.»
- «یک سفارش جدید بساز.»
- «یک پیامک بفرست.»
هر بار انجام دادن، یک اثر جدید دارد.
چرا این موضوع برای Senior مهم است؟ چون در سیستم توزیعشده، تکرار عادی است، نه استثنا:
- کاربر دو بار کلیک میکند. یا صفحه را دوباره بارگذاری میکند.
- کلاینت یا Polly دوباره تلاش میکند. بعد از هر خطای زمان انتظار.
- کافکا و RabbitMQ پیام را دوباره تحویل میدهند. تحویل آنها «حداقل یک بار» است.
- الگوی Outbox پیام را دوباره میفرستد. اگر بعد از ارسال و قبل از علامت زدن بیفتد.
پس سؤال این نیست که «آیا تکرار پیش میآید؟». سؤال این است که «وقتی پیش آمد، چه میشود؟».
روشهای HTTP
استاندارد HTTP میگوید کدام روشها باید Idempotent باشند:
| روش | Idempotent؟ | مثال در فروشگاه |
|---|---|---|
| GET | بله | خواندن سفارش |
| PUT | بله | جایگزین کردن کامل آدرس |
| DELETE | بله | حذف کالا از سبد |
| POST | نه | ساختن سفارش جدید، پرداخت |
| PATCH | لزوماً نه | تغییر بخشی از سفارش |
این فقط یک قرارداد است. اگر کد تو برای PUT یک ردیف جدید بسازد، Idempotent نیست. مشکل اصلی هم معمولاً POST است. برای امن کردن POST به کلید تکرار نیاز داریم.
کلید تکرار (Idempotency Key)
ایده ساده است:
- کلاینت یک شناسه یکتا میسازد. مثلاً یک Guid، وقتی کاربر صفحه پرداخت را باز میکند. یک شناسه برای یک «قصد» کاربر.
- همین شناسه را در همه تلاشها میفرستد. معمولاً در هدری به اسم Idempotency-Key.
- سرور قبل از انجام کار، کلید را چک میکند. اگر کلید جدید است، کار را انجام میدهد و جواب را کنار کلید ذخیره میکند. اگر قبلاً دیده شده، همان جواب ذخیرهشده را برمیگرداند.
مثال زنده
یک بار بدون کلید و یک بار با کلید اجرا کن. شمارنده برداشت از حساب را ببین:
دام اصلی: دو درخواست در یک لحظه
راه سادهای که به ذهن میرسد این است: «اول چک کن کلید هست یا نه. اگر نبود، کار را انجام بده. آخر کار، کلید و جواب را ذخیره کن.» این روش یک مشکل همزمانی (Race Condition) دارد:
- درخواست اول چک میکند: کلید نیست.
- دو میلیثانیه بعد، درخواست دوم چک میکند: باز هم کلید نیست. چون درخواست اول هنوز چیزی ذخیره نکرده است.
- هر دو پرداخت را انجام میدهند.
راه درست: اول کلید را ثبت کن، بعد کار کن. جدول کلیدها یک کلید یکتا (Unique Constraint) روی شناسه مشتری و کلید دارد. دیتابیس تضمین میکند فقط یک درخواست بتواند آن ردیف را بسازد. حتی اگر دو درخواست روی دو 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 };
چند نکته در این کد:
- هش بدنه درخواست. اگر همان کلید با مبلغ دیگری بیاید، یعنی کلاینت باگ دارد. کار را انجام نمیدهیم و خطای ۴۲۲ برمیگردانیم.
- شناسه پرداخت ثابت. همان شناسه را به بانک میفرستیم. بیشتر درگاههای پرداخت با شناسه تکراری، پرداخت جدید نمیسازند. پس حتی بین ما و بانک هم تکرار امن است.
- کد ۴۰۹ برای «در حال انجام». کلاینت کمی صبر میکند و دوباره با همان کلید میپرسد.
مصرفکننده پیام
برای پیامها هم همین ایده کار میکند. هر پیام یک شناسه دارد. گیرنده شناسه را همراه با کارش در یک تراکنش ثبت میکند. اگر پیام دوباره رسید، ثبت شناسه شکست میخورد و کار تکرار نمیشود. به این روش 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);
قانونهای مهم
- هر کار با اثر جانبی را برای تکرار طراحی کن. پرداخت، ساختن سفارش، فرستادن پیامک، کم کردن موجودی.
- کلید را کلاینت میسازد، یک بار برای هر قصد. نه برای هر تلاش.
- اول کلید را ثبت کن، بعد کار کن. با کلید یکتا در دیتابیس، نه با چک در حافظه.
- جواب را ذخیره کن و همان را برگردان. درخواست تکراری باید همان جوابی را بگیرد که درخواست اول گرفت.
- کلید را به شناسه مشتری گره بزن. وگرنه کلید یک مشتری میتواند جواب مشتری دیگر را برگرداند.
- «نمیدانم» را با «نه» یکی نکن. بعد از خطای زمان انتظار، اول وضعیت را بپرس، بعد دوباره تلاش کن.
- کلیدها را بعد از مدتی پاک کن. مثلاً بعد از ۲۴ ساعت. این مدت باید از بیشترین زمان ممکن برای تلاش دوباره کلاینت بیشتر باشد.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| ساختن کلید جدید در هر تلاش | هر تلاش یک پرداخت جدید است. | یک کلید برای کل یک قصد کاربر. |
| اول چک، بعد کار، آخر ثبت کلید | دو درخواست همزمان هر دو انجام میشوند. | اول ثبت کلید با کلید یکتا. |
| استفاده از دستور lock یا قفل در حافظه | روی چند Pod کار نمیکند. | کلید یکتا در دیتابیس. |
| ثبت «ناموفق» بعد از خطای زمان انتظار | پول کم شده، ولی سفارش ناموفق است. | وضعیت نامعلوم و استعلام از بانک. |
| کلید بدون شناسه مشتری | جواب یک مشتری به مشتری دیگر میرسد. | کلید یکتا روی شناسه مشتری و کلید. |
| نادیده گرفتن بدنه متفاوت با کلید تکراری | باگ کلاینت پنهان میماند. | ذخیره هش بدنه و خطای ۴۲۲. |
چه وقت کلید تکرار لازم است؟
لازم است
- پرداخت، انتقال پول و هر کار مالی.
- ساختن سفارش یا هر چیزی که تکرارش یک ردیف اضافه میسازد.
- هر درخواستی که کلاینت یا Polly ممکن است دوباره بفرستد.
لازم نیست
- خواندن داده با GET.
- کارهایی که ذاتاً Idempotent هستند، مثل «وضعیت را این کن».
- کارهایی که تکرارشان هیچ هزینهای ندارد. ولی این را با دقت تصمیم بگیر.
خلاصه در شش خط
- در شبکه، هر درخواست و هر پیام ممکن است دو بار برسد. این عادی است.
- کار Idempotent یعنی چند بار انجام دادن، همان نتیجه یک بار را بدهد.
- برای POST و کارهای مالی، کلاینت یک کلید تکرار برای هر قصد میسازد.
- سرور اول کلید را با کلید یکتا در دیتابیس ثبت میکند، بعد کار را انجام میدهد.
- درخواست تکراری همان جواب ذخیرهشده را میگیرد، یا کد ۴۰۹ اگر هنوز در حال انجام است.
- خطای زمان انتظار یعنی «نمیدانم». اول استعلام کن، بعد دوباره تلاش کن.