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.
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
IDENTITYvà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. DonHangIdkhông tự duy nhất vì khóa chính là(NgayTao, DonHangId), nênBanHangsinh số bằngSEQUENCEvà giữ mọi chỉ mục aligned.NOT INvới danh sách có NULL trả 0 dòng, vàUNIQUEchỉ cho một NULL: dùngNOT EXISTSvà 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
DEFAULTcủa cộtuniqueidentifier, 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.
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 DonHangId
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ố đơn
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.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á
#: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 Core
#: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àmDonHangIdduy nhất." Chỉ cặp hai cột là duy nhất. ThêmUNIQUE (DonHangId, NgayTao)cũng không chặn thêm gì. - "
IDENTITY_CACHE = OFFcho 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(). - "
UNIQUEbỏ qua NULL." Ràng buộcUNIQUEcho đúng một NULL. Chỉ mục duy nhất có lọcWHERE ... IS NOT NULLmớ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
longtừSEQUENCE, lấy bằngNEXT VALUE FORtrước vòng thử lại, hoặcUseHiLovới một sequence riêng cóINCREMENT BYbằng cỡ khối. - Không dùng
Guid.NewGuid()hayGuid.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ùngDEFAULT 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ặcNOT EXISTS, không bằngNOT INtrê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êmWHERE [SoDienThoai] IS NOT NULLvào chỉ mục, kiểm lại trong script migration.
Đọc tiếp
- Bài trước: Ngày giờ, múi giờ và kiểu tham số. Bài tiếp: Chỉ mục B-tree, seek, scan và key lookup.
- Thủ tục ghi đơn chạy trong một transaction: Giao dịch và XACT_ABORT.
- Khóa và vòng thử lại của thủ tục đó: Blocking, deadlock và thử lại giao dịch.
- Khi lỗ hổng trong dãy
SEQUENCEkhông còn vô hại: Số hóa đơn liên tục. - Page split và allocation unit: Page, dòng và extent.