عنوان:

‫شکستن افسانه "فیلتر زودتر، JOIN دیرتر" در بهینه‌سازی عملکرد SQL


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۳/۱۸ ۱۴:۱۶
آدرس: www.dntips.ir
بهینه‌سازی عملکرد پایگاه داده (Database Performance Optimization) همواره یکی از دغدغه‌های اصلی برنامه‌نویسان بوده است. کارایی یک برنامه به طور مستقیم به سرعت و کارآمدی عملیات خواندن و نوشتن داده در پایگاه داده بستگی دارد. در اکوسیستم .NET، با استفاده از ابزارهایی مانند EF Core، توسعه‌دهندگان به راحتی می‌توانند با پایگاه داده تعامل کنند. اما گاهی اوقات، باورهای قدیمی و "ترفندهای بهینه‌سازی" نادرست می‌توانند نه تنها به بهبود عملکرد کمک نکنند، بلکه باعث پیچیدگی‌های بی‌مورد در کد و کاهش خوانایی آن نیز شوند. یکی از این باورهای رایج و متاسفانه غلط، "فیلتر زودتر، JOIN دیرتر" (Filter Early, JOIN Later) است که پیشنهاد می‌دهد برای افزایش سرعت کوئری‌های SQL، ابتدا عملیات فیلتر کردن را در یک زیرکوئری (Subquery) انجام داده و سپس نتیجه را با جداول دیگر JOIN کنیم. این مقاله به بررسی دقیق این موضوع می‌پردازد و نشان می‌دهد چرا این "ترفند" در پایگاه‌های داده مدرن بی‌اثر است و چه چیزی واقعاً پشت صحنه اتفاق می‌افتد.

درک باور غلط: "فیلتر زودتر، JOIN دیرتر"
این باور بر اساس این منطق ظاهری شکل گرفته است که اگر داده‌ها را پیش از عملیات JOIN فیلتر کنیم، حجم داده‌های مورد پردازش در JOIN کاهش می‌یابد و در نتیجه، زمان و حافظه کمتری مصرف می‌شود. به عبارت دیگر، تصور بر این است که با کاهش اندازه جدول (Table) قبل از JOIN، بهینه‌سازی قابل توجهی حاصل خواهد شد.
برای درک بهتر، بیایید به دو مثال کوئری SQL که اغلب برای مقایسه این "ترفند" استفاده می‌شوند، نگاهی بیندازیم. فرض کنید دو جدول users (کاربران) و orders (سفارشات) داریم و می‌خواهیم سفارشاتی را پیدا کنیم که مجموع (total) آن‌ها بیشتر از 500 باشد و اطلاعات کاربر مرتبط با آن‌ها را نیز نمایش دهیم.

کوئری "غیربهینه" (به زعم باور غلط):
SELECT *
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.total > 500;
در این کوئری، ابتدا JOIN بین جداول users و orders انجام می‌شود و سپس شرط فیلتر o.total > 500 اعمال می‌گردد.

کوئری "بهینه شده" (به زعم باور غلط):
SELECT *
FROM (
  SELECT * FROM orders WHERE total > 500
) o
JOIN users u ON u.id = o.user_id;
در این نسخه، یک زیرکوئری ابتدا سفارشات با total > 500 را فیلتر می‌کند و سپس نتیجه این زیرکوئری با جدول users JOIN می‌شود. مدافعان این رویکرد ادعا می‌کنند که این روش به دلیل پردازش کمتر داده در مرحله JOIN، سریع‌تر است.

آزمون واقعی: داده‌ها چه می‌گویند؟
برای اثبات یا رد این ادعا، هیچ چیز بهتر از آزمایش عملی و بررسی برنامه‌های اجرایی (Execution Plans) پایگاه داده نیست. یک پایگاه داده PostgreSQL با 10,000 کاربر و 5,000,000 سفارش (با میانگین 500 سفارش به ازای هر کاربر) در نظر گرفته شد. هدف، فیلتر کردن سفارشاتی بود که مجموع آن‌ها از 500 دلار بیشتر بود. از دستور EXPLAIN ANALYZE که جزئیات دقیق اجرای کوئری را نشان می‌دهد، برای هر دو کوئری استفاده شد.

