Node.js و ابزارها و تکنیک‌ها برای ساخت سرورهای قدرتمند و سریع

۱۴۶ بازدید
آخرین به‌روزرسانی: ۲۱ شهریور ۱۴۰۲
زمان مطالعه: ۱۶ دقیقه
Node.js و ابزارها و تکنیک‌ها برای ساخت سرورهای قدرتمند و سریع

Node.js (نود. جی‌اس) پلتفرم بسیار متنوعی است؛ اما یکی از غالب‌ترین استفاده‌های آن ایجاد پردازش‌های تحت شبکه است. در این مقاله بر روی پیکربندی تنظیمات در یکی از رایج‌ترین این کاربردها، یعنی وب سرورهای HTTP متمرکز شده‌ایم.

اگر تاکنون سعی کرده‌اید هر گونه برنامه‌ای را با استفاده از Node.js بسازید، در این صورت بی‌شک درد و رنج مشکلات ناخواسته سرعت سرور را تجربه کرده‌اید. جاوا اسکریپت یک زبان رویداد-محور و «asynchronous» به معنی ناهمگام یا غیر همزمان است. این مسئله باعث شده است که بهبود عملکرد آن چنان‌که در ادامه مشخص خواهد شد، کاری پیچیده باشد. محبوبیت فزاینده Node.js باعث شده است به ابزار، و تکنیک‌ها و شیوه خاصی از تفکر برای محدودیت‌های جاوا اسکریپت سمت سرور نیاز داشته باشیم.

زمانی که از عملکرد صحبت می‌کنیم، آن مباحثی که در مرورگر مناسب هستند، لزوماً برای Node.js پاسخگو نخواهند بود. از این رو سؤال منطقی این است که چگونه می‌توان اطمینان حاصل نمود که پیاده‌سازی Node.js سریع و برای مقاصد مورد نظر بهینه است؟ در ادامه با ارائه مثالی این مسئله را بیشتر بررسی می‌کنیم.

ابزارها

ما به ابزاری نیاز داریم که بتواند درخواست‌های زیادی را به سمت سرور ارسال کند و همزمان عملکرد آن را نیز اندازه‌گیری کند. برای مثال می‌توانیم از AutoCannon استفاده کنیم:

npm install -g autocannon

