عنوان:

‫فیلتر کردن مجموعه‌های بزرگ در EF Core 10: واکاوی رفتار در مرز ۲۱۰۰ پارامتر در SQL Server


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۶/۲۳ ۰۹:۳۰
آدرس: www.dntips.ir
چکیده: فیلتر کردن پرس‌وجوها با مجموعه‌ای از شناسه‌ها با متد Contains یکی از الگوهای متداول در برنامه‌های مبتنی بر Entity Framework Core است. در ارتباط با SQL Server، همواره محدودیت تاریخی ۲۱۰۰ پارامتر مطرح بوده است. برخلاف تصور عمومی، EF Core 10 با عبور از این مرز خطایی صادر نمی‌کند؛ بلکه استراتژی ترجمه پرس‌وجو را به‌صورت خودکار تغییر می‌دهد. این تغییر رفتار اگرچه از بروز استثنا جلوگیری می‌کند، اما در بازه مقادیر نزدیک به مرز مجاز پدیده‌ای موسوم به افت ناگهانی کارایی (Performance Cliff) ایجاد می‌کند؛ به‌گونه‌ای که یک پرس‌وجو با ۲۰۹۸ شناسه زمان اجرایی معادل ۳۴.۶ میلی‌ثانیه ثبت می‌کند، درحالی‌که با اضافه شدن تنها یک شناسه (۲۰۹۹ شناسه)، زمان اجرا به ۴.۰ میلی‌ثانیه کاهش می‌یابد (تقریباً ۸ برابر سریع‌تر). این مقاله با تکیه بر بنچ‌مارک‌های استاندارد BenchmarkDotNet روی جدولی با ۱ میلیون رکورد، سازوکار داخلی ترجمه در EF Core 10، دسته‌بندی پارامترها (Parameter Bucketing)، محدودیت واقعی ۲۰۹۸ پارامتر در هماهنگی با sp_executesql، اثرات کش نقشه اجرای پرس‌وجو (Plan Cache)، و راهکارهای کارآمد برای کلیدهای ترکیبی (Composite Keys) را منحصراً بر پایه قابلیت‌های توکار فریم‌ورک و رویکردهای Native دات‌نت بررسی می‌کند.

۱. مقدمه
توسعه‌دهندگان بستر دات‌نت سال‌هاست با خطای نام‌آشنای زیر در کار با پایگاه‌داده SQL Server مواجه می‌شوند:
The incoming request has too many parameters. The server supports a maximum of 2100 parameters.
این محدودیت، تنظیماتی در سطح EF Core نیست؛ بلکه سقفی تعریف‌شده در معماری SQL Server برای رویه‌های ذخیره‌شده (Stored Procedures) است. از آن‌جا که درایور Microsoft.Data.SqlClient پرس‌وجوهای پارامتری‌شده را به‌کمک sp_executesql به سرور ارسال می‌کند، این سقف عیناً به پرس‌وجوهای ارسالی اعمال می‌شود.
در نگارش‌های پیشین نظیر EF Core 8 و EF Core 9، فریم‌ورک مجموعه‌های ورودی را به‌صورت پیش‌فرض در قالب یک رشته آرایه JSON به همراه تابع OPENJSON به دیتابیس می‌فرستاد. این شیوه تنها از یک پارامتر بهره می‌برد و خطر برخورد به سقف ۲۱۰۰ پارامتر را در متد Contains به صفر می‌رساند، اما چالش‌هایی نظیر عدم تخمین دقیق کاردینالیتی (Cardinality Estimation) برای مجموعه‌های کوچک ایجاد می‌کرد.
تیم توسعه EF Core در نگارش ۱۰، استراتژی پیش‌فرض ترجمه را به چندین پارامتر اسکالر به ازای هر مقدار (Scalar Parameters) تغییر داد. این تصمیم اگرچه به تولید برنامه‌های اجرایی پایدارتر و تخمین بهتر کمک می‌کند، اما پیامدهای پنهان و شایان توجهی در ابعاد بزرگ داده به همراه دارد.

۲. رفتار ترجمه در EF Core 10 و دسته‌بندی پارامترها
در یک پرس‌وجوی معمولی:
var ids = new List<int> { 1, 2, 3 };

var matched = await context.Products
    .AsNoTracking()
    .Where(p => ids.Contains(p.Id))
    .ToListAsync();
در EF Core 10، عبارت بالا به یک گزاره IN سنتی همراه با پارامترهای مجزا ترجمه می‌شود:
SELECT [p].[Id], [p].[Name], ...
FROM [Products] AS [p]
WHERE [p].[Id] IN (@ids1, @ids2, @ids3)
نکته نام‌گذاری: پیشوند سنتی @__ids_0 در EF Core 10 حذف شده و نام‌ها به فرمت تمیزتر @ids1 تغییر یافته‌اند. این تغییر موجب بی‌اعتبار شدن نقشه‌های اجرایی کش‌شده قبلی (Plan Cache Invalidation) در زمان ارتقای نسخه و بار کاری موقت کامپایل در سرور پایگاه‌داده خواهد شد.

