عنوان:

‫بررسی عملکرد و چالش‌های مرتب‌سازی Guid.CreateVersion7 در Microsoft SQL Server


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۷/۰۴ ۰۸:۳۰
آدرس: www.dntips.ir
چکیده: با معرفی استاندارد RFC 9562 و افزوده شدن متد Guid.CreateVersion7() در .NET 9، بسیاری از توسعه‌دهندگان بر این باورند که مشکل دیرینه مرتب‌سازی کلیدهای اصلی از نوع شناسه یکتا (UUID / GUID) و عارضه قطعه‌قطعه‌شدن شاخص‌ها (Index Fragmentation) به‌طور کامل حل شده است. با این حال، رفتار داخلی موتور پایگاه‌داده Microsoft SQL Server در مرتب‌سازی نوع داده uniqueidentifier از استاندارد لغت‌نامه‌ای و چپ‌به‌راست پیروی نمی‌کند. در این مقاله، معماری مقایسه بایت‌ها در SQL Server را کالبدشکافی کرده و نشان می‌دهیم که چرا Guid.CreateVersion7() در SQL Server رفتاری تقریباً معادل یک GUID تصادفی (Random GUID) دارد. در ادامه، پیامدهای عملکردی ناشی از شکست صفحات (Page Split)، راهکارهای جایگزین در سطح پایگاه‌داده و لایه ORM (مانند EF Core)، و روش بازآرایی بایت‌ها (Byte Reshuffling) به همراه شبیه‌سازی عملکردی مورد بررسی قرار می‌گیرد.

۱. مقدمه
در طراحی پایگاه‌های داده رابطه‌ای، انتخاب نوع داده برای کلید اصلی (Primary Key) همواره محل مناقشه میان دو دیدگاه سنتی بوده است:
  • شناسه‌های عددی خودافزاینده (INT یا BIGINT با ویژگی IDENTITY): دارای ترتیبی خطی، فشرده و بهینه برای ساختارهای B-Tree، اما نامناسب برای معماری‌های توزیع‌شده، سناریوهای Multi-Tenant و سامانه‌های نیازمند تولید کلید در سمت کلاینت پیش از ذخیره‌سازی.
  • شناسه‌های سراسری یکتا (GUID / UUID): غیرقابل حدس، مناسب برای سامانه‌های نامتمرکز و بدون نیاز به دور زدن Round-trip با پایگاه‌داده، اما شدیداً مستعد ایجاد تکه‌تکه‌شدن ایندکس به علت ماهیت تصادفی نسخه ۴ (UUIDv4).

برای حل این تعارض، استاندارد جدید RFC 9562 با معرفی UUIDv7 پا به میدان گذاشت؛ شناسه‌ای زمان‌محور (Time-ordered) که بر اساس یک برچسب زمانی میلی‌ثانیه‌ای ۴۸ بیتی در ابتدای آن مرتب می‌شود. با پشتیبانی توکار دات‌نت ۹ از طریق Guid.CreateVersion7()، انتظار می‌رفت که چالش‌های ترتیبی در SQL Server نیز برطرف شود. با این وجود، به دلیل سازوکار خاص مرتب‌سازی داده در SQL Server، این فرض از اساس نادرست است.

۲. چرا کلیدهای تصادفی به عملکرد SQL Server ضربه می‌زنند؟
در SQL Server، ایندکس خوشه‌ای (Clustered Index) جدول داده‌ها را به شکل یک ساختار درختی مرتب (B-Tree) ذخیره می‌کند. سطوح برگ ایندکس (Leaf Pages)، صفحات فیزیکی داده با حجم ثابت ۸ کیلوبایت هستند.
درج ترتیبی (Append):
[ صفحه ۱ (پر) ]  -->  [ صفحه ۲ (پر) ]  -->  [ صفحه ۳ (در حال پر شدن...) ]

درج تصادفی (Random Insert):
[ صفحه ۱ (پر) ]  + مقدار تصادفی جدید  ==>  [ شکاف صفحه (Page Split) ]
                                             |-> [ صفحه ۱ (۵۰٪) ]
                                             |-> [ صفحه جدید (۵۰٪) ]
  • درج ترتیبی (Sequential Key): داده‌ها همواره به انتهای آخرین صفحه افزوده می‌شوند. صفحات به شکل متراکم پر شده و کمترین میزان سربار I/O و شکست صفحه رخ می‌دهد.
  • درج تصادفی (Random Key): هر مقدار جدید در یک موقعیت دلخواه از درخت قرار می‌گیرد. اگر صفحه هدف پر باشد، فرآیند شکست صفحه (Page Split) اجرا می‌شود؛ نیمی از رکوردهای صفحه به صفحه جدیدی منتقل می‌شوند تا جا برای رکورد ورودی باز شود.

