یافتن شناسههای ناموجود (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 شبکه را به شدت بالا میبرد.مجموعه شناسههای ورودی (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) با خطری جدی همراه است:NULL حاصل UNKNOWN میدهد. اگر ستون هدف حتی شامل یک رکورد با مقدار NULL باشد، گزاره NOT IN به UNKNOWN ارزیابی شده و کوئری کل خروجی را مسدود میکند؛ در نتیجه تعداد ۰ رکورد بازمیگردد.NOT EXISTS یا LEFT JOIN ... WHERE NULL است.CREATE TYPE dbo.StringIdList AS TABLE (
StringID NVARCHAR(100) NOT NULL PRIMARY KEY CLUSTERED
);
GOنکته بهینهسازی پیشرفته: تعریفPRIMARY KEY CLUSTEREDروی نوع داده جدولی، ایندکس منحصربهفرد روی شناسههای ورودی ایجاد میکند. این کار سربار مرتبسازی دادهها را در هنگام اجرایHash JoinیاMerge Joinبه حداقل میرساند.
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;
GOusing 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را فراهم میسازد که دادهها را بدون تخصیص حافظه در قالب شیء واسط، به موتور پایگاه داده استریم مینماید.
IEnumerable را مستقیماً به انواع جدولی SQL Server نگاشت نمیکند، انتقال پارامتریک TVP با استفاده از متد FromSqlRaw همراه با SqlParameter انجام میگیرد.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();
}
}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);
}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();
}| مشخصه / شاخص | Pure LINQ (Contains + Except) | Stored Procedure + TVP | Inline T-SQL (NOT EXISTS + VALUES) |
| پیچیدگی پیادهسازی | بسیار پایین | متوسط (نیاز به شیء پایگاه داده) | پایین |
| مناسب برای ابعاد داده | مقیاس کوچک (کمتر از ۱۰۰ آیتم) | مقیاس متوسط تا حجیم (هزاران آیتم) | تستهای اسکریپتی و موقت |
| پایداری پلن اجرایی | ضعیف (در صورت تغییر طول لیست) | پایدار و کامپایلشده | متوسط |
| ایمنی برابر مقادیر NULL | وابسته به پیکربندی C# و LINQ | بالا (تضمینشده با NOT EXISTS) | بالا |
| سربار مدیریت آبجکتها | ندارد | نیازمند میگریشن نوع جدولی و پروسیجر | ندارد |
NOT IN به دلیل خطر ناشی از مقادیر NULL در منطق سه ارزشی پایگاه داده کاملاً مطرود است و جایگزینی آن با NOT EXISTS عملکردی مطمئن و با تاخیر پایین به همراه دارد. برای سیستمهای مقیاسپذیر در اکوسیستم داتنت، پیادهسازی پارامترهای جدولمقدار (TVP) با ایندکس کلاستر به همراه اتصال از طریق ADO.NET یا FromSqlRaw در EF Core بالاترین استاندارد معماری را تأمین میکند. همزمان، رویکرد LINQ Contains گزینهای چابک و شفاف برای حجمهای پایین محسوب میشود؛ مشروط بر اینکه مرزهای ظرفیت پارامتر و پایداری پلنهای اجرایی در نظر گرفته شوند.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 ساخته میشود و فیلدهای آن بازنویسی و ارسال میگردند؛ در نتیجه تخصیص حافظه برای سطرها به صفر میل میکند.CREATE TYPE dbo.StringIdList AS TABLE (
StringID NVARCHAR(100) NOT NULL PRIMARY KEY CLUSTERED
);
GOusing 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;
}
}
}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;
}
}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 و ایندکس ستونها |