ابزارهای بنچمارک خوب دیگر شامل (Apache Bench (ab و wrk2 هستند؛ اما AutoCannon در نود نوشته شده است، فشار بار کاری مشابه یا در مواردی بالاتر از دو ابزار دیگر ایجاد می‌کند و نصب آن روی ویندوز، لینوکس و مک OS X بسیار آسان است.

بعد از این که ابزار اندازه‌گیری عملکرد خود را راه‌اندازی کردیم؛ در صورتی که بخواهیم پردازش‌هایمان سریع‌تر باشند نیازمند عیب‌یابی برخی مشکلات پردازش هستیم. یک ابزار عالی برای عیب‌یابی مشکلات مختلف عملکردی node clinic نام دارد که آن را نیز با استفاده از npm می‌توان نصب کرد:

npm install -g clinic

دستور فوق در واقع یک مجموعه از ابزارها را روی سرور نصب می‌کند. ما در ادامه از Clinic Doctor و Clinic Flame که یک نرم‌افزار پوششی برای 0x است نیز استفاده خواهیم کرد.

نکته: در این مثال عملی از نود نسخه 8.11.2 یا بالاتر استفاده کنید.

کد

نمونه موردی ما یک سرور ساده REST با یک منبع منفرد به صورت یک payload جیسون (JSON) به صورت یک مسیر GET در /seed/v1 است. این سرور یک پوشه app است که شامل فایل package.json (بسته به restify 7.1.0)، یک فایل index.js و یک فایل util.js است. فایل index.js برای سرور ما چیزی شبیه زیر است:

'use strict'

const restify = require('restify')
const { etagger, timestamp, fetchContent } = require('./util')()
const server = restify.createServer()

server.use(etagger().bind(server))

server.get('/seed/v1', function (req, res, next) {
    fetchContent(req.url, (err, content) => {
        if (err) return next(err)
        res.send({data: content, url: req.url, ts: timestamp()})
        next()
    })
})

server.listen(3000)

این سرور نماینده وضعیت رایجی از سرو محتوای دینامیک کش شده در سمت کلاینت است. این میان‌افزار etagger است که آخرین حالت محتوای یک هدر ETag را محاسبه می‌کند. فایل util.js بخش‌های مختلف پیاده‌سازی را ارائه می‌کند که معمولاً در چنین سناریویی مورد استفاده قرار می‌گیرد. این اجزا شامل تابعی که محتوای مرتبط از یک backend می‌گیرد، یک میان‌افزار etag و تابع timestamp است که نشانه‌های زمانی را در مبنای دقیقه به دقیقه ارائه می‌دهد:

'use strict'

require('events').defaultMaxListeners = Infinity
const crypto = require('crypto')

module.exports = () => {
  const content = crypto.rng(5000).toString('hex')
  const ONE_MINUTE = 60000
  var last = Date.now()

  function timestamp () {
    var now = Date.now()
    if (now — last >= ONE_MINUTE) last = now
    return last
  }

  function etagger () {
    var cache = {}
    var afterEventAttached = false
    function attachAfterEvent (server) {
      if (attachAfterEvent === true) return
      afterEventAttached = true
      server.on('after', (req, res) => {
        if (res.statusCode !== 200) return
        if (!res._body) return
        const key = crypto.createHash('sha512')
          .update(req.url)
          .digest()
          .toString('hex')
        const etag = crypto.createHash('sha512')
          .update(JSON.stringify(res._body))
          .digest()
          .toString('hex')
        if (cache[key] !== etag) cache[key] = etag
      })
    }
    return function (req, res, next) {
      attachAfterEvent(this)
      const key = crypto.createHash('sha512')
        .update(req.url)
        .digest()
        .toString('hex')
      if (key in cache) res.set('Etag', cache[key])
      res.set('Cache-Control', 'public, max-age=120')
      next()
    }
  }

  function fetchContent (url, cb) {
    setImmediate(() => {
      if (url !== '/seed/v1') cb(Object.assign(Error('Not Found'), {statusCode: 404}))
      else cb(null, content)
    })
  }

  return { timestamp, etagger, fetchContent }

}

این کد را به هیچ وجه نمی‌توان بهترین رویه دانست. چند قطعه وجود دارند که مشکلاتی دارند؛ اما با ادامه کار و اندازه‌گیری عملکرد، آن‌ها را شناسایی کرده و به پروفایل کردن برنامه می‌پردازیم. برای دریافت کد کامل نقطه آغازین برای سرور کُند به این لینک مراجعه کنید.

پروفایل کردن سرور

در جهت تهیه پروفایل از سرور به دو پنجره ترمینال نیاز داریم که یکی برای آغاز کردن برنامه و دیگری برای بارگذاری تست استفاده می‌شود.

در یک ترمینال درون پوشه app دستور زیر را اجرا می‌کنیم:

node index.js

در ترمینال دیگر می‌توانیم آن را به ترتب زیر پروفایل کنیم:

autocannon -c100 localhost:3000/seed/v1

نتیجه کار چیزی شبیه زیر است (تست 10 ثانیه‌ای روی http://localhost:3000/seed/v1 به تعداد 100 اتصال)

231 درخواست در 10 ثانیه، 2.4 مگابایت خواندن اطلاعات

نتایج بستگی زیادی به سخت‌افزار سرور دارند. با این حال با در نظر گرفتن این که یک سرور Node.js خیلی ساده به راحتی توانایی مدیریت سی هزار درخواست بر ثانیه را دارد، به این نتیجه می‌رسیم که نتایج فوق به صورت 23 درخواست بر ثانیه با میانگین تأخیر متجاوز از 3 ثانیه مأیوس‌کننده است.

عیب‌یابی

در این مرحله در چند گام مشخص سعی خواهیم کرد مشکلاتی که باعث بروز چنین وضعیتی شده‌اند را شناسایی کرده و در رفع آن‌ها بکوشیم.

شناسایی زمینه مشکل

ما به لطف دستور –on-port در ابزار Clinic Doctor می‌توانیم برنامه را با یک دستور منفرد عیب‌یابی کنیم.

clinic doctor --on-port=’autocannon -c100 localhost:$PORT/seed/v1’ -- node index.js

بدین ترتیب یک فایل HTML ایجاد می‌شود که هنگام پروفایل کردن سرور به طور خودکار در مرورگر باز می‌شود. نتایج چیزی شبیه زیر خواهند بود:

نتایج Clinic Doctor

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

می‌توانیم ببینیم که CPU به طور مداوم تا 100 درصد و یا بیشتر پر شده است، چون این پردازش به سختی تلاش می‌کند تا درخواست‌های صف‌بندی شده را انجام دهد. موتور جاوا اسکریپت نود (V8) در واقع در این مورد از دو هسته سی‌پی‌یو استفاده می‌کند، زیرا این سیستم چندهسته‌ای است و V8 از دو ترد بهره می‌گیرد. یکی برای حلقه Event و دیگری برای زباله روبی (Garbage Collection). وقتی می‌بینیم که سی‌پی‌یو در برخی موارد تا 120 درصد نیز بالا می‌رود، در واقع این پردازش مشغول جمع کردن اشیای مرتبط با درخواست‌های مدیریت شده است.

می‌بینیم که این وضعیت با گراف حافظه نیز مرتبط است. این خط مستقیم در نمودار حافظه، معیار حافظه Heap مورد استفاده است. هر زمان ‌که استفاده از سی‌پی‌یو به اوج می‌رسد، یک سقوط در خط Heap Used می‌بینیم که نشان‌دهنده عدم تخصیص حافظه است.

دستگیره‌های فعال (Active Handles) از تأخیر حلقه Event تأثیر نمی‌پذیرند. یک دستگیره (handle) فعال در واقع شیئی است که نشان‌دهنده I/O (مانند دستگیره سوکت یا فایل) و یا یک تایمر (مانند setInterval) محسوب می‌شود. ما به AutoCannon دستور داده‌ایم که 100 اتصال برقرار کند (c100--). تعداد دستگیره فعال در مقدار ثابت 103 باقی می‌مانند. سه دستگیره دیگر برای STDOUT، STDERR و همچنین یکی برای خود سرور هستند.

اگر بر روی پنل پیشنهاد‌ها در بخش پایین صفحه کلیک کنیم چیزی شبیه به تصویر زیر را می‌بینیم:

مشاهده پیشنهادهای خاص برای مشکل

رفع کوتاه‌ مدت مشکل

تحلیل علت اصلی مشکلات جدی عملکردی ممکن است به زمان زیادی نیاز داشته باشد. در مورد پروژه‌ای که به صورت زنده اجرا شده است، بهتر است از محافظت بار اضافی برای سرورها یا خدمات استفاده شود. ایده حفاظت در مقابل بار اضافی بدین صورت است که تأخیر حلقه event (علاوه بر مسائل دیگر) تحت نظارت قرار گیرد و در صورتی که از آستانه مشخصی فراتر رفت، خطای «503 Service Unavailable» نمایش یابد. بدین ترتیب تعدیل‌کننده بار (Load balancer) امکان می‌یابد تا بار را به وهله‌های دیگر سرور منتقل نموده یا در بدترین حالت کاربر باید صفحه مورد نظر خود را رفرش کند. ماژول حفاظت از بار اضافی این کار را با کمترین سربار برای Express، Koa و Restify ارائه می‌کند. فریمورک Hapi تنظیمات پیکربندی بار را دارد که همین حفاظت را ارائه می‌کند.

درک زمینه مشکل

همان طور که در توضیح کوتاه Clinic Doctor مشخص شد، اگر حلقه Event تا سطحی که مشاهده کردیم دچار تأخیر می‌شود در این صورت این احتمال وجود دارد که یک یا چند تابع در حال مسدودسازی حلقه Event هستند.

در مورد Node.js به طور خاص مهم است که برخی خصوصیات مقدماتی جاوا اسکریپت را بشناسید: رویدادهای ناهمگام نمی‌توانند تا زمانی کد در حال اجرای فعلی نمام نشده است، اجرا شوند. به همین دلیل است که setTimout نمی‌تواند دقیق باشد.

برای نمونه سعی کنید کد زیر را در بخش DevTools مرورگر یا Node REPL اجرا کنید:

console.time('timeout')
setTimeout(console.timeEnd, 100, 'timeout')
let n = 1e7
while (n--) Math.random()

اندازه‌گیری زمان هرگز در حد 100 میلی‌ثانیه نخواهد بود. این زمان در محدوده 150 تا 250 میلی‌ثانیه است. setTimout مسئول زمان‌بندی یک عملیات ناهمگام (console.timeEnd) است؛ اما کد در حال اجرای فعلی، هنوز کامل نشده است و دو خط دیگر مانده است. کد در حال اجرای کنونی به نام تیک (tick) فعلی شناخته می‌شود. برای این که این تیک کامل شود، Math.random باید یک میلیون بار فراخوانی شود. اگر هر کدام از این فراخوانی‌ها 100 میلی‌ثانیه طول بکشد، در این صورت زمان کلی پیش از سر رسیدن timeout برابر با 200 میلی‌ثانیه خواهد بود. این زمان به مقدار زمانی که طول می‌کشد تا تابع setTimout در عمل بتواند timeout را صف‌بندی کند، یعنی معمولاً چند میلی‌ثانیه افزوده می‌شود.

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

سروری که در مثال فوق بررسی کردیم، کدهایی داشت که باعث انسداد حلقه event می‌شد و به همین دلیل در گام بعدی این کدها را شناسایی خواهیم کرد.

تحلیل

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

در ادامه از clinic flame برای تولید گراف flame برنامه نمونه خود استفاده می‌کنیم:

clinic flame --on-port=’autocannon -c100 localhost:$PORT/seed/v1’ -- node index.js

نتیجه اجرای دستور فوق در یک مرورگر چیزی شبیه به تصویر زیر خواهد بود:

بصری‌سازی گراف flame در clinic

عرض بلوک نشان‌دهنده مقدار زمانی است که در مجموع از سوی سی‌پی‌یو صرف شده است. سه استک عمده را می‌توان مشاهده کرد که غالب زمان سی‌پی‌یو را در اختیار گرفته‌اند و همه آن‌ها server.on را به عنوان داغ‌ترین تابع نشان می‌دهند. در واقع همه استک ها یکی هستند. دلیل واگرایی آن‌ها این است که در طی پروفایل کردن، با تابع‌های بهینه و غیر بهینه به صورت قالب‌های فراخوانی جداگانه‌ای رفتار شده است. تابع‌هایی که دارای پیشوند * هستند، از طریق موتور جاوا اسکریپت بهینه‌سازی شده‌اند و آن‌هایی که دارای پیشوند ~ هستند، غیر بهینه محسوب می‌شوند. اگر وضعیت بهینه برای ما مهم نباشد، می‌توانیم گراف را با فشردن دکمه Merge خلاصه‌تر بکنیم. بدین ترتیب تصویری مانند زیر به دست خواهیم آورد:

ادغام گراف flame

با یک نگاه گذرا می‌توانیم ببینیم که کد معیوب در فایل util.js برنامه قرار دارد.

تابع کُند نیز یک handler رویداد است: توابع منتهی به این تابع بخشی از ماژول events هستند و server.on یک نام بازخورد برای تابع ناشناسی است که به صورت تابع مدیریت رویداد، ارائه شده است. همچنین می‌توانیم ببینیم که این کد در همان تیک کدی که واقعاً درخواست را مدیریت می‌کند قرار ندارد. اگر چنین بود تابع‌های ماژول‌های اصلی http, net و stream نیز باید در استک حضور می‌داشتند.

چنین تابع‌های اصلی را می‌توان با باز کردن بخش‌های دیگر و غالباً کوچک‌تر گراف flame مشاهده کرد. برای نمونه اگر از امکان جستجو در بخش بالا-راست رابط کاربری استفاده کنید و به دنبال عبارت send بگردید (این عبارت نام متدهای داخلی restify و همچنین http است) نتایج جستجو معمولاً در بخش راست گراف حضور دارد و تابع‌ها به صورت الفبایی مرتب می‌شوند.

جستجوی گراف flame برای تابع‌های پردازش HTTP

توجه داشته باشید که بلوک‌های HTTP واقعی به طور نسبی چقدر کوچک هستند. می‌توانید بر روی هر یک از بلوک‌ها کلیک کنید تا به رنگ فیروزه‌ای هایلایت شود و با باز کردن آن تابع‌هایی مانند writeHead و write را در فایل http_outgoing.js مشاهده کنید (بخشی از کتابخانه http نود است).

باز کردن گراف flame در استک های مرتبط با HTTP

می‌توانیم روی All Stacks کلیک کنیم تا به نمای اصلی بازگردیم. نکته کلیدی این است که گرچه تابع server.on در همان تیک که درخواست‌های واقعی را مدیریت می‌کند، قرار ندارد؛ اما همچنان از طریق ایجاد تأخیر در اجرای کد غیر بهینه، بر عملکرد کلی سرور تأثیر می‌گذارد.

دیباگ کردن

از طریق بررسی گراف flame می‌دانیم که تابع مشکل‌دار handler رویداد ارسال شده به server.on در فایل urtil.js است. نگاهی به این کد می‌اندازیم:

server.on('after', (req, res) => {
  if (res.statusCode !== 200) return
  if (!res._body) return
  const key = crypto.createHash('sha512')
    .update(req.url)
    .digest()
    .toString('hex')
  const etag = crypto.createHash('sha512')
    .update(JSON.stringify(res._body))
    .digest()
    .toString('hex')
  if (cache[key] !== etag) cache[key] = etag
})

مشهور است که رمزنگاری نیز همانند سریال‌سازی (JSON.stringify) تابعی پر هزینه است، اما سؤال اینجا است که چرا در گراف flame حضور ندارد؟ این عملیات‌ها در نمونه‌های به دست آمده وجود دارند؛ اما زیر فیلتر cpp پنهان شده‌اند. اگر دکمه cpp را فشار دهیم با تصویری مانند زیر روبرو می‌شویم:

آشکارسازی فریم‌های رمزنگاری و سریال‌سازی ++c

در این حالت دستورالعمل‌های درونی V8 در ارتباط با سریال‌سازی و رمزنگاری به صورت داغ‌ترین استک ها نمایش می‌یابد، زیرا اغلب زمان را به خود اختصاص داده‌اند. متد JSON.stringify به طور مستقیم کد c++ را فراخوانی می‌کند؛ به همین دلیل است که هیچ تابع جاوا اسکریپت را نمی‌بینیم. در مورد رمزنگاری، تابع‌هایی مانند createHash و update در داده‌ها هستند؛ اما یا درون‌خطی هستند که در نمای ادغام‌شده ناپدید می‌شوند و یا آن قدر کوچک هستند که به چشم نمی‌آیند.

زمانی که شروع به بررسی کد موجود در تابع etagger می‌کنیم، به سرعت مشخص می‌شود که این کد دارای ضعف ساختاری است. چرا باید وهله server را در چارچوب تابع بخواهیم؟ مقدار زیادی درهم سازی (hashing) در آن صورت می‌گیرد و پرسش کلیدی این است که آیا این کار ضروری است؟ همچنین هیچ گونه پشتیبانی هدر If-None-Match در پیاده‌سازی مشاهده نمی‌شود. وجود این پشتیبانی می‌توانست باعث کاهش مقداری از بار در برخی حالت‌های عملی استفاده از برنامه بشود، زیرا کلاینت‌ها تنها در مواردی اقدام به درخواست هِد می‌کردند که می‌خواستند از تازگی اطلاعات مطمئن شوند.

اجازه بدهید در این لحظه همه این نکات را نادیده بگیریم و ابتدا به اثبات این مسئله بپردازیم که کار واقعی که در server.on انجام می‌گیرد، در واقع گلوگاه کد ما محسوب می‌شود. این مسئله از طریق تبدیل کد server.on به یک تابع خالی و ایجاد یک گراف flame جدید امکان‌پذیر است. تابع etagger را به صورت زیر تغییر می‌دهیم:

function etagger () {
  var cache = {}
  var afterEventAttached = false
  function attachAfterEvent (server) {
    if (attachAfterEvent === true) return
    afterEventAttached = true
    server.on('after', (req, res) => {})
  }
  return function (req, res, next) {
    attachAfterEvent(this)
    const key = crypto.createHash('sha512')
      .update(req.url)
      .digest()
      .toString('hex')
    if (key in cache) res.set('Etag', cache[key])
    res.set('Cache-Control', 'public, max-age=120')
    next()
  }
}

تابع گوش‌دهنده رویداد (event listener) که به server.on ارسال می‌شد، اینک یک تابع غیرعملیاتی است. clinic flame را مجدداً اجرا می‌کنیم:

clinic flame --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js

دستور فوق یک نمودار flame شبیه به تصویر زیر ایجاد می‌کند:

گراف flame سرور وقتی server.on یک تابع خالی است.

این وضعیت بهتر به نظر می‌رسد و تعداد درخواست‌ها بر ثانیه نیز افزایش یافته است. اما چرا کد ارسال رویداد چنان داغ است؟ در این نقطه می‌بایست انتظار می‌داشتیم که کدهای پردازش HTTP اغلب زمان سی‌پی‌یو را صرف خود کرده باشند، چون در حال حاضر هیچ اتفاقی در رویداد server.on اجرا نمی‌شود. این نوع از گلوگاه ناشی از تابعی است که بیش از مقدار مورد نیاز اجرا می‌شود. کد مشکوک زیر در ابتدای util.js می‌تواند سرنخی به دست بدهد:

require('events').defaultMaxListeners = Infinity

این خط را از کد خود حذف کرده و پردازش را با فلگ trace-warnings-- آغاز می‌کنیم:

node --trace-warnings index.js

اگر با استفاده از AutoCannon در ترمینال دیگری اقدام به پروفایل کردن کد به صورت زیر بکنیم:

autocannon -c100 localhost:3000/seed/v1

می‌بینیم که پردازش ما چیزی شبیه زیر خواهد بود:

(node:96371) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit
  at _addListener (events.js:280:19)
  at Server.addListener (events.js:297:10)
  at attachAfterEvent 
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14)
  at Server.
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7)
  at call
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9)
  at next
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9)
  at Chain.run
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5)
  at Server._runUse
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19)
  at Server._runRoute
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10)
  at Server._afterPre
    (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10)

