اندر باب «گلوگاه - Bottleneck»‌ در سیستم‌های نرم‌افزاری - بخش نخست

در خصوص این عکس و مفهوم فنی «گلوگاه» می‌توان ساعت‌ها در کُنجی نشست و از «زیبایی» نوشت.

عکاس : فرید کامران‌نیا
عکاس : فرید کامران‌نیا
راهبری یک سیستم نرم‌افزاری و مساله «گلوگاه - bottleneck» مشابه داستان این عکس هست [همین تصویر محض]، همچون گَله‌ای با چند ده یا چند صد راس چارپای داده‌ای و عملیاتی که عموماً نیاز به هدایت‌گری دارند، و شبانی، به دنبال هدایت آن‌ها از روی گذرگاه‌هایی در بستری [با تاب و توانی محدود] است.

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

طبیعتاً در یک سیستم نرم‌افزاری نیز، به طور کلی، هر مولفه (component)ای که کارایی کل سیستم یا بخش مهمی [زیر سیستمی - sub system] از آن را به صورت لحظه‌ای یا مداوم محدود کند، گلوگاه گفته می‌شود. به عنوان نمونه یک سیستم حسابداری نسبتا بزرگی را متصور شوید که از هسته پردازشی قدرتمندی برای محاسبه جزئیات کارمزدها، مالیات، بیمه و هر قاعده پیچیده و دهن پرکن دیگری برخوردار باشد اما کارایی آن توسط زیر سیستم گزارش‌گیری [با عملیات نسبتا ساده] برای کاربران، ضمن برخورداری از منابع زیرساختی بالا با بهره‌گیری (utilization) پایین محدود شود. از این قصه‌گویی که بگذریم در ادامه در مورد چند نوع مرسوم از این گلوگاه صحبت خواهیم کرد.

  • کاشتن گلوگاه‌ با انتقال تاخیر موجود در APIهای سرویس بیرونی به درون سیستم

یکی از گلوگاه‌هایی که طراحان با دست خود در درون سیستم [به‌ویژه یک سیستم با طراحی monolithic] می‌کارند، وابسته کردن و در انتظار نگه‌داشتن منابع [مثلا threadها] به تعدادی API بیرونی است. یکی از پیش‌فرض‌های این نوع طراحی، این است که سیستم بیرونی همیشه در وضعیت پایدار (stable) قرار دارد و زمان پاسخ تضمینی به ازای هر درخواست ارائه می‌دهد، اما اغلب اوقات، این فرض در عمل می‌تواند نقض شود، و سیستم بیرونی علاوه بر تاخیر، دچار قطعی و شکست نیز بشود. چیزی که در آن لحظه درک جزییات تاخیر را در سیستم بیرونی برای طراح سیستم فعلی سخت می‌کند وجود انتزاع (abstraction) ای است که شکل گرفته است. این انتزاع هرچند برای سادگی و ارائه تصویر یکپارچه ایجاد شده است اما جزئیات مساله را با کشیدن نقابی از ابهام، پنهان کرده است. بنابراین زیر سیستم یا ماژولی که مسئولیت اصلی اتصال به APIهای سیستم بیرونی را [آن‌هم به صورت همگام] دارد می‌تواند یک گلوگاه بالقوه باشد.

به عنوان نمونه سیستمی را فرض کنید که قیمت رمزارزهای متنوعی را از سیستم‌های بیرونی مختلفی دریافت می‌کند و به صورت لحظه‌ای در صفحه‌ای نموداری را ترسیم و به‌روزرسانی می‌کند. در این سیستم برای رسم نمودار در صورتی که از رویکردهای ناهمگام و Push شدن داده‌ها استفاده نشود، ماژول ترسیم نمودار بایستی به ازای بیشترین تاخیر ممکن در مجموعه API بیرونی، منتظر بماند.

احتمالاً یکی از دم دستی‌ترین رهیافت‌ها، در این مواقع، تغییر رویکرد اتصال همگام به رویکردی ناهمگام با قابلیت صف‌بندی (queuing) و ذخیره‌سازی (persistence) مناسب با بهره‌گیری از Callback متدها و یا دیگر روش‌های مطرح، باشد. در این وضعیت هر Thread حاملِ درخواست با تحویل درخواست به Endpoint فراخوانی ناهمگام، به وضعیت آزاد در می‌آید و منابع به سیستم باز می‌گردد، ضمن اینکه سیستم نوسان پایین و نسبتا پایداری را تجربه می‌کند.

+اینجا، کیان، به خوبی به اهمیت ارتباط ناهمگام پرداخته است.

  • نشت حافظه، تبدیل مویرگی حافظه به گلوگاهی بالفعل

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

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