مکانیسم لایه‌بندی پارامترها (Parameter Bucketing)
برای جلوگیری از آلودگی کش نقشه اجرا (Plan Cache Pollution) در اثر طول‌های متفاوت لیست‌ها، EF Core 10 از تکنیک Padding (تکمیل ظرفیت تا سقف یک باکت مشخص) استفاده می‌کند. به عنوان مثال، اگر ۸ شناسه ارسال کنید، EF Core تعداد ۱۰ پارامتر تولید می‌کند و پارامترهای ۹ و ۱۰ را با مقدار پارامتر ۸ تکرار می‌نماید تا تغییری در نتیجه منطقی شرط ایجاد نشود.
قاعده این دسته‌بندی در متد داخلی CalculateParameterBucketSize در سورس‌کد درایور SQL Server پیاده‌سازی شده است:
protected override int CalculateParameterBucketSize(int count, RelationalTypeMapping elementTypeMapping)
{
    if (count <= 5) return 1;
    if (count <= 150) return 10;
    if (count <= 750) return 50;
    if (count <= 2000) return 100;
    if (count <= 2070) return 10;
    if (count <= MaxParameterCount) return 1; // عدم Padding بین 2070 تا سقف نهایی
    return 200;
}

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

دامنه طول لیستاندازه باکت (گام افزایش)نمونه طول ورودیتعداد پارامتر ارسالی به SQL Server
۱ تا ۵دقیق (بدون تکمیل)۳۳
۶ تا ۱۵۰مضرب بعدی ۱۰۸۱۰
۱۵۱ تا ۷۵۰مضرب بعدی ۵۰۱۵۱۲۰۰
۷۵۱ تا ۲۰۰۰مضرب بعدی ۱۰۰۷۵۱۸۰۰
۲۰۰۱ تا ۲۰۷۰مضرب بعدی ۱۰۲۰۰۱۲۰۱۰
۲۰۷۱ تا ۲۰۹۸دقیق (بدون تکمیل)۲۰۹۴۲۰۹۴
این رفتار نشان می‌دهد که به دلیل Padding، تعداد پارامترهای ارسالی همواره بزرگ‌تر یا مساوی طول لیست شماست و مرز بحرانی زودتر از آنچه به نظر می‌رسد فرا می‌رسد.

۳. سقف واقعی: ۲۰۹۸ پارامتر و رفتار پس از عبور از آن
سقف مستندشده SQL Server عدد ۲۱۰۰ است، اما در واقعیت سقف کاربردی در پرس‌وجوهای پارامتری برابر با ۲۰۹۸ است. دو پارامتر از این بودجه صرف پارامترهای کنترلی دستور sp_executesql در لایه SqlClient می‌شود (این نکته در انتشار EF Core 10.0.2 اصلاح و نهایی شد).

عبور نامحسوس به استراتژی JSON
هنگامی که طول لیست شما از ۲۰۹۸ پارامتر فراتر رود، متد داخلی VisitIn در موتور تحلیل عبارت‌های EF Core نوع ترجمه را تغییر می‌دهد:

تعداد شناسه‌هاتعداد پارامتر ارسالیاستراتژی تولید کد SQL
۲۰۹۶۲۰۹۶IN (@ids1, ... @ids2096)
۲۰۹۸۲۰۹۸IN (@ids1, ... @ids2098)
۲۰۹۹۱IN (SELECT [Value] FROM OPENJSON(@ids) ...)
۵۰۰۰۱IN (SELECT [Value] FROM OPENJSON(@ids) ...)
۱۰۰,۰۰۰۱IN (SELECT [Value] FROM OPENJSON(@ids) ...)
کد ترجمه‌شده در مقادیر ۲۰۹۹ و بالاتر:
SELECT COUNT(*)
FROM [Products] AS [p]
WHERE [p].[Id] IN (
    SELECT [__openjson0].[Value]
    FROM OPENJSON(@ids) WITH ([Value] int '$') AS [__openjson0]
)

مقایسه کارایی در مرز تغییر استراتژی
تغییر استراتژی بدون استثنا انجام می‌گیرد، اما اثر آن بر عملکرد سیستم چشمگیر است. نتایج ارزیابی بر روی یک جدول با ۱ میلیون رکورد دارای ایندکس خوشه‌ای:
  • ۲۰۹۸ شناسه (استراتژی پیش‌فرض پارامتری): زمان اجرا ۳۴.۶ میلی‌ثانیه | حافظه تخصیص‌داده‌شده: ۳,۳۹۳ کیلوبایت
  • ۲۰۹۹ شناسه (استراتژی OPENJSON): زمان اجرا ۴.۰ میلی‌ثانیه | حافظه تخصیص‌داده‌شده: ۷۴۳ کیلوبایت

این پدیده بیانگر آن است که محدوده ۱۰۰۰ تا ۲۰۹۸ پارامتر در استراتژی پیش‌فرض، پرهزینه‌ترین بازه پردازشی در EF Core 10 است؛ پدیده‌ای که در کد #C اثری از آن دیده نمی‌شود.

