Idempotency: تکرار عملیات بدون اثر دوباره
· 17 دقیقه مطالعهمهندسی معماری نرمافزار #csharp #dotnet #idempotency #background-processing
یک عملیات زمانی idempotent است که چند بار انجام دادن آن، همان اثر موردنظرِ یک بار انجام دادنش را داشته باشد. به این ویژگی idempotency میگوییم: کد میتواند چند بار اجرا شود، بدون اینکه همان تغییر در وضعیت کسبوکار دوباره اعمال شود.
مثلاً یک کار پسزمینه، ۱۰۰ امتیاز پاداش به حساب مشتری اضافه میکند. امتیاز ثبت میشود، اما تأیید موفقیت به فراخواننده نمیرسد. فراخواننده با timeout1 مواجه میشود و کار را دوباره میفرستد. اگر worker2 بار دوم را درخواست تازهای برای افزودن امتیاز بداند، مشتری برای یک خرید ۲۰۰ امتیاز میگیرد.
بعد از timeout، فراخواننده نمیداند عملیات پیش از هر تغییری شکست خورده، با موفقیت تمام شده یا هنوز در حال اجراست. فقط میداند که نتیجه بهموقع نرسیده است. همین وضعیت ممکن است با قطع اتصال API، توقف worker پیش از تأیید دریافت پیام یا ارسال دوبارهی یک کار زمانبندیشده پیش بیاید.
تلاش مجدد به بازیابی از خطاهای موقت کمک میکند، اما ممکن است همان تغییر را دوباره در کسبوکار اعمال کند. پیش از تعیین سیاست تلاش مجدد، مشخص کنید برنامه چطور با این تکرار برخورد میکند. بیشتر کردن timeout شاید دفعات بروز مشکل را کمتر کند، اما روشن نمیکند که تلاش قبلی موفق بوده یا نه.
نمونهی این نوشته یک worker پسزمینه در C# است، اما همین مسئله در APIها و مصرفکنندههای پیام هم پیش میآید.
از اثر عملیات شروع کنید، نه سازوکار تلاش مجدد
اگر زبان ترجیحی یک حساب را انگلیسی تعیین کنید، تکرار این کار مشکلی ایجاد نمیکند؛ زبان حساب همچنان انگلیسی میماند. اضافه کردن ۱۰۰ امتیاز فرق دارد: هر بار اضافه کردن، موجودی را دوباره تغییر میدهد. برای اینکه تکرار درخواست امتیاز دوباره اضافه نکند، سیستم باید تشخیص دهد که چند تلاش مربوط به یک عملیات هستند.
در HTTP هم معیار همین اثر موردنظر است. RFC 9110، متدهای PUT، DELETE و متدهای امن را idempotent میداند، اما لازم نیست پاسخ همهی درخواستهای تکراری یکسان باشد.[1] مثلاً هنگام تکرار درخواست حذف، ممکن است منبع دیگر وجود نداشته باشد، ولی هدف حذف همچنان برآورده شده است.
در این نمونه، نتیجهی عملیات افزودن امتیاز را بهعنوان رسید ذخیره میکنیم و در تلاش مجدد برمیگردانیم. فراخواننده از این راه نتیجهای را که به او نرسیده بازیابی میکند. برگرداندن رسید بخشی از قرارداد این برنامه است؛ idempotency بهتنهایی فقط میگوید اثر موردنظر نباید با تکرار تغییر کند.
ثبت لاگ برای هر تلاش لزوماً این ویژگی را نقض نمیکند، اما اضافه کردن امتیاز در هر تلاش آن را نقض میکند.
صف هم ممکن است یک کار را چند بار تحویل دهد. مستندات Amazon SQS میگویند صفهای استاندارد ممکن است یک نسخه از پیام را دوباره تحویل دهند و توصیه میکنند پردازش پیام idempotent باشد.[2] قرارداد صف خود را بررسی کنید؛ قواعد تحویل یکسان نیستند و صف محلی هم بهخودیخود امکان بازیابی را فراهم نمیکند.
برای هر عملیات یک شناسه داشته باشید
در تلاشهای مجدد، کلید عملیات اصلی را نگه دارید. اگر برای هر تلاش کلید تازهای بسازید، گیرنده چند عملیات جدا برای افزودن امتیاز میبیند، نه چند بار دریافت همان عملیات.
برای پاداش خرید میتوان از شناسهی سفارش استفاده کرد؛ به شرط آنکه قاعدهی کسبوکار برای هر سفارش دقیقاً یک پاداش از این نوع مجاز بداند. اگر یک سفارش چند عملیات پاداش دارد، شناسهی سفارش بهتنهایی کافی نیست. کلید باید این عملیاتها را از هم جدا کند و در تلاشهای مجدد هر کدام ثابت بماند.
در این نمونه، کلید به یک حساب و عملیات افزودن امتیاز محدود است. همان کلید در حسابی دیگر به عملیات جداگانهای اشاره میکند.
اگر برنامه چند مستأجر یا چند نوع عملیات دارد، آنها را هم در محدودهی کلید در نظر بگیرید. با یک فضای مشترک برای همهی کلیدها، ممکن است کلید فراخوانندههای نامرتبط با هم یکسان شود.
استفادهی دوباره از کلید یعنی درخواست هم باید همان درخواست قبلی باشد. فراخواننده نمیتواند با یک حساب و کلید، ۱۰۰ امتیاز بخواهد و بعد مقدار را به ۲۰۰ تغییر دهد و آن را تلاش مجدد بنامد. گیرنده باید این ناسازگاری را رد کند؛ نه اینکه بیصدا نتیجهای نامرتبط برگرداند یا درخواست تغییرکرده را اعمال کند.
یکسان بودن payload3 هم ثابت نمیکند که دو درخواست برای یک عملیات هستند. مشتری ممکن است از دو خرید جداگانه، هر بار ۱۰۰ امتیاز بگیرد. اگر فقط حساب و مقدار امتیاز را معیار تشخیص تکرار قرار دهید، پاداش خرید دوم را نادیده میگیرید.
کلید عملیات هدف درخواست را مشخص میکند؛ مقایسهی payload جلوی استفاده از همان کلید برای کاری متفاوت را میگیرد.
یک نمونهی ساده از پردازش پسزمینه
سولوشن C# این نمونه یک پروژهی کنسول .NET 10 دارد: عملیات افزودن امتیاز، مخزن داده در حافظه و مصرفکنندهی پسزمینه در همین پروژه هستند. با اجرای برنامه، هم تلاش مجدد را میبینید و هم رفتار موردانتظار را بررسی میکنید.
از ریشهی ریپازیتوری code-samples اجرا کنید:
dotnet run --project idempotency-make-retries-safe/IdempotencyDemo
در GrantPoints.cs، عملیات فقط با داده توصیف میشود:
public sealed record GrantPoints(string Key, string AccountId, int Points);
هر بار تحویل این داده، یک تلاش برای پردازش آن است. ممکن است امتیاز اضافه شود، اما فراخواننده از موفقیت آن تلاش باخبر نشود.
PointWorker.cs درخواستها را با Channel<T> از صفی درون همان فرایند عبور میدهد. تولیدکنندهها در صف مینویسند و مصرفکننده بهصورت غیرهمگام از آن میخواند. Channel بخشی از فریمورک مشترک .NET است. با ظرفیت محدود و تنظیم Wait، نوشتن غیرهمگام تا آزاد شدن فضا منتظر میماند و درخواستها کنار گذاشته نمیشوند.[3]
private readonly Channel<Delivery> inbox = Channel.CreateBounded<Delivery>(
new BoundedChannelOptions(16) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
ظرفیت صف برای همین نمونه است؛ آن را مبنای توان پردازش برنامهی خود نگذارید. تولیدکنندهها میتوانند همزمان کار بفرستند، اما فقط یک مصرفکننده داریم. این مصرفکننده در همان فرایند اجرا میشود؛ سرویس جداگانهای نیست و لزوماً نخ اختصاصی هم ندارد.
این Channel یک بروکر پیام با ذخیرهسازی ماندگار نیست و بعد از توقف ناگهانی فرایند، پیام ازدسترفته را دوباره تحویل نمیدهد. در نمونه، خود فراخواننده تلاش مجدد را ارسال میکند تا دقیقاً مشخص باشد چه زمانی رخ میدهد.
از افزودن امتیاز محافظت کنید و نتیجه را نگه دارید
مخزن داده، موجودی حسابها و رسید عملیاتهای تکمیلشده را نگه میدارد. منظور از رسید، نتیجهی ثبتشدهی عملیات است: حساب، کلید عملیات، مقدار امتیاز اضافهشده و موجودی حاصل از آن. GrantResult.cs این نتیجه را تعریف میکند.
متد Apply در PointStore.cs هم درخواست تازه و هم تلاش مجدد را پردازش میکند. کد کامل متد را میبینید:
public GrantResult Apply(GrantPoints grant)
{
ArgumentNullException.ThrowIfNull(grant);
ArgumentException.ThrowIfNullOrWhiteSpace(grant.Key);
ArgumentException.ThrowIfNullOrWhiteSpace(grant.AccountId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(grant.Points);
lock (gate)
{
var key = (grant.AccountId, grant.Key);
if (completed.TryGetValue(key, out var previous))
{
if (previous.Points != grant.Points)
throw new InvalidOperationException("The same key cannot describe a different grant.");
return previous;
}
var balance = checked(balances.GetValueOrDefault(grant.AccountId) + grant.Points);
var result = new GrantResult(grant.Key, grant.AccountId, grant.Points, balance);
balances[grant.AccountId] = balance;
completed.Add(key, result);
return result;
}
}
متد ابتدا ورودی را بررسی میکند. نبودن شناسهی حساب، خالی بودن کلید یا مثبت نبودن مقدار امتیاز، خطای موقت تحویل نیست. ارسال دوبارهی همان ورودی نامعتبر مشکل را حل نمیکند. محاسبه با checked انجام میشود؛ بنابراین سرریز عدد پیش از تغییر موجودی یا ثبت عملیات بهعنوان تکمیلشده، خطا ایجاد میکند.
داخل قفل، مخزن داده ترکیب حساب و کلید را جستوجو میکند. اگر رسیدی وجود داشته باشد، یکسان بودن مقدار امتیاز را بررسی میکند و بدون افزودن امتیاز، همان رسید را برمیگرداند. در غیر این صورت، موجودی جدید را محاسبه میکند، حساب را تغییر میدهد و نتیجه را ذخیره میکند.
قفل باید از جستوجوی کلید تا تغییر موجودی و ثبت رسید را پوشش دهد. وگرنه ممکن است دو فراخواننده همزمان ببینند که کلید ثبت نشده و هر دو امتیاز را اضافه کنند. حتی اگر خود دیکشنری دسترسی همزمان را مدیریت کند، تغییر موجودی و ثبت رسید هنوز به هماهنگی نیاز دارند.
همهی فراخوانندهها باید از یک نمونهی مشترک مخزن داده استفاده کنند. مخزن تازه برای هر بار تحویل، موجودیها، رسیدها و قفل خودش را دارد. فرایندهای جداگانهی برنامه هم مخزنهای جدا دارند و قفل محلی نمیتواند آنها را هماهنگ کند.
قفل فراخوانندهها را هماهنگ میکند، اما نمیتواند تغییر نیمهتمام را برگرداند یا دادهها را پس از توقف ناگهانی یا راهاندازی مجدد بازیابی کند. مثلاً اگر موجودی تغییر کند ولی ثبت رسید خطا بدهد، اطلاعات دو دیکشنری با هم سازگار نخواهند بود. قفل جای تراکنش پایگاه داده را نمیگیرد.
در بخش راهاندازی مجدد، استفاده از تراکنش و محدودیت یکتایی را برای نگهداری دادهها در پایگاه داده توضیح میدهیم.
وقتی عملیات موفق است، اما تأیید آن نمیرسد
worker نرسیدن تأیید را بعد از برگشتن store.Apply شبیهسازی میکند: امتیاز اضافه شده، اما فراخواننده بهجای نتیجه، خطای timeout میگیرد. در حلقهی مصرفکنندهی PointWorker.cs، خطا را دقیقاً در همین نقطه ایجاد میکنیم:
private async Task ConsumeAsync()
{
await foreach (var delivery in inbox.Reader.ReadAllAsync())
{
try
{
var result = store.Apply(delivery.Grant);
if (delivery.LoseAcknowledgement)
throw new TimeoutException("Simulated acknowledgement loss after the grant succeeded.");
delivery.Completion.SetResult(result);
}
catch (Exception error) when (error is ArgumentException or InvalidOperationException or OverflowException or TimeoutException)
{
delivery.Completion.SetException(error);
}
}
}
هر بار که فراخواننده کار را میفرستد، نتیجه یا استثنای پردازش را از طریق یک تسک دریافت میکند. worker خطاهای موردانتظار را به او گزارش میکند و سراغ درخواستهای بعدی میرود. اگر درخواست ناسازگار باشد، خطا را پنهان نمیکند و آن را موفق نشان نمیدهد.
در این پروژه اتصال شبکه، رقابت زمانی با تایمر یا تأیید دریافت پیام از بروکر نداریم. ایجاد استثنا در نقطهای ثابت، خطای شبیهسازیشده را تکرارپذیر میکند: امتیاز پیش از رسیدن خطای timeout به فراخواننده اضافه شده است.
فراخواننده همان عملیات اصلی را دوباره درخواست میکند. این بخش از Program.cs کپی شده و بررسیهایی را هم دارد که در صورت نقض رفتار موردنظر، استثنا ایجاد میکنند:
var backgroundStore = new PointStore();
await using (var worker = new PointWorker(backgroundStore))
{
var work = new GrantPoints("order-1042", "account-7", 100);
await ExpectAsync<TimeoutException>(() => worker.ProcessAsync(work, loseAcknowledgement: true),
"the first delivery loses its acknowledgement after applying the grant");
Check(backgroundStore.GetBalance("account-7") == 100,
"the grant succeeded despite the missing acknowledgement");
var retry = await worker.ProcessAsync(work);
Check(retry.Balance == 100 && backgroundStore.GetBalance("account-7") == 100,
"retrying through the worker adds no extra points");
}
وقتی تلاش اول timeout را گزارش میکند، موجودی از قبل ۱۰۰ شده است. تلاش مجدد همان موجودی ثبتشدهی ۱۰۰ را برمیگرداند و حساب را دوباره تغییر نمیدهد. worker دو بار همان درخواست افزودن امتیاز را پردازش کرده است.
برنامه بررسی میکند که payload تغییرکرده رد شود، کلیدها به حساب محدود باشند و درخواست تازه امتیاز اضافه کند. بررسیهای دیگر، فراخوانندههای همزمان مخزن، ورودی نامعتبر، سرریز عدد، ادامهی پردازش بعد از رد یک درخواست و رد درخواستهای تازه پس از خاموش شدن worker را پوشش میدهند. اگر هر بررسی شکست بخورد، dotnet run با خطا خارج میشود.
رسید ذخیرهشده، وضعیت فعلی حساب نیست
فرض کنید خرید بعدی ۲۵ امتیاز دیگر اضافه کند. حالا حساب ۱۲۵ امتیاز دارد. درخواست دوبارهی عملیات اولیهی ۱۰۰ امتیازی باید رسید همان عملیات را برگرداند؛ رسیدی که موجودی حاصل در آن ۱۰۰ بود. موجودی فعلی حساب باید همان ۱۲۵ باقی بماند.
رسید نتیجهی همان عملیات قبلی را نشان میدهد؛ محاسبهی دوبارهی آن از روی موجودی فعلی، معنایش را عوض میکند. برای دریافت آخرین موجودی، یک عملیات خواندن جداگانه در اختیار فراخواننده بگذارید.
نمونه این رفتار را بررسی میکند، بعد درخواست را با کلیدی تازه میفرستد و تأیید میکند که امتیاز دوباره اضافه میشود. مخزن داده آن کلید را عملیات جدیدی میداند. حفظ کلید اصلی در همهی تلاشهای مجدد، مسئولیت فراخواننده است؛ مخزن نمیتواند حدس بزند که کلید تازه قرار بوده تلاش مجدد باشد.
وقتی فرایند دوباره راهاندازی میشود
با خروج فرایند، موجودیها، درخواستهای داخل صف و رسیدها از بین میروند. نمونه نشان میدهد تا وقتی فرایند در حال اجراست، درخواست تکراری چطور پردازش میشود. بعد از راهاندازی مجدد نمیتواند عملیات قبلی را تشخیص دهد.
ذخیرهی ماندگار کلیدها بهتنهایی کافی نیست. اگر پیش از افزودن امتیاز، عملیات را تکمیلشده ثبت کنید، ممکن است فرایند متوقف شود و سابقهی تکمیل باقی بماند، بیآنکه امتیازی اضافه شده باشد.
اگر اول امتیاز را اضافه کنید و بعد کلید را ثبت کنید، ممکن است امتیاز باقی بماند، اما رسید ثبت نشده باشد. در این حالت، تلاش مجدد میتواند دوباره امتیاز اضافه کند.
اگر وضعیت کسبوکار و رسیدها در یک پایگاه داده هستند، نوشتن هر دو را در یک تراکنش انجام دهید. در همان مخزن مشترک، با محدودیت یکتایی جلوی ثبت دوبارهی کلید در محدودهی عملیات را بگیرید تا workerهای جداگانه نتوانند همان عملیات را مستقل از هم commit کنند.
موفقیت را فقط بعد از commit برگردانید یا تأیید کنید. اگر تأیید بعد از commit از دست برود، worker در تلاش مجدد میتواند رسید ثبتشده را بخواند.
اگر دو تلاش بهدلیل محدودیت یکتایی با هم تعارض پیدا کنند، تلاشی که نمیتواند تغییراتش را ثبت نهایی کند باید آنها را برگرداند و نتیجهی ثبتشده را بخواند. این تلاش نباید امتیاز را دوباره اضافه کند.
برای کار طولانی در یک سرویس بیرونی، جداگانه مشخص کنید کدام worker مسئول عملیات است و worker دیگر چطور میتواند آن را بازیابی کند. نگه داشتن قفل محلی، فراخوانی سرویس بیرونی را تراکنشی نمیکند.
پروژهی کنسول پایگاه داده ندارد تا بدون تنظیمات اولیه اجرا شود و بتوانید کد رسیدگی به درخواست تکراری را مستقیم دنبال کنید. مخزن مشترک آن فقط تا وقتی فرایند در حال اجراست، فراخوانندهها را هماهنگ میکند. برای حفظ این تضمین پس از راهاندازی مجدد یا میان چند فرایند، به ذخیرهسازی ماندگار و تراکنش نیاز دارید.
زمان نگهداری سوابق و اثرهای بیرونی، محدودهی تضمین را مشخص میکنند
اگر سابقهی عملیاتهای تکمیلشده را حذف کنید، ممکن است کلید قدیمی دوباره تازه به نظر برسد. سوابق را آنقدر نگه دارید که تلاشهای مجدد موردنیاز را پوشش دهند؛ از جمله کارهای دیررس، بازیابی و پردازش مجدد دستی. زمان حذف سوابق تعیین میکند کدام تکرارها بدون اثر دوباره هستند؛ فقط راهی برای صرفهجویی در فضای ذخیرهسازی نیست.
قواعد ارائهدهندهها یکسان نیست. طبق مستندات API v1 در Stripe، پس از شروع اجرای endpoint، اولین پاسخ ذخیره میشود و استفاده از همان کلید با پارامترهای متفاوت خطا میدهد. کلیدها را هم میتوان پس از گذشت حداقل ۲۴ ساعت حذف کرد. همان مستندات برای API v2 رفتار متفاوتی در تلاش مجدد توضیح میدهند.[4] زمان نگهداری کلید یا قواعد برگرداندن پاسخ خطای یک ارائهدهنده را به همهی برنامهها تعمیم ندهید.
نمونه همهی رسیدها را تا پایان اجرای خود نگه میدارد؛ سیاست انقضا یا فرایند پاکسازی ندارد.
در یک سرویس واقعی مشخص کنید تا وقتی تلاش اول در حال اجراست، درخواست تکراری چه پاسخی میگیرد و کدام خطاها را میتوان دوباره امتحان کرد. همچنین مشخص کنید آیا پس از انقضای رسید، از روی دادههای ماندگار کسبوکار هنوز میتوان عملیات را شناسایی کرد یا نه.
مجوز دسترسی را هم باید در هر تلاش بررسی کنید. کلید عملیات یک شناسه است، نه مجوز خواندن رسید حساب دیگر. در صورت نیاز، محدودهی مستأجر و حساب را از هویت احرازشدهی فراخواننده به دست بیاورید و اجازه ندهید حدس زدن کلیدها نتیجههای ذخیرهشده را در اختیار دیگران بگذارد.
ارسال ایمیل، فراخوانی ارائهدهندهی پرداخت یا فرستادن پیام به یک سرویس دیگر، خارج از تراکنش پایگاه دادهی محلی قرار دارد. ثبت اینکه امتیاز اضافه شده، ثابت نمیکند که ایمیل فقط یک بار ارسال شده است. اگر گیرنده از شناسهی عملیات پشتیبانی میکند، در ارتباط با آن از شناسهای ثابت استفاده کنید.
الگوی Outbox میتواند درخواست ارسال را همراه با تغییر وضعیت در همان تراکنش ثبت کند، اما تحویل پیام همچنان ممکن است تکرار شود. سرویس مقصد هم باید از اعمال دوبارهی اثر جلوگیری کند یا راهی برای بازیابی داشته باشد.
فقط خطاهایی را دوباره امتحان کنید که احتمال برطرف شدنشان وجود دارد و تعداد تلاشها را محدود کنید. payload تغییرکرده و ورودی نامعتبر به اصلاح نیاز دارند. خطای موقت ممکن است تلاش دوباره با همان شناسه را توجیه کند. اگر نتیجه همچنان نامعلوم ماند، وضعیت واقعی عملیات را بررسی کنید، نه اینکه بیپایان تلاش کنید.
منابع
پانویسها
- timeout یعنی زمان تعیینشده برای انتظار نتیجه تمام شده است؛ نه اینکه عملیات حتماً شکست خورده باشد.
- worker بخشی از برنامه است که کارهای دریافتشده را در پسزمینه پردازش میکند.
- payload دادههای درخواست است؛ در این مثال، کلید عملیات، شناسهی حساب و مقدار امتیاز.