عنوان:

‫بررسی تاثیر تبدیل ضمنی نوع‌های داده‌ی رشته‌ای بر روی کارآیی کوئری‌ها


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۲۶ ۰۹:۰۰
آدرس: www.dntips.ir
در دنیای توسعه نرم‌افزار، گاهی بزرگ‌ترین بحران‌های کارایی (Performance) از دل ساده‌ترین و تمیزترین کدهای ممکن بیرون می‌آیند. تصور کنید برنامه‌ای که توسعه داده‌اید در محیط عملیاتی (Production) با مشکل جدی مواجه شده است؛ میانگین مصرف پردازنده (CPU) بالای ۵۰ درصد است و مداوم به مرز ۹۰ درصد نیز آسیب می‌زند. پس از بررسی دقیق بارزترین پرس‌وجوها (Top Queries)، متوجه می‌شوید که متهم ردیف اول، یک پرس‌وجوی بسیار ساده و استاندارد در Dapper است: یک شرط WHERE ساده روی یک ستون که اتفاقاً ایندکس‌گذاری (Indexed) هم شده‌است.
این پرس‌وجو به ظاهر باید در چند میکروثانیه اجرا شود، اما در عمل برای هر اجرا چندین هزار میلی‌ثانیه زمان پردازنده مصرف می‌کند و روزانه صدها هزار بار نیز فراخوانی می‌شود. ریشه این فاجعه کجاست؟ یک عدم تطابق نوع داده‌ای (Type Mismatch) دو کاراکتری که در کدهای #C کاملاً مخفی و غایب است. در این مقاله به بررسی این موضوع می‌پردازیم که چگونه رفتارهای پیش‌فرض Dapper و SQL Server می‌توانند بدون سر و صدا، ایندکس‌های دیتابیس شما را نابود کنند و برای حل آن چه باید کرد.

بررسی ریشه‌ای مشکل: تبدیل ضمنی (CONVERT_IMPLICIT)
الگوی زیر تقریباً در تمامی پروژه‌های دات‌نتی که از ابزار محبوب Dapper (یک Micro-ORM قدرتمند) استفاده می‌کنند، دیده می‌شود:
const string sql = "SELECT * FROM Products WHERE ProductCode = @productCode";
var result = await connection.QueryFirstOrDefaultAsync<Product>(sql, new { productCode });
کد فوق بسیار تمیز و خواناست. اما اگر ستون ProductCode در پایگاه داده شما از نوع VARCHAR باشد، همین دو خط کد کارایی دیتابیس را به شدت کاهش می‌دهد.

اما در پشت صحنه چه رخ می‌دهد؟
زمانی که شما یک رشته (string) در #C را از طریق یک شیء ناشناس (Anonymous Object) به Dapper پاس می‌دهید، Dapper به صورت پیش‌فرض آن را به نوع داده NVARCHAR(4000) نگاشت (Map) می‌کند. این رفتار، نگاشت پیش‌فرض برای System.String در ADO.NET است و منطقاً به عنوان یک انتخاب امن (Safe Default) برای پشتیبانی از کاراکترهای یونی‌کد (Unicode) در نظر گرفته شده است.
مشکل زمانی آغاز می‌شود که پایگاه داده با یک پارامتر NVARCHAR مواجه می‌شود، در حالیکه ستون مورد نظر در جدول از نوع VARCHAR (غیر یونی‌کد) است. در قوانین اولویت انواع داده در SQL Server، نوع داده NVARCHAR اولویت بالاتری نسبت به VARCHAR دارد. بنابراین، موتور دیتابیس برای مقایسه دو مقدار، نمی‌تواند پارامتر شما را به VARCHAR تبدیل کند؛ بلکه مجبور است تک‌تک مقادیر موجود در آن ستون از جدول را به NVARCHAR تبدیل کند.
به این فرآیند CONVERT_IMPLICIT یا تبدیل ضمنی می‌گویند. وقتی این اتفاق می‌افتد، SQL Server دیگر نمی‌تواند از عملیات بهینه Index Seek (یافتن مستقیم ردیف از طریق ایندکس) استفاده کند و مجبور به انجام عملیات سنگین Index Scan یا حتی Table Scan (پیمایش کامل ایندکس یا جدول) می‌شود. یعنی دیتابیس برای پیدا کردن یک ردیف، باید تمام ردیف‌ها را بخواند و تبدیل کند!
اگر به برنامه اجرای پرس‌وجو (Execution Plan) در SQL Server نگاه کنید، این هشدار به وضوح قابل مشاهده است:
CONVERT_IMPLICIT(nvarchar(255), [Sales].[ProductCode], 0)
این پیام یعنی: «من یک ایندکس عالی داشتم، اما شما مرا مجبور کردید تمام ردیف‌ها را به یونی‌کد تبدیل کنم تا با پارامتر ورودی مقایسه شوند؛ در نتیجه نتوانستم از ایندکس استفاده کنم.»