۴. مقایسه حالت‌های ترجمه توکار و اثر آن بر کش نقشه اجرا (Plan Cache)
فریم‌ورک EF Core سه حالت اصلی ترجمه برای مجموعه‌ها ارائه می‌دهد:

۱. تنظیم سراسری (Global Configuration)
از طریق متد UseParameterizedCollectionMode می‌توان استراتژی پیش‌فرض را در سطح DbContext تغییر داد:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer(connectionString, sqlOptions =>
    {
        // تغییر رفتار پیش‌فرض به استراتژی تک‌پارامتری JSON
        sqlOptions.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter);
    });
}
نکته مهاجرت: متدهای قدیمی‌تر مانند TranslateParameterizedCollectionsToParameters در EF Core 10 منسوخ (Obsolete) شده و با متد فوق جایگزین شده‌اند.

۲. تنظیم در سطح پرس‌وجو (Per-Query Overrides)
می‌توان بدون تغییر پیکربندی سراسری، استراتژی فیلتر را صراحتاً تعیین کرد:
// ۱. پیش‌فرض نگارش ۱۰: ارسال پارامترهای اسکالر به تعداد عناصر (با اعمال Padding)
.Where(p => EF.MultipleParameters(ids).Contains(p.Id))

// ۲. رفتار نگارش‌های ۸ و ۹: یک پارامتر آرایه JSON همراه با OPENJSON
.Where(p => EF.Parameter(ids).Contains(p.Id))

// ۳. جای‌گذاری مقادیر به صورت صریح در متن SQL (Inlined Constants)
.Where(p => EF.Constant(ids).Contains(p.Id))
نکته امنیتی و لاگینگ: در EF Core 10 مقادیر درون‌خطی (EF.Constant) برای اهداف امنیتی به‌صورت پیش‌فرض در خروجی لاگ‌ها سانسور می‌شوند (IN (?, ?, ?))؛ مگر آنکه گزینه EnableSensitiveDataLogging() در زمان پیکربندی فعال شده باشد.

سنجش عملی آلودگی Plan Cache
در آزمایشی با اجرای ۲۰ پرس‌وجو در اندازه‌های مختلف لیست پس از پاکسازی کامل کش SQL Server (DBCC FREEPROCCACHE)، نتایج به شرح زیر ثبت شد:

استراتژیتعداد نقشه‌های متمایز ذخیره‌شده در Cacheتحلیل رفتار
EF.Parameter (تک پارامتر JSON)۲بالاترین انطباق؛ بدون تأثیرپذیری از طول یا محتوای لیست
پیش‌فرض (EF.MultipleParameters)۴مکانیزم Padding موفق شده است ۲۰ طول مختلف را در ۴ نقشه ادغام کند
EF.Constant (ثوابت درون‌خطی)۲۱به ازای هر ترکیب منحصربه‌فرد، یک کامپایل و نقشه جدید ثبت می‌شود
استفاده از EF.Constant صرفاً برای مجموعه‌های کوتاه، ثابت و پرتکرار (مانند فیلتر کردن وضعیت‌های Enum یا نقش‌های ثابت سیستم) توجیه‌پذیر است؛ چرا که به موتور بهینه‌ساز امکان بهره‌گیری از آمار واقعی مقادیر (Histogram) را می‌دهد، بدون اینکه کش نقشه اجرا دچار تورم شود.

۵. ارزیابی جامع کارایی (Benchmarks)
آزمون‌های زیر با استفاده از کتابخانه BenchmarkDotNet 0.15.8 روی سیستم‌عامل تحت .NET 10.0.11 و پایگاه‌داده SQL Server 2025 با جدولی شامل یک میلیون سطر و شناسه با ایندکس خوشه‌ای (Clustered Index) به ازای سناریوهای مختلف اجرا شده است. تمامی پرس‌وجوها با AsNoTracking() اجرا شده‌اند تا سربار Change Tracker حذف شود:

