عنوان:

‫یافتن شناسه‌های ناموجود (Non-Existing IDs) در SQL Server


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۷/۰۸ ۰۹:۴۰
آدرس: www.dntips.ir
چکیده: در مهندسی سامانه‌های توزیع‌شده و پردازش دسته‌ای تراکنش‌ها، سنجش هم‌زمانی یک مجموعه از شناسه‌های ورودی با داده‌های پایگاه داده و استخراج موارد ناموجود، نیازمندی شایعی در عملیات اعتبارسنجی (Validation) و همگام‌سازی (Reconciliation) است. پیاده‌سازی غیراصولی این فرایند با چالش‌هایی نظیر تله‌های منطقی سه ارزشی SQL (Three-Valued Logic)، سربار حافظه در لایه داده و افت شاخص‌های زمان پاسخ‌گویی همراه خواهد بود. این مقاله راهکارهای استاندارد مبتنی بر T-SQL را برای استخراج شناسه‌های ناموجود تحلیل کرده، مزیت‌های الگوی NOT EXISTS و LEFT JOIN ... WHERE NULL را در مقایسه با مخاطرات NOT IN شرح می‌دهد و معماری تبادل مجموعه‌ای داده‌ها را از طریق پارامترهای جدول‌مقدار (Table-Valued Parameters - TVP) با بهره‌گیری از ADO.NET و Entity Framework Core تبیین می‌نماید. در نهایت، راهکار جایگزین سمت کلاینت در EF Core ارزیابی شده و ماتریس جامعی از تصمیم‌گیری فنی بر مبنای حجم بار کاری ارائه می‌گردد.

مقدمه
هنگامی که یک آرایه از کلیدها از منابع خارجی (مانند وب‌سرویس‌ها، فایل‌های اکسل یا سامانه‌های پیام‌رسان) دریافت می‌شود، توسعه‌دهندگان باید سریعاً تعیین کنند کدام‌یک از این اقلام از قبل در پایگاه داده وجود ندارند. ارسال تک‌تک کوئری‌ها به ازای هر آیتم در یک حلقه، خطای مهلک N+1 Query Problem را ایجاد می‌کند و هزینه Round-Trip شبکه را به شدت بالا می‌برد.
رویکرد استاندارد، ارسال یکجای این لیست به پایگاه داده و اجرای یک عملیات رابطه‌ای برای یافتن مابه‌ازاهای ناموجود است. با این حال، پیاده‌سازی بهینه نیازمند تسلط بر نحوه ارزیابی عملگرها در موتور پایگاه داده SQL Server و چگونگی نگاشت کارآمد این ساختارها در لایه نرم‌افزار دات‌نت است.

رویکردهای T-SQL برای استخراج شناسه‌های ناموجود
برای پردازش یک لیست ثابت در پایگاه داده، ابتدا لیست به عنوان یک جدول مجازی در حافظه شبیه‌سازی شده و سپس عملیات مقایسه تفاضلی انجام می‌گیرد.
مجموعه شناسه‌های ورودی (Literal/TVP)
                 [ 101, 102, 103, X999 ]
                           │
       ┌───────────────────┴───────────────────┐
       ▼                                       ▼
روش ۱: LEFT JOIN ... WHERE NULL        روش ۲: NOT EXISTS
  (ایجاد Join فیزیکی کامل                 (ارزیابی به شیوه Short-Circuit؛
   و فیلتر سطرهای NULL)                   به محض یافتن اولین تطابق متوقف می‌شود)
       │                                       │
       └───────────────────┬───────────────────┘
                           ▼
             شناسه‌های ناموجود در جدول مقصد
                       [ X999 ]

روش اول: استفاده ازLEFT JOIN(روش خوانا و بصری)
در این الگو، شناسه‌های ورودی از طریق عبارت VALUES و یک عبارت جدولی عمومی (Common Table Expression - CTE) به جدول مجازی تبدیل می‌شوند. پیوند خارجی چپ به جدول هدف متصل شده و سطرهایی که مقدار کلید آن‌ها NULL است فیلتر می‌گردند:
WITH IncomingList(ID) AS (
    SELECT * FROM (VALUES (101), (102), (103), (104), (105)) AS V(ID)
)
SELECT l.ID
FROM IncomingList l
LEFT JOIN dbo.TargetTable t ON l.ID = t.ID
WHERE t.ID IS NULL;

