Cơ sở dữ liệuSQL Server, phần 10/24

Khóa chính: IDENTITY, SEQUENCE, GUID và NULL

Chọn khóa chính cho BanHang: khóa thay thế hay tự nhiên, IDENTITY hay SEQUENCE, GUID nào không làm vỡ page, giữ DonHangId duy nhất trong bảng phân vùng, và hai cái bẫy NULL của NOT IN và UNIQUE.

Mục lục
  1. 1. Chọn khóa và cách sinh số
  2. 2. DonHangId trong một bảng phân vùng
  3. 3. NULL
  4. 4. Áp dụng trong .NET: sinh khóa ở ứng dụng
  5. Những chỗ hay hiểu sai
  6. Kết luận
  7. Đọc tiếp
  8. Nguồn

Khóa chính quyết định giá trị nào bị chép sang mọi bảng tham chiếu, dòng mới rơi vào đâu trong cây B-tree, và engine kiểm được tính duy nhất tới đâu. Ở BanHang, khóa GUID ngẫu nhiên để lại page lá chỉ đầy hai phần ba, số đơn trong bảng phân vùng không tự duy nhất, và một phiếu trả hàng thiếu số đơn làm báo cáo trả 0 dòng. Đọc xong, bạn chọn được cách sinh khóa, giữ DonHangId duy nhất mà SWITCH vẫn chạy, và tránh hai cái bẫy NULL.

Đọc nhanh

  • IDENTITY và SEQUENCE đều để lại lỗ trong dãy số; số cần liên tục tuyệt đối phải lấy từ bảng đếm.
  • GUID ngẫu nhiên, kể cả Guid.CreateVersion7(), làm vỡ page vì SQL Server so GUID từ nhóm byte cuối.
  • DonHangId không tự duy nhất vì khóa chính là (NgayTao, DonHangId), nên BanHang sinh số bằng SEQUENCE và giữ mọi chỉ mục aligned.
  • NOT IN với danh sách có NULL trả 0 dòng, và UNIQUE chỉ cho một NULL: dùng NOT EXISTS và chỉ mục duy nhất có lọc.

1. Chọn khóa và cách sinh số

Khóa thay thế và khóa tự nhiên

MaSanPham dạng SP-00042 là khóa tự nhiên: người dùng thấy, in trên nhãn, có thể đổi định dạng. SanPhamId int là khóa thay thế: chỉ hệ thống thấy, không bao giờ đổi. BanHang dùng khóa thay thế làm khóa chính và giữ khóa tự nhiên bằng UQ_SanPham_Ma.

Khóa chính được chép sang mọi bảng tham chiếu. ChiTietDonHang.SanPhamId int chiếm 4 byte. Nếu tham chiếu bằng MaSanPham varchar(20), giá trị SP-00042 chiếm 8 byte dữ liệu cộng khoảng 4 byte quản lý cột biến độ dài, thêm khoảng 8 byte mỗi dòng.

30 triệu dòng chi tiết vì vậy thêm khoảng 30.000.000 × 8 = 240.000.000 byte, gần 229 MiB, ước lượng chưa tính chỉ mục. Đổi định dạng mã thì phải sửa 30 triệu dòng thay vì 5.000.

IDENTITY và SEQUENCE

IDENTITY SEQUENCE
Gắn với Một cột của một bảng Đối tượng riêng, nhiều bảng dùng chung được
Lấy số trước khi INSERT Không. Lấy sau bằng SCOPE_IDENTITY() hoặc OUTPUT Có, NEXT VALUE FOR
Thêm vào cột đã có Không. Phải tạo cột mới Có, qua DEFAULT
Cache Bật mặc định. Từ SQL Server 2017 tắt được cho cả database bằng IDENTITY_CACHE = OFF CACHE n hoặc NO CACHE cho từng sequence
Lỗ hổng Khi rollback, và khi máy dừng đột ngột lúc còn số trong cache Như vậy

Cả hai không bảo đảm số liền nhau. Số đã cấp cho giao dịch rollback thì mất. Sequence có cache mất phần số còn trong cache khi engine dừng đột ngột, nhưng tài liệu bảo đảm không cấp trùng một số, trừ khi sequence được khai báo CYCLE hoặc bị RESTART tay.