نتیجه اجرای کوئری "غیربهینه":
Hash Join  (cost=280.00..96321.92 rows=2480444 width=27) (actual time=1.014..641.202 rows=2499245 loops=1)
  Hash Cond: (o.user_id = u.id)
  ->  Seq Scan on orders o  (cost=0.00..89528.00 rows=2480444 width=14) (actual time=0.006..368.857 rows=2499245 loops=1)
        Filter: (total > '500'::numeric)
        Rows Removed by Filter: 2500755
  ->  Hash  (cost=155.00..155.00 rows=10000 width=13) (actual time=0.998..0.999 rows=10000 loops=1)
        Buckets: 16384  Batches: 1  Memory Usage: 577kB
        ->  Seq Scan on users u  (cost=0.00..155.00 rows=10000 width=13) (actual time=0.002..0.341 rows=10000 loops=1)
Planning Time: 0.121 ms
Execution Time: 685.818 ms

نتیجه اجرای کوئری "بهینه شده":
Hash Join  (cost=280.00..96321.92 rows=2480444 width=27) (actual time=1.019..640.613 rows=2499245 loops=1)
  Hash Cond: (orders.user_id = u.id)
  ->  Seq Scan on orders  (cost=0.00..89528.00 rows=2480444 width=14) (actual time=0.005..368.260 rows=2499245 loops=1)
        Filter: (total > '500'::numeric)
        Rows Removed by Filter: 2500755
  ->  Hash  (cost=155.00..155.00 rows=10000 width=13) (actual time=1.004..1.005 rows=10000 loops=1)
        Buckets: 16384  Batches: 1  Memory Usage: 577kB
        ->  Seq Scan on users u  (cost=0.00..155.00 rows=10000 width=13) (actual time=0.003..0.348 rows=10000 loops=1)
Planning Time: 0.118 ms
Execution Time: 685.275 ms
همانطور که مشاهده می‌شود، برنامه‌های اجرایی کاملاً یکسان هستند. هر دو کوئری حدود 685 میلی‌ثانیه زمان بردند و "بهینه‌سازی" ادعایی هیچ تأثیری نداشت.

برنامه اجرایی ساده شده برای هر دو کوئری به شرح زیر است:
Hash Join
  Hash Cond: (o.user_id = u.id)
  ->  Seq Scan on orders o
        Filter: (total > '500'::numeric)
        Rows Removed by Filter
  ->  Hash
        ->  Seq Scan on users u
عملیات اصلی انجام شده عبارتند از:
  • اسکن ترتیبی (Sequential Scan) جدول orders با اعمال فیلتر.
  • اسکن ترتیبی جدول users.
  • عملیات Hash برای ساخت جدول هش (Hash Table) از جدول users.
  • عملیات Hash Join با استفاده از شرط هش بر روی user_id.

چرا بهینه‌سازهای کوئری مدرن از شما باهوش‌ترند؟
راز این موضوع در عملکرد بهینه‌سازهای مبتنی بر هزینه (Cost-Based Optimizers) در پایگاه‌های داده مدرن است. زمانی که شما یک کوئری SQL را اجرا می‌کنید، مراحل زیر در پایگاه داده اتفاق می‌افتد:
  • تجزیه و تحلیل (Parser): کد SQL شما به یک درخت نحو انتزاعی (Abstract Syntax Tree) تبدیل می‌شود.
  • بهینه‌سازی (Optimizer): این مهم‌ترین مرحله است. بهینه‌ساز کوئری شما را بازنویسی می‌کند تا کارآمدترین شکل اجرایی را پیدا کند.
  • اجرا (Executor): در نهایت، برنامه بهینه‌سازی شده توسط اجرا کننده، اجرا می‌شود.

بهینه‌ساز پایگاه داده اهمیتی نمی‌دهد که شما کوئری را چگونه نوشته‌اید. وظیفه آن یافتن بهترین راه برای اجرای کوئری است. در مورد مثال‌های بالا، هر دو کوئری به یک برنامه بهینه شده یکسان تبدیل می‌شوند:
  • ابتدا جدول orders را فیلتر می‌کند (زیرا این کار باعث حذف زودهنگام ردیف‌ها می‌شود).
  • سپس یک جدول هش از users (جدول کوچک‌تر) می‌سازد.
  • در نهایت، سفارشات فیلتر شده را با کاربران JOIN می‌کند.

