پاسخ دهی به ۱ میلیون درخواست فقط با ۲ گیگ رم

این مقاله نحوه عملکردی را برای مقیاس‌پذیری بک‌اند از ۵۰ هزار درخواست به ۱ میلیون درخواست (حدود ۱۶ هزار درخواست در دقیقه) با منابع محدود (۲ گیگابایت رم، ۱ هسته پردازنده و پهنای باند شبکه ۵۰-۱۰۰ مگابیت بر ثانیه) شرح می‌دهد.

این مقاله فرض می‌کند که شما با بک‌اند و نوشتن API آشنایی دارید. همچنین خوب است کمی در مورد زبان برنامه نویسی Go بدانید. اما اگر هم نمی‌دانید، مشکلی نیست. هنوز هم می‌توانید مطالب را دنبال کنید، زیرا منابعی برای کمک به درک هر موضوع ارائه داده‌ام. (اگر GO را نمی‌شناسید، اینجا یک مقدمه‌ی کوتاه است)

چکیده:

اول، یک خط لوله‌ی مشاهده (observability pipeline) می‌سازیم که به ما در نظارت بر تمام جنبه‌های بک‌اند کمک می‌کند. سپس، تست استرس (stress testing) بک‌اند را تا تست شکست (breakpoint testing) تا زمانی که همه‌چیز در نهایت خراب می‌شود، شروع می‌کنیم.

بررسی backend

اجازه دهید توضیح مختصری در مورد بک‌اند ارائه دهم:

  • این یک API مونولیتیک (monolithic) و RESTful است که با Golang نوشته شده است.
  • با فریمورک GIN و از GORM به عنوان ORM استفاده می‌کند.
  • از Aurora Postgres به عنوان تنها پایگاه داده اصلی خود که روی AWS RDS میزبانی می‌شود، استفاده می‌کند.
  • بک‌اند docker شده است و ما آن را در نمونه‌ی t2.small روی AWS اجرا می‌کنیم. این نمونه دارای ۲ گیگابایت رم، پهنای باند شبکه ۵۰-۱۰۰ مگابیت بر ثانیه و ۱ هسته‌ی پردازنده است.
  • بک‌اند احراز هویت، عملیات CRUD (ایجاد، خواندن، به‌روزرسانی، حذف)، push notification و به‌روزرسانی‌ها را ارائه می‌دهد
  • برای به‌روزرسانی‌ها، یک اتصال وب‌سوکتی بسیار سبک‌وزن باز می‌کنیم که دستگاه را از به‌روزرسانی موجودیت‌ها مطلع می‌کند.


برنامه‌ی ما بیشتر بر خواندن تمرکز دارد، اگر بخواهم نسبتی به شما بدهم، ۶۵٪ خواندن / ۳۵٪ نوشتن است.

می‌توانم یک وبلاگ جداگانه در مورد اینکه چرا معماری مونولیتیک، Golang یا Postgress را انتخاب کرده‌ایم بنویسم، اما برای اینکه خلاصه‌اش را به شما بگویم، در MsquareLabs ما به «ساده نگه داشتن و طراحی کدی که به ما امکان حرکت با سرعتی باورنکردنی را می‌دهد» اعتقاد داریم.

بررسی داده‌ها

قبل از هر گونه تولید بار مصنوعی (mock load generation)، ابتدا قابلیت مشاهده (observability) را در بک‌اند خود پیاده‌سازی کردم. این قابلیت شامل ردیابی (traces)، متریک‌ها (metrics)، پروفایل‌گیری (profiling) و لاگ‌ها (logs) می‌شود. این کار باعث می‌شود پیدا کردن مشکلات و تشخیص دقیق علت آن‌ها بسیار آسان شود. هنگامی که چنین نظارت قدرتمندی بر روی بک‌اند خود داشته باشید، ردیابی سریع‌تر مشکلات در محیط تولید نیز آسان‌تر خواهد بود.