ALTER DATABASE SCOPED CONFIGURATION SET IDENTITY_CACHE = OFF giảm lỗ hổng của IDENTITY sau khởi động lại hoặc failover, đổi lại INSERT chậm hơn. Giá trị hiện tại nằm ở sys.database_scoped_configurations. Số cần liên tục tuyệt đối không lấy từ hai cơ chế này, mà từ một bảng đếm cập nhật trong cùng giao dịch, đổi lại là mọi lần ghi phải xếp hàng qua dòng đếm đó.

Khóa GUID

uniqueidentifier 16 byte, gấp đôi bigint, và mọi chỉ mục không clustered mang 16 byte đó. NEWID() sinh giá trị ngẫu nhiên. Với khóa clustered chỉ gồm GUID, mỗi dòng mới rơi vào một page bất kỳ giữa cây. Page đó đầy thì tách đôi, như chương lưu trữ đã mô tả: ghi log nhiều hơn chèn vào mép phải và để lại hai page đầy khoảng một nửa.

Trong bản tiện tay ở bài byte trên page, NgayTao đứng đầu khóa nên chèn vẫn theo thời gian; cái giá còn lại là kích thước. NEWSEQUENTIALID() sinh GUID tăng dần, nhưng có điều kiện:

  • Chỉ dùng được trong DEFAULT của cột uniqueidentifier, không gọi được trong câu truy vấn.
  • Chỉ tăng trong một lần khởi động Windows trên một máy. Khởi động lại máy, failover sang máy khác, hoặc chuyển database thì có thể bắt đầu từ một khoảng thấp hơn.
  • Đoán được giá trị kế tiếp. Không dùng làm mã truy cập.

SQL Server so sánh uniqueidentifier từ nhóm byte cuối trước. ORDER BY trên ba giá trị 00000000-0000-0000-0000-000000000001, 00000001-0000-0000-0000-000000000000, 00000000-0000-0000-0001-000000000000 trả giá trị thứ hai đứng đầu và giá trị thứ nhất đứng cuối. UUID phiên bản 7 sinh ở ứng dụng đặt thời gian ở đầu chuỗi, nên tăng dần theo chuỗi ký tự nhưng không tăng theo thứ tự của SQL Server.

SQL Server so trước 1 2 3 4 5 CreateVersion7 thời gian ở đầu 01a10228 eff0 7a78 83f2 c142d08e7d04 EF Core thời gian ở cuối e1e5408c fdd7 41c7 ca07 08df2158d782 4 byte 2 byte 2 byte 2 byte 6 byte Tăng theo thời gian sinh Ngẫu nhiên
Số trong vòng tròn là thứ tự SQL Server so các nhóm. Hai giá trị là GUID thật do chương trình ở mục 4 in ra.

2. DonHangId trong một bảng phân vùng

Khóa chính (NgayTao, DonHangId) chỉ bảo đảm cặp hai cột là duy nhất. Hai đơn cùng DonHangId, khác NgayTao, đều được nhận. Trong khi đó hóa đơn in cho khách chỉ có số đơn, và tra cứu, trả hàng, đối soát đều đi bằng DonHangId.

Chỉ mục duy nhất trên riêng DonHangId, aligned với bảng, bị từ chối. Bỏ mệnh đề ON cũng vậy, vì chỉ mục trên bảng phân vùng mặc định dùng partition scheme của bảng:

CREATE UNIQUE INDEX UX_DonHang_DonHangId
    ON dbo.DonHang (DonHangId)
    ON ps_DonHang_Ngay (NgayTao);
Msg 1908, Level 16, State 1
Column 'NgayTao' is partitioning column of the index 'UX_DonHang_DonHangId'.
Partition columns for a unique index must be a subset of the index key.

Đặt chỉ mục đó lên một filegroup (ON FG_DATA) thì tạo được, vì nó không phân vùng. Lần SWITCH kế tiếp ở chương kỹ thuật thất bại với lỗi 7733: The table 'BanHang.dbo.DonHang' is partitioned while index 'UX_DonHang_DonHangId' is not partitioned.