روش دوم: استفاده ازNOT EXISTS(بهینه‌ترین الگو)
عملگر NOT EXISTS برای بررسی‌های سلبی درون دیتابیس بهینه‌ترین گزینه است. این عملگر از مکانیزم ارزیابی میان‌بر (Short-Circuit Evaluation) بهره می‌برد؛ بدین معنا که پردازشگر جستجو به محض کشف نخستین رکورد منطبق، پویش ایندکس را متوقف می‌کند.
SELECT l.ID
FROM (VALUES (101), (102), (103), (104), (105)) AS l(ID)
WHERE NOT EXISTS (
    SELECT 1 
    FROM dbo.TargetTable t 
    WHERE t.ID = l.ID
);

هشدار فنی: چرا نباید ازNOT INاستفاده کرد؟
استفاده از الگوی WHERE ID NOT IN (SELECT ID FROM TargetTable) با خطری جدی همراه است:
  • منطق سه ارزشی (Three-Valued Logic): در استاندارد ANSI SQL، مقایسه هر مقداری با NULL حاصل UNKNOWN می‌دهد. اگر ستون هدف حتی شامل یک رکورد با مقدار NULL باشد، گزاره NOT IN به UNKNOWN ارزیابی شده و کوئری کل خروجی را مسدود می‌کند؛ در نتیجه تعداد ۰ رکورد بازمی‌گردد.
  • بنابراین، حفظ قطعیت رفتار کوئری مستلزم پایبندی به الگوهای NOT EXISTS یا LEFT JOIN ... WHERE NULL است.

پیاده‌سازی حرفه‌ای با Table-Valued Parameters (TVP)
در سامانه‌های واقعی، مقادیر نمی‌توانند به صورت مستقیم (Hardcoded) در متن کوئری نوشته شوند. ایجاد رشته‌های متنی دینامیک خطر تزریق کد مخرب به پایگاه داده (SQL Injection) را تشدید کرده و به دلیل عدم کامپایل مجدد، سبب آلودگی کش پلن‌های اجرایی (Plan Cache Bloat) می‌شود.
بهترین رویکرد در SQL Server، استفاده از پارامترهای جدول‌مقدار (TVP) و نوع جدولی تعریف‌شده توسط کاربر (User-Defined Table Type - UDTT) است.

گام اول: تعریف نوع داده جدولی
CREATE TYPE dbo.StringIdList AS TABLE (
    StringID NVARCHAR(100) NOT NULL PRIMARY KEY CLUSTERED
);
GO
نکته بهینه‌سازی پیشرفته: تعریف PRIMARY KEY CLUSTERED روی نوع داده جدولی، ایندکس منحصر‌به‌فرد روی شناسه‌های ورودی ایجاد می‌کند. این کار سربار مرتب‌سازی داده‌ها را در هنگام اجرای Hash Join یا Merge Join به حداقل می‌رساند.

گام دوم: پیاده‌سازی رویه ذخیره‌شده (Stored Procedure)
پارامتر ورودی از نوع جدولی باید اجباراً با نشانه READONLY تزریق شود:
CREATE PROCEDURE dbo.GetNonExistingStringIDs
    @IncomingList dbo.StringIdList READONLY
AS
BEGIN
    SET NOCOUNT ON;

    SELECT l.StringID
    FROM @IncomingList l
    WHERE NOT EXISTS (
        SELECT 1 
        FROM dbo.TargetTable t 
        WHERE t.Code = l.StringID
    );
END;
GO

یکپارچه‌سازی در لایه دات‌نت
۱. پیاده‌سازی با ADO.NET خام (Native ADO.NET)
برای کاربردهایی که نیازمند بالاترین توان پردازشی و کمترین تاخیر حافظه هستند، نگاشت مستقیم ساختار #C به پارامتر TVP توصیه می‌شود:
using System;
using System.Collections.Generic;
using System.Data;
using Microsoft.Data.SqlClient;

public class SqlRepository
{
    private readonly string _connectionString;

    public SqlRepository(string connectionString) => _connectionString = connectionString;