پیامدهای مخرب شکاف صفحات:
  • کاهش تراکم صفحه (Low Page Density): صفحات با حدود ۵۰ تا ۷۰ درصد ظرفیت اشغال می‌شوند که منجر به هدررفت حافظه RAM (Buffer Pool) و فضای دیسک می‌گردد.
  • افزایش شدید I/O: خواندن و پیمایش ایندکس‌های قطعه‌قطعه شده نیازمند واکشی تعداد صفحات بسیار بیشتری است.
  • سربار گزارش‌گیری Transaction Log: عملیات Page Split باید به طور کامل در لاگ تراکنش ثبت شود که به تاخیر عملیات و قفل‌گذاری‌های سنگین دامن می‌زند.

۳. تحلیل ریشه‌ای: چرا UUIDv7 در SQL Server ترتیبی نیست؟
یک شناسه استاندارد UUIDv7 طبق RFC 9562 به شکل زیر سازمان‌دهی می‌شود:
  • بخش اول (۴۸ بیت): برچسب زمانی یونیکس (Unix Epoch Timestamp) با دقت میلی‌ثانیه.
  • بخش دوم (۱۶ بیت): نسخه پروتکل (Version) و بخشی از داده تصادفی/شمارنده.
  • بخش سوم (۶۴ بیت): واریانت (Variant) و داده‌های کاملاً شبه‌تصادفی.

نمونه‌ای از شناسه‌های متوالی تولید شده با متد دات‌نت:
01a0ba47-e8d0-7198-9f36-28a04dcb9a21
01a0ba47-e8d6-771b-979a-a1ccc7581c77
01a0ba47-e8d9-7621-8d8b-bf27581ec544
پایگاه‌های داده‌ای نظیر PostgreSQL مقدار UUID را به صورت لغت‌نامه‌ای (Lexicographical) از چپ به راست و بایت‌به‌بایت مقایسه می‌کنند؛ در نتیجه، شناسه‌های فوق بدون نقص در انتهای ایندکس قرار می‌گیرند.

رفتار نامتعارف SQL Server و SqlGuid
در نقطه مقابل، نوع داده uniqueidentifier در SQL Server بر اساس ساختار حافظه‌ای تاریخچه‌ای کتابخانه OLE DB / RPC ویندوز مرتب می‌شود. پایگاه داده SQL Server ساختار بایت‌های آرایه GUID را با ترتیب زیر (از باارزش‌ترین به کم‌ارزش‌ترین برای مقایسه و سورت) ارزیابی می‌کند:
  • بایت‌های ۱۰ تا ۱۵ (گروه آخر): باارزش‌ترین بخش (بخش تصادفی در UUIDv7)
  • بایت‌های ۸ تا ۹ (گروه چهارم): نوع شناسه و بیت‌های تصادفی
  • بایت‌های ۶ تا ۷ (گروه سوم): نسخه و بیت‌های تصادفی
  • بایت‌های ۴ تا ۵ (گروه دوم): بخش پایینی برچسب زمانی
  • بایت‌های ۰ تا ۳ (گروه اول): بخش بالایی برچسب زمانی (کم‌ارزش‌ترین در مقایسه!)

در نتیجه، برچسب زمانی که باید هدایت مرتب‌سازی را بر عهده داشته باشد، در کم‌ارزش‌ترین بخش الگوریتم سورت SQL Server قرار می‌گیرد و بایت‌های کاملاً تصادفی در جایگاه باارزش‌ترین بخش تعیین‌کننده موقعیت درج می‌شوند. از دیدگاه موتور SQL Server، یک مقدار Guid.CreateVersion7() رفتاری دقیقاً مشابه یک GUID کاملاً تصادفی دارد.

اثبات تجربی رفتار در دات‌نت
برای اثبات این رفتار در محیط دات‌نت، می‌توان از کلاس SqlGuid (واقع در فضای‌نام System.Data.SqlTypes) استفاده کرد که منطق مقایسه‌ای دقیقاً همگام با موتور SQL Server را پیاده‌سازی می‌کند. آزمایشی شبیه‌سازی شد که طی آن ۵٬۰۰۰ کلید با فاصله زمانی ۱ میلی‌ثانیه درج شده و نرخ قرارگیری کلید در انتهای شاخص سنجیده شد:
using System.Data.SqlTypes;

static double PercentAtEnd(Func<Guid> generate, int count = 5_000)
{
    var max = new SqlGuid(generate());
    var atEnd = 0;

    for (var i = 0; i < count; i++)
    {
        Thread.Sleep(1);
        var next = new SqlGuid(generate());
        if (next > max)
        {
            max = next;
            atEnd++;
        }
    }

    return 100.0 * atEnd / count;
}

Console.WriteLine($"Guid.NewGuid():          {PercentAtEnd(Guid.NewGuid):F1}%");
Console.WriteLine($"Guid.CreateVersion7():   {PercentAtEnd(Guid.CreateVersion7):F1}%");
Console.WriteLine($"Reshuffled version 7:    {PercentAtEnd(CreateVersion7ForSqlServer):F1}%");
نتایج آزمایش:
  • Guid.NewGuid(): ۰.۱٪ درج در انتها (کاملاً تصادفی)
  • Guid.CreateVersion7(): ۰.۲٪ درج در انتها (رفتار مشابه تصادفی)
  • Reshuffled version 7: ۱۰۰.۰٪ درج در انتها (کاملاً ترتیبی)