Phương án DonHangId duy nhất SWITCH Giá phải trả
A. SEQUENCE sinh số, không chỉ mục duy nhất Do cách sinh số. Engine không kiểm Giữ Một câu kiểm tra trùng định kỳ. Dòng nhập tay với số tự chọn có thể trùng
B. Chỉ mục duy nhất không aligned trên FG_DATA Engine bảo đảm Mất. Muốn SWITCH phải xóa chỉ mục rồi tạo lại trên 10 triệu dòng Chỉ mục không chia partition, rebuild nguyên khối
C. Chỉ mục duy nhất aligned (DonHangId, NgayTao) Không thêm gì. Cùng tập cột với khóa chính Giữ Thêm một chỉ mục mà ràng buộc không mạnh hơn
D. Bảng sổ số đơn dbo.SoDonHang (DonHangId PRIMARY KEY) không phân vùng, ghi cùng giao dịch Engine bảo đảm Giữ Thêm một lần ghi mỗi đơn và một bảng phải giữ mãi

Phương án C hay bị chọn vì tạo được và trông như ràng buộc. Nó chặn đúng những gì PK_DonHang đã chặn.

BanHang chọn A. Mọi đơn được ghi qua thủ tục ghi đơn, cùng chỗ đang giữ toàn vẹn thay cho khóa ngoại. SWITCH là đường lưu trữ năm cũ của chương kỹ thuật, nên B trả giá quá đắt. Khi có nguồn ghi khác ngoài thủ tục (nhập hàng loạt, hệ đối tác), chuyển sang D.

Tạo sequence, đặt số bắt đầu sau MAX(DonHangId) hiện có, và gắn DEFAULT. MAX(DonHangId) quét chỉ mục hẹp nhất một lần lúc tạo:

Tạo dbo.seq_DonHangId, RESTART sau số lớn nhất, gắn DEFAULT cho DonHangIdSQL · 18 dòng
CREATE SEQUENCE dbo.seq_DonHangId
    AS bigint
    START WITH 1
    INCREMENT BY 1
    CACHE 1000;
GO

DECLARE @TiepTheo bigint = (SELECT ISNULL(MAX(DonHangId), 0) + 1 FROM dbo.DonHang);
DECLARE @Sql nvarchar(200) =
    N'ALTER SEQUENCE dbo.seq_DonHangId RESTART WITH '
    + CAST(@TiepTheo AS nvarchar(20)) + N';';
EXEC sys.sp_executesql @Sql;
GO

ALTER TABLE dbo.DonHang
    ADD CONSTRAINT DF_DonHang_DonHangId
    DEFAULT (NEXT VALUE FOR dbo.seq_DonHangId) FOR DonHangId;
GO

Phía gọi lấy số trước, bằng SELECT NEXT VALUE FOR dbo.seq_DonHangId, rồi truyền vào thủ tục ghi đơn để dùng cùng số đó cho DonHang và các dòng ChiTietDonHang. Lấy số ngoài thủ tục, trước lần thử đầu tiên, để lần thử lại sau deadlock dùng đúng số cũ thay vì sinh thêm một đơn. Thủ tục nằm ở Giao dịch và XACT_ABORT, vòng thử lại ở Blocking, deadlock và thử lại giao dịch. Ràng buộc DEFAULT không cần có trên bảng staging để SWITCH chạy.

Câu kiểm tra trùng, chạy mỗi đêm:

SELECT DonHangId, COUNT(*) AS so_dong
FROM dbo.DonHang
GROUP BY DonHangId
HAVING COUNT(*) > 1;

Tra theo số đơn không kèm ngày là việc thường xuyên thì thêm một chỉ mục thường, aligned: CREATE INDEX IX_DonHang_DonHangId ON dbo.DonHang (DonHangId) ON ps_DonHang_Ngay (NgayTao). Dòng lá gồm DonHangId 8 byte, NgayTao 6 byte và 4 byte quản lý, cộng 2 byte slot là 20 byte, 404 dòng mỗi page, khoảng 24.753 page, gần 193 MiB cho 10 triệu đơn (ước lượng).

Câu WHERE DonHangId = 10042 không có NgayTao nên seek một lần trong mỗi partition, bốn lần với pf_DonHang_Ngay. Bảng staging dùng cho SWITCH phải có chỉ mục tương ứng.

3. NULL

SQL dùng logic ba giá trị: TRUE, FALSE, UNKNOWN. Mọi phép so sánh với NULL, kể cả NULL = NULL, ra UNKNOWN. WHERE chỉ giữ dòng có điều kiện TRUE. COUNT(*) đếm dòng, COUNT(cot) bỏ dòng có cot là NULL.

NOT IN với danh sách có NULL

Phiếu trả hàng ghi số đơn in trên hóa đơn. Khách trả tại quầy mà không mang hóa đơn thì DonHangId để trống:

