چطور یک چت سنگین میتوانست API ما را از کار بیندازد
در ۲۸ سپتامبر، حدود ۱۷ دقیقه بعد از یک انتشار، هر دو سرور API تولیدی ما همزمان از حافظه تمام شدند، ریاستارت شدند، و برای حدود پنج دقیقه متعادلکننده بار هیچ سرور سالمی برای ارسال ترافیک نداشت. اولین رفعمان به API دو برابر حافظه داد. علت واقعی همان روز بعدتر پیدا شد: باز کردن یک چت خاص باعث میشد API در یک درخواست ۵۳ مگابایت پیام بارگذاری کند. حالا تاریخچهی چت در صفحاتی بارگذاری میشود که هم با تعداد و هم با حجم محدود شدهاند، و آن چت با یک صفحهی اول ۵ مگابایتی باز میشود، نه با کرش.
چه چیزی از کار افتاد
API ما بهعنوان یک کانتینر روی هر کدام از دو سرور پشت یک متعادلکننده بار اجرا میشود. هر کانتینر سقف حافظهی ۳۸۴ مبایت داشت. در حالت عادی ۱۳۰ تا ۱۴۰ مبایت مصرف میکند، پس این سقف فضای خالی زیادی به نظر میرسید.
هر دو کانتینر همزمان به آن سقف رسیدند و با پیام «JavaScript heap out of memory» کرش کردند. heap حافظهای است که یک فرایند Node.js برای اشیایی که با آن کار میکند استفاده میکند. چون هر دو سرور با هم از کار افتادند، جایی برای مسیریابی درخواستها باقی نماند. این بدترین نوع قطعی برای یک راهاندازی با دو سرور است: افزونگی فقط وقتی کمک میکند که خرابیها همزمان نباشند.
رفع اول: فضای بیشتر
زمانبندی به انتشار اشاره میکرد. بعد از یک انتشار، نوبتهای عامل که انتشار قطعشان کرده بود از سر گرفته میشوند و رونوشتهایشان دوباره ذخیره میشود، و این موج کار مشغولکننده به نظر منبع واضح یک جهش حافظه بود.
پس اولین تغییر، سقف حافظهی API را از ۳۸۴ مبایت به ۷۶۸ مبایت برد. آن را همان روز بهصورت زنده روی هر دو سرور اعمال کردیم، و در پیکربندی استقرار هم ثبت کردیم تا استقرار بعدی بیسروصدا آن را به ۳۸۴ برنگرداند. رفعی که دستی روی یک سرور انجام شود و استقرار بعدی آن را پس بزند، راه معمولی برگشت همان قطعی است.
حافظهی بیشتر یک حاشیهی ایمنی معقول بود. توضیح نبود.
علت واقعی: یک درخواست، ۷۱ مگابایت
بعداً همان روز کرش را روی یک رونوشت از یک چت بازتولید کردیم، با سقف قدیمی حافظهی تولید. آن رونوشت بهتنهایی API را کرش داد، بدون دخالت هیچ انتشاری.
وقتی یک پروژه را باز میکنید، برنامهی وب تاریخچهی چت را از API میخواهد. نقطهی پایانی جدیدترین ۳۰ پیام را کامل برمیگرداند. برای بیشتر چتها، ۳۰ پیام کم است. برای این یکی، جدیدترین ۳۰ پیام در پایگاه داده ۵۳ مگابایت بودند و پاسخ ۷۱ مگابایت. چند پاسخ عامل هرکدام ۴ تا ۱۰ مگابایت بودند، بیشترشان رونوشت زیرعاملها: عاملهای یاریگری که عامل اصلی برای بخشی از یک کار بهصورت موازی شروع میکند، و کار کاملشان همراه پاسخ ذخیره میشود.
ساختن آن یک پاسخ بیش از heapای که API داشت لازم داشت. و هر باز کردن یا رفرش آن پروژه، درخواست را دوباره میفرستاد. یک چت میتوانست هر سروری که به آن میرسید را از کار بیندازد، و چون صفحه قابل رفرش بود، میتوانست به هر دو برسد.
«۳۰ پیام» هرگز یک محدودیت واقعی بر حجم نبود. محدودیتی بر تعداد بود که برای چتهای معمولی تصادفاً کوچک بود.
رفع: صفحاتی که هم با حجم و هم با تعداد محدود میشوند
حالا یک صفحه از تاریخچه، ۳۰ پیام یا حدود ۴ مگابایت از آنها است، هرکدام زودتر برسد.
دو گام دارد:
- اول اندازهها را بپرس. API از پایگاه داده شناسهها و اندازههای ذخیرهشدهی جدیدترین پیامها را میخواهد، بدون محتوایشان. این پرسوجو بدون توجه به اندازهی پیامها ارزان است.
- فقط آنچه جا میشود را بارگذاری کن. از جدیدترین به قدیمیترین حرکت میکند و پیام اضافه میکند تا پیام بعدی صفحه را از ۴ مگابایت بیشتر کند. فقط آن ردیفها بعد کامل بارگذاری میشوند.
دو قاعده این را امن نگه میدارد:
- جدیدترین پیام همیشه در صفحه میآید، هر اندازهای که داشته باشد. صفحهای که هیچ نداشته باشد، چت را گیر میکند و راهی برای بالا رفتن در تاریخچه باقی نمیگذارد.
- چیزی بریده نمیشود. هیچ پیامی کوتاه یا حذف نمیشود. بقیهی چت از طریق کنترل «برای پیامهای قدیمیتر بالا بروید» بارگذاری میشود، که پیامهای قدیمیتر از قبل از آن استفاده میکردند، یک صفحه در هر بار.
یک جای دیگر با همان شکل پیدا کردیم. وقتی یک نوبت را متوقف میکنید، API چند پاسخ آخر را بررسی میکند تا ببیند آیا خالیاند و باید پاک شوند. این کار را با بارگذاری هشت پیام کامل انجام میداد. روی چتی با پاسخهای چندمگابایتی، این همان مشکل در شکلی کوچکتر است. حالا از پایگاه داده یک سؤال بلهیاخیر میپرسد — آیا این پیام محتوایی دارد؟ — بدون اینکه خود محتوا را بارگذاری کند.
چطور آزمودیم
روی رونوشت چت، با سقف قدیمی ۳۸۴ مبایت:
- صفحهی اول ۵ مگابایت است. پیشتر همان درخواست API را کرش میداد.
- پنج تب مرورگر که همزمان چت را باز میکنند به اوج ۱۵۴ مگابایت حافظه میرسند.
- بالا رفتن در کل چت همهی ۱۹۰ پیام را برمیگرداند، هرکدام دقیقاً یک بار، به ترتیب.
منطق انتخاب صفحه هم تست واحد دارد، از جمله یکی که از اندازههای واقعی جدیدترین پیامهای آن چت ساخته شده است. صفحهی اول یک پیام برمیگرداند، میگوید پیام بیشتری هست، و صفحهی بعدی از پیامی بعد از آن شروع میشود.
آنچه از آن یاد گرفتیم
- تعداد حد حجم نیست. هر نقطهی پایانی که «جدیدترین N» از چیزی را برمیگرداند که میتواند بیحد بزرگ شود، به یک بودجهی بایت هم نیاز دارد. داده در نهایت آن شکاف را پیدا میکند.
- مظنون واضح همیشه علت نیست. استقرار یک محرک واقعی کار اضافه بود، و حافظهی بیشتر ارزشش را داشت. اما کرش بدون استقرار هم بازتولید شد، و اگر بازتولیدش نکرده بودیم، در افزایش حافظه متوقف میشدیم.
- دو سرور وقتی درخواست یکسانی بگیرند با هم خراب میشوند. افزونگی از خرابی یک سرور محافظت میکند. از درخواستی که هر سروری را که به آن برسد میکشد محافظت نمیکند، بهخصوص درخواستی که کاربر طبیعتاً با رفرش کردن دوباره میفرستد.
- در پیکربندی درستش کنید، نه فقط روی دستگاه. افزایش حافظه همان روز به پیکربندی استقرار رفت، پس استقرار بعدی آن را نگه داشت.
این دومین داستان حافظهی اخیر مربوط به خروجی عامل است. اولی یک برچسب وضعیت که ۶۹۰ مگابایت رشته در صف گذاشت بود. اگر در Mythex از زیرعاملها استفاده میکنید، میتوانید دربارهی کارکردشان در مستندات بخوانید.