راه‌حل‌های عملیاتی برای محیط تولید
برای غلبه بر این مسئله در سیستم‌هایی که از SQL Server استفاده می‌کنند، چند رویکرد مشخص وجود دارد:

۱. بازآرایی دستی بایت‌ها (Byte Reshuffling)
می‌توان بایت‌های خروجی UUIDv7 را به‌گونه‌ای جابه‌جا کرد که بخش زمانی آن در بایت‌های ۱۰ تا ۱۵ قرار گیرد:
public static Guid CreateVersion7ForSqlServer()
{
    Span<byte> rfc = stackalloc byte[16];
    Guid.CreateVersion7().TryWriteBytes(rfc, bigEndian: true, out _);

    Span<byte> sql = stackalloc byte[16];
    
    // انتقال برچسب زمانی ۴۸ بیتی به باارزش‌ترین بخش مقایسه در SQL Server
    rfc[0..6].CopyTo(sql[10..]);  
    rfc[6..8].CopyTo(sql[8..]);
    rfc[8..10].CopyTo(sql[6..]);
    rfc[10..12].CopyTo(sql[4..]);
    rfc[12..16].CopyTo(sql[0..]);

    return new Guid(sql);
}
نکته معماری مهم: این کار باعث می‌شود شناسه نهایی دیگر یک استاندارد معتبر RFC 9562 نباشد. اگر سیستم‌های خارجی یا سرویس‌های جانبی بر مبنای متن استاندارد UUIDv7 وابسته به استخراج برچسب زمانی باشند، این روش پروتکل آن‌ها را دچار خطا می‌کند.

۲. بهره‌گیری از رفتارهای پیش‌فرض EF Core
در صورت استفاده از Entity Framework Core، فراهم‌کننده رسمی SQL Server به‌طور پیش‌فرض از کلاس SequentialGuidValueGenerator برای تولید شناسه‌ها استفاده می‌کند که بایت‌ها را مطابق با ساختار مقایسه‌ای SQL Server مرتب می‌سازد. انتساب دستی شناسه از طریق Guid.CreateVersion7() در کد دامنه، این ویژگی بهینه‌ساز را نادیده گرفته و غیرفعال می‌کند.

۳. تفویض وظیفه به پایگاه داده باNEWSEQUENTIALID()
اگر ایجاد شناسه در لایه کلاینت الزامی نیست، می‌توان از تابع بهینه‌شده داخلی خود SQL Server استفاده کرد:
CREATE TABLE dbo.Orders
(
    Id UNIQUEIDENTIFIER NOT NULL DEFAULT NEWSEQUENTIALID() PRIMARY KEY CLUSTERED,
    OrderDate DATETIME2 NOT NULL
);

۴. استفاده از الگوهای COMB و کتابخانه‌های تخصصی
استفاده از کتابخانه‌هایی مانند RT.Comb یا پیاده‌سازی‌های معتبر UUIDv7 برای SQL Server که منطق بازآرایی بایت‌ها را درون خود کپسوله کرده‌اند، در معماری‌های با بار کاری بالا (High-throughput) تضمین‌کننده حفظ ترتیبی بودن عملیات درج هستند.

پایش و رفع تکه‌تکه‌شدگی فعلی
تغییر الگوریتم تولید شناسه صرفاً روند درج رکوردهای جدید را اصلاح می‌کند. شاخص‌های تکه‌تکه‌شده فعلی تا زمان بازسازی دست‌نخورده باقی می‌مانند.

برای بررسی سلامت شاخص:
SELECT 
    i.name AS IndexName, 
    ps.avg_fragmentation_in_percent, 
    ps.avg_page_space_used_in_percent, 
    ps.page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.Orders'), NULL, NULL, 'SAMPLED') AS ps
JOIN sys.indexes AS i 
    ON i.object_id = ps.object_id AND i.index_id = ps.index_id;
در صورت بالا بودن میزان تکه‌تکه‌شدگی (معمولاً بالای ۳۰٪)، شاخص باید به شکل زیر بازسازی شود:
ALTER INDEX ALL ON dbo.Orders REBUILD;

نتیجه‌گیری
مفهوم «ترتیبی بودن» (Sequentiality) یک خصلت ذاتی و مطلق برای یک شناسه نیست، بلکه ارتباط مستقیمی با موتور مقایسه‌کننده آن در سیستم مقصد دارد. استاندارد RFC 9562 و متد Guid.CreateVersion7() در دات‌نت پیشرفتی چشمگیر برای پایگاه‌های داده استاندارد (نظیر PostgreSQL) به شمار می‌روند؛ با این حال، تفاوت تاریخی SQL Server در تقدم ارزیابی بایت‌ها باعث می‌شود استفاده مستقیم از آن به افت عملکرد و شکست صفحات بینجامد. برای پروژه‌های متکی بر SQL Server، انتخاب هوشمندانه بین NEWSEQUENTIALID()، مولد پیش‌فرض EF Core یا شناسه‌های اختصاصی بازآرایی‌شده، کلید حفظ مقیاس‌پذیری و بهره‌وری سیستم خواهد بود.