قبل از اینکه ادامه دهیم، اجازه دهید خلاصه‌ای از متریک‌ها، پروفایل‌گیری، لاگ‌ها و ردیابی ارائه دهم:


  • لاگ‌ها: همه ما می‌دانیم لاگ‌ها چه هستند، آن‌ها پیام‌های متنی زیادی هستند که هنگام وقوع یک رویداد ایجاد می‌کنیم.
  • ردیابی: این لاگ‌های ساختاریافته با قابلیت دید بالا هستند که به ما کمک می‌کنند یک رویداد را با ترتیب و زمان‌بندی صحیح کپسوله‌سازی کنیم.
  • متریک‌ها: تمام داده‌های عددی پردازش‌شده مانند استفاده از CPU، درخواست‌های فعال و گوروتین‌های فعال (active goroutines).
  • پروفایل‌گیری: متریکهای لحظه‌ای را برای کد ما و تأثیر آن‌ها بر روی سخت‌افزار را ارائه می‌دهد که به ما کمک می‌کند بفهمیم چه اتفاقی در حال رخ دادن است. برای اینکه درباره نحوه‌ی پیاده‌سازی observability در بک‌اند بیشتر بدانید، این بخش را به وبلاگ دیگری منتقل کردم تا از سردرگمی خواننده جلوگیری کنم و فقط روی یک موضوع تمرکز کنیمکه آن هم بهینه‌سازی (OPTIMIZATION) است.

تصویر زیر در مورد نحوه‌ی نمایش ردیابی، لاگ‌ها و متریکها است:

بنابراین اکنون یک ابزار مانیتورینگ قوی + یک داشبورد مناسب برای شروع داریم


شبیه‌سازی ۱۰۰۰۰۰ کاربر

"فقط زمانی که سرویس بک‌اند را تحت فشار شدید قرار می‌دهید، قدرتواقعی آن را پیدا می‌کنید ✨" -

گرافانا (Grafana) همچنین یک ابزار تست بار (load testing) را ارائه می‌دهد، بنابراین بدون فکر زیاد تصمیم گرفتم از آن استفاده کنم، زیرا فقط راه‌اندازی کمی با چند خط کد نیاز دارد و شما یک سرویس شبیه‌سازی آماده دارید.

به جای دست زدن به تمام مسیرهای API، بر روی مهم‌ترین مسیرهایی تمرکز کردم که مسئول ۹۰ درصد ترافیک ما بودند.

خلاصه‌ی کوتاهی در مورد k6، این یک ابزار تست مبتنی بر جاوااسکریپت و golang است، جایی که می‌توانید به سرعت رفتاری را که در نظر داردی را شبیه‌سازی و تعریف کنید. این ابزار مسئولیت تست بار را برعهده می‌گیرد. هر آنچه را که در تابع اصلی تعریف می‌کنید یک تکرار (iteration) نامیده می‌شود، k6 واحدهای کاربر مجازی (VU) متعددی را راه‌اندازی می‌کند که این تکرار را تا رسیدن به زمان یا تعداد تکرار مشخص پردازش می‌کند.

هر تکرار شامل ۴ درخواست است: ایجاد تسک (Creating Task) -> به‌روزرسانی تسک (Updating Task) -> دریافت تسک (Fetching the Task) -> حذف تسک (Delete Task)

با شروع آهسته، بیایید ببینیم برای حدود ۱۰ هزار درخواست ->‌ ۱۰۰ واحد کاربر مجازی (VU) با ۳۰ تکرار -> ۳۰۰۰ تکرار ‍× ۴ درخواست => که معادل ۱۲ هزار درخواست است، چطور عمل می‌کند.

این کار بسیار راحت بود، هیچ نشانه‌ای از نشت حافظه، فشار بیش از حد CPU یا هر نوع گلوگاهی وجود نداشتو این عالی است!

خلاصه‌ی k6 در تصویر زیر آمده است، ۱۳ مگابایت داده ارسال شده، ۸۹ مگابایت دریافت شده است و میانگین بیش از ۵۲ درخواست در ثانیه با میانگین تأخیر ۲۷۸ میلی‌ثانیه که با در نظر گرفتن اجرای همه این موارد روی یک ماشین نتیجه بدست آمده بد نیست.

بیایید ۱۲ هزار درخواست را به ۱۰۰ هزار درخواست را افزایش دهیم، ۶۶ مگابایت ارسال شده، ۴۶۲ مگابایت دریافت شده، استفاده از CPU به ۶۰ درصد و استفاده حافظه به ۵۰ درصد رسیده است و ۴۰ دقیقه طول کشید تا اجرا شود (میانگین ۲۵۰۰ درخواست در دقیقه)

همه چیز خوب به نظر می رسید تا اینکه چیز عجیبی در لاگ هایمان دیدم، "::gorm: Too many connections ::"، با بررسی سریع متریک‌های RDS تأیید شد که تعداد اتصالات باز به ۴۱۰ عدد رسیده است که این حداکثر تعداد مجاز برای اتصالات باز است. این مقدار توسط خود Aurora Postgres بر اساس حافظه‌ی در دسترس برای آن نمونه تنظیم می‌شود.