عمق فاجعه چقدر است؟
بیایید ریاضیات پشت این اتفاق را بررسی کنیم. فرض کنید جدولی با ۱ میلیون ردیف و یک ایندکس غیرخوشه‌ای (Non-Clustered Index) روی ستون ProductCode دارید:
  • حالت بهینه (انواع داده همخوانی دارند): دیتابیس یک عملیات Index Seek انجام می‌دهد. مستقیم به سراغ ردیف مورد نظر می‌رود. این کار تنها نیاز به چند خواندن منطقی (Logical Reads) دارد و در حد میکروثانیه زمان می‌برد.
  • حالت ناخواسته (تبدیل ضمنی رخ می‌دهد): دیتابیس عملیات Index Scan انجام می‌دهد. تمام ۱ میلیون ردیف را می‌خواند، تک‌تک را تبدیل می‌کند و سپس مقایسه را انجام می‌دهد. تعداد خواندن‌های منطقی از چند عدد به ده‌ها هزار افزایش می‌یابد.

نکته‌ای در مورد Collation دیتابیس:
میزان آسیب این مشکل تا حدی به Collation پایگاه داده شما بستگی دارد. در رایج‌ترین حالت یعنی SQL_Latin1_General_CP1_CI_AS، شما دقیقاً با بدترین سناریو یعنی Full Index Scan مواجه می‌شوید. در برخی از Collationهای ویندوزی (مانند Latin1_General_CI_AS) ممکن است موتور دیتابیس همچنان بتواند Index Seek انجام دهد، اما بار پردازشی (Overhead) ناشی از تبدیل ضمنی همچنان پابرجا خواهد بود. در هر صورت، همخوانی داشتن نوع پارامتر با نوع ستون، تنها راهکار اصولی است.

سنجش کارایی (Benchmark)
برای درک بهتر، یک آزمایش با ابزار استاندارد BenchmarkDotNet روی یک دیتابیس SQL Server شامل ۱ میلیون محصول و ۵۰۰,۰۰۰ سفارش انجام شده‌است. نتیجه ۱,۰۰۰ مرتبه جستجوی تک‌ردیفی به شرح زیر است:

جدول مقایسه زمان اجرا
سناریوحالت پیش‌فرض (NVARCHAR)حالت اصلاح شده (VARCHAR)میزان کندتر بودن حالت پیش‌فرض
جدول محصولات (۱ میلیون ردیف)۲۳ ثانیه۰.۱۳ ثانیه۱۷۶ برابر کندتر
جدول سفارشات (۵۰۰ هزار ردیف)۳۵ ثانیه۰.۱۳ ثانیه۲۶۸ برابر کندتر
این ارقام خطای تایپی نیستند! دیتابیس در حالت پیش‌فرض برای جدول محصولات، ۱۷۶ برابر و برای سفارشات، ۲۶۸ برابر کندتر عمل کرده‌است.

مقایسه متریک‌های پایگاه داده
شاخص (Metric)حالت پیش‌فرض (NVARCHAR)حالت اصلاح شده (VARCHAR)
نوع پیمایش (Scan Type)Index SCANIndex SEEK
تعداد Logical Readsده‌ها هزارتک‌رقمی
زمان CPU به ازای هر اجرامیلی‌ثانیهمیکروثانیه
هزینه تخمینی پرس‌وجو (Query Cost)7.780.007
طبق تخمین خود SQL Server، هزینه اجرای پرس‌وجو در حالت پیش‌فرض بیش از ۱۰۰۰ برابر گران‌تر است.

راهکار حل مشکل در Dapper
راه حل این مشکل بسیار ساده است؛ کافی است به صورت صریح به Dapper بگویید که پارامتر ورودی از نوع VARCHAR (یا همان ANSI String) است و نه NVARCHAR. برای این کار دو روش استاندارد وجود دارد:
روش اول: استفاده از DynamicParameters و DbType.AnsiString
این روش بسیار دقیق است و کنترل کاملی روی اندازه (Size) پارامتر به شما می‌دهد:
const string sql = "SELECT * FROM Products WHERE ProductCode = @productCode";

