Cơ sở dữ liệuSQL Server, phần 9/34
Khách 42 tên gì lúc lập hóa đơn: temporal table và mốc giờ UTC
Bảng temporal dbo.KhachHang giữ mọi phiên bản của khách, nhưng câu tra đầu tiên truyền giờ máy vào TemporalAsOf nên lệch 7 giờ và trả tên hiện tại. Đổi mốc sang UTC cho đúng 3 trên 3 mốc, và chỉ mục theo khách trên bảng lịch sử 1.000.000 dòng đưa câu tra từ 9.742 xuống 9 logical reads.
Mục lục
- 1. Vấn đề: tra lại tên lúc lập hóa đơn nhưng nhận tên hiện tại
- 2. Mục đích: đúng phiên bản, đúng múi giờ, tra nhanh
- 3. Cơ sở lý thuyết: hai bảng, mỗi phiên bản một khoảng thời gian UTC
- 4. Cách giải quyết: temporal table, mốc UTC và chỉ mục theo khách
- 5. Cách cài đặt: một chỉ mục và một phép đổi giờ
- 6. Chứng minh: 3 trên 3 mốc đúng, tra từ 9.742 xuống 9 logical reads
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: Đại lý 42 hỏi hóa đơn lập lúc 12:00 in tên gì; câu tra lịch sử đầu tiên truyền giờ máy nên trả tên hiện tại, và mỗi lần tra đọc gần 10.000 page.
- Cách giải: Hỏi bảng temporal
dbo.KhachHangbằngFOR SYSTEM_TIME AS OFvới mốc đã đổi sang UTC, và thêm chỉ mục(KhachHangId, SysEnd, SysStart)cho bảng lịch sử. - Chứng minh: Ba mốc UTC trả đúng 3 trên 3 phiên bản, mốc giờ máy trả sai; tra một khách trong lịch sử 1.000.000 dòng từ 9.742 xuống 9 logical reads.
- Trong .NET: EF Core 10 ánh xạ bằng
IsTemporal, đọc bằngTemporalAsOf(gioVn.UtcDateTime)vớigioVnlàDateTimeOffsetcó độ lệch +7.
1. Vấn đề: tra lại tên lúc lập hóa đơn nhưng nhận tên hiện tại
Ngày 2026-10-03, đại lý 42 (khách B2B của chi nhánh 3) khiếu nại: hóa đơn lập lúc 12:00 ngày 2026-10-02 in tên cũ, trong khi họ đã báo đổi tên. Màn hình khách hàng chỉ hiện tên hiện tại. Nhân viên hỗ trợ cần biết tên và số điện thoại của khách 42 đúng lúc 12:00 hôm đó, và tên được đổi lúc nào.
dbo.KhachHang của BanHang đã là bảng temporal từ đầu, nên dữ liệu cũ vẫn còn. Lập trình viên thêm câu tra bằng TemporalAsOf của EF Core và gặp hai chuyện:
- Truyền
DateTime.Nowvào mốc, câu tra trả "Đại lý A - Chi nhánh 3" cho một thời điểm trước lần đổi tên. Máy chạy giờ Việt Nam, đi trước UTC 7 giờ, nên mốc rơi vào tương lai. - Bảng lịch sử có 1.000.000 dòng sau vài đợt cập nhật hàng loạt. Mỗi lần tra một khách đọc 9.742 page.
Trả lời sai một khiếu nại về hóa đơn còn tệ hơn không trả lời. Hóa đơn điều chỉnh lập theo câu trả lời đó cũng sai.
2. Mục đích: đúng phiên bản, đúng múi giờ, tra nhanh
- Tên và số điện thoại của khách 42 đúng tại mọi mốc đã hỏi: 3 trên 3 mốc.
- Mốc truyền từ .NET là giờ UTC; một mốc nhập theo giờ Việt Nam sinh ra đúng
AS OFgiờ UTC. - Tra một khách khi bảng lịch sử có 1.000.000 dòng: dưới 100 logical reads.
- Biết cái giá: bảng lịch sử lớn bao nhiêu, và một lệnh
UPDATEhàng loạt chậm thêm bao nhiêu. - Ngoài phạm vi: ai đã sửa (temporal không lưu người sửa), và đồng bộ thay đổi sang hệ khác (Change Tracking và CDC).
3. Cơ sở lý thuyết: hai bảng, mỗi phiên bản một khoảng thời gian UTC
Ai ghi khoảng thời gian
Bảng temporal có hai cột datetime2 do engine điền: SysStart và SysEnd. Khi INSERT, SysStart là thời điểm bắt đầu giao dịch theo giờ UTC, SysEnd là 9999-12-31. Khi UPDATE, phiên bản cũ được chép sang dbo.KhachHang_LichSu với SysEnd bằng thời điểm bắt đầu giao dịch đang sửa, còn dòng hiện tại nhận SysStart mới. DELETE chuyển dòng sang bảng lịch sử.
Có ba hệ quả cần nhớ:
- Mốc lấy theo lúc giao dịch bắt đầu, không theo lúc câu lệnh chạy. Mọi dòng sửa trong một giao dịch dài nhận cùng một mốc.
- Mọi lệnh sửa đều thêm một dòng lịch sử, kể cả khi giá trị không đổi.
- Người dùng không sửa được bảng lịch sử khi
SYSTEM_VERSIONING = ON, kể cả người có quyền trên bảng đó.
AS OF chọn phiên bản nào
FOR SYSTEM_TIME AS OF t gộp bảng hiện tại với bảng lịch sử, rồi giữ phiên bản có SysStart <= t AND SysEnd > t. Lần thử trên LocalDB tạo ba phiên bản của khách 42 trong sáu giây. Mốc 03:34:43 UTC rơi đúng phiên bản đầu. Mốc 10:34:43 là cùng khoảnh khắc đó nhưng tính theo giờ máy; nó lớn hơn mọi SysStart, nên khớp dòng hiện tại.
EF Core ghép giá trị DateTime vào câu SQL như một hằng số. Engine không có thông tin múi giờ để đổi giúp, nên mốc sai một múi giờ là sai phiên bản, không có lỗi nào báo ra.
Vì sao tra một khách lại đọc gần 10.000 page
SQL Server tự tạo bảng lịch sử với chỉ mục clustered (SysEnd, SysStart). Điều kiện SysEnd > t seek được trên chỉ mục đó, nhưng nó trả mọi phiên bản đóng sau mốc t của mọi khách. Mốc càng xa trong quá khứ, phần phải quét càng lớn; điều kiện KhachHangId = 42 chỉ lọc sau khi đọc.
Chỉ mục (KhachHangId, SysEnd, SysStart) đặt khách lên đầu. Câu tra seek thẳng tới vài phiên bản của khách 42 rồi mới so khoảng thời gian.
4. Cách giải quyết: temporal table, mốc UTC và chỉ mục theo khách
| Cách | Giữ gì | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|---|
Cột NgaySua trên bảng |
Lần sửa cuối | Rẻ | Mất mọi phiên bản trước | Chỉ cần biết sửa lần cuối lúc nào |
| Trigger ghi bảng nhật ký tự viết | Tùy thiết kế | Ghi được người sửa | Tự viết, tự bảo trì cho từng bảng | Cần lưu người sửa và không đổi được bảng |
| Temporal table | Mọi phiên bản kèm khoảng thời gian | Engine tự ghi, lịch sử không sửa được, có cú pháp AS OF |
Không lưu người sửa; lịch sử lớn theo số lần sửa | Hỏi "lúc đó dữ liệu là gì" |
| Change Tracking hoặc CDC | Khóa đã đổi, hoặc ảnh trước và sau để đồng bộ | Hợp cho hệ khác kéo thay đổi | Không trả lời AS OF trực tiếp |
Đồng bộ sang hệ tìm kiếm, kho dữ liệu |
Restore theo STOPAT ra máy khác |
Toàn bộ database tại một thời điểm | Không cần chuẩn bị gì thêm | Một lần restore cho mỗi câu hỏi | Sự cố, không phải tra cứu hằng ngày |
BanHang dùng bảng temporal có sẵn và sửa đúng hai chỗ hỏng:
- Mọi mốc vào
TemporalAsOflà giờ UTC. Mốc nhập theo giờ Việt Nam đi quaDateTimeOffsetcó độ lệch+07:00, rồi lấy.UtcDateTime. - Thêm chỉ mục
(KhachHangId, SysEnd, SysStart)chodbo.KhachHang_LichSu. - Tài khoản ứng dụng có quyền
SELECTtrên bảng lịch sử. Thiếu quyền này thì câuAS OFbị từ chối, dù quyền trên bảng chính vẫn đủ.
5. Cách cài đặt: một chỉ mục và một phép đổi giờ
Chỉ mục cho bảng lịch sử. Lệnh CREATE INDEX chạy được khi SYSTEM_VERSIONING đang bật:
CREATE INDEX IX_KhachHang_LichSu_Khach
ON dbo.KhachHang_LichSu (KhachHangId, SysEnd, SysStart)
WITH (DATA_COMPRESSION = PAGE);
Câu hỏi của nhân viên hỗ trợ, viết thẳng bằng SQL: 12:00 giờ Việt Nam là 05:00 UTC.
SELECT KhachHangId, Ten, SoDienThoai, SysStart, SysEnd
FROM dbo.KhachHang FOR SYSTEM_TIME AS OF '2026-10-02T05:00:00'
WHERE KhachHangId = 42;
Phía .NET dùng EF Core 10. IsTemporal ánh xạ SysStart, SysEnd thành shadow property, nên lớp KhachHang không cần hai cột đó. Mốc nhập theo giờ Việt Nam đổi sang UTC trước khi vào TemporalAsOf:
e.ToTable("KhachHang", "dbo", t => t.IsTemporal(h =>
{
h.HasPeriodStart("SysStart");
h.HasPeriodEnd("SysEnd");
h.UseHistoryTable("KhachHang_LichSu", "dbo");
}));
// Nhân viên hỏi theo giờ Việt Nam: đổi sang UTC trước khi truyền vào TemporalAsOf.
var gioVn = new DateTimeOffset(2026, 10, 2, 12, 0, 0, TimeSpan.FromHours(7));
var tenLucDo = await db.KhachHang.TemporalAsOf(gioVn.UtcDateTime)
.Where(k => k.KhachHangId == 42).Select(k => k.Ten).SingleAsync();
// Toàn bộ lịch sử của khách, theo thứ tự thời gian.
var lichSu = await db.KhachHang.TemporalAll().Where(k => k.KhachHangId == 42)
.OrderBy(k => EF.Property<DateTime>(k, "SysStart"))
.Select(k => new { k.Ten, k.SoDienThoai, Tu = EF.Property<DateTime>(k, "SysStart") })
.ToListAsync();
Mỗi toán tử temporal của EF Core sinh đúng một mệnh đề FOR SYSTEM_TIME, và mọi tham số thời gian của chúng đều so với cột UTC:
| EF Core | SQL sinh ra | Giữ phiên bản có |
|---|---|---|
TemporalAsOf(t) |
AS OF t |
SysStart <= t AND SysEnd > t |
TemporalFromTo(a, b) |
FROM a TO b |
SysStart < b AND SysEnd > a |
TemporalBetween(a, b) |
BETWEEN a AND b |
SysStart <= b AND SysEnd > a |
TemporalContainedIn(a, b) |
CONTAINED IN (a, b) |
SysStart >= a AND SysEnd <= b |
TemporalAll() |
ALL |
Mọi phiên bản, hiện tại và lịch sử |
Câu "tên khách đổi lúc nào trong tháng 10" dùng TemporalBetween với hai mốc đầu và cuối tháng đã đổi sang UTC. Việt Nam không có giờ mùa hè, nên độ lệch cố định +7 là đủ. Bản đầy đủ dưới chạy trên LocalDB SQL Server 2019 (15.0.4382), .NET 10.0.401, Microsoft.EntityFrameworkCore.SqlServer 10.0.12, database Kumeo_C. Dòng #:property PublishAot=false cần vì file-based app mặc định bật PublishAot, mà EF Core không dựng model lúc chạy dưới chế độ đó.
Chương trình tạo dbo.KhachHang đúng định nghĩa của BanHang, ghi khách 42, đổi tên rồi đổi số điện thoại, và nhớ một mốc UTC trước mỗi lần sửa. Sau đó nó nạp 200.000 khách, cập nhật cả bảng năm lượt để bảng lịch sử lên 1.000.000 dòng, và đo câu tra trước và sau khi thêm chỉ mục.
KhachHangLichSu.cs: đọc theo mốc, đo chi phí ghi và chi phí tra lịch sử (dotnet run KhachHangLichSu.cs)
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:property PublishAot=false
// Bảng temporal dbo.KhachHang: đọc lại tên khách tại một mốc bằng EF Core, đo chi phí ghi và chi phí tra lịch sử.
// Chạy: dotnet run KhachHangLichSu.cs (LocalDB SQL Server 2019, tạo database Kumeo_C nếu chưa có)
using System.Diagnostics;
using System.Globalization;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.CurrentCulture = new CultureInfo("vi-VN");
const string May = @"Server=(localdb)\MSSQLLocalDB;Integrated Security=true;TrustServerCertificate=true";
const string Cs = May + ";Database=Kumeo_C";
await TaoBang();
// 1. Khách 42: ghi, đổi tên, đổi số điện thoại. Nhớ mốc UTC trước mỗi lần sửa.
await using (var db = new BanHangDb(Cs))
{
db.KhachHang.Add(new KhachHang { KhachHangId = 42, ChiNhanhId = 3, Ten = "Đại lý A", SoDienThoai = "0901000042" });
await db.SaveChangesAsync();
}
await Task.Delay(1000);
var moc1Utc = DateTime.UtcNow; var moc1May = DateTime.Now;
await Task.Delay(1000);
await SuaAsync(k => k.Ten = "Đại lý A - Chi nhánh 3");
await Task.Delay(1000);
var moc2Utc = DateTime.UtcNow;
await Task.Delay(1000);
await SuaAsync(k => k.SoDienThoai = "0909000042");
await Task.Delay(1000);
var moc3Utc = DateTime.UtcNow;
await using (var db = new BanHangDb(Cs))
{
var q = db.KhachHang.TemporalAsOf(moc1Utc).Where(k => k.KhachHangId == 42).Select(k => k.Ten);
Console.WriteLine(q.ToQueryString());
Console.WriteLine();
foreach (var (nhan, moc) in new[] { ("mốc 1 (UTC)", moc1Utc), ("mốc 2 (UTC)", moc2Utc), ("mốc 3 (UTC)", moc3Utc), ("mốc 1 (giờ máy)", moc1May) })
{
var k = await db.KhachHang.TemporalAsOf(moc).Where(k => k.KhachHangId == 42)
.Select(k => new { k.Ten, k.SoDienThoai }).SingleAsync();
Console.WriteLine($"AS OF {moc:HH:mm:ss} {nhan,-16} {k.Ten,-24} {k.SoDienThoai}");
}
Console.WriteLine();
var lichSu = await db.KhachHang.TemporalAll().Where(k => k.KhachHangId == 42)
.OrderBy(k => EF.Property<DateTime>(k, "SysStart"))
.Select(k => new { k.Ten, k.SoDienThoai, Tu = EF.Property<DateTime>(k, "SysStart"), Den = EF.Property<DateTime>(k, "SysEnd") })
.ToListAsync();
foreach (var d in lichSu) Console.WriteLine($"{d.Ten,-24} {d.SoDienThoai} {d.Tu:HH:mm:ss.fff} → {d.Den:yyyy-MM-dd HH:mm:ss.fff}");
Console.WriteLine();
// Nhân viên hỏi theo giờ Việt Nam: đổi sang UTC trước khi truyền vào TemporalAsOf.
var gioVn = new DateTimeOffset(2026, 10, 2, 12, 0, 0, TimeSpan.FromHours(7));
Console.WriteLine($"12:00 ngày 2026-10-02 giờ Việt Nam = {gioVn.UtcDateTime:yyyy-MM-dd HH:mm} UTC, {gioVn.UtcDateTime.Kind}");
Console.WriteLine(db.KhachHang.TemporalAsOf(gioVn.UtcDateTime).Where(k => k.KhachHangId == 42).Select(k => k.Ten).ToQueryString());
Console.WriteLine();
}
// 2. Nạp 200.000 khách, thêm bản sao không lịch sử để so chi phí ghi.
await NapKhach(200_000);
// 3. Một lệnh UPDATE cả 200.000 khách, 5 lượt mỗi bảng: thời gian, CPU và log ghi ra. Bảng lịch sử lên khoảng 1 triệu dòng.
for (var i = 0; i < 5; i++)
foreach (var bang in new[] { "dbo.KhachHang_KhongLichSu", "dbo.KhachHang" })
{
await using var cn = new SqlConnection(Cs);
await cn.OpenAsync();
await using var cmd = new SqlCommand($"""
DECLARE @cpu0 int = (SELECT cpu_time FROM sys.dm_exec_requests WHERE session_id = @@SPID);
BEGIN TRAN;
UPDATE {bang} SET SoDienThoai = '09' + RIGHT('0000000' + CAST(KhachHangId * 7 + {i} AS varchar(12)), 8);
SELECT (SELECT cpu_time FROM sys.dm_exec_requests WHERE session_id = @@SPID) - @cpu0,
database_transaction_log_bytes_used, database_transaction_log_record_count
FROM sys.dm_tran_database_transactions
WHERE database_id = DB_ID() AND transaction_id = CURRENT_TRANSACTION_ID();
COMMIT;
""", cn) { CommandTimeout = 600 };
var sw = Stopwatch.StartNew();
await using var r = await cmd.ExecuteReaderAsync();
await r.ReadAsync();
var (cpu, logBytes, logRec) = (r.GetInt32(0), r.GetInt64(1), r.GetInt64(2));
await r.CloseAsync();
Console.WriteLine($"lượt {i + 1} {bang,-26} UPDATE 200.000 dòng: {sw.ElapsedMilliseconds,6:N0} ms, CPU {cpu,5:N0} ms, log {logBytes / 1048576.0,6:N1} MB, {logRec:N0} log record");
}
Console.WriteLine($"Bảng lịch sử: {await Scalar("SELECT COUNT_BIG(*) FROM dbo.KhachHang_LichSu;"):N0} dòng, "
+ $"{await Scalar("SELECT SUM(used_page_count) * 8 / 1024 FROM sys.dm_db_partition_stats WHERE object_id = OBJECT_ID(N'dbo.KhachHang_LichSu');"):N0} MB; "
+ $"bảng chính {await Scalar("SELECT SUM(used_page_count) * 8 / 1024 FROM sys.dm_db_partition_stats WHERE object_id = OBJECT_ID(N'dbo.KhachHang');"):N0} MB");
await using (var cn = new SqlConnection(Cs))
{
await cn.OpenAsync();
await using var cmd = new SqlCommand("""
SELECT i.name COLLATE DATABASE_DEFAULT + N' ' + i.type_desc COLLATE DATABASE_DEFAULT + N' (' + STRING_AGG(c.name COLLATE DATABASE_DEFAULT, N', ') WITHIN GROUP (ORDER BY ic.key_ordinal) + N') ' + MAX(p.data_compression_desc) COLLATE DATABASE_DEFAULT
FROM sys.indexes i
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal > 0
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
JOIN sys.partitions p ON p.object_id = i.object_id AND p.index_id = i.index_id
WHERE i.object_id = OBJECT_ID(N'dbo.KhachHang_LichSu') GROUP BY i.name, i.type_desc;
""", cn);
Console.WriteLine($"Chỉ mục có sẵn của bảng lịch sử: {await cmd.ExecuteScalarAsync()}");
}
Console.WriteLine();
// 4. Tra tên khách 42 tại mốc 1 khi bảng lịch sử lớn: logical reads trước và sau khi thêm chỉ mục theo khách.
for (var lan = 1; lan <= 2; lan++) Console.WriteLine($"AS OF, chỉ mục mặc định (SysEnd, SysStart): {await DocAsOf(moc1Utc)}");
await Exec("CREATE INDEX IX_KhachHang_LichSu_Khach ON dbo.KhachHang_LichSu (KhachHangId, SysEnd, SysStart) WITH (DATA_COMPRESSION = PAGE);");
for (var lan = 1; lan <= 2; lan++) Console.WriteLine($"AS OF, thêm (KhachHangId, SysEnd, SysStart): {await DocAsOf(moc1Utc)}");
async Task<string> DocAsOf(DateTime mocUtc)
{
await using var db = new BanHangDb(Cs);
// Đếm logical reads của phiên trước và sau câu truy vấn, trên cùng một kết nối.
await db.Database.OpenConnectionAsync();
const string DemDoc = "SELECT logical_reads AS Value FROM sys.dm_exec_sessions WHERE session_id = @@SPID";
var truoc = await db.Database.SqlQueryRaw<long>(DemDoc).SingleAsync();
var sw = Stopwatch.StartNew();
var ten = await db.KhachHang.TemporalAsOf(mocUtc).Where(k => k.KhachHangId == 42).Select(k => k.Ten).SingleAsync();
sw.Stop();
var sau = await db.Database.SqlQueryRaw<long>(DemDoc).SingleAsync();
return $"{ten}; {sau - truoc:N0} logical reads; {sw.Elapsed.TotalMilliseconds:N1} ms";
}
async Task SuaAsync(Action<KhachHang> sua)
{
await using var db = new BanHangDb(Cs);
var k = await db.KhachHang.SingleAsync(k => k.KhachHangId == 42);
sua(k);
await db.SaveChangesAsync();
}
async Task NapKhach(int soKhach)
{
// Khách 1..200.000 trừ khách 42 đã có; bản sao không lịch sử để so chi phí ghi.
await Exec($"""
WITH n AS (SELECT TOP ({soKhach}) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS i FROM sys.all_columns a CROSS JOIN sys.all_columns b)
INSERT dbo.KhachHang (KhachHangId, ChiNhanhId, Ten, SoDienThoai)
SELECT i, 1 + i % 120, N'Khách ' + CAST(i AS nvarchar(10)), '09' + RIGHT('0000000' + CAST(i AS varchar(10)), 8) FROM n WHERE i <> 42;
SELECT KhachHangId, ChiNhanhId, Ten, SoDienThoai INTO dbo.KhachHang_KhongLichSu FROM dbo.KhachHang;
ALTER TABLE dbo.KhachHang_KhongLichSu ADD CONSTRAINT PK_KhachHang_KhongLichSu PRIMARY KEY CLUSTERED (KhachHangId);
""");
}
async Task TaoBang()
{
await using (var m = new SqlConnection(May))
{
await m.OpenAsync();
await using var c = new SqlCommand("IF DB_ID(N'Kumeo_C') IS NULL CREATE DATABASE Kumeo_C COLLATE Vietnamese_100_CI_AS;", m);
await c.ExecuteNonQueryAsync();
}
// Đúng định nghĩa dbo.KhachHang dùng chung của BanHang. Xóa bản cũ để chạy lại được.
await Exec("""
IF OBJECT_ID(N'dbo.KhachHang') IS NOT NULL
BEGIN
ALTER TABLE dbo.KhachHang SET (SYSTEM_VERSIONING = OFF);
DROP TABLE dbo.KhachHang;
DROP TABLE dbo.KhachHang_LichSu;
END;
DROP TABLE IF EXISTS dbo.KhachHang_KhongLichSu;
CREATE TABLE dbo.KhachHang (
KhachHangId int NOT NULL CONSTRAINT PK_KhachHang PRIMARY KEY CLUSTERED,
ChiNhanhId int NOT NULL,
Ten nvarchar(200) NOT NULL,
SoDienThoai varchar(20) NULL,
SysStart datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
SysEnd datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
PERIOD FOR SYSTEM_TIME (SysStart, SysEnd)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.KhachHang_LichSu));
""");
}
async Task Exec(string sql)
{
await using var cn = new SqlConnection(Cs);
await cn.OpenAsync();
await using var cmd = new SqlCommand(sql, cn) { CommandTimeout = 600 };
await cmd.ExecuteNonQueryAsync();
}
async Task<long> Scalar(string sql)
{
await using var cn = new SqlConnection(Cs);
await cn.OpenAsync();
await using var cmd = new SqlCommand(sql, cn);
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}
class KhachHang
{
public int KhachHangId { get; set; }
public int ChiNhanhId { get; set; }
public string Ten { get; set; } = "";
public string? SoDienThoai { get; set; }
}
class BanHangDb : DbContext
{
public BanHangDb(string cs) : base(new DbContextOptionsBuilder<BanHangDb>().UseSqlServer(cs).Options) { }
public DbSet<KhachHang> KhachHang => Set<KhachHang>();
protected override void OnModelCreating(ModelBuilder m) => m.Entity<KhachHang>(e =>
{
e.ToTable("KhachHang", "dbo", t => t.IsTemporal(h =>
{
h.HasPeriodStart("SysStart");
h.HasPeriodEnd("SysEnd");
h.UseHistoryTable("KhachHang_LichSu", "dbo");
}));
e.Property(k => k.KhachHangId).ValueGeneratedNever();
e.Property(k => k.Ten).HasMaxLength(200);
e.Property(k => k.SoDienThoai).HasMaxLength(20).IsUnicode(false);
});
}
Phần đầu output: câu SQL EF Core sinh ra, bốn lần tra theo mốc, lịch sử của khách 42 và phép đổi giờ.
SELECT [k].[Ten]
FROM [dbo].[KhachHang] FOR SYSTEM_TIME AS OF '2026-10-04T03:34:43.0926850Z' AS [k]
WHERE [k].[KhachHangId] = 42
AS OF 03:34:43 mốc 1 (UTC) Đại lý A 0901000042
AS OF 03:34:45 mốc 2 (UTC) Đại lý A - Chi nhánh 3 0901000042
AS OF 03:34:48 mốc 3 (UTC) Đại lý A - Chi nhánh 3 0909000042
AS OF 10:34:43 mốc 1 (giờ máy) Đại lý A - Chi nhánh 3 0909000042
Đại lý A 0901000042 03:34:41.972 → 2026-10-04 03:34:44.936
Đại lý A - Chi nhánh 3 0901000042 03:34:44.936 → 2026-10-04 03:34:47.050
Đại lý A - Chi nhánh 3 0909000042 03:34:47.050 → 9999-12-31 23:59:59.999
12:00 ngày 2026-10-02 giờ Việt Nam = 2026-10-02 05:00 UTC, Utc
SELECT [k].[Ten]
FROM [dbo].[KhachHang] FOR SYSTEM_TIME AS OF '2026-10-02T05:00:00.0000000Z' AS [k]
WHERE [k].[KhachHangId] = 42
Phần sau của output: chi phí ghi và chi phí tra
lượt 1 dbo.KhachHang_KhongLichSu UPDATE 200.000 dòng: 542 ms, CPU 529 ms, log 21,4 MB, 200.002 log record
lượt 1 dbo.KhachHang UPDATE 200.000 dòng: 1.538 ms, CPU 1.488 ms, log 27,7 MB, 202.436 log record
lượt 2 dbo.KhachHang_KhongLichSu UPDATE 200.000 dòng: 454 ms, CPU 445 ms, log 19,9 MB, 200.002 log record
lượt 2 dbo.KhachHang UPDATE 200.000 dòng: 1.901 ms, CPU 1.889 ms, log 57,5 MB, 400.004 log record
lượt 3 dbo.KhachHang_KhongLichSu UPDATE 200.000 dòng: 414 ms, CPU 408 ms, log 19,9 MB, 200.002 log record
lượt 3 dbo.KhachHang UPDATE 200.000 dòng: 1.947 ms, CPU 1.937 ms, log 57,5 MB, 400.004 log record
lượt 4 dbo.KhachHang_KhongLichSu UPDATE 200.000 dòng: 605 ms, CPU 589 ms, log 19,9 MB, 200.002 log record
lượt 4 dbo.KhachHang UPDATE 200.000 dòng: 2.268 ms, CPU 2.189 ms, log 26,2 MB, 202.426 log record
lượt 5 dbo.KhachHang_KhongLichSu UPDATE 200.000 dòng: 720 ms, CPU 711 ms, log 19,9 MB, 200.002 log record
lượt 5 dbo.KhachHang UPDATE 200.000 dòng: 3.048 ms, CPU 2.928 ms, log 26,2 MB, 202.346 log record
Bảng lịch sử: 1.000.002 dòng, 76 MB; bảng chính 14 MB
Chỉ mục có sẵn của bảng lịch sử: ix_KhachHang_LichSu CLUSTERED (SysEnd, SysStart) NONE
AS OF, chỉ mục mặc định (SysEnd, SysStart): Đại lý A; 13.725 logical reads; 807,7 ms
AS OF, chỉ mục mặc định (SysEnd, SysStart): Đại lý A; 9.742 logical reads; 105,7 ms
AS OF, thêm (KhachHangId, SysEnd, SysStart): Đại lý A; 37 logical reads; 5,7 ms
AS OF, thêm (KhachHangId, SysEnd, SysStart): Đại lý A; 9 logical reads; 1,3 ms
6. Chứng minh: 3 trên 3 mốc đúng, tra từ 9.742 xuống 9 logical reads
Môi trường: LocalDB SQL Server 2019 Express 15.0.4382, máy Windows 11 đặt giờ Việt Nam, máy dùng chung nên thời gian dao động giữa các lần.
Đúng phiên bản và đúng múi giờ
Mốc truyền vào TemporalAsOf |
Phiên bản đúng | Câu tra trả | Đạt |
|---|---|---|---|
| 03:34:43 UTC, trước lần đổi tên | Đại lý A, 0901000042 | Đại lý A, 0901000042 | Có |
| 03:34:45 UTC, giữa hai lần sửa | Đại lý A - Chi nhánh 3, 0901000042 | Đại lý A - Chi nhánh 3, 0901000042 | Có |
| 03:34:48 UTC, sau lần sửa thứ hai | Đại lý A - Chi nhánh 3, 0909000042 | Đại lý A - Chi nhánh 3, 0909000042 | Có |
| 10:34:43 giờ máy, cùng khoảnh khắc với mốc đầu | Đại lý A, 0901000042 | Đại lý A - Chi nhánh 3, 0909000042 | Không |
Ba mốc UTC đúng cả ba. Mốc giờ máy sai mà không báo lỗi gì. Mốc 12:00 giờ Việt Nam đi qua DateTimeOffset ra đúng AS OF '2026-10-02T05:00:00.0000000Z', nên tiêu chí thứ hai đạt.
Tra nhanh khi bảng lịch sử lớn
Câu tra khách 42 tại mốc đầu, chạy hai lần, lấy lần thứ hai để bỏ phần biên dịch và đọc đĩa của lần đầu:
Chỉ mục của dbo.KhachHang_LichSu |
Logical reads | Thời gian |
|---|---|---|
Mặc định, clustered (SysEnd, SysStart) |
9.742 | 105,7 ms |
Thêm (KhachHangId, SysEnd, SysStart) |
9 | 1,3 ms |
Cái giá khi ghi
Năm lượt UPDATE cả 200.000 khách tạo đúng 1.000.000 dòng lịch sử: mỗi lần sửa một dòng sinh một dòng lịch sử. Bảng lịch sử chiếm 76 MB, gấp 5,4 lần 14 MB của bảng chính, và trên LocalDB nó được tạo không nén (NONE).
Cùng lệnh UPDATE trên bản sao không lịch sử mất 542 ms (trung vị 5 lượt), trên bảng temporal mất 1.947 ms: chậm khoảng 3,6 lần với cập nhật hàng loạt. Lượng log của bảng temporal dao động giữa 26,2 MB và 57,5 MB theo lượt; bài không dùng con số này.
7. Kết luận
Bảng temporal trả lời đúng câu "lúc đó dữ liệu là gì" khi mốc là giờ UTC và bảng lịch sử có chỉ mục theo khóa cần tra. Sai múi giờ không báo lỗi, chỉ trả sai phiên bản.
Trong dự án .NET của bạn:
- Ánh xạ bảng temporal bằng
IsTemporalvà chỉ truyền giờ UTC vàoTemporalAsOf; mốc nhập theo giờ Việt Nam đi quaDateTimeOffset(..., TimeSpan.FromHours(7)).UtcDateTime. - Thêm chỉ mục
(KhachHangId, SysEnd, SysStart)cho bảng lịch sử, hoặc tạo bảng lịch sử theo ý mình trênFG_ARCHIVEtrước khi bậtSYSTEM_VERSIONING. - Cấp
SELECTtrêndbo.KhachHang_LichSucho tài khoản ứng dụng cần tra lịch sử. - Đặt
HISTORY_RETENTION_PERIOD(từ SQL Server 2017) theo thời hạn lưu nghiệp vụ cần, để bảng lịch sử không lớn mãi.
Những chỗ hay hiểu sai
- "
SysStartlà giờ Việt Nam vì máy chủ đặt giờ Việt Nam." Engine ghi giờ UTC, bất kể múi giờ của máy. - "Bảng temporal cho biết ai đã sửa." Nó chỉ giữ giá trị và khoảng thời gian; muốn biết người sửa phải ghi thêm ở chỗ khác.
- "Lịch sử chỉ lớn khi giá trị thay đổi." Mọi lệnh sửa đều thêm một dòng, kể cả khi giá trị giữ nguyên.
Đọc tiếp
- Bài trước: Hủy job dài phải chờ rollback.
- Bài sau: TDE: file backup bị mang ra ngoài.
- Khi cần đẩy thay đổi sang hệ khác thay vì tra tại chỗ: Change Tracking và CDC.
- Giờ Việt Nam hay UTC, và kiểu tham số ngày giờ: Đếm đơn theo ngày: BETWEEN, mốc .999 và giờ UTC.
- Khi không có bảng temporal, restore theo thời điểm: Xóa nhầm lúc 12:07, khôi phục về đúng 12:06.
Nguồn
- Temporal tables: khoảng thời gian theo UTC, thời điểm bắt đầu giao dịch, bảng điều kiện của
AS OF. - Create a system-versioned temporal table: bảng lịch sử mặc định và chỉ mục clustered
(end, start), bảng lịch sử tự tạo trước. - Temporal table security: không sửa được lịch sử, quyền
SELECTtrên bảng lịch sử. - Manage historical data in system-versioned temporal tables:
HISTORY_RETENTION_PERIODtừ SQL Server 2017. - EF Core: SQL Server temporal tables