نود به ما می‌گوید که رویدادهای زیادی به شی‌ء server متصل شده‌اند. این مسئله عجیب است، زیرا یک مقدار بولی وجود دارد که بررسی می‌کند آیا رویداد اتصال یافته است یا نه و سپس یک مقدار مقدمتاً ضروری attachAfterEvent را پس از این که رویداد اتصال یافت به صورت غیرعملیاتی (no-op) درمی‌آورد. در ادامه نگاهی به تابع attachAfterEvent داریم:

var afterEventAttached = false
function attachAfterEvent (server) {
  if (attachAfterEvent === true) return
  afterEventAttached = true
  server.on('after', (req, res) => {})
}

می‌بینیم که بررسی شرطی نادرست است! در این کد به جای این که afterEventAttached بررسی شود؛ True بودن attachAfterEvent بررسی می‌شود. این بدان معنی است که رویداد در هر درخواستی به وهله‌ای از server اتصال می‌یابد و سپس همه رویدادهای اتصال یافته قبلی پس از هر درخواست منقضی می‌شوند.

بهینه‌سازی

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

اقدامات عاجل

کد listener server.on را به حالت قبلی برمی‌گردانیم (یعنی دیگر به صورت تابع خالی نیست) و از نام بولی صحیح در بررسی شرطی استفاده می‌کنیم. تابع etagger ما اینک باید به صورت زیر باشد:

