وقتی دربارهی «بهبود سرعت نرمافزار» حرف میزنیم، خیلی وقتها همه چیز را به سرعت CPU، بهبود UI یا بهینهسازی کدهای بیرونی محدود میکنیم. اما در دنیای واقعی—خصوصاً وقتی پای پایگاه داده وسط است—بزرگترین تفاوتها اغلب از تصمیمهای کوچک در سطح کوئری و شیوهی پردازش دادهها میآید. اگر به این بخش توجه نکنید، نرمافزار میتواند از نظر ظاهری درست کار کند، ولی در پسزمینه زمان زیادی صرف انجام کارهای بیفایده مانند درخواستها کند شوند. در این مقاله یک روش برای بهبود سرعت کوئری را باهم بررسی خواهیم کرد و نتایج بهبود را بررسی خواهیم کرد. تمامی مراحل به صورت کد رد گیت هاب قابل دسترس خواهد بود.
فرض کنید سیستم شما دو موجودیت اصلی دارد:
و حالا یک واقعیت رایج در بسیاری از سیستمهای سازمانی وجود دارد:
اکثر کاربران inactive هستند و فقط تعداد کمی active محسوب میشوند.
در تست ما این دادهها (تقریباً) چنین توزیعی دارند:
100,000 کاربر
500 نقش
2,000 کاربر فعال
باقی کاربران غیرفعال
این یعنی اگر کوئری شما قبل از فیلتر کردن وضعیت، مجبور شود تعداد زیادی سطر را در JOIN درگیر کند، احتمالاً بخش بزرگی از کار واقعاً «لازم نیست». شما دارید روی دادههایی حساب میکنید که قرار است در نهایت کنار گذاشته شوند—ولی تا قبل از اعمال فیلتر، همانها نقش بازی میکنند.
در روش اول (که برای خیلیها شهودی و طبیعی است):
ابتدا User را JOIN کنید با Role
بعد در شرط WHERE، کاربران فعال را فیلتر کنید
ایدهی ذهنی این است که:
«اول ساختار داده را با JOIN کامل میکنم، بعد نتیجه را پالایش میکنم.»
اما مشکل اینجاست که در بسیاری از موتورهای پایگاه داده، اگر فیلتر شما انتخابپذیری بالایی ندارد (یا اگر سیستم مجبور شود قبل از اعمال شرط status حجم زیادی را بسازد/تطبیق دهد)، ممکن است این کوئری مجبور شود مقدار زیادی از ترکیبهای JOIN را بررسی کند و تازه بعد بفهمد که بیشترشان در نهایت به خاطر inactive بودن حذف میشوند.
به زبان خودمانی: شبیه این است که شما قبل از اینکه بفهمید «کدام مشتری واقعاً فعال است»، لیست کامل مشتریها را با اطلاعات مربوط به نقششان ترکیب کنید، بعد تازه غربال کنید و بگویید: «نه… خیلیها اصلاً نباید اینجا باشند.»
روش دوم دقیقاً برعکس است:
ابتدا کاربران فعال را فیلتر کنید
بعد باقیماندهها را با شرایط مربوط به نقش (role condition) محدود کنید
این کار باعث میشود حجم کاری خیلی زود کم شود. وقتی اکثر کاربران غیرفعالاند، فیلتر کردن status در ابتدای مسیر یعنی:
تعداد سطرهای ورودی به JOIN کوچک میشود
تعداد ردیفهای میانی کاهش پیدا میکند
موتور پایگاه داده مجبور نیست میلیونها (یا دهها هزار) تطبیق JOIN بیفایده انجام دهد
نتیجهی عملی معمولاً این است: کوئری دوم سریعتر میشود چون «working set» یعنی مجموعهی دادههایی که واقعاً وارد محاسبه میشوند زود محدود میشود.
اگر نگاهی به میزان بهبودی که ایجاد شده بیندازیم خواهیم دید در لپتاپ من بهبودی چند برابری حاصل شد! که این عدد ممکن است در لپتاپ شما متفاوت باشد:
joinThenFilterElapsedMs=659.063375ms
filterThenJoinElapsedMs=101.911583ms
timeDifferenceMs=557.151792ms
شما میتوانید این ازمایش را با اجرا کردن این تست تکرار کنید.
حتی اگر شما به عنوان توسعهدهنده فکر کنید «ترتیب منطقی» کوئری مهم است، موتور دیتابیس معمولاً برای اجرای واقعی، مسیر خودش را انتخاب میکند:
این مسیر شامل مراحل میانی مثل ساختن نتایج JOIN، اعمال فیلترها، و انتخاب روشهای بهینه (مانند استفاده از ایندکسها یا ترتیب عملگرها) است.
در سناریوی ما، چون دادهها بسیار نامتقارن (skewed) هستند (یعنی اکثریت inactive)، اعمال فیلتر status از نظر هزینهای خیلی «پربازده» است. بنابراین اجرای صحیحتر معمولاً میتواند زودتر جلوی انباشت کار بیفایده را بگیرد.
تجربهی این Benchmark دقیقاً یک پیام کلاسیک را با عدد و حس واقعی تبدیل میکند:
اگر یک predicate مثل status = ACTIVE انتخابپذیر و بسیار محدودکننده باشد، فیلتر کردن زودتر معمولاً بهتر است.
اگر JOIN شما میتواند روی جدول بزرگی انجام شود که قبل از JOIN شدن باید محدود شود، معمولاً JOIN کردن قبل از فیلتر هزینهی زیادی اضافه میکند.
بهخصوص وقتی دادهها بزرگاند، skew شدیدتر است، و تعداد غیرفعالها غالب است، این تفاوت بیشتر خودش را نشان میدهد.
در این مقاله دیدیم که عملکرد نرمافزار فقط بهینهسازیهای ظاهری نیست؛ بلکه در لایهی پایگاه داده هم میتواند با تصمیمهای ریز، تفاوتهای بزرگی بسازد. سپس با یک سناریوی Big Table Join، نشان دادیم که وقتی بیشتر سطرها inactive هستند، اجرای فیلتر قبل از JOIN (filter-first) معمولاً «مجموعهی کاری» را کوچکتر میکند و زمان اجرای کوئری را کاهش میدهد، در حالی که JOIN-first ممکن است قبل از حذف شدن، تعداد زیادی ترکیب بیفایده بسازد.