عنوان:

‫بهینه‌سازی انقلابی مکانیزم Split Queries در EF Core 11


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۱۸ ۰۹:۲۵
آدرس: www.dntips.ir
در نگاشت‌های Entity Framework Core، نحوه بارگذاری داده‌های مرتبط (Related Data) همواره یکی از چالش‌های اساسی بوده‌است. به طور پیش‌فرض، EF Core برای واکشی موجودیت اصلی و تمام وابستگی‌های آن که از طریق متد Include مشخص شده‌اند، از راهبرد کوئری واحد (Single Query) استفاده می‌کند. در این راهبرد، تمام داده‌ها در قالب یک دستور SELECT مجهز به پیوندهای چندگانه (مبتنی بر LEFT JOIN) ترجمه و اجرا می‌شوند.
اگرچه این روش به دلیل اجرای یکباره کوئری در پایگاه داده و کاهش رفت‌وبرگردهای شبکه (Network Roundtrips) مزیت دارد، اما هنگام مواجهه با مجموعه‌های مرتبط متعدد (Collection Navigations)، پدیده‌ای مخرب به نام انفجار دکارتی (Cartesian Explosion) رخ می‌دهد. این عارضه با تکثیر سطر‌های والد به ازای هر سطر فرزند، حجم داده‌های ارسالی روی شبکه را به طور تصاعدی افزایش داده و پردازش نگاشت داده‌ها (Data Mapping) را در سمت برنامه به شدت کند می‌کند.
برای حل این بحران، مکانیزم کوئری‌های مجزا (Split Queries) معرفی شد که به توسعه‌دهندگان اجازه می‌دهد با فراخوانی متد AsSplitQuery، دستورات را تفکیک کنند؛ به طوری که یک کوئری برای موجودیت ریشه و کوئری‌های جداگانه‌ای برای هر یک از مجموعه‌های فرزند اجرا شود. با این حال، تا نسخه EF Core 10، معماری ساخت کوئری در این مکانیزم با نوعی ناپختگی و عدم بهینه‌سازی در بخش روابط مرجع (Reference Navigations) مواجه بود. در این مقاله به بررسی تحول شگرف و بهینه‌سازی هوشمندی که در EF Core 11 برای رفع این مشکل و بهبود چشمگیر کارایی (Performance) اعمال شده است، می‌پردازیم.

ریشه‌یابی مشکل در نسخه‌های پیشین (EF Core 10 و عقب‌تر)
برای درک عمیق نقص ساختاری گذشته، یک سناریوی رایج را در نظر بگیرید؛ فرض کنید یک موجودیت وبلاگ (Blog) داریم که دارای یک رابطه مرجع یا تک‌مقداری (مانند نوع وبلاگ یا BlogType) و یک رابطه مجموعه‌ای (مانند لیست مقالات یا Posts) است. قطعه کد زیر را برای واکشی داده‌ها در نظر بگیرید:
var result = context.Blogs
    .Include(x => x.BlogType) // ناوبری مرجع (Reference Navigation)
    .Include(x => x.Posts)    // ناوبری مجموعه‌ای (Collection Navigation)
    .AsSplitQuery()
    .ToList();
تا پیش از انتشار EF Core 11، این عبارت LINQ به دو کوئری مجزا در پایگاه داده ترجمه می‌شد که بررسی کوئری دوم، پرده از یک ناکارآمدی بزرگ برمی‌داشت. دستورات SQL تولید شده به شرح زیر بودند:
کوئری اول: واکشی موجودیت ریشه و رابطه مرجع
SELECT [b].[Id], [b].[BlogDetailId], [b].[BlogTypeId], [b].[Name], [b0].[Id], [b0].[Type]
FROM [Blogs] AS [b]
LEFT JOIN [BlogType] AS [b0] ON [b].[BlogTypeId] = [b0].[Id]
ORDER BY [b].[Id], [b0].[Id]
کوئری دوم: واکشی مجموعه‌های فرزند (Posts)
SELECT [p].[Id], [p].[BlogId], [p].[Title], [b].[Id], [b0].[Id]
FROM [Blogs] AS [b]
LEFT JOIN [BlogType] AS [b0] ON [b].[BlogTypeId] = [b0].[Id]
INNER JOIN [Post] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id], [b0].[Id]

تحلیل نقص فنی:
تمرکز دقیق روی کوئری دوم نشان می‌دهد که جدول BlogType به طور کاملاً غیرضروری باز هم JOIN شده‌است! نه‌تنها جدول، پیوند خورده، بلکه فیلد کلید آن انتخاب شده و در بند ORDER BY نیز قرار گرفته است. از منظر منطق پایگاه داده، برای انتساب و متصل کردن هر Post به وبلاگ مربوطه، داشتن کلید اصلی خود وبلاگ (BlogId) کفایت می‌کند و اطلاعات ناوبری تک‌مقداری (مرجع) هیچ نقشی در این میان ندارند.