select * from pg_settings where name='max_connections'; ⇒ 410

اینطور می‌توانید بررسی کنید:

در واقع Postgres برای هر اتصال یک process ایجاد می‌کند که با در نظر گرفتن اینکه با هر درخواست جدید یک اتصال جدید باز می‌شود و کوئری قبلی هنوز در حال اجرا است کاری بسیار پرهزینه است. بنابراین، Postgres محدودیتی را برای تعداد اتصالات همزمان که می‌توانند باز شوند اعمال می‌کند. هنگامی که به این محدودیت رسید، هر تلاش دیگری برای اتصال به پایگاه داده برای جلوگیری از خرابی نمونه (که می‌تواند باعث از دست رفتن داده‌ها شود) مسدود می‌شود.

بهینه‌سازی ۱: استخر اتصال (Connection Pooling) ⚡️

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

در اینجا دو گزینه وجود دارد: استخر سمت کاربر (client-side) یا استفاده از یک سرویس جداگانه مانند pgBouncer (که به عنوان یک پروکسی عمل می‌کند). pgBouncer در واقع زمانی که در مقیاس بزرگ هستیم و یک معماری توزیع‌شده داریم که به همان پایگاه داده متصل می‌شود که گزینه بهتری است. بنابراین، برای سادگی و ارزش‌های اصلی‌مان، تصمیم گرفتیم با pooling سمت کاربر پیش برویم.

خوشبختانه ORM ای که از آن استفاده می‌کنیم (GORM) از connectionpooling پشتیبانی می‌کند، اما در لایه زیرین از database/SQL (بسته استاندارد golang ) برای مدیریت آن استفاده می‌کند.

برای مدیریت این کار، روش‌های بسیار ساده‌ای وجود دارد:

  • ‏SetMaxIdleConns -> حداکثر تعداد اتصالات غیرفعال که باید در حافظه نگه داشته شود تا بتوانیم دوباره از آن‌ها استفاده کنیم (به کاهش تأخیر و هزینه باز کردن اتصال کمک می‌کند)
  • ‏SetConnMaxIdleTime -> حداکثر مدت زمانی که باید اتصال غیرفعال را در حافظه نگه داریم.
  • ‏SetMaxOpenConns -> حداکثر تعداد اتصال باز به پایگاه داده است، زیرا ما دو محیط را روی همان نمونه RDS اجرا می‌کنیم.
  • ‏SetConnMaxLifetime -> حداکثر مدت زمانی که هر اتصالی باز می‌ماند.


حالا یک قدم دیگر به جلو می‌رویم، پس از ۵۰۰ هزار درخواست (۴۰۰۰ درخواست در دقیقه) در مدت ۲۰ دقیقه، سرور با مشکل مواجه شد . حالا بیایید بررسی کنیم.

با نگاهی سریع به متریک‌ها مشکل شناسایی شد! استفاده از CPU و حافظه به طور ناگهانی افزایش یافته است. بار (Open Telemetry Collector) به جای container API ما، تمام CPU و حافظه را اشغال می‌کرد.

بهینه‌سازی ۲: آزاد کردن منابع از بار مربوط به (Open Telemetry Collector)

ما سه container درون نمونه کوچک از سرور t2 خود اجرا می‌کنیم:

  • ‏API توسعه
  • ‏API مرحله استقرار (Staging)
  • ‏Alloy

در واقع Alloy یک توزیع OpenTelemetry Collector منبع باز با Prometheus pipelines داخلی و پشتیبانی از متریک‌ها، لاگ‌ها، ردیابی‌ها و پروفایل کننده است.

از آنجایی که بارهای زیادی را روی سرور توسعه‌مان می‌ریزیم، شروع به تولید لاگ + ردیابی با همان سرعت می‌کند و در نتیجه باعث افزایش تصاعدی استفاده از CPU و خروج شبکه می‌شود.


بنابراین، مهم است که اطمینان حاصل شود alloy container هرگز از محدودیت‌های منابع عبور نمی‌کند و مانع سرویس‌های حیاتی نمی‌شود.

از آنجایی که Alloy درون یک docker container اجرا می‌شود، اعمال این محدودیت آسان‌تر بود.

همچنین، این دفعه لاگ‌ها خالی نبودند، چندین خطای لغو شدن context وجود داشت - دلیل آن این بود که درخواست دچار timed out شده بود و اتصال به طور ناگهانی بسته شده بود.