Bảng dbo.PhieuTraHang với một phiếu có số đơn 10042 và một phiếu không có số đơnSQL · 11 dòng
CREATE TABLE dbo.PhieuTraHang (
    PhieuTraHangId int NOT NULL CONSTRAINT PK_PhieuTraHang PRIMARY KEY CLUSTERED,
    DonHangId bigint NULL,
    NgayTra datetime2(0) NOT NULL,
    LyDo nvarchar(200) NOT NULL
);

INSERT dbo.PhieuTraHang (PhieuTraHangId, DonHangId, NgayTra, LyDo)
VALUES
    (1, 10042, '2026-10-02T15:00:00', N'Lỗi sản phẩm'),
    (2, NULL, '2026-10-02T16:10:00', N'Trả tại quầy, không mang hóa đơn');

Đơn tháng 10/2026 chưa từng bị trả:

SELECT COUNT(*) AS so_don
FROM dbo.DonHang AS d
WHERE d.NgayTao >= CAST('20261001' AS datetime2(0))
  AND d.NgayTao < CAST('20261101' AS datetime2(0))
  AND d.DonHangId NOT IN (SELECT p.DonHangId FROM dbo.PhieuTraHang AS p);

Kết quả: 0. x NOT IN (10042, NULL) nghĩa là x <> 10042 AND x <> NULL. Vế sau luôn UNKNOWN, nên cả điều kiện không bao giờ TRUE. Một phiếu trả không có số đơn làm báo cáo trả 0 dòng mà không báo lỗi.

SELECT COUNT(*) AS so_don
FROM dbo.DonHang AS d
WHERE d.NgayTao >= CAST('20261001' AS datetime2(0))
  AND d.NgayTao < CAST('20261101' AS datetime2(0))
  AND NOT EXISTS (
      SELECT 1
      FROM dbo.PhieuTraHang AS p
      WHERE p.DonHangId = d.DonHangId
  );

NOT EXISTS trả mọi đơn trong tháng trừ đơn 10042. Phiếu có DonHangId NULL không khớp đơn nào nên không loại đơn nào. IN không mắc lỗi này: x IN (10042, NULL) vẫn TRUE khi x là 10042.

Thêm WHERE p.DonHangId IS NOT NULL vào truy vấn con cũng sửa được NOT IN, nhưng thói quen an toàn là NOT EXISTS, vì cột đang NOT NULL hôm nay có thể thành nullable sau một lần đổi schema.

Duy nhất khi có giá trị

KhachHang.SoDienThoai cho phép NULL. Yêu cầu: hai khách không trùng số, nhưng nhiều khách được để trống. Ràng buộc UNIQUE coi các NULL là trùng nhau và chỉ cho một NULL. ALTER TABLE dbo.KhachHang ADD CONSTRAINT UQ_KhachHang_SoDienThoai UNIQUE (SoDienThoai) thất bại với lỗi 1505 ngay khi bảng có hai khách không có số: The duplicate key value is (<NULL>).

Chỉ mục duy nhất có lọc chỉ chứa dòng có số, nên chỉ kiểm tra trùng giữa các số thật:

CREATE UNIQUE INDEX UX_KhachHang_SoDienThoai
    ON dbo.KhachHang (SoDienThoai)
    WHERE SoDienThoai IS NOT NULL;

Gán cho khách thứ hai một số đã có thì lỗi 2601 Cannot insert duplicate key row ... with unique index 'UX_KhachHang_SoDienThoai'. Gán NULL cho bao nhiêu khách cũng được. Chỉ mục lọc cần đúng các SET option như ghi chú ở bài collation.

Lệnh tạo chỉ mục đọc mọi dòng, không chịu row-level security. Câu SELECT tìm số trùng trước khi tạo thì chịu, và chỉ thấy khách của chi nhánh trong SESSION_CONTEXT. Cần nhìn cả bảng thì tạm tắt lọc trong cửa sổ bảo trì bằng ALTER SECURITY POLICY dbo.pol_KhachHang WITH (STATE = OFF), rồi bật lại.

4. Áp dụng trong .NET: sinh khóa ở ứng dụng