⚠️ اثر تجمعی ناکارآمدی (Cumulative Degradation Effect):
اگر در یک پروژه واقعی، موجودیت ریشه شما به جای یک رابطه مرجع، دارای پنج یا ده رابطه مرجع (مانند Author، Category، Status و...) باشد، این نقص فنی به صورت تصاعدی تشدید می‌شود. در EF Core 10، به ازای تک‌تک کوئری‌های مجموعه‌ای فرزند، تمام آن ۵ یا ۱۰ جدول مرجع مجدداً JOIN و ORDER BY می‌شدند. نتیجه این امر، تحمیل بار پردازشی سنگین به موتور بهینه‌ساز پایگاه داده (Query Planner)، اشغال بیهوده حافظه پنهان و افت شدید کارایی کل سیستم بود.

تحول در EF Core 11: هرس‌سازی روابط مرجع (Reference Navigation Pruning)
تیم توسعه دات‌نت در مایکروسافت، در نسخه 11 از EF Core (از زمان انتشار نسخه پیش‌نمایش 3)، موتور تولید کوئری را بازطراحی کرده‌اند تا رفتارهای ناوبری را هوشمندانه تحلیل کند. اکنون مکانیزم تولید SQL، روابط مرجع غیرضروری را از کوئری‌های مربوط به مجموعه‌ها حذف یا اصطلاحاً هرس (Prune) می‌کند.
در EF Core 11، کوئری دوم دقیقا همان‌گونه تولید می‌شود که یک کارشناس ارشد پایگاه داده آن را به صورت بهینه و دستی می‌نویسد:
SELECT [p].[Id], [p].[BlogId], [p].[Title], [b].[Id]
FROM [Blogs] AS [b]
INNER JOIN [Post] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id]
همان‌طور که مشاهده می‌کنید، تمامی آثار جدول BlogType، اعم از دستور LEFT JOIN، فیلدهای انتخابی در سگمنت SELECT و ستون‌های اضافی در ORDER BY به طور کامل حذف شده‌اند. این بهینه‌سازی ساختاری، پیچیدگی کامپایل و اجرای کوئری را در سمت DBMS به شدت کاهش می‌دهد.

ارزیابی کارایی و بنچمارک (Benchmarks)
برای راستی‌آزمایی علمی این ادعا، عملکرد سیستم تحت یک آزمون استاندارد با ابزار BenchmarkDotNet مورد بررسی قرار گرفته است. در این بنچمارک، یک پایگاه داده درون‌حافظه‌ای SQLite با حجم داده اولیه ۵,۰۰۰ وبلاگ و ۵ مقاله به ازای هر وبلاگ (در مجموع ۲۵,۰۰۰ مقاله) ایجاد شد. موجودیت وبلاگ در این سناریو دارای ۳ ناوبری مرجع (Author, Category, Series) و ۱ ناوبری مجموعه‌ای (Posts) است تا اثر هرس‌سازی بستر واقعی‌تری پیدا کند.

کد کلاس بنچمارک
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net10_0)]
[SimpleJob(RuntimeMoniker.Net11_0)]
public class SplitQueryBenchmarks
{
    private SqliteConnection _connection = null!;
    private DbContextOptions<BloggingContext> _options = null!;

    [GlobalSetup]
    public void Setup()
    {
        _connection = new SqliteConnection("Data Source=:memory:");
        _connection.Open();

        _options = new DbContextOptionsBuilder<BloggingContext>()
            .UseSqlite(_connection)
            .Options;

        using var context = new BloggingContext(_options);
        context.Database.EnsureCreated();
        Seeder.Seed(context, blogCount: 5_000, postsPerBlog: 5);
    }

    [GlobalCleanup]
    public void Cleanup() => _connection.Dispose();

    [Benchmark]
    public List<Blog> SplitQuery()
    {
        using var context = new BloggingContext(_options);
        return BlogQuery(context).AsSplitQuery().ToList();
    }

    private static IQueryable<Blog> BlogQuery(BloggingContext context)
        => context.Blogs
            .AsNoTracking()
            .Include(b => b.Author)
            .Include(b => b.Category)
            .Include(b => b.Series)
            .Include(b => b.Posts);
}