var parameters = new DynamicParameters();
parameters.Add("productCode", productCode, DbType.AnsiString, size: 100);

var result = await connection.QueryFirstOrDefaultAsync<Product>(sql, parameters);
در این کد، DbType.AnsiString به ADO.NET اعلام می‌کند که یک پارامتر از نوع VARCHAR ارسال کند. مقدار size نیز باید دقیقاً با اندازه ستون در پایگاه داده (مثلاً VARCHAR(100)) مطابقت داشته باشد. این انطباق اندازه به SQL Server کمک می‌کند تا طرح‌های پرس‌وجوی کش‌شده (Cached Query Plans) را بهینه‌تر بازاستفاده (Reuse) کند.

روش دوم: استفاده ازDbString
اگر همچنان ترجیح می‌دهید از اشیاء ناشناس (Anonymous Objects) استفاده کنید، Dapper کلاس کاربردی DbString را برای این منظور ارائه داده است:
var result = await connection.QueryFirstOrDefaultAsync<Product>(sql,
    new { productCode = new DbString { Value = productCode, IsAnsi = true, Length = 100 } });
هر دو روش نتیجه یکسانی دارند و پارامتر را به صورت VARCHAR ارسال می‌کنند که بلافاصله مشکل تبدیل ضمنی را برطرف می‌سازد.

محافظت از کد در برابر بازنویسی (Refactoring)
یکی از چالش‌های بزرگ در پروژه‌های تیمی این است که توسعه‌دهندگان دیگر در آینده ممکن است در حین بازنویسی یا تمیزکاری کدها، کلاس DynamicParameters را ببینند و با خود بگویند: "چرا این کد این‌قدر طولانی است؟ بگذار آن را به یک شیء ناشناس ساده تبدیل کنم". آنها کد شما را ساده می‌کنند و بدون اینکه متوجه شوند، این باگ مهلک کارایی را دوباره به سیستم بازمی‌گردانند!
برای جلوگیری از این اتفاق، حتماً دلیل استفاده از این ساختار را کاملاً واضح کامنت‌گذاری کنید:
var parameters = new DynamicParameters();

// استفاده از DbType.AnsiString الزامی است: 
// ستون Products.ProductCode از نوع varchar(100) است.
// در صورت عدم تعیین، داپرت به صورت پیش‌فرض nvarchar(4000) ارسال می‌کند
// که باعث رخ دادن CONVERT_IMPLICIT روی تمام ردیف‌ها شده و ایندکس را غیرفعال می‌کند.
parameters.Add("productCode", productCode, DbType.AnsiString, size: 100);
نوشتن این کامنت‌ها مثل یک سرعت‌گیر عمل می‌کند و مانع از خرابکاری ناخواسته در آینده می‌شود.

چگونه این مشکل را در دیتابیس خود پیدا کنیم؟
اگر حدس می‌زنید که پایگاه داده فعلی شما نیز با این مشکل دست‌وپنجه نرم می‌کند، روش‌های زیر برای کشف آن وجود دارد:
۱. بررسی Query Store برای یافتن تبدیل‌های ضمنی
اگر ویژگی Query Store در پایگاه داده شما فعال است، با اجرای اسکریپت SQL زیر می‌توانید ۲۰ پرس‌وجوی برتری که با این مشکل مواجه هستند و بیشترین مصرف پردازنده را دارند پیدا کنید:
SELECT TOP 20
    qsqt.query_sql_text,
    qsrs.avg_cpu_time,
    qsrs.count_executions
FROM sys.query_store_runtime_stats qsrs
JOIN sys.query_store_plan qsp ON qsrs.plan_id = qsp.plan_id
JOIN sys.query_store_query qsq ON qsp.query_id = qsq.query_id
JOIN sys.query_store_query_text qsqt ON qsq.query_text_id = qsqt.query_text_id
WHERE qsqt.query_sql_text LIKE '%@%nvarchar(4000)%'
ORDER BY qsrs.avg_cpu_time * qsrs.count_executions DESC;

۲. جستجو در کدهای #C پروژه
در متن کدهای خود به دنبال بخش‌هایی بگردید که رشته‌ها به صورت مستقیم و بدون تعیین نوع، به ستون‌های VARCHAR پاس داده می‌شوند:
// این الگو مشکوک به ایجاد افت کارایی شدید است
await connection.QueryAsync<T>(sql, new { someVarcharColumn });

