بیشتر آموزش‌های Node.js با مدیریت خطا به عنوان یک موضوع فرعی برخورد می‌کنند. شما یک هندلر مسیر (route handler) را در یک بلوک try/catch قرار می‌دهید، استک تریس (stack trace) را لاگ می‌کنید و یک خطای ۵00 برمی‌گردانید. این طرز فکر به این دلیل پابرجا مانده که یک انسان واقعی در آن سوی یک درخواست HTTP منتظر است. کارهای پس‌زمینه (Background jobs) متفاوت هستند. در یک سیستم صف، هیچ کلاینت بی‌صبری برای پاسخ دادن وجود ندارد و خبری از رفرش خودکار مرورگر نیست. تنها یک ورکر (worker)، یک پلود (payload) و یک شمارنده تلاش مجدد (retry counter) وجود دارد که در سکوت بالا می‌رود. وقتی اوضاع خراب می‌شود، ابتدا به آرامی و سپس ناگهان همه چیز با هم از هم می‌پاشد. یک خطای دسته‌بندی‌شده به اشتباه می‌تواند کل یک خط لوله (pipeline) را متوقف کند یا یک مهندس را ساعت سه صبح بیدار کند.

این گسست ساده است. چرخه‌های درخواست-پاسخ سریع و با صدای بلند (آشکار) شکست می‌خورند. شکست در یک صف، بی‌صدا است. یک ورکر ممکن است پیش از قطع شدن اتصال پایگاه داده، صدها کار را انجام دهد. بدون قوانین مدیریت شفاف، ورکر بلافاصله تلاش مجدد می‌کند، به پایگاه داده‌ای که از قبل درگیر است فشار می‌آورد و در نهایت کرش می‌کند. از آنجایی که کسی مستقیماً ورکر را زیر نظر ندارد، اولین نشانه مشکل اغلب انباشت زنجیره‌ای (cascading backup) یا پر شدن دیسک از فایل‌های لاگ است. شما به چیزی فراتر از بلوک‌های catch نیاز دارید. شما به استراتژی‌ای نیاز دارید که با شکست‌های مختلف، متفاوت برخورد کند و از بقیه سیستم در برابر یک کار خراب محافظت کند.

دو نوع شکست

با تقسیم هر خطا به یکی از دو دسته شروع کنید.

خطاهای قابل تلاش مجدد (Retryable errors) گذرا هستند. تایم‌اوت شبکه به یک API شخص ثالث، پاسخ محدودیت نرخ (rate-limit) ۴۲۹، یا تأخیر موقت در نسخه بازسازی (replica) پایگاه داده نسبت به نسخه اصلی. این‌ها نشانه‌های فشار هستند، نه باگ. سیستم ممکن است در عرض سی ثانیه خودش را ترمیم کند. کارهای قابل تلاش مجدد مستحق یک فرصت دیگر هستند، اما فقط تحت شرایط کنترل‌شده.

خطاهای دائمی (Permanent errors) اشتباه هستند. JSON نامعتبر در پلود، نبودن شناسه کاربر (user ID)، یا فایلی مورد نیاز که در فضای ذخیره‌سازی وجود ندارد. این‌ها در تلاش صدم نیز دقیقاً همان‌گونه شکست می‌خورند که در تلاش اول شکست خوردند. تلاش مجدد برای آن‌ها باعث مصرف چرخه CPU، هدر رفتن ظرفیت‌های صف و ایجاد فشار معکوس (back-pressure) سمی می‌شود که کارهای سالم را به تأخیر می‌اندازد. تنها جای مفید برای یک شکست دائمی، یک لاگ، یک هشدار یا یک صف پیام‌های نامعتبر (dead-letter queue) است. این خطاها متعلق به حلقه تلاش مجدد نیستند.

ساخت یک موتور تصمیم‌گیر

بلافاصله دسته‌بندی کنید. این تصمیم را به فریم‌ورک صف واگذار نکنید. لحظه‌ای که خطایی را دریافت کردید، سرنوشت آن را تعیین کنید.