نتایج آماری خروجی
نتایج حاصل از اجرای بنچمارک فوق، مقایسه مستقیمی را میان زمان اجرا و میزان تخصیص حافظه در محیط‌های دات‌نت 10 و دات‌نت 11 به دست می‌دهد:
MethodRuntimeMeanErrorStdDevMedianGen 0Gen 1Gen 2Allocated
SplitQuery.NET 10.0 (EF 10)68.53 ms1.358 ms2.742 ms67.54 ms4375.01000.0375.033.35 MB
SplitQuery.NET 11.0 (EF 11)62.07 ms1.213 ms2.788 ms61.10 ms4111.11333.3444.430.15 MB
تحلیل یافته‌ها:
ارقام به وضوح نشان‌دهنده بهبود کارایی در ارتقا به نسخه جدید هستند. زمان اجرا (Mean) از 68.53 میلی‌ثانیه به 62.07 میلی‌ثانیه کاهش یافته (نزدیک به ۱۰٪ افزایش سرعت) و میزان تخصیص حافظه در هیت (Allocated Memory) نیز از 33.35 مگابایت به 30.15 مگابایت تقلیل یافته است (حدود ۱۰٪ بهینه‌سازی حافظه). این دستاورد صرفاً ناشی از حذف پردازش‌های بیهوده روی رشته‌های SQL و عدم نیاز به تخصیص حافظه برای ستون‌های اضافه در معماری داخلی هسته EF Core است.

💡 نکته مهندسی و تحلیل معماری تجاری:
توجه داشته باشید که این بنچمارک روی دیتابیس درون‌حافظه‌ای SQLite اجرا شده است که در آن هزینه JOIN و ارتباطات به دلیل عدم وجود لایه شبکه فیزیکی بسیار ناچیز است. در محیط‌های عملیاتی صنعتی (Enterprise) که از بانک‌های اطلاعاتی مجزا مانند SQL Server یا PostgreSQL استفاده می‌کنند، حذف لایه‌های اضافی پیوند (JOIN) و کاهش پهنای باند شبکه، تاثیر به مراتب چشمگیرتری (گاهی تا چندین برابر بهبود کارایی) در کاهش تاخیر (Latency) کل لایه داده خواهد داشت.

ملاحظات معماری: چه زمانی نباید از Split Queries استفاده کرد؟
با وجود این بهینه‌سازی شگفت‌انگیز در EF Core 11، متد AsSplitQuery یک راهکار همه‌منظوره یا اصطلاحاً گلوله نقره‌ای (Silver Bullet) نیست. توسعه‌دهندگان ارشد باید همواره سبک‌سنگین کردن‌های (Trade-offs) زیر را در طراحی معماری لایه داده مد نظر قرار دهند:
  • تعدد رفت‌وبرگشت‌های شبکه (Network Roundtrips): تفکیک دستورات به معنای اجرای چندین لایه کوئری مجزا است. اگر تاخیر شبکه (Network Latency) بین سرور برنامه و سرور دیتابیس بالا باشد، هزینه رفت‌وبرگشت‌های متعدد ممکن است از مزیت حذف انفجار دکارتی پیشی بگیرد.
  • عدم ثبات و یکپارچگی داده‌ها (Non-Atomic Operations): بر خلاف کوئری واحد که در قالب یک تراکنش واحد و منسجم، نمایی پایدار از داده‌ها (Snapshot) را برمی‌گرداند، در Split Queries هر کوئری در تراکنش مجزای خود اجرا می‌شود (مگر اینکه به طور صریح تراکنش سراسری تعریف شده باشد). این یعنی اگر داده‌ای بین اجرای کوئری اول و کوئری دوم توسط کاربر دیگری تغییر کند، احتمال بروز پدیده داده‌های ناسازگار (Inconsistent Data) وجود دارد.

نتیجه‌گیری
نسخه EF Core 11 گام بلندی در جهت هوشمندسازی لایه دسترسی به داده‌ها برده است. قابلیت هرس‌سازی خودکار روابط مرجع (Reference Navigation Pruning) در مکانیزم Split Queries نمونه‌ای بارز از بهینه‌سازی‌های بدون هزینه (Zero-Cost Abstractions) است که بدون نیاز به تغییر حتی یک خط از کدهای تجاری سیستم، بلافاصله پس از ارتقای پلتفرم، کارایی، سرعت واکشی و مصرف حافظه برنامه را بهبود می‌بخشد.
پیشنهاد می‌شود در لایه‌های بزرگ نرم‌افزاری که به شدت به الگوهای بارگذاری حریصانه (Eager Loading) متمایل هستند، با رعایت ملاحظات مربوط به تراکنش‌ها و شبکه، از پتانسیل بالای این نسخه جدید برای دستیابی به بالاترین سطح از کارایی بهره‌برداری شود.

منابع و مراجع فنی