بهینه‌ساز به طور خودکار "بهینه‌سازی" را انجام می‌دهد. بنابراین، زیرکوئری دستی شما نه تنها سرعت را افزایش نمی‌دهد، بلکه کد SQL شما را پیچیده‌تر و خوانایی آن را دشوارتر می‌کند.

نحوه کار بهینه‌سازی مبتنی بر هزینه
بهینه‌ساز کوئری دارای آمار دقیقی درباره جداول شماست:
  • تعداد ردیف‌ها (Row Counts)
  • توزیع داده‌ها (Data Distribution)
  • موجودیت ایندکس‌ها (Index Availability)
  • قابلیت انتخاب‌پذیری ستون (Column Selectivity)

این آمارها برای تخمین هزینه استراتژی‌های مختلف اجرایی استفاده می‌شوند:
  • کدام جدول ابتدا اسکن شود.
  • کدام الگوریتم JOIN (مانند Hash Join، Nested Loop Join، Merge Join) استفاده شود.
  • چه زمانی فیلترها اعمال شوند (Predicate Pushdown).
  • از کدام ایندکس‌ها استفاده شود.

سپس بهینه‌ساز ارزان‌ترین (کارآمدترین) طرح را انتخاب می‌کند. "بهینه‌سازی" دستی شما که با نیت خیر انجام شده، نادیده گرفته می‌شود زیرا بهینه‌ساز بهتر می‌داند.

ارتباط با توسعه‌دهندگان .NET و EF Core
برای توسعه‌دهندگان .NET، این موضوع اهمیت ویژه‌ای دارد. هنگام استفاده از ORMهایی (Object-Relational Mappers) مانند EF Core (Entity Framework Core)، شما کوئری‌ها را به زبان LINQ (Language Integrated Query) می‌نویسید. EF Core مسئول تبدیل این کوئری‌های LINQ به SQL مناسب برای پایگاه داده است.
به عنوان مثال، معادل LINQ برای کوئری‌های بالا در EF Core می‌تواند به صورت زیر باشد:
// "Bad" version equivalent in LINQ
var result = from u in context.Users
             join o in context.Orders on u.Id equals o.UserId
             where o.Total > 500
             select new { u, o };

// "Optimized" version equivalent in LINQ (though not truly optimized in SQL)
var resultOptimized = from o in context.Orders.Where(order => order.Total > 500)
                      join u in context.Users on o.UserId equals u.Id
                      select new { u, o };
در هر دو حالت، EF Core سعی می‌کند بهترین SQL ممکن را تولید کند. بهینه‌ساز پایگاه داده، صرف نظر از اینکه EF Core دقیقاً چه ساختار SQLی تولید می‌کند (چه با زیرکوئری و چه بدون آن)، آن را به کارآمدترین شکل اجرا خواهد کرد. بنابراین، نگرانی در مورد جزئیات کوچک نحوه نوشتن کوئری در LINQ با هدف "فیلتر زودتر، JOIN دیرتر" معمولاً بی‌مورد است. تمرکز شما باید بر روی نوشتن کد LINQ خوانا و منطقی باشد، و بگذارید EF Core و بهینه‌ساز پایگاه داده وظایف خود را انجام دهند.
با این حال، این بدان معنا نیست که باید کاملاً از بهینه‌سازی غافل شوید. در EF Core، استفاده از Select‌های محدود (فقط انتخاب ستون‌های مورد نیاز)، Includeهای هوشمندانه برای بارگذاری ارتباطات، و استفاده از AsNoTracking() برای کوئری‌های فقط خواندنی (Read-Only) می‌تواند تفاوت‌های قابل توجهی در عملکرد ایجاد کند، چرا که این موارد بر تولید SQL یا نحوه مدیریت حافظه در EF Core تأثیر مستقیم دارند و نه صرفاً بر ترتیب عملیات منطقی که بهینه‌ساز SQL آن را تغییر می‌دهد.


خودآزمایی
کدام یک از عبارات زیر در مورد بهینه‌سازی کوئری‌های SQL در پایگاه داده‌های مدرن صحیح است؟
الف) استفاده از زیرکوئری‌ها برای فیلتر کردن داده‌ها قبل از JOIN، همیشه عملکرد کوئری را بهبود می‌بخشد.
ب) بهینه‌سازهای مبتنی بر هزینه در پایگاه داده‌های مدرن، کوئری را به کارآمدترین شکل ممکن بازنویسی می‌کنند، صرف نظر از نحوه نوشتن اولیه آن.
ج) برای دستیابی به بهترین عملکرد، توسعه‌دهندگان باید همیشه از INNER JOIN به جای LEFT JOIN استفاده کنند.
د) EXPLAIN ANALYZE فقط زمان برنامه‌ریزی کوئری را نمایش می‌دهد و برای بررسی زمان اجرای واقعی مناسب نیست.