Khóa sinh ở ứng dụng có hai câu hỏi: giá trị có tăng theo thứ tự so của SQL Server không, và giá trị có giữ nguyên khi thử lại không. Chương trình KhoaGuid.cs trả lời câu đầu: năm bảng cùng cấu trúc dòng của DonHang, chỉ khác khóa clustered, mỗi bảng nhận 100.000 lần INSERT một dòng như API ghi đơn, rồi đọc tầng lá ngay, chưa rebuild. Chạy bằng dotnet run KhoaGuid.cs trên .NET 10.0.12, Microsoft.EntityFrameworkCore.SqlServer 10.0.12, Dapper 2.1.89, SQL Server 2019 LocalDB, Intel Core Ultra 5 125U; #:property PublishAot=false tắt AOT mà file-based app bật mặc định, vì Dapper và EF Core sinh code lúc chạy:

Khóa clustered Page lá Độ đầy page Phân mảnh
bigint tăng dần 459 99,6% 0,4%
Guid.NewGuid() 845 65,8% 99,5%
Guid.CreateVersion7() 847 65,6% 99,4%
SequentialGuidValueGenerator của EF Core 559 99,4% 0,7%
DEFAULT NEWSEQUENTIALID() 559 99,4% 0,5%

GUID ngẫu nhiên, kể cả CreateVersion7, cần khoảng 845 page lá; GUID theo thứ tự của SQL Server cần 559

bigint tăng dần459 pageGuid.NewGuid()845 pageGuid.CreateVersion7()847 pageEF Core SequentialGuid559 pageNEWSEQUENTIALID()559 page
100.000 lần INSERT một dòng mỗi bảng, đo tầng lá ngay sau khi chèn, chưa rebuild. .NET 10.0.12, SQL Server 2019 LocalDB.
Bảng số liệu
Giá trị
bigint tăng dần459 page
Guid.NewGuid()845 page
Guid.CreateVersion7()847 page
EF Core SequentialGuid559 page
NEWSEQUENTIALID()559 page

Guid.CreateVersion7() (có từ .NET 9) cho kết quả như GUID ngẫu nhiên: page tách đôi liên tục, đầy khoảng hai phần ba. Năm GUID v7 sinh cách nhau 2 ms, ORDER BY của SQL Server xếp thành 4, 2, 3, 1, 5.

EF Core mặc định sinh khóa Guid bằng SequentialGuidValueGenerator, đặt bộ đếm theo thời gian vào 8 byte cuối, đúng phần SQL Server so trước. Năm giá trị của nó ra đúng thứ tự sinh, và page đầy như NEWSEQUENTIALID(). Kích thước vẫn đắt hơn bigint: 559 page so với 459, chỉ vì 8 byte khóa dư.

// BanHang: số đơn lấy từ SEQUENCE một lần, ngoài vòng thử lại
var donHangId = await conn.ExecuteScalarAsync<long>("SELECT NEXT VALUE FOR dbo.seq_DonHangId;");
for (var lan = 1; ; lan++)
{
    try
    {
        await conn.ExecuteAsync("dbo.usp_DonHang_Tao",
            new { DonHangId = donHangId, KhachHangId = 42, TongTien = 1_750_000m },
            commandType: CommandType.StoredProcedure);
        break;
    }
    catch (SqlException ex) when (ex.Number == 1205 && lan < 3)
    {
        await Task.Delay(50 * lan);   // deadlock: thử lại với đúng donHangId cũ
    }
}

// EF Core: HiLo cấp số ngay lúc Add, trước SaveChanges
b.HasSequence<long>("seq_DonHangHiLo", "dbo").StartsAt(1).IncrementsBy(10);
e.Property(d => d.DonHangId).UseHiLo("seq_DonHangHiLo", "dbo");

Chương trình SoDon.cs chạy cả hai đường trên LocalDB. Đường Dapper ghi đơn 1 ở lần thử đầu (không có deadlock để thử lại). Đường EF Core thêm 25 đơn: sau AddRange, trước SaveChanges, chúng đã mang DonHangId 1 đến 25, và cả lần lưu chỉ gọi NEXT VALUE FOR 3 lần, sequence dừng ở current_value 21. Cỡ khối HiLo bằng INCREMENT BY của sequence, nên HiLo cần sequence riêng; dbo.seq_DonHangId với INCREMENT BY 1 sẽ tốn một lần gọi cho mỗi đơn.

KhoaGuid.cs: năm cách sinh khóa clustered, 100.000 lần INSERT mỗi cách, đo page láC# · 81 dòng
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:package Dapper@2.1.89
#:property PublishAot=false
// Năm cách sinh khóa clustered, mỗi cách 100.000 lần INSERT một dòng như API ghi đơn.
// Đo tầng lá ngay sau khi chèn, chưa rebuild: số page, độ đầy page, độ phân mảnh.
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore.ValueGeneration;

const int SoDong = 100_000;
using var conn = new SqlConnection(Db.Cs);
conn.Open();

var efGuid = new SequentialGuidValueGenerator();   // EF Core dùng bộ sinh này cho khóa Guid trên SQL Server
var cachSinh = new (string Bang, string KieuKhoa, Func<long, object>? Sinh)[]
{
    ("Khoa_Bigint",       "bigint",           i => i),
    ("Khoa_NewGuid",      "uniqueidentifier", _ => Guid.NewGuid()),
    ("Khoa_GuidV7",       "uniqueidentifier", _ => Guid.CreateVersion7()),
    ("Khoa_EfSequential", "uniqueidentifier", _ => efGuid.Next(null!)),
    ("Khoa_NewSeqId",     "uniqueidentifier", null),   // DEFAULT NEWSEQUENTIALID() ở server
};

foreach (var (bang, kieu, sinh) in cachSinh)
{
    conn.Execute($"""
        DROP TABLE IF EXISTS dbo.{bang};
        CREATE TABLE dbo.{bang} (
            Khoa {kieu} NOT NULL{(sinh is null ? " DEFAULT NEWSEQUENTIALID()" : "")} CONSTRAINT PK_{bang} PRIMARY KEY CLUSTERED,
            NgayTao datetime2(0) NOT NULL,
            KhachHangId int NOT NULL,
            TrangThai tinyint NOT NULL,
            TongTien decimal(18, 2) NOT NULL);
        """);
    using var cmd = new SqlCommand(sinh is null
        ? $"INSERT dbo.{bang} (NgayTao, KhachHangId, TrangThai, TongTien) VALUES (@NgayTao, @KhachHangId, 4, @TongTien);"
        : $"INSERT dbo.{bang} (Khoa, NgayTao, KhachHangId, TrangThai, TongTien) VALUES (@Khoa, @NgayTao, @KhachHangId, 4, @TongTien);", conn);
    if (sinh is not null) cmd.Parameters.Add("@Khoa", kieu == "bigint" ? SqlDbType.BigInt : SqlDbType.UniqueIdentifier);
    cmd.Parameters.Add("@NgayTao", SqlDbType.DateTime2).Scale = 0;
    cmd.Parameters.Add("@KhachHangId", SqlDbType.Int);
    var tien = cmd.Parameters.Add("@TongTien", SqlDbType.Decimal); tien.Precision = 18; tien.Scale = 2;

    // Mỗi INSERT là một câu lệnh riêng; gom 1.000 câu vào một giao dịch chỉ để LocalDB không chờ flush log từng dòng.
    for (long i = 1; i <= SoDong; i++)
    {
        if (i % 1000 == 1) cmd.Transaction = conn.BeginTransaction();
        if (sinh is not null) cmd.Parameters["@Khoa"].Value = sinh(i);
        cmd.Parameters["@NgayTao"].Value = new DateTime(2026, 10, 2).AddSeconds(i);
        cmd.Parameters["@KhachHangId"].Value = (int)(i % 200_000) + 1;
        cmd.Parameters["@TongTien"].Value = 1_750_000m;
        cmd.ExecuteNonQuery();
        if (i % 1000 == 0) cmd.Transaction!.Commit();
    }

    var ps = conn.QuerySingle<(long Page, double Day, double PhanManh)>($"""
        SELECT page_count, avg_page_space_used_in_percent, avg_fragmentation_in_percent
        FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.{bang}'), 1, NULL, 'DETAILED')
        WHERE index_level = 0;
        """);
    Console.WriteLine($"{bang,-18} {ps.Page,4} page lá, đầy {ps.Day,4:F1}%, phân mảnh {ps.PhanManh,4:F1}%");
}

// Thứ tự: 5 GUID sinh lần lượt (cách nhau 2 ms), ORDER BY của SQL Server xếp lại ra sao.
Console.WriteLine("v7:            " + ThuTu(() => Guid.CreateVersion7()));
Console.WriteLine("EF Sequential: " + ThuTu(() => efGuid.Next(null!)));
Console.WriteLine("Mẫu v7: " + Guid.CreateVersion7() + "   mẫu EF: " + efGuid.Next(null!));

string ThuTu(Func<Guid> sinhGuid)
{
    var ds = new List<Guid>();
    for (var i = 0; i < 5; i++) { ds.Add(sinhGuid()); Thread.Sleep(2); }
    var values = string.Join(",", ds.Select((g, i) => $"({i + 1}, CAST('{g}' AS uniqueidentifier))"));
    var thuTu = conn.Query<int>($"SELECT t.LanSinh FROM (VALUES {values}) AS t (LanSinh, G) ORDER BY t.G;");
    return "sinh 1, 2, 3, 4, 5; ORDER BY ra " + string.Join(", ", thuTu);
}

static class Db
{
    public const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_kieudulieu;Integrated Security=true;TrustServerCertificate=true";
}
SoDon.cs: lấy DonHangId trước vòng thử lại bằng Dapper, và HiLo trong EF CoreC# · 94 dòng
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:package Dapper@2.1.89
#:property PublishAot=false
// Số đơn lấy từ SEQUENCE trước lần ghi đầu tiên: Dapper gọi NEXT VALUE FOR, EF Core dùng HiLo.
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

using var conn = new SqlConnection(Db.Cs);
await conn.OpenAsync();
await conn.ExecuteAsync("""
    DROP TABLE IF EXISTS dbo.DonHangHiLo;
    DROP TABLE IF EXISTS dbo.DonHangSo;
    DROP PROCEDURE IF EXISTS dbo.usp_DonHang_Tao;
    DROP SEQUENCE IF EXISTS dbo.seq_DonHangId;
    DROP SEQUENCE IF EXISTS dbo.seq_DonHangHiLo;
    CREATE SEQUENCE dbo.seq_DonHangId AS bigint START WITH 1 INCREMENT BY 1 CACHE 1000;
    CREATE TABLE dbo.DonHangSo (DonHangId bigint NOT NULL, NgayTao datetime2(0) NOT NULL,
        KhachHangId int NOT NULL, TongTien decimal(18, 2) NOT NULL, PRIMARY KEY (NgayTao, DonHangId));
    """);
await conn.ExecuteAsync("""
    CREATE PROCEDURE dbo.usp_DonHang_Tao @DonHangId bigint, @KhachHangId int, @TongTien decimal(18, 2)
    AS
    INSERT dbo.DonHangSo (DonHangId, NgayTao, KhachHangId, TongTien)
    VALUES (@DonHangId, CAST(SYSUTCDATETIME() AT TIME ZONE 'UTC' AT TIME ZONE 'SE Asia Standard Time' AS datetime2(0)),
            @KhachHangId, @TongTien);
    """);

// 1. Dapper: lấy số một lần, ngoài vòng thử lại; mọi lần thử gửi cùng số đó.
var donHangId = await conn.ExecuteScalarAsync<long>("SELECT NEXT VALUE FOR dbo.seq_DonHangId;");
for (var lan = 1; ; lan++)
{
    try
    {
        await conn.ExecuteAsync("dbo.usp_DonHang_Tao",
            new { DonHangId = donHangId, KhachHangId = 42, TongTien = 1_750_000m },
            commandType: System.Data.CommandType.StoredProcedure);
        Console.WriteLine($"Ghi đơn {donHangId} ở lần thử {lan}");
        break;
    }
    catch (SqlException ex) when (ex.Number == 1205 && lan < 3)
    {
        await Task.Delay(50 * lan);   // deadlock: thử lại với đúng donHangId cũ
    }
}

// 2. EF Core HiLo: số được cấp ngay lúc Add, trước SaveChanges; mỗi lần gọi sequence lấy một khối 10 số.
var soLanGoiSequence = 0;
await using (var db = new HiLoDb(msg => { if (msg.Contains("NEXT VALUE FOR")) soLanGoiSequence++; }))
{
    await db.Database.ExecuteSqlRawAsync(db.Database.GenerateCreateScript().Replace("GO", ""));
    var don = Enumerable.Range(1, 25)
        .Select(i => new DonHangHiLo { NgayTao = new DateTime(2026, 10, 2, 11, 58, 0), KhachHangId = 42, TongTien = 1_750_000m })
        .ToList();
    db.AddRange(don);
    Console.WriteLine($"Sau AddRange, trước SaveChanges: DonHangId {don[0].DonHangId} .. {don[^1].DonHangId}");
    await db.SaveChangesAsync();
}
Console.WriteLine($"25 đơn, {soLanGoiSequence} lần gọi NEXT VALUE FOR");
Console.WriteLine(await conn.QuerySingleAsync<string>(
    "SELECT CONCAT('seq_DonHangHiLo: increment ', CAST(increment AS bigint), ', current_value ', CAST(current_value AS bigint)) FROM sys.sequences WHERE name = 'seq_DonHangHiLo';"));

class DonHangHiLo
{
    public long DonHangId { get; set; }
    public DateTime NgayTao { get; set; }
    public int KhachHangId { get; set; }
    public decimal TongTien { get; set; }
}

class HiLoDb(Action<string> log) : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder o) => o
        .UseSqlServer(Db.Cs)
        .LogTo(log, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
    protected override void OnModelCreating(ModelBuilder b)
    {
        b.HasSequence<long>("seq_DonHangHiLo", "dbo").StartsAt(1).IncrementsBy(10);
        b.Entity<DonHangHiLo>(e =>
        {
            e.ToTable("DonHangHiLo");
            e.HasKey(d => new { d.NgayTao, d.DonHangId });
            e.Property(d => d.DonHangId).UseHiLo("seq_DonHangHiLo", "dbo");
            e.Property(d => d.NgayTao).HasPrecision(0);
            e.Property(d => d.TongTien).HasPrecision(18, 2);
        });
    }
}

static class Db
{
    public const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_kieudulieu;Integrated Security=true;TrustServerCertificate=true";
}

Những chỗ hay hiểu sai

  • "Khóa chính (NgayTao, DonHangId) làm DonHangId duy nhất." Chỉ cặp hai cột là duy nhất. Thêm UNIQUE (DonHangId, NgayTao) cũng không chặn thêm gì.
  • "IDENTITY_CACHE = OFF cho số liền nhau." Rollback vẫn để lại lỗ hổng.
  • "NEWSEQUENTIALID() luôn tăng." Chỉ tăng trong một lần khởi động Windows trên một máy.
  • "UUID v7 tăng dần nên hợp làm khóa clustered trên SQL Server." SQL Server so nhóm byte cuối trước; với v7 đó là phần ngẫu nhiên, nên page vỡ như NEWID().
  • "UNIQUE bỏ qua NULL." Ràng buộc UNIQUE cho đúng một NULL. Chỉ mục duy nhất có lọc WHERE ... IS NOT NULL mới bỏ qua NULL.

Kết luận

Khóa chính tốt là khóa hẹp, tăng theo thứ tự so của engine, và được sinh trước lần thử đầu tiên. Tính duy nhất chỉ có thật khi engine kiểm được, hoặc khi mọi đường ghi đi qua một chỗ sinh số.

Trong dự án .NET của bạn:

  • Khóa đơn hàng dùng long từ SEQUENCE, lấy bằng NEXT VALUE FOR trước vòng thử lại, hoặc UseHiLo với một sequence riêng có INCREMENT BY bằng cỡ khối.
  • Không dùng Guid.NewGuid() hay Guid.CreateVersion7() làm khóa clustered trên SQL Server. Cần GUID thì để EF Core sinh bằng bộ sinh mặc định, hoặc dùng DEFAULT NEWSEQUENTIALID(), hoặc giữ GUID trong một chỉ mục duy nhất không clustered.
  • Truy vấn "chưa có trong bảng kia" viết bằng !db.PhieuTraHang.Any(p => p.DonHangId == d.DonHangId) hoặc NOT EXISTS, không bằng NOT IN trên cột nullable.
  • Ràng buộc "duy nhất khi có giá trị" khai báo bằng HasIndex(k => k.SoDienThoai).IsUnique() trên thuộc tính nullable: EF Core 10 tự thêm WHERE [SoDienThoai] IS NOT NULL vào chỉ mục, kiểm lại trong script migration.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

Chỉ mục B-tree, seek, scan và key lookup

Cây B-tree của chỉ mục, seek, scan, key lookup và điểm lật, tính bằng số trên bảng DonHang 10 triệu dòng, kèm cách chiếu cột trong EF Core để khỏi quay lại bảng.

14 phút đọc

Trong SQL Server

Ngày giờ, múi giờ và kiểu tham số

Vì sao BETWEEN '23:59:59.997' làm sót hoặc đếm trùng đơn, lưu giờ Việt Nam hay UTC, và vì sao kiểu tham số mà Dapper, ADO.NET hay EF Core gửi xuống quyết định cả kết quả lẫn kế hoạch.

12 phút đọc