    public List<string> FetchMissingIds(IReadOnlyCollection<string> candidateIds)
    {
        var result = new List<string>();

        // نگاشت لیست ورودی به DataTable هم‌ساختار با نوع داده در SQL
        var idTable = new DataTable();
        idTable.Columns.Add("StringID", typeof(string));

        foreach (var id in candidateIds)
        {
            idTable.Rows.Add(id);
        }

        using var connection = new SqlConnection(_connectionString);
        using var command = new SqlCommand("dbo.GetNonExistingStringIDs", connection)
        {
            CommandType = CommandType.StoredProcedure
        };

        // تنظیم نوع ساختاریافته (Structured) برای TVP
        var tvpParam = command.Parameters.AddWithValue("@IncomingList", idTable);
        tvpParam.SqlDbType = SqlDbType.Structured;
        tvpParam.TypeName = "dbo.StringIdList";

        connection.Open();
        using var reader = command.ExecuteReader();
        while (reader.Read())
        {
            result.Add(reader.GetString(0));
        }

        return result;
    }
}
نکته کارایی دات‌نت: برای مجموعه‌داده‌های چند هزارتایی، کلاس DataTable سربار حافظه بالایی به بار می‌آورد. دات‌نت مدرن امکان پیاده‌سازی رابط IEnumerable را فراهم می‌سازد که داده‌ها را بدون تخصیص حافظه در قالب شیء واسط، به موتور پایگاه داده استریم می‌‌نماید.

۲. پیاده‌سازی در Entity Framework Core با استفاده از TVP
از آنجا که فریم‌ورک EF Core نوع داده جنریک IEnumerable را مستقیماً به انواع جدولی SQL Server نگاشت نمی‌کند، انتقال پارامتریک TVP با استفاده از متد FromSqlRaw همراه با SqlParameter انجام می‌گیرد.

مدل‌سازی نتیجه (Keyless Entity Type)
public class NonExistingIdResult
{
    public string StringID { get; set; } = null!;
}

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }

    public DbSet<NonExistingIdResult> NonExistingIdResults => Set<NonExistingIdResult>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // معرفی مدل به عنوان نهاد بدون کلید اصلی
        modelBuilder.Entity<NonExistingIdResult>().HasNoKey();
    }
}

اجرای فراخوانی نامتقارن (Asynchronous Execution)
public async Task<List<string>> GetMissingIdsWithTvpAsync(
    ApplicationDbContext context, 
    List<string> candidateIds, 
    CancellationToken cancellationToken = default)
{
    var idTable = new DataTable();
    idTable.Columns.Add("StringID", typeof(string));
    foreach (var id in candidateIds)
    {
        idTable.Rows.Add(id);
    }

    var tvpParam = new SqlParameter("@IncomingList", idTable)
    {
        SqlDbType = SqlDbType.Structured,
        TypeName = "dbo.StringIdList"
    };

    return await context.NonExistingIdResults
        .FromSqlRaw("EXEC dbo.GetNonExistingStringIDs @IncomingList", tvpParam)
        .Select(r => r.StringID)
        .ToListAsync(cancellationToken);
}

۳. رویکرد خالص در EF Core (مناسب برای حجم‌های ورودی محدود)
در سناریوهایی که تعداد شناسه‌ها اندک است (زیر ۱۰۰ آیتم)، اعمال پیچیدگی پایگاه داده نظیر ایجاد User-Defined Table Type و Stored Procedure توجیه معماری ندارد. در این شرایط، ترکیب متد Contains در LINQ با متد مجموعه در حافظه Except کارآمدترین راهکار است:
public async Task<List<string>> GetMissingIdsPureLinqAsync(
    ApplicationDbContext context, 
    List<string> candidateIds, 
    CancellationToken cancellationToken = default)
{
    // دریافت شناسه‌های موجود در پایگاه داده
    var existingIds = await context.TargetEntities
        .Where(t => candidateIds.Contains(t.Code))
        .Select(t => t.Code)
        .ToListAsync(cancellationToken);

    // محاسبه تفاضل مجموعه‌ها در حافظه سرور برنامه
    return candidateIds.Except(existingIds).ToList();
}

ملاحظات ارزیابی عملکرد این روش:

  • محدودیت پارامتر در SQL Server: این موتور حداکثر ۲,۱۰۰ پارامتر را در یک فراخوانی می‌پذیرد. اگر طول لیست ورودی از این آستانه فراتر رود، خطای استثنایی رخ خواهد داد.
  • تخریب کش پلن (Plan Cache Fragmentation): تغییر مکرر تعداد عناصر در لیست ورودی باعث تولید ساختارهای SQL با تعداد پارامترهای گوناگون شده و سیستم کش برنامه را تحت فشار قرار می‌دهد. از این رو، این روش صرفاً برای لیست‌های با اندازه کوچک و مشخص توصیه می‌شود.

تحلیل تطبیقی و راهنمای تصمیم‌گیری معماری