function etagger () {
  var cache = {}
  var afterEventAttached = false
  function attachAfterEvent (server) {
    if (afterEventAttached === true) return
    afterEventAttached = true
    server.on('after', (req, res) => {
      if (res.statusCode !== 200) return
      if (!res._body) return
      const key = crypto.createHash('sha512')
        .update(req.url)
        .digest()
        .toString('hex')
      const etag = crypto.createHash('sha512')
        .update(JSON.stringify(res._body))
        .digest()
        .toString('hex')
      if (cache[key] !== etag) cache[key] = etag
    })
  }
  return function (req, res, next) {
    attachAfterEvent(this)
    const key = crypto.createHash('sha512')
      .update(req.url)
      .digest()
      .toString('hex')
    if (key in cache) res.set('Etag', cache[key])
    res.set('Cache-Control', 'public, max-age=120')
    next()
  }
}

اینک می‌توانیم با یک پروفایلینگ دیگر این اصلاح خود را بررسی کنیم. سرور را در یک ترمینال آغاز می‌کنیم:

node index.js

سپس با AutoCannon پروفایل می‌کنیم:

autocannon -c100 localhost:3000/seed/v1

می‌بینیم که بهبودی در حدود 200 مرتبه ایجاد شده است (اجرای تست 10 ثانیه‌ای بر روی آدرس http://localhost:3000/seed/v1 یا صد اتصال)

50 هزار درخواست در 10 ثانیه، 519.64 مگابایت خواندن اطلاعات

برقراری تعادل بین کاهش‌های بالقوه هزینه‌های سرور و هزینه‌های توسعه حائز اهمیت است. ما می‌بایست در چارچوب خودمان تعریف کنیم که برای بهینه‌سازی یک پروژه تا کجا می‌خواهیم پیش برویم. در غیر این صورت به راحتی می‌توان 80 درصد از تلاش‌ها را صرف 20 درصد بهبود سرعت کرد. آیا محدودیت‌های پروژه، این مقدار بهینه‌سازی را تأیید می‌کنند؟

در برخی وضعیت‌ها رسیدن به یک بهینه‌سازی 200 برابری با یک رویکرد سریع مثلاً یک‌روزه قابل تحسین است. در برخی موارد دیگر ممکن است بخواهیم پیاده‌سازی خود را تا حد امکان سریع‌تر سازیم. همه چیز به اولویت‌بندی‌های پروژه مربوط است.

یک روش کنترل مصرف منابع این است که هدفی تعیین کنیم. برای نمونه بهبود 10 برابری یا 4000 درخواست بر ثانیه. مبنا قرار دادن این عدد بر اساس نیازهای تجاری بهتر است. برای مثال اگر هزینه‌های سرور 100 درصد بالاتر از بودجه مورد نظر است، می‌توان بهبود 2 برابری ایجاد کرد.

بهینه‌سازی‌های بیشتر

اینک اگر یک گراف flame از سرور خود بسازیم، تصویری شبیه زیر را می‌بینیم:

گراف flame پس از اصلاح باگ عملکردی

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

در یک پیاده‌سازی بهینه که بی‌شک اندکی محدودیت کار بیشتر است، خصوصیات عملکردی زیر را می‌توان به دست آورد: (اجرای تست به مدت 10 ثانیه در آدرس http://localhost:3000/seed/v1 - برای 10 اتصال)

92 هزار درخواست در 11 ثانیه، 937.22 مگابایت خواندن اطلاعات

با این که بهینه‌سازی 1.6 برابر مهم است؛ اما در عمل به میزان تلاش، تغییرات و دستکاری‌های ضروری برای ایجاد بهبود بستگی دارد که آیا این بهینه‌سازی توجیه دارد نه. به خصوص وقتی با بهبود 200 برابری در پیاده‌سازی اولیه که با یک اصلاح باگ ساده به دست آمد مقایسه می‌کنیم، این تفاوت ملموس‌تر خواهد بود.

برای دستیابی به این بهینه‌سازی از همان تکنیک تکرار پروفایل، تولید نمودار flame، تحلیل، دیباگ و بهینه‌سازی برای رسیدن به سرور بهینه نهایی استفاده می‌شود. کد این سرور در این لینک قابل دانلود است.

تغییرات نهایی برای رسیدن به 8000 درخواست بر ثانیه چنین بوده است:

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

این تغییرات به اندکی کار بیشتر نیاز دارند، کد اصلی بیشتر دست‌کاری می‌شود و میان‌افزار etagger انعطاف‌پذیری کمتری خواهد یافت، زیرا مانعی بر سر مسیر ارائه مقدار Etag ایجاد می‌کند. اما با انجام تغییرات فوق 3000 درخواست بر ثانیه به ظرفیت سیستم پروفایل شونده افزوده شده است. در ادامه نگاهی به گراف flame برای بهینه‌سازی‌های نهایی خواهیم داشت:

گراف flame سالم پس از همه بهینه‌سازی‌های عملکردی

داغ‌ترین بخش گراف flame بخشی از کد اصلی Node در ماژول net است که در حالت ایده‌آلی قرار دارد.

اجتناب از مشکلات عملکردی

برای پایان‌بندی این مقاله، برخی پیشنهادها در مورد روش‌های جلوگیری از مشکلات عملکردی پیش از deploy کردن سرور ارائه شده است.

استفاده از ابزارهای عملکرد به عنوان چک پوینت‌های غیررسمی در طی مراحل توسعه می‌تواند موجب شود که باگ‌های عملکردی مخفی شده و پیش از زیر بار بردن سرور شناسایی نشوند. پیشنهاد می‌شود از AutoCannon و Clinic به عنوان ابزارهای روزمره توسعه استفاده کنید.

زمانی که فریمورکی را خریداری می‌کنید، ابتدا بررسی کنید که سیاست این فریمورک در خصوص عملکرد چیست. اگر فریمورک مربوطه به عملکرد اولویت نمی‌دهد، در این صورت لازم است بررسی شود که آیا این مسئله با رویه‌های زیرساختی و اهداف تجاری شما همسو است یا نه. برای نمونه Restify به وضوح (از نسخه 7 خود به بعد) بر روی بهینه‌سازی عملکرد کتابخانه‌اش سرمایه‌گذاری کرده است. با این حال اگر هزینه پایین و سرعت بالا یک اولویت مطلق است، می‌بایست استفاده از Fastify را ترجیح بدهید، چون مشخص شده است که 17 درصد از رقیب خود یعنی Restify است.

انتخاب‌های دیگر کتابخانه‌ها به خصوص کتابخانه‌های logging را نیز باید در نظر گرفت. زمانی که توسعه‌دهندگان مشکلات را اصلاح می‌کنند؛ ممکن است تصمیم بگیرند که خروجی لاگ دیگری را اضافه کنند تا به حل مسائل مرتبط با دیباگ در آینده کمک کند. اگر یک لاگر غیر بهینه مورد استفاده قرار گیرد، این مسئله می‌تواند در طی زمان و کم‌کم منجر به کاهش شدید عملکرد شود. لاگر pino یک لاگر جیسون با قالب‌بندی new line است که سریع‌ترین لاگر موجود برای Node.js محسوب می‌شود.

در نهایت همیشه باید به خاطر داشته باشید که حلقه Event یک منبع مشترک است. یک سرور Node.js در نهایت محدود به کٌندترین منطق در داغ‌ترین مسیر خواهد بود.

اگر به این نوشته علاقه‌مند بودید، پیشنهاد می‌کنیم موارد زیر را نیز ملاحظه کنید:

==

بر اساس رای ۳ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
smashingmagazine
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *