Guid.CreateVersion7() KHÔNG phải GUID tuần tự dành cho SQL Server

Tôi nghĩ đây là tiêu đề blog rõ ràng nhất mà mình từng dùng trong nhiều năm qua. Vì sao tôi lại đề cập đến chuyện này? Hãy cùng tìm hiểu.

Cách đây không lâu, chúng tôi phát hiện một vấn đề về hiệu năng trong các ứng dụng. Nguyên nhân gốc rễ là một chỉ mục bị phân mảnh do sử dụng GUID thông thường thay vì GUID tuần tự.

Trong quá trình tìm cách khắc phục, tôi phải xem lại một bài viết trước đây của mình: GUID tuần tự với .NET 9. Trong bài viết đó, tôi có đề cập rằng bạn có thể dùng Guid.CreateVersion7() để tạo GUID tuần tự. Điều này đúng về mặt kỹ thuật, nhưng không phải là giải pháp cho SQL Server.

Vì sao GUID ngẫu nhiên gây ảnh hưởng xấu?

Chỉ mục clustered trong SQL Server là một cây B được sắp xếp. Khi khóa có giá trị ngẫu nhiên, mỗi lần chèn sẽ rơi vào một trang dữ liệu bất kỳ. Nếu trang đó đã đầy, SQL Server phải tách trang. Kết quả là các trang bị để trống một phần, chỉ mục bị phân mảnh và số lần thao tác I/O tăng lên.

Với một khóa tuần tự, bản ghi mới luôn được thêm vào cuối. Nhờ đó, các trang dữ liệu được lấp đầy và ít bị xáo trộn.

Vì sao GUID phiên bản 7 không tuần tự đối với SQL Server?

UUID phiên bản 7, được định nghĩa trong RFC 9562, bắt đầu bằng dấu thời gian Unix dài 48 bit, tính theo mili giây. Phần còn lại là dữ liệu ngẫu nhiên. Dưới đây là ba giá trị được tạo cách nhau vài mili giây:

01a0ba47-e8d0-7198-9f36-28a04dcb9a21<br>01a0ba47-e8d6-771b-979a-a1ccc7581c77<br>01a0ba47-e8d9-7621-8d8b-bf27581ec544

Nếu đọc từ trái sang phải, chúng đúng là theo thứ tự được tạo. Một cơ sở dữ liệu so sánh UUID theo từng byte từ trái sang phải, chẳng hạn PostgreSQL, sẽ thêm các giá trị này vào cuối chỉ mục. Tuy nhiên, SQL Server không làm như vậy.

SQL Server không so sánh uniqueidentifier từ trái sang phải. Các số byte dưới đây tương ứng với bố cục nhận được từ Guid.ToByteArray(), cũng là cách SQL Server lưu trữ giá trị này. Theo thứ tự từ phần có ý nghĩa lớn nhất đến phần có ý nghĩa nhỏ nhất, SQL Server so sánh:

  • byte 10–15, nhóm cuối: dữ liệu ngẫu nhiên;
  • byte 8–9, nhóm thứ tư: variant và dữ liệu ngẫu nhiên;
  • byte 6–7, nhóm thứ ba: version và dữ liệu ngẫu nhiên;
  • byte 4–5, nhóm thứ hai: phần thấp của dấu thời gian;
  • byte 0–3, nhóm đầu tiên: phần cao của dấu thời gian.

Như vậy, dấu thời gian nằm ở phần có ý nghĩa nhỏ nhất trong khóa sắp xếp, còn các bit ngẫu nhiên lại nằm ở phần có ý nghĩa lớn nhất. Từ góc nhìn của SQL Server, GUID v7 hoạt động giống như một GUID ngẫu nhiên.

Có những lựa chọn nào?

  • Để EF Core tạo mã định danh. Với khóa chính kiểu Guid, provider SQL Server sử dụng SequentialGuidValueGenerator theo mặc định. Bộ sinh này tạo ra các GUID được tối ưu cho khóa clustered trong SQL Server. Nếu tự gán Guid.CreateVersion7() trong mã nguồn, bạn đã bỏ qua cơ chế mặc định đó.
  • Để SQL Server tạo mã định danh thông qua NEWSEQUENTIALID():

CREATE TABLE Orders<br>(<br>    Id UNIQUEIDENTIFIER NOT NULL DEFAULT NEWSEQUENTIALID() PRIMARY KEY,<br>    OrderDate DATETIME2 NOT NULL<br>);

  • Tự tạo mã định danh theo thứ tự của SQL Server. Một số thư viện như RT.Comb hỗ trợ cách này. Thư viện SequentialGuid cung cấp GuidV7.NewSqlGuid(), với chức năng sắp xếp lại các byte để phù hợp với quy tắc sắp xếp của SQL Server. Bạn cũng có thể tự thực hiện việc hoán đổi này.

Bên lề: Sắp xếp lại byte của GUID phiên bản 7

Ý tưởng rất đơn giản: chúng ta vẫn tạo GUID v7, nhưng di chuyển các byte để dấu thời gian rơi vào những byte mà SQL Server ưu tiên so sánh trước.

public static Guid CreateVersion7ForSqlServer()<br>{<br>    Span<byte> rfc = stackalloc byte[16];<br>    Guid.CreateVersion7().TryWriteBytes(rfc, bigEndian: true, out _);<br><br>    Span<byte> sql = stackalloc byte[16];<br>    // SQL Server so sánh byte 10-15 trước, sau đó là 8-9, 6-7, 4-5 và cuối cùng là 0-3<br>    rfc[0..6].CopyTo(sql[10..]);   // Dấu thời gian 48 bit trở thành phần có ý nghĩa lớn nhất<br>    rfc[6..8].CopyTo(sql[8..]);<br>    rfc[8..10].CopyTo(sql[6..]);<br>    rfc[10..12].CopyTo(sql[4..]);<br>    rfc[12..16].CopyTo(sql[0..]);<br>    return new Guid(sql);<br>}

new Guid(ReadOnlySpan<byte>) sử dụng cùng bố cục byte với SQL Server. Vì thế, giá trị bạn tạo ra cũng chính là giá trị bạn thấy trong bảng. Dấu thời gian hiện nằm ở nhóm cuối:

59ad233b-03fc-d091-77b3-01a0ba47e8dc<br>a16717c9-bf50-85a7-7aa6-01a0ba47e8df<br>68d1fa99-9500-90b4-72c8-01a0ba47e8e2

Hãy thử nghiệm

Tôi mô phỏng một chỉ mục clustered nhận một lần chèn mỗi mili giây, sau đó đếm số lần một giá trị mới được sắp xếp sau toàn bộ các giá trị trước đó, tức là được thêm vào cuối chỉ mục.

Tôi dùng SqlGuid để so sánh vì kiểu này tuân theo quy tắc sắp xếp của SQL Server:

static double PercentAtEnd(Func<Guid> generate, int count = 5_000)<br>{<br>    var max = new SqlGuid(generate());<br>    var atEnd = 0;<br>    for (var i = 0; i < count; i++)<br>    {<br>        Thread.Sleep(1); // khoảng một lần chèn mỗi mili giây<br>        var next = new SqlGuid(generate());<br>        if (next > max) { max = next; atEnd++; }<br>    }<br>    return 100.0 * atEnd / count;<br>}<br><br>Console.WriteLine($"Guid.NewGuid():        {PercentAtEnd(Guid.NewGuid):F1}%");<br>Console.WriteLine($"Guid.CreateVersion7():  {PercentAtEnd(Guid.CreateVersion7):F1}%");<br>Console.WriteLine($"Reshuffled version 7:   {PercentAtEnd(CreateVersion7ForSqlServer):F1}%");

Kết quả:

Guid.NewGuid():        0.1%<br>Guid.CreateVersion7():  0.2%<br>Reshuffled version 7:   100.0%

Trong trường hợp này, Guid.CreateVersion7() không tốt hơn GUID ngẫu nhiên. Phiên bản đã được sắp xếp lại luôn được thêm vào cuối.

Lưu ý: Giá trị sau khi sắp xếp lại không còn là UUID v7 hợp lệ khi biểu diễn dưới dạng văn bản. Không nên truyền nó cho các hệ thống mong đợi bố cục RFC 9562 hoặc đọc dấu thời gian từ bố cục đó.

Đây không phải là cách tôi sẽ sử dụng trong các ứng dụng của mình, nhưng nó là một thử nghiệm hữu ích để hiểu vì sao CreateVersion7() không phải điều chúng ta cần trong trường hợp này.

Kiểm tra các chỉ mục hiện có

Thay đổi cách tạo mã định danh chỉ có tác dụng với các bản ghi mới. Tình trạng phân mảnh hiện tại vẫn còn cho đến khi bạn xây dựng lại chỉ mục:

SELECT i.name, ps.avg_fragmentation_in_percent, ps.avg_page_space_used_in_percent, ps.page_count<br>FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.Orders'), NULL, NULL, 'SAMPLED') AS ps<br>JOIN sys.indexes AS i ON i.object_id = ps.object_id AND i.index_id = ps.index_id;<br><br>ALTER INDEX ALL ON dbo.Orders REBUILD;

Vậy là xong. Một giá trị chỉ tuần tự đối với hệ thống thực hiện việc sắp xếp. Với SQL Server, thứ tự đó không phải là thứ tự của RFC 9562.

Thông tin thêm