نتیجه‌گیری و قاعده کلی
این دسته از مشکلات به این دلیل خطرناک هستند که کاملاً نامرئی به نظر می‌رسند. کد کار می‌کند، خروجی درست است، هیچ خطایی در لاگ‌ها ثبت نمی‌شود؛ همه‌چیز عالی است اما سیستم به شدت کند عمل می‌کند.
به عنوان یک قاعده کلی (Rule of Thumb) همیشه به یاد داشته باشید:
  • اگر ستون دیتابیس شما از نوع VARCHAR است، در کدهای #C الزاماً از DbType.AnsiString یا DbString(IsAnsi = true) استفاده کنید و طول آن را مشخص نمایید.
  • اگر ستون دیتابیس از نوع NVARCHAR است، رفتار پیش‌فرض Dapper (یعنی همان DbType.String) کاملاً درست و بهینه است.

همین امروز کدهای پروژه خود را بازبینی (Audit) کنید. هر شیء ناشناسی که یک رشته ساده را به یک ستون VARCHAR می‌فرستد، پتانسیل این را دارد که یک پیمایش کامل جدول (Full Table Scan) را به سیستم شما تحمیل کند. اصلاح این اشتباهات کوچک، تفاوتی شگرف در پایداری و کارایی سرورهای شما ایجاد خواهد کرد.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۳/۲۶ ۰۹:۰۶
    در جهت تکمیل بحث مربوط به نگاشت رشته‌ها و مشکل تبدیل ضمنی (CONVERT_IMPLICIT)، بررسی رفتار Entity Framework Core (EF Core) بسیار حائز اهمیت است؛ چراکه این فریم‌ورک به عنوان محبوب‌ترین ORM در دنیای دات‌نت، رفتار پیش‌فرض مشابهی با Dapper دارد، اما روش‌های مدیریت و حل آن به دلیل ساختار شیءگراتر و وجود کنترلر Context متفاوت است. در ادامه، رفتار پیش‌فرض EF Core در مواجهه با رشته‌ها، فاجعه کارایی ناشی از آن و راهکارهای پیشگیری جامع را بررسی می‌کنیم.

    رفتار پیش‌فرض EF Core با رشته‌ها و بروز تبدیل ضمنی
    در EF Core، نوع داده پیش‌فرض برای تمامی پروپرتی‌های از نوع string در کلاس‌ها، نوع داده یونی‌کد یعنی NVARCHAR(MAX) یا NVARCHAR(4000) در SQL Server است.
    اگر شما یک ستون در پایگاه داده داشته باشید که نوع آن VARCHAR است و پروپرتی متناظر آن در کدهای #C را بدون پیکربندی خاصی رها کنید، با همان مشکل کشنده مواجه خواهید شد. برای مثال، فرض کنید چنین کوئری LINQ ساده‌ای را بنویسید:
    var product = await _context.Products
        .FirstOrDefaultAsync(p => p.ProductCode == "ABC12345");
    اگر ستون ProductCode در دیتابیس VARCHAR باشد، EF Core در پشت صحنه پارامتری با نوع NVARCHAR می‌سازد و کوئری زیر را به SQL Server ارسال می‌کند:
    exec sp_executesql N'SELECT TOP(1) [p].[Id], [p].[ProductCode], [p].[Name]
    FROM [Products] AS [p]
    WHERE [p].[ProductCode] = @__productCode_0',N'@__productCode_0 nvarchar(4000)',@__productCode_0=N'ABC12345'
    همانطور که مشاهده می‌کنید، پارامتر به صورت nvarchar(4000) ارسال شده است که بلافاصله موتور دیتابیس را مجبور به انجام فرآیند CONVERT_IMPLICIT روی تک‌تک ردیف‌های ستون VARCHAR کرده و ایندکس شما را کاملاً بی‌اثر می‌کند.

    راهکارهای حل مشکل در EF Core
    برخلاف Dapper که باید در هر کوئری به صورت جداگانه نوع پارامتر را مشخص کنید، در EF Core می‌توانید این مشکل را یک‌بار برای همیشه در سطح مدل‌سازی دیتابیس (Data Modeling) حل کنید تا خود فریم‌ورک هنگام تولید اسکریپت‌های SQL، پارامترها را با نوع داده صحیح ارسال کند.

    روش اول: استفاده از Fluent API (روش اصولی و پیشنهادی)
    بهترین و امن‌ترین راه برای تنظیم نوع داده ستون‌ها، استفاده از DbContext و متد OnModelCreating است. با این کار به EF Core می‌گویید این رشته از نوع کدهای استاندارد ANSI (یعنی VARCHAR) است:
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.Property(p => p.ProductCode)
                  .IsUnicode(false) // تبدیل رشته به VARCHAR
                  .HasMaxLength(100); // تعیین دقیق طول ستون
        });
    }
    متد IsUnicode(false) دقیقاً معادل همان کاری است که DbType.AnsiString در Dapper انجام می‌داد.

    روش دوم: استفاده از Data Annotations (ویژگی‌های بالای پروپرتی)
    اگر ترجیح می‌دهید تنظیمات دیتابیس را روی خود کلاس‌های مدل (Entity) اعمال کنید، می‌توانید از اتریبیوت [Column] بهره ببرید:
    using System.ComponentModel.DataAnnotations;
    using System.ComponentModel.DataAnnotations.Schema;
    
    public class Product
    {
        public int Id { get; set; }
    
        [Column(TypeName = "varchar")]
        [StringLength(100)]
        public string ProductCode { get; set; } = string.Empty;
    }

    یک گام فراتر: حل مشکل برای کل پروژه (Global Configuration)
    اگر پروژه‌ای بزرگ با صدها جدول دارید که اکثر ستون‌های متنی آن به دلایل بهینه‌سازی حجم دیتابیس از نوع VARCHAR هستند، بررسی تک‌تک پروپرتی‌ها کار سختی است. در EF Core می‌توانید یک قانون کلی (Convention) وضع کنید تا تمام رشته‌ها به صورت پیش‌فرض به VARCHAR تبدیل شوند، مگر اینکه خلاف آن ثابت شود!
    این کار در EF Core 7 به بعد با استفاده از ConfigureConventions بسیار ساده شده است:
    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        // تمام پروپرتی‌های رشته‌ای در کل پروژه به صورت پیش‌فرض VARCHAR(255) می‌شوند
        configurationBuilder.Properties<string>()
                            .HaveIsUnicode(false)
                            .HaveMaxLength(255);
    }
    با این تنظیمات، اگر جایی نیاز به کاراکترهای فارسی یا یونی‌کد داشته باشید (مثل ستون نام یا توضیحات)، کافیست به صورت استثنا روی آن پروپرتی خاص متد .IsUnicode(true) یا نوع داده NVARCHAR را تنظیم کنید.

    یک تله بزرگ در EF Core: متدEF.Functions.Like
    توسعه‌دهندگان زیادی برای جستجوهای متنی در پایگاه داده از متد Like استفاده می‌کنند:
    var results = await _context.Products
        .Where(p => EF.Functions.Like(p.ProductCode, $"%{searchTerm}%"))
        .ToListAsync();
    نکته تاریک اینجاست: حتی اگر شما پروپرتی ProductCode را با IsUnicode(false) به عنوان VARCHAR معرفی کرده باشید، اگر متغیر searchTerm یک رشته‌ی ساده‌ی #C باشد، EF Core ممکن است باز هم پارامتر ورودی لایک را به صورت NVARCHAR به دیتابیس بفرستد!
    برای جلوگیری از تبدیل ضمنی در بدنه دستورات LIKE، حتماً مطمئن شوید که نوع داده ورودی با استفاده از روش‌های بالا کاملاً با ساختار مدل همخوانی دارد، یا در صورت نیاز از قابلیت تعریف صریح نوع پارامتر در زمان استفاده از کوئری‌های خام (FromSqlRaw) بهره ببرید.

    چک‌لیست نهایی برای توسعه‌دهندگان EF Core
    • ستون‌های غیریونی‌کد را مشخص کنید: کدهایی مانند کد ملی، شماره تلفن، کدهای رهگیری، هش‌های MD5/SHA و آدرس‌های ایمیل معمولاً نیازی به کاراکترهای یونی‌کد ندارند. حتماً آن‌ها را در دیتابیس VARCHAR تعریف کرده و در EF Core با متد IsUnicode(false) تنظیم کنید.
    • طول رشته‌ها را محدود کنید: رها کردن رشته‌ها به صورت VARCHAR(MAX) یا NVARCHAR(MAX) از بازاستفاده بهینه کدهای نقشه پرس‌وجو (Query Plan) در SQL Server جلوگیری می‌کند. همیشه طول منطقی (HasMaxLength) برای ستون‌ها در نظر بگیرید.
    • مایگریشن‌ها را بازبینی کنید: زمان تولید Migration در EF Core، فایل خروجی طراح را بررسی کنید تا مطمئن شوید ستون‌های ایندکس‌شده شما با مشخصه unicode: false ساخته شده باشند.