در عمل، این به معنای ایجاد کلاس‌های خطای سفارشی یا توابع wrapper است که قبل از بالا فرستادن خطا (bubbling up)، آن را بررسی می‌کنند. اگر یک درایور پایگاه داده خطای connection reset بدهد، هندلر شما باید آن را به عنوان retryable علامت‌گذاری کند. اگر یک اعتبارسنج پلود (payload validator) خطای عدم تطابق طرحواره (schema mismatch) بدهد، آن را به عنوان permanent علامت‌گذاری کنید. بسیاری از پردازشگرهای کار (job processors) به طور پیش‌فرض همه چیز را دوباره تلاش می‌کنند که گران‌ترین تصمیمی است که می‌توانید بگیرید. کارهای دائمی را فوراً رد کنید. یا آن‌ها را رها کنید یا به یک dead-letter queue هدایت کنید تا نتوانند خط لوله اصلی را مسموم کنند. این عادت به تنهایی، اثرات گلوله‌برفی (snowball effects) را بسیار قابل‌اعتمادتر از هر تغییر زیرساختی جلوگیری می‌کند.

عقب‌نشینی کنید، اما هوشمندانه‌تر

وقتی تلاش مجدد می‌کنید، هرگز آن را بلافاصله انجام ندهید. اگر پایگاه داده از دسترس خارج شده باشد، هجوم ورکرها که هر ثانیه به آن ضربه می‌زنند، شبیه به یک حمله منع سرویس (DoS) از درون به نظر می‌رسد. از پس‌روی نمایی (exponential backoff) استفاده کنید. یک دقیقه صبر کنید، سپس پنج دقیقه، و سپس پانزده دقیقه. به سیستم بالادستی (upstream) فرصت بازیابی بدهید.

اما exponential backoff به تنهایی کافی نیست. اگر هزار کار به دلیل ری‌استارت شدن یک سرویس همزمان شکست بخورند، زمان‌بندی تلاش مجدد آن‌ها با هم هم‌راستا می‌شود. آن‌ها وقتی سرویس دوباره آنلاین شود، همزمان به آن حمله می‌کنند و احتمالاً دوباره آن را از کار می‌اندازند. جیتر (jitter) را اضافه کنید: یک آفست تصادفی کوچک به هر تأخیر. پراکنده کردن تلاش‌های مجدد در طول چند ثانیه از هجوم‌های همزمان (synchronized stampedes) جلوگیری می‌کند. ریاضیات آن ساده است، اما پایداری‌ای که به ارمغان می‌آورد عظیم است.

شواهد را حفظ کنید

یک dead-letter queue ردپای حسابرسی (audit trail) شماست، نه یک سطل زباله. وقتی یک کار آخرین تلاش مجدد خود را مصرف کرد، آن را به سادگی حذف نکنید. کل پلود را به همراه بافت خطا (error context) و تاریخچه تلاش مجدد به یک DLQ منتقل کنید.

این کار شواهد را حفظ می‌کند. یک انسان می‌تواند کار را بررسی کند، باگ را اصلاح کند و در صورت نیاز آن را به صورت دستی دوباره اجرا کند. مهم‌تر از آن، عمق DLQ خود را مانیتور کنید. افزایش ناگهانی کارهای ارسال شده به dead-letter اغلب اولین هشدار برای یک استقرار (deploy) بد، یک تغییر طرحواره (schema) اشتباه یا نقض قرارداد توسط یک تامین‌کننده خارجی است. رشد DLQ را به عنوان یک شاخص پیشرو (leading indicator) در نظر بگیرید، نه یک شاخص پس‌رو (trailing indicator). اگر DLQ شما در حال پر شدن است، چیزی در سیستم بالادستی تغییر کرده و تیم شما باید قبل از گسترش عقب‌ماندگی (backlog) از آن مطلع شود.

برای تلاش مجدد طراحی کنید

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