و سپس تأخیر را بررسی کردیم، نتیجه دیوانه کننده بود! بعد از مدت معینی، میانگین تأخیر ۳۰ تا ۴۰ ثانیه بود. به لطف ردیابی‌ها (traces)، حالا می‌توانم دقیقاً مشخص کنم که چه چیزی باعث چنین تأخیرهای عظیمی شده است.

سپس تأخیر را بررسی کردم که نتیجه دیوانه کننده بود 😲 بعد از یک دوره معین، متوسط ​​تأخیر 30 تا 40 ثانیه بود. به لطف ردیابی، اکنون می توانم دقیقاً مشخص کنم که چه چیزی باعث چنین تاخیرهای بزرگی شده است.

کوئری‌های ما در عملیات GET بسیار کند بود، بیایید دستور EXPLAIN ANALYZE را روی این کوئری اجرا کنیم،

دستور LEFT JOIN حدود ۱۴.۶ ثانیه و دستور LIMIT نیز ۱۴.۶ ثانیه دیگر طول کشید، چگونه می‌توانیم این را بهینه کنیم؟ قطعا به کمک با ایجاد فهرست (Indexing)

بهینه‌سازی ۳: افزودن فهرست (Indexing)

اضافه کردن فهرست (index) به فیلدهایی که اغلب در عبارات WHERE یا ORDER BY استفاده می‌شوند، می‌تواند عملکرد کوئری را تا ۵ برابر بهبود بخشد. بعد از افزودن index برای فیلدهای جدول LEFT JOIN و ORDER BY. همان کوئری قبلی حدود ۵۰ میلی‌ثانیه طول کشید. باور می‌کنید، از ۱۴.۶ ثانیه به ۵۰ میلی‌ثانیه رسید؟

(اما مراقب باشید که به طور کورکورانه index اضافه نکنید، زیرا می‌تواند باعث کندی تدریجی عملیات CREATE/UPDATE/DELETE شود.)

این کار همچنین اتصالات را سریعتر آزاد می‌کند و به بهبود ظرفیت کلی برای مدیریت بارهای همزمان عظیم کمک می‌کند.


بهینه‌سازی ۴: اطمینان حاصل کنید که در حین تست، تراکنش‌ها block نشده باشد

از لحاظ فنی این یک بهینه‌سازی نیست، بلکه یک رفع مشکل است، با این حال باید این موضوع را به خاطر داشته باشید. کد شما نباید در هنگام تست استرس، همزمان یک موجودیت (entity) را به‌روزرسانی یا حذف کند.

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

تنها همین رفع مشکل، توان عملیاتی را تا ۲ برابر افزایش داد.

بهینه‌سازی ۵: رد کردن تراکنش پیش فرض GORM

به طور پیش فرض، GORM هر کوئری را درون یک تراکنش اجرا می‌کند که می‌تواند عملکرد را آهسته کند، زیرا ما یک مکانیزم تراکنش بسیار قوی داریم، احتمال اینکه تراکنشی در یک ناحیه حیاتی از دست برود تقریباً غیرممکن است (مگر اینکه کارآموز باشید! ).

ما یک واسط (middleware) برای ایجاد تراکنش قبل از رسیدن به لایه مدل و یک تابع مرکزی برای اطمینان از انجام commit/rollback آن تراکنش در لایه کنترلر خود داریم.

با غیرفعال کردن این مورد، می‌توانیم حداقل ۳۰ درصد افزایش عملکرد داشته باشیم.

"دلیل اینکه روی ۴-۵ هزار درخواست در دقیقه گیر کرده بودیم همین بود و من فکر می‌کردم این مشکل بخاطر پهنای باند شبکه‌ی لپ‌تاپم است."

تمام این بهینه‌سازی‌ها منجر به افزایش ۵ برابری توان عملیاتی شد، حالا به تنهایی لپ‌تاپ من می‌تواند ترافیک ۱۲ تا ۱۸ هزار درخواست در دقیقه ایجاد کند.

یک میلیون درخواست

یک میلیون درخواست حدوداً با ۱۰ تا ۱۳ هزار درخواست در دقیقه، حدود ۲ ساعت طول کشید، این کار باید زودتر انجام می‌شد، اما به دلیل راه‌اندازی مجدد Alloy(به دلیل محدودیت منابع)، تمام متریک‌ها با آن از بین رفتند.

آنچه مرا شگفت‌زده کرد این بود که حداکثر استفاده از CPU در آن مدت زمان ۶۰ درصد و استفاده از حافظه فقط ۱۵۰ مگابایت بود.

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