نتیجه‌گیری نهایی
باور "فیلتر زودتر، JOIN دیرتر" یادگاری از سیستم‌های پایگاه داده قدیمی است که بهینه‌سازهای پیشرفته‌ای نداشتند. پایگاه داده‌های مدرن مانند PostgreSQL، MySQL و SQL Server به طور خودکار عملیاتی نظیر Predicate Pushdown (اعمال فیلترها در اسرع وقت) و Join Reordering (تغییر ترتیب JOINها برای بهترین عملکرد) را انجام می‌دهند. بنابراین، "بهینه‌سازی‌های" دستی شما نه تنها بی‌فایده هستند، بلکه ممکن است کد را پیچیده‌تر و نگهداری آن را دشوارتر کنند.
درس واقعی این است: هر نکته بهینه‌سازی که آنلاین می‌خوانید را باور نکنید. همیشه از ابزارهایی مانند EXPLAIN ANALYZE استفاده کنید تا بفهمید پایگاه داده شما واقعاً چه کاری انجام می‌دهد. بر روی نوشتن SQL خوانا و واضح تمرکز کنید و اجازه دهید بهینه‌ساز پایگاه داده وظیفه خود را به نحو احسن انجام دهد. این رویکرد نه تنها منجر به کدی با خوانایی بیشتر می‌شود، بلکه تضمین می‌کند که از قابلیت‌های پیشرفته پایگاه داده خود به طور کامل استفاده می‌کنید.


پاسخ خودآزمایی
پاسخ صحیح: ب) بهینه‌سازهای مبتنی بر هزینه در پایگاه داده‌های مدرن، کوئری را به کارآمدترین شکل ممکن بازنویسی می‌کنند، صرف نظر از نحوه نوشتن اولیه آن.


مشاهده مطلب اصلی

نظرات

  • جواد غدیری در ۱۴۰۴/۰۴/۱۶ ۱۷:۳۱
    مخالف این دیدگاه هستم
    این باور که از عنوان غلط برای آن یاد کردید من واقعا برخورد داشتم و برعکس آن صحیح بوده.
    در مجموعه ای که پایگاه داده MS SQL Server 2019 هم استفاده می کنیم و با مجموعه ای از تراکنشهای بسیار زیاد بر بستر شبکه حدود 100 تراکنش در ثانیه مواجه هستیم زمانی که در گزارش ها می خواستیم از حالت پیوت برای کوئری گرفتن و با فیلترهای متعدد استفاده کنیم در زمانی که فیلتر بعد از join داشتیم گزارش حدود 5 دقیقه طول می کشید ولی وقتی join ها رو بعد از فیلتر ها و در حالت همان SubQuery اضافه کردم با کمال تعجب دیدیم که سرعت تهیه گزارش به کمتر از 1 دقیقه رسید (از این مدل چندین بار روی جداول لاگ تست های مختلف داشتم و دقیقا همه موارد از کش و ... را بررسی نهایی کردم که شرایط یکسانی اتفاق بیافتد).
    بر همین مبنا حدود سه سال قبل کوئری های گزارشات را به نوعی در سامانه تغییر دادیم و بسیار سریعتر از قبل گزارش گیریها انجام شد

    این در حالیست که نسخه MS SQL Server2019 هم که استفاده می کردیم نسخه تقریبا جدیدی بوده و نسخه قدیمی از لحاظ موتور جستجو نیست و همچنین ساختار تراکنشها در جداول هم بسیار بهینه ذخیره سازی میشده (با این حجم تراکنشها در جداول اصلی لاگها فقط ستونهایی از نوع تاریخ و زمان و عددی داشتیم) که Join با جداول دیگر داشتند . ضمن اینکه در این تست به دلیل حجم بالای اطلاعات کوئری ها به صورت متنی روی SQL Server تست شده و از کوئری های به حالت Linq استفاده نشده بود