روش اجرایی۱,۰۰۰ آیتم۲,۰۹۸ آیتم۲,۰۹۹ آیتم۵,۰۰۰ آیتم۱۰,۰۰۰ آیتم۱۰۰,۰۰۰ آیتم
Contains (پیش‌فرض)12.8 ms34.6 ms4.0 ms6.2 ms14.8 ms273 ms
EF.Parameter (تک پارامتر)2.6 ms4.3 ms4.6 ms7.6 ms13.5 ms243 ms
EF.Constant (ثوابت مستقیم)3.6 ms5.9 ms5.1 ms102 ms118 msشکست (خطای پردازشگر SQL)
تکه‌تکه‌کردن (Chunking 2000)16.0 ms34.3 ms35.5 ms72 ms145 ms1,347 ms
جدول موقت دستی (Temp Table)5.8 ms9.9 ms7.8 ms93 ms101 ms187 ms
تحلیل داده‌ها
  • افت در مجاورت سقف: در مرز ۲۰۹۸ شناسه، متد پیش‌فرض ۳۴.۶ میلی‌ثانیه زمان برده در حالی که استفاده از EF.Parameter آن را به ۴.۳ میلی‌ثانیه کاهش داده است (بهبودی نزدیک به ۸ برابر تنها با یک تغییر کلمه در LINQ).
  • ناکارآمدی الگوی Chunking: قطعه‌قطعه کردن لیست به دسته‌های ۲۰۰۰تایی (که پاسخی سنتی در پروژه‌ها بود) بدترین کارایی را ثبت کرده است. در ۱۰۰,۰۰۰ رکورد، این الگو ۵۰ رفت‌وبرگشت به دیتابیس (Round-trip) تحمیل کرده و بیش از ۱.۳ ثانیه زمان برده است.
  • ریزش شدید EF.Constant: تا مرز ۲۰۰۰ آیتم عملکرد مناسبی دارد، اما در ۵۰۰۰ آیتم با افت چشمگیر مواجه شده و در مقیاس ۱۰۰,۰۰۰ آیتم، موتور بهینه‌سازی SQL Server با خطای کمبود منابع داخلی در تولید Query Plan متوقف می‌شود (۲۰.۵ ثانیه نگه‌داشتن اتصال پیش از لغو).
  • برتری جدول موقت در مقیاس بسیار بزرگ: تا ۱۰,۰۰۰ آیتم، راهکارهای توکار دات‌نت به مراتب سریع‌تر از تولید جدول موقت هستند. تنها در ۱۰۰,۰۰۰ آیتم است که ساخت جدول موقت دستی با ۱۸۷ میلی‌ثانیه گوی سبقت را می‌رباید.

۶. عبور از فیلترهای اسکالر: چالش کلیدهای ترکیبی (Composite Keys)
تمامی راهکارهای مبتنی بر متد Contains محدود به کلیدهای تک‌ستونی (Scalar) هستند. در سناریوهایی نظیر کلیدهای ترکیبی (TenantId, ProductId)، هیچ‌یک از ساختارهای زیر در EF Core قابل ترجمه به SQL نیستند:
// تمام این الگوها خطای ترجمه (Translation Failure) صادر می‌کنند:
.Where(i => keys.Contains(new { i.TenantId, i.ProductId }))
.Where(i => keys.Any(k => k.TenantId == i.TenantId && k.ProductId == i.ProductId))

۱. تله استفاده از ادغام رشته‌ای (String Concatenation Trap)
برخی توسعه‌دهندگان ستون‌ها را به یک رشته واحد تبدیل می‌کنند تا از Contains استفاده کنند:
var keys = pairs.Select(p => $"{p.TenantId}-{p.ProductId}").ToList();

var matched = await context.Inventory
    .Where(i => keys.Contains(i.TenantId + "-" + i.ProductId))
    .ToListAsync();
  • پیامد: ستون‌های کلیدی داخل تابع تبدیل (CAST) قرار می‌گیرند. این امر موجب از بین رفتن خاصیت SARGability و ناکارآمد شدن ایندکس می‌شود.
  • دیتابیس برای تمامی رکوردهای جدول، تبدیل و الحاق رشته‌ای را ارزیابی می‌کند. در آزمون ۱۰۰۰ جفت‌کلید، این روش به اتمام مهلت فرمان (Command Timeout - ۳۰ ثانیه) برخورد کرد.

۲. زنجیره شرطیORو خطر سرریز پشته (Stack Overflow)
الگوی معمول دیگر، ترکیب شروط با عملگر OR به کمک حلقه یا افزونه‌های ساخت Expression Tree است:
WHERE ([TenantId] = @p0 AND [ProductId] = @p1)
   OR ([TenantId] = @p2 AND [ProductId] = @p3) ...
  • فروپاشی پردازه در ۴۹۰ شرط: ایجاد زنجیره با الحاق مستقیم در حلقه، یک درخت عبارت با عمق زیاد (Left-Deep Expression Tree) می‌سازد. در زمان تحلیل بازگشتی این درخت در لایه داخلی EF Core، پردازه دات‌نت در ۴۹۰ شرط با خطای بحرانی 0xC00000FD (STATUS_STACK_OVERFLOW) بسته می‌شود؛ خطایی که قابل رهگیری با try-catch نیست و به خاموشی ناگهانی پردازه منجر می‌گردد.
  • درخت متعادل (Balanced Tree): در صورت بازنویسی الگوریتم به‌صورت درخت دودویی متعادل، مشکل سرریز پشته برطرف می‌شود، اما با رسیدن به ۱۰۴۹ جفت‌کلید (معادل ۲۰۹۸ پارامتر اسکالر)، خطای واقعی Too many parameters رخ می‌دهد؛ زیرا عبارت OR ساختار آرایه‌ای ندارد و از مکانیزم نجات‌بخش OPENJSON بهره‌مند نمی‌شود.

// الگوریتم ساخت درخت متعادل برای جلوگیری از Stack Overflow
static Expression BuildBalancedOrTree(IReadOnlyList<Expression> expressions)
{
    var current = expressions;
    while (current.Count > 1)
    {
        var next = new List<Expression>((current.Count + 1) / 2);
        for (int i = 0; i < current.Count; i += 2)
        {
            next.Add(i + 1 < current.Count 
                ? Expression.OrElse(current[i], current[i + 1]) 
                : current[i]);
        }
        current = next;
    }
    return current[0];
}