هر کوئری برای تکمیل شدن ۲۰۰ تا ۴۰۰ میلی‌ثانیه طول می‌کشید، قدم بعدی کشف دلیل این زمان است، حدس من این است که connection pooling و مسدود شدن IO باعث کند شدن کوئری می‌شوند.

میانگین تأخیر به حدود ۲ ثانیه کاهش یافت، اما هنوز جای زیادی برای بهبود وجود دارد.


بهینه‌سازی ضمنی

بهینه‌سازی ۶: افزایش محدودیت حداکثر توصیف‌گر فایل

با توجه به اینکه بک‌اند ما را در یک سیستم عامل لینوکس اجرا می‌کنیم، هر اتصال شبکه‌ای که باز می‌کنیم یک توصیف‌گر فایل (file descriptor) ایجاد می‌کند، به طور پیش فرض لینوکس این مقدار را به ازای هر process به ۱۰۲۴ محدود می‌کند که مانع رسیدن به عملکرد نهایی می‌شود.

از آنجایی که ما اتصالات web-socket متعددی را باز می‌کنیم، اگر ترافیک همزمان زیادی وجود داشته باشد، به راحتی به این محدودیت برخورد می‌کنیم.


‏Docker Compose یک سطح انتزاع خوبی روی آن ارائه می‌دهد:

بهینه‌سازی ۷: از اضافه بار گذاشتن روی Goroutine خودداری کنید.

به عنوان یک توسعه‌دهنده Go، ما اغلب Goroutine را نادیده می‌گیریم و فقط کارهای غیر بحرانی زیادی را در داخل یک Goroutine اجرا می‌کنیم، قبل از یک تابع کلمه go (برای راه اندازی Goroutine) را اضافه می‌کنیم و سپس اجرای آن را فراموش می‌کنیم، اما در شرایط بحرانی می‌تواند به یک گلوگاه تبدیل شود.

برای اطمینان از اینکه هرگز به گلوگاه تبدیل نمی‌شود، برای سرویس‌هایی که اغلب در Goroutine اجرا می‌شوند، از یک صف درون حافظه (in-memory queue) با n-worker برای اجرای یک کار استفاده می‌کنم.

قدم‌های بعدی

پیشرفت‌ها: انتقال از t2 به t3 یا t3a

این t2 نسل قدیمی ماشین‌های چندمنظوره AWS است، در حالی که t3 و t3a، t4g نسل جدیدتر هستند. این ماشین‌ها نمونه‌های قابل تست در شرایط بحرانی (burstable) هستند که پهنای باند شبکه و عملکرد بسیار بهتری را برای استفاده طولانی‌مدت از CPU نسبت به t2 ارائه می‌دهند.

درک نمونه‌های Burstable

به طور کلی AWS نوع نمونه‌های burstable را عمدتا برای کارهایی معرفی کرد که برای اکثر مواقع به ۱۰۰ درصد CPU نیاز ندارند. بنابراین، این نمونه‌ها با عملکرد پایه (۲۰ تا ۳۰ درصد) کار می‌کنند. آن‌ها یک سیستم اعتباری را نگهداری می‌کنند که هر زمان که نمونه‌های شما به CPU نیاز نداشته باشند، اعتبار مالی شما جمع و حفظ می‌شود. هنگامی که جهش CPU رخ می‌دهد، از آن اعتبارها استفاده می‌کند. این کار هزینه و اتلاف محاسبات برای AWS شما را کاهش می‌دهد.

انتخاب خانواده t3a به خاطر نسبت هزینه به بازده بهتر در بین خانواده‌های نمونه‌های burstable، انتخاب خوبی است.

اینجا یک بلاگ خوب برای مقایسه t2 و t3 وجود دارد.

بهبود‌ها:

کوئری (Query)

برای بهبود سرعت، کارهای زیادی می‌توانیم روی کوئری و اسکما انجام دهیم، برخی از آن‌ها عبارتند از:

- دسته‌ای کردن (Batching) رکوردهای INSERT در جداول با حجم بالای ورودی یاINSERTها

- اجتناب از LEFT JOIN با غیرنرمال سازی Denormalization

- لایه کش (Caching)

-‏ Sharding و Partitioning

پروفایل‌گیری (Profiling)

قدم بعدی برای بهبود کارایی برنامه، فعال کردن profiling و کشف دقیق اتفاقات در زمان اجرا است.

تست Breakpoint Testing

برای کشف محدودیت‌ها و ظرفیت سرور Breakpoint Testing، قدم بعدی است.

یادداشت پایانی

اگر تا انتها خوانده‌اید، عالی است، تبریک می‌گم!

منبع

موارد بیشتر system-design.ir