راه حل، ایدمپوتنت بودن (Idempotency) است. قبل از انجام یک اثر جانبی، بررسی کنید که آیا قبلاً انجام شده است یا خیر. از یک شناسه منحصربه‌فرد در پیلود (payload) وظیفه به عنوان کلید ایدمپوتنت استفاده کنید. آن کلید را در یک کش کوتاه‌مدت یا یک جدول پایگاه داده با محدودیت یکتا بودن (uniqueness constraint) ذخیره کنید. اگر کلید وجود داشت، از انجام کار صرف‌نظر کرده و نتیجه موفقیت را برگردانید. این کار تلاش‌های مجدد را از یک ریسک به یک عملیات بی‌اثر (no-op) تبدیل می‌کند. این کار چند خط کد اضافی می‌طلبد، اما شما را از توضیح دادن به بخش مالی درباره اینکه چرا درآمد یک‌شبه دو برابر شده است، نجات می‌دهد.

از فرآیند محافظت کنید

رد شدن‌های نامحدود Promise و استثناهای (exceptions) سرگردان می‌توانند بدون هشدار، یک فرآیند Node.js را از کار بیندازند. در یک ورکر، این به معنای از دست رفتن وظایف و تلاش بی‌وقفه یک ارکستراتور برای راه‌اندازی مجدد کانتینر است.

مدیریت‌کننده‌های سراسری (global handlers) را برای unhandledRejection و uncaughtException ثبت کنید. وظیفه آن‌ها نجات دادن اپلیکیشن نیست؛ بلکه انجام حداقل پاکسازی لازم و سپس خروج است. اجازه دهید Docker، Kubernetes یا systemd ورکر را با یک وضعیت حافظه پاک راه‌اندازی مجدد کنند. ادامه دادن به کار با وضعیت نیمه‌کاره پس از اجرای یک مدیریت‌کننده سراسری، باعث نشت حافظه (memory leaks) و وضعیت فاسد می‌شود. یک مرگ سریع و تمیز، ایمن‌تر از یک فرآیند زامبی کند است که وظایف را به اشتباه پردازش می‌کند. به ارکستراتور خود اعتماد کنید تا شما را دوباره بالا بیاورد؛ سعی نکنید با یک زمان اجرای (runtime) فاسد، هوشمندانه رفتار کنید.

به سیگنال‌ها احترام بگذارید

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

به دنبال SIGTERM و SIGINT باشید. وقتی سیگنالی دریافت شد، دریافت وظایف جدید از صف را متوقف کنید. اگر می‌توانید، وظیفه فعلی را تمام کنید. یک زمان انتظار (timeout) قطعی، مثلاً سی ثانیه، تعیین کنید که پس از آن بدون توجه به شرایط، خارج شوید. این «خروج آرام» (graceful shutdown) به صف احترام می‌گذارد و از خطاهای کاذب جلوگیری می‌کند. خط لوله استقرار (deployment pipeline) شما باید ورکر را که به صورت تمیز خارج شده است، سالم در نظر بگیرد، در حالی که یک ورکر کرش‌کرده باید باعث ایجاد هشدار شود.

نکته اصلی

مدیریت قابل اعتماد صف به معنای گرفتن هر خطا نیست؛ بلکه به معنای اتخاذ تصمیمات آگاهانه برای هر حالت خطا است. خطاهای گذرا را با صبر دوباره تلاش کنید. خطاهای دائمی را سریعاً کنار بگذارید. از ورکرهای خود در برابر هجوم‌ها (stampedes) محافظت کنید، از داده‌های خود با کلیدهای ایدمپوتنت محافظت کنید و اجازه دهید فرآیندهای در حال مرگ، به صورت تمیز خارج شوند. وقتی هر خطا مسیر مشخصی داشته باشد، ساعت سه صبح هم فقط یک ساعت معمولی خواهد بود. خط لوله شما به حرکت خود ادامه می‌دهد و تیم شما به خوابیدن.