۷. راهکارهای پیشنهادی بدون اتکا به پکیج‌های ثالث
برای مدیریت حجم‌های بزرگ داده و به‌ویژه کلیدهای ترکیبی، بدون نیاز به لایسنس یا پکیج‌های تجاری، دو رویکرد توکار و پایدار وجود دارد:

راهکار اول: جدول موقت دستی به همراهSqlBulkCopy
برای مقیاس‌های بسیار بزرگ (۱۰۰,۰۰۰ شناسه یا کلیدهای ترکیبی حجیم)، ایجاد جدول موقت روی کانکشن جاریِ DbContext بالاترین کارایی را فراهم می‌سازد:
public async Task<List<Product>> FilterByLargeKeysAsync(
    AppDbContext context, 
    DataTable keysDataTable, 
    CancellationToken ct = default)
{
    var connection = (SqlConnection)context.Database.GetDbConnection();
    await context.Database.OpenConnectionAsync(ct);

    try
    {
        // ۱. ساخت جدول موقت
        await using (var cmd = connection.CreateCommand())
        {
            cmd.CommandText = @"
                CREATE TABLE #TempKeys (
                    TenantId INT NOT NULL,
                    ProductId INT NOT NULL,
                    PRIMARY KEY (TenantId, ProductId)
                );";
            await cmd.ExecuteNonQueryAsync(ct);
        }

        // ۲. درج فوق سریع مقادیر با SqlBulkCopy
        using (var bulkCopy = new SqlBulkCopy(connection))
        {
            bulkCopy.DestinationTableName = "#TempKeys";
            bulkCopy.ColumnMappings.Add("TenantId", "TenantId");
            bulkCopy.ColumnMappings.Add("ProductId", "ProductId");
            await bulkCopy.WriteToServerAsync(keysDataTable, ct);
        }

        // ۳. اجرای پرس‌وجو با اتصال (Join) روی جدول موقت در بستر EF Core
        var result = await context.Inventory
            .FromSqlRaw(@"
                SELECT i.* 
                FROM [Inventory] AS i
                INNER JOIN #TempKeys AS k 
                    ON i.[TenantId] = k.[TenantId] 
                   AND i.[ProductId] = k.[ProductId]")
            .AsNoTracking()
            .ToListAsync(ct);

        return result;
    }
    finally
    {
        await context.Database.CloseConnectionAsync();
    }
}
این روش با حفظ مدیریت تراکنش و استفاده از FromSqlRaw، نتیجه را مستقیماً به موجودیت‌های EF Core نگاشت می‌کند، بدون آنکه پارامتری به SQL Server ارسال شود.

راهکار دوم: استفاده از User-Defined Table Type (UDTT) و پارامتر ساختاریافته
برای حجم داده‌های متوسط تا بزرگ با کلیدهای اسکالر یا ترکیبی، تعریف ساختار جدولی درون دیتابیس یک رویکرد استاندارد سازمانی است:
-- یک‌بار در دیتابیس ایجاد می‌شود
CREATE TYPE [dbo].[IntIdList] AS TABLE (
    [Id] INT PRIMARY KEY
);
سپس در برنامه:
var table = new DataTable();
table.Columns.Add("Id", typeof(int));
foreach (var id in ids) table.Rows.Add(id);

var parameter = new SqlParameter("@IdList", SqlDbType.Structured)
{
    TypeName = "dbo.IntIdList",
    Value = table
};

var matched = await context.Products
    .FromSqlRaw("SELECT p.* FROM [Products] p INNER JOIN @IdList t ON p.[Id] = t.[Id]", parameter)
    .AsNoTracking()
    .ToListAsync();
این روش علاوه بر ارسال تنها یک پارامتر به پایگاه‌داده، کامپایل بهینه، تایپ امن و قابلیت بازاستفاده در رویه‌های ذخیره‌شده را به همراه دارد.

۸. پایش و مراقبت در محیط عملیاتی (Observability)
تغییر آرام و بدون استثنای استراتژی در EF Core 10 ایجاب می‌کند سیاست‌های مانیتورینگ متفاوتی اتخاذ شود:

رهگیری تعداد واقعی پارامترها با Interceptor
تعداد آیتم‌های لیست ارسالی نشان‌دهنده تعداد پارامترها نیست. برای آگاهی از پارامترهای واقعی، یک اینترسپتور ساده پیاده‌سازی کنید:
public sealed class ParameterCountDiagnosticsInterceptor : DbCommandInterceptor
{
    private readonly ILogger<ParameterCountDiagnosticsInterceptor> _logger;

    public ParameterCountDiagnosticsInterceptor(ILogger<ParameterCountDiagnosticsInterceptor> logger)
    {
        _logger = logger;
    }

    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, 
        CommandEventData eventData, 
        InterceptionResult<DbDataReader> result)
    {
        // ثبت هشدار در صورت ورود به بازه هزینه‌بر
        if (command.Parameters.Count >= 1000 && command.Parameters.Count <= 2098)
        {
            _logger.LogWarning(
                "Query executed in the expensive parameter band. Parameter Count: {Count}. Command: {Sql}",
                command.Parameters.Count,
                command.CommandText);
        }

        return base.ReaderExecuting(command, eventData, result);
    }
}