مشخصه / شاخصPure LINQ (Contains + Except)Stored Procedure + TVPInline T-SQL (NOT EXISTS + VALUES)
پیچیدگی پیاده‌سازیبسیار پایینمتوسط (نیاز به شیء پایگاه داده)پایین
مناسب برای ابعاد دادهمقیاس کوچک (کمتر از ۱۰۰ آیتم)مقیاس متوسط تا حجیم (هزاران آیتم)تست‌های اسکریپتی و موقت
پایداری پلن اجراییضعیف (در صورت تغییر طول لیست)پایدار و کامپایل‌شدهمتوسط
ایمنی برابر مقادیر NULLوابسته به پیکربندی C# و LINQبالا (تضمین‌شده با NOT EXISTS)بالا
سربار مدیریت آبجکت‌هانداردنیازمند میگریشن نوع جدولی و پروسیجرندارد
نتیجه‌گیری
طراحی الگوهای استخراج داده بر مبنای مجموعه‌های خارجی نیازمند درک متقابل کارکرد موتور پایگاه داده و انتزاع‌های فریم‌ورک‌های رابطه‌ای است. استفاده مستقیم از عملگر NOT IN به دلیل خطر ناشی از مقادیر NULL در منطق سه ارزشی پایگاه داده کاملاً مطرود است و جایگزینی آن با NOT EXISTS عملکردی مطمئن و با تاخیر پایین به همراه دارد. برای سیستم‌های مقیاس‌پذیر در اکوسیستم دات‌نت، پیاده‌سازی پارامترهای جدول‌مقدار (TVP) با ایندکس کلاستر به همراه اتصال از طریق ADO.NET یا FromSqlRaw در EF Core بالاترین استاندارد معماری را تأمین می‌کند. هم‌زمان، رویکرد LINQ Contains گزینه‌ای چابک و شفاف برای حجم‌های پایین محسوب می‌شود؛ مشروط بر اینکه مرزهای ظرفیت پارامتر و پایداری پلن‌های اجرایی در نظر گرفته شوند.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۷/۰۸ ۰۹:۵۸
    روش پیاده‌سازی استریم داده به TVP با استفاده از <IEnumerable<SqlDataRecord ؛ یک روش پیاده‌سازی بدون تخصیص حافظه (Zero-Allocation) با SqlDataRecord

    در برنامه‌نویسی با دات‌نت و ارتباط با SQL Server، ارسال داده‌های حجیم از طریق DataTable به عنوان Table-Valued Parameter (TVP) با یک چالش اساسی مواجه است: سربار حافظه (Memory Allocation) و تحمیل فشار به Garbage Collector (GC). وقتی از DataTable استفاده می‌کنید، کل مجموعه داده باید ابتدا در حافظه رم (RAM) شی‌ءسازی شود و هر سطر درون یک شیء DataRow بسته‌بندی شود. در سناریوهای با بار پردازشی بالا یا مجموعه‌داده‌های چند ده هزارتایی، پیاده‌سازی اینترفیس IEnumerable راهکار استاندارد و مبتنی بر استریم (Streaming / Low-Allocation) است. در این شیوه، داده‌ها رکورد‌به‌رکورد در حین خوانده‌شدن به سمت سوکت شبکه و پایگاه داده جریان می‌یابند.

    مکانیزم کار چگونه است؟
    کلاینت Microsoft.Data.SqlClient پارامترهای با نوع SqlDbType.Structured را نه تنها از طریق DataTable، بلکه از طریق هر شیئی که پیاده‌ساز IEnumerable باشد می‌پذیرد. با استفاده از قابلیت yield return در سی‌شارپ، یک شیء واحد SqlDataRecord ساخته می‌شود و فیلدهای آن بازنویسی و ارسال می‌گردند؛ در نتیجه تخصیص حافظه برای سطرها به صفر میل می‌کند.

    ۱. پیاده‌سازی متد الحاقی (Extension Method) یا ژنراتور
    فرض کنید ساختار جدول نوع کاربری (UDTT) در پایگاه داده به شکل زیر باشد:
    CREATE TYPE dbo.StringIdList AS TABLE (
        StringID NVARCHAR(100) NOT NULL PRIMARY KEY CLUSTERED
    );
    GO
    کد #C زیر یک متد کمکی (Streaming Iterator) پیاده‌سازی می‌کند:
    using System.Collections.Generic;
    using System.Data;
    using Microsoft.Data.SqlClient.Server; // توجه: در دات‌نت جدید این فضا در Microsoft.Data.SqlClient.Server موجود است
    
    public static class SqlStreamExtensions
    {
        // تعریف ساختار فراداده متناظر با نوع جدولی دیتابیس
        private static readonly SqlMetaData[] StringIdMetaData = new[]
        {
            new SqlMetaData("StringID", SqlDbType.NVarChar, 100)
        };
    
        public static IEnumerable<SqlDataRecord> ToSqlDataRecords(this IEnumerable<string> source)
        {
            // تخصیص تنها یک رکورد در حافظه برای کل جریان
            var record = new SqlDataRecord(StringIdMetaData);
    
            foreach (var item in source)
            {
                // مقداردهی ستون اول (ایندکس 0) با مقدار فعلی
                if (item == null)
                {
                    record.SetDBNull(0);
                }
                else
                {
                    record.SetString(0, item);
                }
    
                // ارسال رکورد به کلاینت SQL بدون ذخیره در یک آرایه یا جدول میانی
                yield return record;
            }
        }
    }

    ۲. فراخوانی پروسیجر با استفاده از ADO.NET
    اکنون می‌توانید مستقیماً خروجی متد استریمینگ را به ویژگی Value پارامتر انتساب دهید:
    using System;
    using System.Collections.Generic;
    using System.Data;
    using Microsoft.Data.SqlClient;
    
    public class HighThroughputRepository
    {
        private readonly string _connectionString;
    
        public HighThroughputRepository(string connectionString)
        {
            _connectionString = connectionString;
        }
    
        public List<string> FetchMissingIdsStreamed(IEnumerable<string> candidateIds)
        {
            var result = new List<string>();
    
            using var connection = new SqlConnection(_connectionString);
            using var command = new SqlCommand("dbo.GetNonExistingStringIDs", connection)
            {
                CommandType = CommandType.StoredProcedure
            };
    
            // ایجاد پارامتر TVP و متصل کردن استریم IEnumerable<SqlDataRecord>
            var tvpParam = command.Parameters.Add("@IncomingList", SqlDbType.Structured);
            tvpParam.TypeName = "dbo.StringIdList";
            tvpParam.Value = candidateIds.ToSqlDataRecords(); // استریمینگ فعال می‌شود
    
            connection.Open();
            using var reader = command.ExecuteReader();
            while (reader.Read())
            {
                result.Add(reader.GetString(0));
            }
    
            return result;
        }
    }

    ۳. پشتیبانی از سطرهای چند ستونه (Multi-Column TVP)
    اگر نوع داده جدولی شما شامل چندین ستون با انواع مختلف باشد، ساختار فراداده و مقداردهی رکوردها به سادگی گسترش می‌یابد:
    public class OrderItemDto
    {
        public int ProductId { get; set; }
        public decimal UnitPrice { get; set; }
        public int Quantity { get; set; }
    }
    
    public static class OrderStreamExtensions
    {
        private static readonly SqlMetaData[] OrderItemMetaData = new[]
        {
            new SqlMetaData("ProductId", SqlDbType.Int),
            new SqlMetaData("UnitPrice", SqlDbType.Decimal, 18, 2),
            new SqlMetaData("Quantity", SqlDbType.Int)
        };
    
        public static IEnumerable<SqlDataRecord> ToSqlDataRecords(this IEnumerable<OrderItemDto> items)
        {
            var record = new SqlDataRecord(OrderItemMetaData);
    
            foreach (var item in items)
            {
                record.SetInt32(0, item.ProductId);
                record.SetDecimal(1, item.UnitPrice);
                record.SetInt32(2, item.Quantity);
    
                yield return record;
            }
        }
    }

    مقایسه عملکردی: DataTable در برابر IEnumerable

    شاخص ارزیابیرویکرد DataTableرویکرد SqlDataRecord (استریمینگ)
    میزان مصرف رم (Memory)بسیار بالا؛ متناسب با حجم داده‌ها مقیاس می‌گیرد (O(N))نزدیک به صفر؛ حافظه مصرفی ثابت است (O(1))
    چرخه بازیافت حافظه (GC Pressure)سنگین؛ تخصیص آبجکت‌های متعدد در Gen 0/1/2حداقلی؛ شیء SqlDataRecord یک‌بار نمونه‌سازی و بازیافت می‌شود
    تاخیر قبل از ارسال (TTFB)باید تمام سطرها کامل لود شوند تا ارسال آغاز گرددبلافاصله پس از در دسترس بودن اولین سطر، انتقال داده آغاز می‌شود
    سهولت کدنویسیساده و سرراستنیازمند تعریف صریح SqlMetaData و ایندکس ستون‌ها
    این رویکرد به‌ویژه در خطوط لوله ETL، پردازش فایل‌های CSV یا JSON پرحجم، و سرویس‌های با ترافیک پردازشی بالا که عملکرد و مصرف بهینه منابع سرور اولویت دارد، استاندارد طلایی کار با TVP در اکوسیستم دات‌نت محسوب می‌شود.