برای نمونه، APP جاوایی رو فرض کنید که در یک سیستم مخابراتی به عنوان یک مولفه واسط [Proxy] بین دو APP دیگر عمل می‌کند، به این شکل که هر گونه داده جریانی (Streaming data) عبوری را پردازش کرده و ممکن است یک سری فراداده (meta data یا payload) به انتهای بسته‌های داده‌ای اضافه می‌کند. فرض کنید که تمام پردازش‌ها نیز به صورت حافظه‌ای (In-memory) باشد و دیسک نیز درگیر نیست. در این حالت با تنها یک ایراد جزئی در مدیریت حافظه و عدم آزادسازی دستی منابعی که GC قادر به تشخیص آن‌ها نباشد [بحث Unclosed resource، Static fields و موارد دیگر در جاوا] حافظه به یک گلوگاه تبدیل می‌شود.

در این اینجا (+لینک و +لینک) به چند نمونه از مواردی که ممکن است رخ بدهد، اشاره شده است.

  • معطلی Threadها در پردازش همگام درخواست‌های دریافتی از وب‌سرور

فرض کنید سیستمی وجود دارد که ضمن برخورداری از منابع قدرتمندِ زیرساختی [مقیاس‌پذیر عمودی Vertically scalable]، تنها تعداد محدودی Thread می‌تواند از سیستم‌عامل بگیرد [مثلا 2500تا با اولویت یکسان، کاری با ذاتی یا تعریف‌شده بودن این محدودیت نداریم] و در مقابلِ این سیستم نیز، وب‌سرور قدرتمندی مثل Nginx قرار داده شده است که صرفا درخواست‌ها را به سمت Endpoint تنظیم شده روانه می‌کند و درخواست‌های داخلی این سیستم با زیرسیستم‌های آن نیز به صورت کاملا همگام (synchronous) هندل می‌شود و تا اتمام درخواست Thread مشغول می‌ماند. برای راحتی کار فرض کنید اغلب این درخواست‌ها نیز به یک تراکنش در دیتابیس رابطه‌ای با ظرفیت محدود [مثلا 200 درخواست بر ثانیه در بهترین حالت] نگاشت می‌شود.

در این وضعیت در صورتی که سیستم، در لایه App، تعداد درخواست بالاتر از میزان گذردهی دیتابیس رابطه‌ای را تجربه کند، به مرور تاخیر از DB به لایه‌های بالاتر سرایت می‌کند و میانگین زمان پاسخ (response time) درخواست‌ها در سمت App افزایش پیدا می‌کند، به حدی که با افزایش درخواست‌های ارسالی، سیستم با میزانی بالاتر از 2500 درخواست، به طور پیوسته و در زمان مشخصی روبرو می‌شود، در این حالت سیستم [یا بهتر بگم OS] احتمالا تلاش می‌کند context را بین Threadها مرتب switch کند تا بتواند درخواست‌ها را بگیرد و دوباره به سمت دیتابیسی که ظرفیت عادی خود را ندارد [بدون وجود مکانیزم Back pressure] و [از طریق Appای که به] سقف connection pool خود نیز رسیده است ارسال کند. حالا احتمالاً دو مساله رخ داده است:

- دیتابیس مطابق روال عادی 200 درخواست بر ثانیه را پاسخگو نیست و زمان اجرای پرس و جو به مراتب بالاتر رفته است.

- دوم اینکه، Threadها همگی در وضعیت Busy و منتظر پاسخ از سمت دیتابیس اند و سیستم به طور کلی قفل شده است.

در این وضعیت به ترتیب دو گلوگاه در سمت دیتابیس و اپلیکیشن رخ داده است که به نوعی مرتبط با همدیگر اند و همبستگی (correlation) عملیاتی بین آن‌ها وجود دارد. این حالت نوعاً یکی از انواع گلوگاه‌های چند سطحی است که در گلوگاه بعدی یک تصویر انتزاعی مشابه از آن آورده‌ام.

  • محدود بودن کارایی عمل کلیدی در یک سیستم، در نتیجه، مخفی ماندن گلوگاه‌های عملیات جانبی

در یک سیستم فروشگاه اینترنتی فرض کنید، یکی از عملیات اصلی، ثبت کالا یا خدمت در سبد خرید (Register new order) باشد و یکی از شاخص‌های سطح بالای سنجش کارایی سیستم نیز، «طول مدت زمان ثبت سفارش تا پرداخت الکترونیکی»، با صرف نظر از زمان‌های گشت و گذار باشد. در شرایط عادی این عمل و عملیات دیگر با تاخیر کمتری اتفاق بیافتد، اما در پیک کاری سیستم، به عنوان نمونه در زمان «حراج» یا «رخدادهای تقویمی مثل اعیاد و بلک‌فرایدِی» کندی در سیستم مشاهده شود، به طوری که تعداد «عملیات ثبت در سبد/واحد زمانی» کاهش داشته باشد و یا حد بالای تعداد آن به یک عدد پایین‌تر از انتظار افت کند. منظور از کندی نیز افزایش معنادار همین شاخص زمانی و همبستگی آن با تعداد سفارشات باشد که اشاره شد.

