ارتباط بین دو سرور که ۲.۵٪ مواقع قطع میشد؛ و پست تکراری
حدود ۲.۵٪ درخواستها بین دو سرور timeout میشد و تلاش دوباره، پست تکراری میساخت. ماجرای کلید idempotency، ذخیره نتیجه و retry با backoff.
سرور فروشگاه ما داخل ایران است و کارهای هوش مصنوعی را به یک سرویس روی سرور خارج از کشور میفرستد. بیشتر درخواستها بیدردسر انجام میشد، اما حدود ۲.۵٪ آنها با timeout یا قطع اتصال شکست میخورد. بدتر از خود شکستها این بود که بعضی از آنها واقعاً شکست نخورده بودند: کار انجام شده بود و فقط پاسخ به ما نرسیده بود. این یادداشت درباره همین تفاوت کوچک و پیامدش است.
ماجرا و علامتها
جریان کار ساده بود. سرور داخل ایران که فروشگاه و سامانههای ما روی آن اجرا میشود، کار را به سرویس اتوماسیون هوش مصنوعی روی سرور خارج میفرستاد و منتظر پاسخ میماند. هر وقت پاسخ نمیرسید، کد ما آن را شکست حساب میکرد و کار را دوباره میفرستاد.
علامت اول، خطای ظاهراً معمولی بود: حدود ۲.۵٪ درخواستها با timeout یا قطع اتصال تمام میشد. این رقم بهتنهایی نگرانکننده نبود؛ هر ارتباط بینسروری مقداری شکست دارد و retry برای همین ساخته شده است.
علامت دوم مهمتر بود: بعضی از همین timeoutها کار را از نیمه خراب نکرده بودند، بلکه کار کامل انجام شده بود. نمونهاش پستی در شبکه اجتماعی بود که منتشر شده بود، در حالی که سرور ما فقط timeout دیده بود. retry خودکار همان کار را دوباره فرستاد و پست تکراری ساخته شد. یعنی سازوکاری که برای جبران خطا گذاشته بودیم، خودش خطای دومی میساخت.
نکات کلیدی
- حدود ۲.۵٪ درخواستها timeout یا قطع اتصال میشد
- بعضی از این شکستها در واقع موفقیتِ بیپاسخ بودند
- retry ساده در این موارد پست تکراری میساخت
ردگیری علت
اول سؤال این بود که چرا بخشی از اتصالها قطع میشود. ما مسیر بین دو سرور را در اختیار کامل نداریم و علت دقیق قطعشدن آن ۲.۵٪ را هم نهایتاً به یک عامل مشخص نرساندیم؛ این را صادقانه مینویسم. چیزی که بعداً فهمیدیم این بود که پیدا کردن آن علت اصلاً مسئلهی اصلی نبود.
نقطهی عطف، پست تکراری بود. وقتی دیدیم کار انجام شده ولی پاسخ نیامده، پرسش از «چرا شبکه قطع میشود» به «کد ما با قطعشدن چه میکند» تغییر کرد. شبکهی بین دو سرور هیچوقت صددرصد مطمئن نخواهد بود، پس باید فرض کنیم همیشه بخشی از پاسخها گم میشوند و طراحی را بر آن پایه بگذاریم.
در لاگ دو سرور هم میشد دید که سرویس خارجی کار را اجرا کرده، ولی سمت فروشگاه همان درخواست با خطا بسته شده بود. این دو لاگ کنار هم همان چیزی بود که تصویر را روشن کرد.
نکات کلیدی
- علت دقیق قطعشدن اتصال به یک عامل مشخص نرسید
- سؤال درست این شد: وقتی پاسخ گم میشود کد ما چه میکند؟
علت اصلی: timeout یعنی «نمیدانم»
علت اصلی خود شبکه نبود، فرضی بود که در کد ما نشسته بود: «پاسخ نیامد، پس کار انجام نشد». این فرض غلط است. وقتی یک درخواست HTTP میفرستید، سه پایان ممکن دارد: سرور درخواست را نگرفته، سرور گرفته و انجام داده، یا سرور گرفته و در نیمهی راه شکست خورده. timeout از دید کلاینت این سه حالت را از هم جدا نمیکند. فقط میگوید «تا موعد مقرر چیزی نشنیدم».
مقالهی Builders' Library آمازون همین را صریح میگوید: timeout یا شکست لزوماً به معنی نبودن اثر جانبی نیست و API هایی که اثر جانبی دارند فقط وقتی برای retry امناند که idempotent باشند. پیشنویس IETF برای هدر Idempotency-Key هم مسئله را همینطور شروع میکند: کلاینتی که روی POST timeout دیده، دربارهی وضعیت منبع مطمئن نیست و نمیداند retry امن است یا نه.
روشهایی مثل GET و PUT طبق معنایشان idempotent هستند، اما «ساخت پست» یا «ارسال کار به مدل» معمولاً POST است و هر بار اجرا، اثر تازهای میسازد. راهحل این است که idempotency را خودمان به آن اضافه کنیم: کلاینت برای هر کار منطقی یک شناسهی یکتا میسازد و سرور با دیدن همان شناسه میفهمد این تکرارِ همان درخواست است، نه درخواست جدید.
نکات کلیدی
- از دید کلاینت، timeout سه حالت متفاوت را از هم جدا نمیکند
- «پاسخ نیامد» برابر «انجام نشد» نیست
- برای POST باید idempotency را صراحتاً بسازیم
راهحل: کلید، ذخیرهی نتیجه، و retry با backoff
راهحل ما سه جزء داشت. اول، برای هر کار یک کلید idempotency ساخته میشود و در همهی تلاشهای همان کار ثابت میماند. کلید باید هنگام ساخت کار تولید و ذخیره شود، نه در لحظهی ارسال؛ وگرنه retry کلید دیگری میسازد و همه چیز بیاثر میشود.
دوم، سرویس خارجی نتیجهی هر کلید را ذخیره میکند. اگر کلید تکراری رسید، کار را دوباره اجرا نمیکند و همان نتیجهی قبلی را برمیگرداند. دو نکتهی ظریف هم از مستندات یاد گرفتیم: اگر همان کلید با محتوای متفاوت آمد باید خطا بدهیم (پیشنویس IETF کد 422 را پیشنهاد میکند)، و اگر درخواست اول هنوز در حال اجراست، تکرار باید با 409 رد شود نه اینکه کار دوباره شروع شود. Stripe هم کلیدها را حداقل ۲۴ ساعت نگه میدارد و بعد از آن ممکن است پاک کند؛ مدت نگهداری را باید خودمان بر اساس رفتار retry تعیین کنیم.
سوم، retry با backoff نمایی و jitter. تلاش فوری و پشتسرهم، وقتی مشکل از فشار یا ناپایداری مسیر باشد، آن را بدتر میکند. آمازون توصیه میکند backoff سقف داشته باشد، تعداد تلاشها محدود باشد، و retry فقط در یک لایه انجام شود تا تلاشهای لایهها در هم ضرب نشوند.
نمونهی سادهشدهی سمت سرویس (با ذخیرهی نتیجه در یک جدول با کلید یکتا):
// سمت سرویس: نمونهی سادهشده
app.post('/jobs', async (req, res) => {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });
const fingerprint = hash(JSON.stringify(req.body));
// درج اتمیک: اگر کلید قبلاً وجود داشته باشد، درج انجام نمیشود
const inserted = await db.insertIfAbsent('idem', { key, fingerprint, status: 'running' });
if (!inserted) {
const row = await db.get('idem', key);
if (row.fingerprint !== fingerprint) return res.status(422).json({ error: 'key reused with different payload' });
if (row.status === 'running') return res.status(409).json({ error: 'in progress' });
return res.status(row.httpStatus).json(row.body); // همان نتیجهی قبلی
}
const result = await runJob(req.body);
await db.update('idem', key, { status: 'done', httpStatus: 200, body: result });
res.json(result);
});
// سمت کلاینت: کلید از ابتدا همراه کار ذخیره شده است
async function sendWithRetry(job, maxAttempts = 5) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await post('https://ai.example.com/jobs', job.payload, {
'Idempotency-Key': job.idempotencyKey,
timeout: 30000,
});
} catch (err) {
const base = Math.min(30000, 1000 * 2 ** attempt);
await sleep(Math.random() * base); // backoff نمایی با jitter
}
}
throw new Error('gave up; outcome unknown');
}نکات کلیدی
- کلید همراه کار ساخته و ذخیره میشود، نه هنگام ارسال
- کلید تکراری یعنی بازگرداندن همان نتیجه، نه اجرای دوباره
- کلید یکسان با محتوای متفاوت باید خطا بدهد
- backoff سقف دارد و jitter دارد
درسها و چکلیست
مهمترین درس این بود که شکستخوردن و نامعلومبودن دو چیز متفاوتاند. در کد ما خطای شبکه و خطای «سرور گفت نه» هر دو به یک شاخهی شکست میرفتند، و همین ما را به retry کورکورانه رساند. حالا تصمیم برای retry به نوع خطا و به وجود کلید بستگی دارد.
درس دوم این است که ایمنی retry را باید در طراحی سرویس تضمین کرد، نه در امید به اینکه شبکه خوب بماند. اگر یک سرویس را فقط از طریق اینترنت بین دو سرور صدا میزنید، قطعشدن ارتباط حالت عادی است نه استثنا؛ این موضوع در طراحی زیرساخت سرورها هم باید از ابتدا لحاظ شود. اگر با مسائل مشابهِ صف و تلاش دوباره سروکار دارید، ماجرای صفی که بیصدا یک تلاش را میسوزاند هم از همین خانواده است.
چکلیستی که از این ماجرا برداشتیم:
۱. هر عملیات دارای اثر جانبی (انتشار، پرداخت، ارسال پیام) کلید idempotency دارد. ۲. کلید هنگام ساخت کار تولید و ذخیره میشود و در همهی تلاشها یکی است. ۳. سرویس نتیجه را ذخیره میکند و برای کلید تکراری همان را برمیگرداند. ۴. کلید تکراری با محتوای متفاوت و درخواست همزمان، رفتار مشخص (۴۲۲ و ۴۰۹) دارند. ۵. مدت نگهداری کلیدها صریح تعیین شده است. ۶. retry با backoff نمایی، سقف و jitter انجام میشود، و فقط در یک لایه. ۷. وقتی تلاشها تمام شد، وضعیت کار «نامعلوم» ثبت میشود، نه «ناموفق»، تا بشود بعداً با همان کلید استعلام کرد.
نکات کلیدی
- timeout را «نمیدانم» بخوانید، نه «انجام نشد»
- retry فقط روی عملیات idempotent امن است
- وضعیت «نامعلوم» را در دادهها صریحاً نگه دارید
جمعبندی
ما نتوانستیم شبکهی بین دو سرور را کاملاً قابلاعتماد کنیم و لازم هم نبود. چیزی که تغییر داد این بود که از timeout نتیجهی «انجام نشد» نگیریم. با کلید idempotency، ذخیرهی نتیجه در سرویس و retry با backoff، قطعشدن اتصال دیگر پست تکراری نمیسازد؛ فقط تلاش دوبارهای است که همان نتیجه را برمیگرداند.
منابع
- The Idempotency-Key HTTP Header Field (IETF Internet-Draft)datatracker.ietf.org
- Stripe API: Idempotent requestsdocs.stripe.com
- Timeouts, retries, and backoff with jitter (Amazon Builders' Library)aws.amazon.com