۹. ماتریس تصمیم‌گیری (Decision Matrix)

شرایط سناریواستراتژی پیشنهادیعلت و منطق فنی
کمتر از ۱,۰۰۰ شناسه اسکالرمتد پیش‌فرض Containsتفاوت کارایی روش‌ها در این دامنه ناچیز است؛ سادگی کد اولویت دارد.
۱,۰۰۰ تا ۲,۰۹۸ شناسه اسکالرEF.Parameter(ids)جلوگیری از افت کارایی پیش‌فرض؛ حدود ۸ برابر سریع‌تر در سقف بازه.
بیش از ۲,۰۹۸ شناسه اسکالرمتد پیش‌فرض Containsفریم‌ورک به‌صورت خودکار به استراتژی OPENJSON سوئیچ می‌کند.
مجموعه کوتاه و پایدار (مانند Enum)EF.Constant(ids)ایجاد نقشه بهینه بر اساس مقادیر واقعی بدون آلوده ساختن Plan Cache.
فیلتر با کلیدهای ترکیبیجدول موقت دستی با SqlBulkCopy یا FromSqlRawعدم پشتیبانی موتور ترجمه از تاپل‌ها؛ عبور از ریسک Stack Overflow در عبارات OR.
بیش از ۱۰۰,۰۰۰ شناسه (تمام حالات)جدول موقت دستی با SqlBulkCopyبالاترین کارایی و کمترین مصرف حافظه در مقیاس‌های کلان.
۱۰. نتیجه‌گیری
رفتار ترجمه پرس‌وجوها در EF Core 10، دچار تحولی زیربنایی شده است. رفع خطای مرز ۲۱۰۰ پارامتر در متد Contains به بهای رفتارهای نامحسوس پردازشی در بازه‌های نزدیک به سقف تمام شده است. کلید تسلط بر عملکرد پایگاه‌داده در دات‌نت، شناخت نحوه تعامل لایه‌های انتزاعی با سرور پایگاه‌داده است:
  • مرز عملیاتی مفید پارامترها با در نظر گرفتن sp_executesql برابر با ۲۰۹۸ است نه ۲۱۰۰.
  • استراتژی پیش‌فرض برای مقادیر بین ۱,۰۰۰ تا ۲,۰۹۸ شناسه نامناسب‌ترین بهره‌وری را دارد؛ در این بازه استفاده صریح از EF.Parameter توصیه می‌شود.
  • در مقیاس‌های کلان یا ساختارهای پیچیده مانند کلیدهای ترکیبی، نباید به لایه ترجمه خودکار اتکا کرد؛ بلکه باید از الگوهای مبتنی بر مجموعه‌های جدولی موقت (مانند Table Types یا Temp Tables با Bulk Copy) استفاده نمود.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۶/۲۳ ۰۹:۳۶
    سؤال: چگونه یک اکستنشن متد جنریک بر روی IQueryable پیاده‌سازی کنیم که با استفاده از SqlBulkCopy و Temp Table، کلیدهای ترکیبی را بدون نیاز به پکیج‌های جانبی فیلتر کند؟

    برای پیاده‌سازی یک اکستنشن‌متد جنریک روی IQueryable که بدون وابستگی به پکیج‌های جانبی، مجموعه‌ای از کلیدها (اسکالر یا ترکیبی) را در قالب یک Temp Table با SqlBulkCopy درج کرده و سپس نتیجه را با کوئری اصلی فیلتر (JOIN) کند، با یک چالش اساسی روبه‌رو هستیم:
    چالش معماری:IQueryable تنها یک تعریف درخت عبارت (Expression Tree) است و تا زمانی که ماتریالایز نشود (مثلاً فراخوانی ToListAsync)، به دیتابیس متصل نمی‌شود. از طرفی یک جدول موقت لوکال (#TempTable) فقط در طول عمر اتصال (Connection Session) جاری معتبر است. بنابراین، فرآیند درج در جدول موقت و اجرای پرس‌وجوی نهایی حتماً باید بر روی همان کانکشن باز و فعال DbContext رخ دهد.
    در ادامه، یک پیاده‌سازی تمیز، جنریک و بهینه برای حل این مسئله با استفاده از قابلیت‌های پیش‌فرض دات‌نت و EF Core ارائه شده است.

    ۱. پیاده‌سازی کلاس کمکیGenericBulkFilterExtensions
    این متد کلیدهای ارسالی را می‌گیرد، با خواندن متادیتای کلاس کمکی یا Anonymous Type جدول موقت محلی را می‌سازد، عملیات درج را با SqlBulkCopy انجام می‌دهد و سپس از طریق FromSqlInterpolated و ترکیب عبارات LINQ کوئری فیلترشده را خروجی می‌دهد.
    using System.Data;
    using System.Linq.Expressions;
    using System.Reflection;
    using Microsoft.Data.SqlClient;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore.Infrastructure;
    
    namespace EfCoreCustomBulkExtensions;
    
    public static class GenericBulkFilterExtensions
    {
        /// <summary>
        /// فیلتر کردن IQueryable بر اساس مجموعه‌ای از کلیدهای اسکالر یا ترکیبی به کمک Temp Table و SqlBulkCopy
        /// </summary>
        /// <typeparam name="TEntity">موجودیت اصلی پایگاه داده</typeparam>
        /// <typeparam name="TKey">نوع کلید یا کلاس نگهدارنده کلید ترکیبی</typeparam>
        public static async Task<List<TEntity>> WhereBulkContainsAsync<TEntity, TKey>(
            this IQueryable<TEntity> source,
            DbContext context,
            IEnumerable<TKey> keys,
            CancellationToken cancellationToken = default)
            where TEntity : class
            where TKey : class
        {
            var keyList = keys as IReadOnlyList<TKey> ?? keys.ToList();
            if (keyList.Count == 0)
            {
                return new List<TEntity>();
            }
    
            var properties = typeof(TKey).GetProperties(BindingFlags.Public | BindingFlags.Instance);
            if (properties.Length == 0)
            {
                throw new InvalidOperationException($"نوع داده {typeof(TKey).Name} هیچ Property عمومی معتبری ندارد.");
            }
    
            // ۱. ساخت نام یکتا برای جدول موقت جهت جلوگیری از تداخل در نشست‌های همزمان
            var tempTableName = $"#TempKeys_{Guid.NewGuid():N}";
    
            // ۲. تبدیل کلیدها به DataTable به صورت جنریک
            using var dataTable = BuildDataTable(keyList, properties);
    
            // ۳. دسترسی به Connection و مدیریت نشست
            var connection = (SqlConnection)context.Database.GetDbConnection();
            var currentTransaction = context.Database.CurrentTransaction?.GetDbTransaction() as SqlTransaction;
    
            bool shouldCloseConnection = false;
            if (connection.State != ConnectionState.Open)
            {
                await connection.OpenAsync(cancellationToken);
                shouldCloseConnection = true;
            }
    
            try
            {
                // ۴. ساخت جدول موقت متناظر با مشخصات ستون‌ها
                await CreateTempTableAsync(connection, currentTransaction, tempTableName, properties, cancellationToken);
    
                // ۵. انتقال سریع داده‌ها با SqlBulkCopy بر بستر همان کانکشن و تراکنش جاری
                using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.Default, currentTransaction))
                {
                    bulkCopy.DestinationTableName = tempTableName;
                    bulkCopy.BatchSize = Math.Min(keyList.Count, 10_000);
    
                    foreach (var prop in properties)
                    {
                        bulkCopy.ColumnMappings.Add(prop.Name, prop.Name);
                    }
    
                    await bulkCopy.WriteToServerAsync(dataTable, cancellationToken);
                }
    
                // ۶. استخراج متادیتای جدول مبدا از EF Core جهت استخراج نام ستون‌ها
                var entityType = context.Model.FindEntityType(typeof(TEntity))
                    ?? throw new InvalidOperationException($"نوع {typeof(TEntity).Name} به عنوان Entity در مدل EF ثبت نشده است.");
    
                var tableName = entityType.GetTableName();
                var schema = entityType.GetSchema() ?? "dbo";
    
                // ۷. ساخت شرط JOIN بین ستون‌های موجودیت و جدول موقت
                var joinConditions = properties.Select(p =>
                {
                    var storeProperty = entityType.FindProperty(p.Name)
                        ?? throw new InvalidOperationException($"Property به نام {p.Name} در Entity با نام {typeof(TEntity).Name} یافت نشد.");
                    
                    var columnName = storeProperty.GetColumnName();
                    return $"source.[{columnName}] = temp.[{p.Name}]";
                });
    
                var joinClause = string.Join(" AND ", joinConditions);
    
                // ۸. اجرای نهایی پرس‌وجو به صورت Native و نگاشت به Entity مربوطه
                var rawSql = $@"
                    SELECT source.* 
                    FROM [{schema}].[{tableName}] AS source
                    INNER JOIN {tempTableName} AS temp 
                        ON {joinClause}";
    
                // استفاده از FromSqlRaw برای بهره‌مندی از Change Tracking یا AsNoTracking بسته به کوئری ورودی
                var query = context.Set<TEntity>().FromSqlRaw(rawSql);
    
                if (!source.Provider.CreateQuery(source.Expression).ElementType.Equals(typeof(TEntity)) ||
                    source.Expression.ToString().Contains(".AsNoTracking("))
                {
                    query = query.AsNoTracking();
                }
    
                return await query.ToListAsync(cancellationToken);
            }
            finally
            {
                // ۹. پاکسازی جدول موقت و در صورت نیاز بستن کانکشن
                try
                {
                    await using var dropCmd = connection.CreateCommand();
                    dropCmd.Transaction = currentTransaction;
                    dropCmd.CommandText = $"IF OBJECT_ID('tempdb..{tempTableName}') IS NOT NULL DROP TABLE {tempTableName};";
                    await dropCmd.ExecuteNonQueryAsync(CancellationToken.None);
                }
                catch
                {
                    // نادیده گرفتن خطای Drop در زمان بروز خطاهای لغو عملیات
                }
    
                if (shouldCloseConnection)
                {
                    await connection.CloseAsync();
                }
            }
        }
    
        private static DataTable BuildDataTable<TKey>(IReadOnlyList<TKey> items, PropertyInfo[] properties)
        {
            var dt = new DataTable();
            foreach (var prop in properties)
            {
                var propType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
                dt.Columns.Add(prop.Name, propType);
            }
    
            foreach (var item in items)
            {
                var row = dt.NewRow();
                foreach (var prop in properties)
                {
                    row[prop.Name] = prop.GetValue(item) ?? DBNull.Value;
                }
                dt.Rows.Add(row);
            }
    
            return dt;
        }
    
        private static async Task CreateTempTableAsync(
            SqlConnection connection,
            SqlTransaction? transaction,
            string tempTableName,
            PropertyInfo[] properties,
            CancellationToken cancellationToken)
        {
            var columnDefinitions = properties.Select(p =>
            {
                var sqlType = GetSqlType(p.PropertyType);
                return $"[{p.Name}] {sqlType} NOT NULL";
            });
    
            var pkColumns = string.Join(", ", properties.Select(p => $"[{p.Name}]"));
    
            var sql = $@"
                CREATE TABLE {tempTableName} (
                    {string.Join(",\n", columnDefinitions)},
                    PRIMARY KEY ({pkColumns})
                );";
    
            await using var cmd = connection.CreateCommand();
            cmd.Transaction = transaction;
            cmd.CommandText = sql;
            await cmd.ExecuteNonQueryAsync(cancellationToken);
        }
    
        private static string GetSqlType(Type type)
        {
            var underType = Nullable.GetUnderlyingType(type) ?? type;
    
            return underType switch
            {
                _ when underType == typeof(int) => "INT",
                _ when underType == typeof(long) => "BIGINT",
                _ when underType == typeof(short) => "SMALLINT",
                _ when underType == typeof(byte) => "TINYINT",
                _ when underType == typeof(Guid) => "UNIQUEIDENTIFIER",
                _ when underType == typeof(string) => "NVARCHAR(450)",
                _ when underType == typeof(DateTime) => "DATETIME2",
                _ when underType == typeof(bool) => "BIT",
                _ => throw exoticType(underType)
            };
    
            static NotSupportedException exoticType(Type t) =>
                new($"نوع داده {t.Name} پشتیبانی نمی‌شود. در صورت نیاز نگاشت آن را اضافه کنید.");
        }
    }

    ۲. نحوه استفاده در سناریوهای واقعی
    سناریو: فیلتر موجودی انبار بر اساس کلید ترکیبی(TenantId, ProductId)
    فرض کنید انبارداری چندمستأجره دارید که کلید اصلی آن شامل دو فیلد است:
    public class InventoryItem
    {
        public int TenantId { get; set; }
        public int ProductId { get; set; }
        public int StockCount { get; set; }
    }
    
    // کلاس مدل یا record برای ارسال کلیدها
    public record InventoryKey(int TenantId, int ProductId);
    اکنون می‌توانید ۵۰,۰۰۰ جفت‌کلید را بدون نگرانی از خطای سقف پارامترها یا Stack Overflow واکشی کنید:
    var targetKeys = new List<InventoryKey>
    {
        new(TenantId: 1, ProductId: 101),
        new(TenantId: 1, ProductId: 205),
        new(TenantId: 2, ProductId: 101),
        // ... هزاران کلید دیگر
    };
    
    // فراخوانی اکستنشن متد
    var items = await context.Inventory
        .AsNoTracking()
        .WhereBulkContainsAsync(context, targetKeys);
    ۳. تحلیل مزایا و نکات ظریف پیاده‌سازی
    - مدیریت صحیح Connection و Transaction:
    • چنانچه کد شما درون یک IDbContextTransaction در حال اجرا باشد، با استفاده از GetDbTransaction()، عملیات SqlBulkCopy و دستور ساخت جدول موقت مستقیماً وارد همان تراکنش می‌شوند و با شکست عملیات Rollback خواهند شد.

    - پرهیز از تداخل همزمانی (Concurrency-Safe):
    • با استفاده از نام رندوم برای جدول موقت (#TempKeys_{guid})، درخواست‌های موازی روی کانکشن‌های مختلف، با نام تکراری جدول روبه‌رو نمی‌شوند.

    - ایندکس Primary Key در Temp Table:
    • در زمان ایجاد جدول موقت، کلیه فیلدهای کلید به عنوان PRIMARY KEY تعریف می‌شوند. این امر باعث ایجاد Clustered Index شده و INNER JOIN با جدول اصلی را از نوع پرسرعت Merge Join یا Index Seek قرار می‌دهد.

    - Zero Parameters:
    • تعداد پارامترهای ارسالی به دیتابیس ۰ عدد است. در نتیجه با صدها هزار ردیف نیز هرگز با خطای ۲۱۰۰ پارامتر یا افت کارایی مواجه نخواهید شد.