با بررسی Logها مشخص می‌شود که کندی در فرآیند «ثبت کالا در سبد» در سطح دیسک و دیتابیس رخ می‌دهد [گلوگاه 1] و تیم فنی نیز به عنوان مثال با یک رویکرد نهان‌سازی (caching) این تاخیر را برطرف می‌کند و تعداد «سفارشات/واحد زمان»، را به میزان قابل توجهی افزایش می‌دهد. نکته جالب توجه اینجاست که با رفع این گلوگاه، تاخیر درشاخص مطرح‌شده همچنان معنادار [بالاتر از سطح انتظار] است. پس از بررسی بیشتر مشخص می‌شود که این‌بار عمل «بارگذاری سبد خرید» برای مشتری نیز تاخیر قابل توجهی دارد که پیش‌تر قابل استخراج نبود [گلوگاه 2] و پس از رفع آن، مجددا تاخیر دیگری در ثبت تراکنش مالی در دیتابیس پس از بازگشت از درگاه پرداخت نیز دیده می‌شود [گلوگاه 3].

تصویر زیر یک نمایش انتزاعی [و نه کاملا منطبق] مرتبط با این مساله است.

علت اصلی این نوع مساله، پنهان‌شدن گلوگاه‌ها در پشت عملیات اصلی به صورت آبشاری (cascaded) است و تا زمانی‌که تعداد سفارشات بالا نباشد، تعداد «بارگذاری سبد خرید/واحد زمان» و «تعداد پرداخت‌های موفق برگشتی/واحد زمان» نیز ترافیک بالایی را تجربه نخواهد کرد تا بتوان تاخیر آن‌ها را نیز به صورت مجزا احساس کرد.

در عمل، گلوگاه‌ها همیشه در سطح اول ظاهر نمی‌شوند و ممکن است چند سطحی بوده و به نوعی ارتباط عِلّی داشته باشند؛ مثل آن‌چه که در تصویر بالا نمایش داده شده‌است؛ پیدا کردن گلوگاه B و C که پشت گلوگاه A به اصطلاح قایم شده اند، همیشه سر راست نیست و آشکارشدن اش نوعاً پیامدی (consequence) از رفع گلوگاه n-1 سطح قبلی است.
  • عدم پیکربندی درست و پایین‌بودن توان connection pool

یک نوع رایجی از گلوگاه‌ها، محدود بودن توان عملیاتی یک مولفه میانی مثل connection pool بین دو زیرسیستم یا مولفه قدرتمند است. محدود بودن شاید ویژگی ذاتی این مولفه نباشد و ممکن است Pool size با مصالحه (Trade-off) درستی تنظیم نشده است.

به طور کلی مشابه تصویر بالایی، در یک طرف، App ای وجود دارد که به شکل خوبی بهینه شده است و در مقابل تلاش می‌کند به منابع قدرتمندی [مثل دیتابیس رابطه‌ای یا غیر رابطه‌ای، سرویس‌دهنده ایمیل، ذخیره‌ساز ابری یا object storage و غیره] که در دسترس است متصل شود و با داشتن انتظار بالایی از توان عملیاتی، ترافیک بالایی را نیز هندل کند. اما با توجه به محدودیت‌های connection pool این انتظار برآورده نمی‌شود.

+اینجا یک نمونه از این مساله مطرح شده است.

به طور کلی، +این‌جا طبقه‌بندی خوبی از 20 نوع مرسوم گلوگاه‌ در لایه‌های مختلفی از جمله پردازنده، دیسک، حافظه اصلی، دیتابیس‌ها، چارچوب‌های نهان‌سازی (Caching)، شبکه و... آورده شده است که هر کدام از آن‌ها جزئیات و ویژگی‌های خاص خودش را دارد.


پی‌نوشت:

  • برای اینکه خواندن این نوشته حوصله‌ سربر نباشه، سعی می‌کنم در چند پست مجزا موارد دیگر رو بنویسم.
  • برخی از این موارد، به نوعی تجربی است و در جریان فعالیت‌ام به همراه تیم توسعه فنی [High-performance app در حوزه مالی] از نزدیک دیده ام. فکر می‌کنم طرح «مسائل» جذاب‌تر از خود راه‌حل‌ها باشه بنابراین در خصوص «راه‌حل»ها سعی کردم بحثی رو باز نکنم.
  • در مواردی ممکنه که برداشت من ناقص و اشتباه باشه، مشتاقم در بخش نظرات دیدگاه خودتون رو مطرح کنید و در خصوص این موارد و هر آن‌چه که مرتبط است صحبت کنیم.