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. 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. 2. Mục đích: đúng phiên bản, đúng múi giờ, tra nhanh
  3. 3. Cơ sở lý thuyết: hai bảng, mỗi phiên bản một khoảng thời gian UTC
  4. 4. Cách giải quyết: temporal table, mốc UTC và chỉ mục theo khách
  5. 5. Cách cài đặt: một chỉ mục và một phép đổi giờ
  6. 6. Chứng minh: 3 trên 3 mốc đúng, tra từ 9.742 xuống 9 logical reads
  7. 7. Kết luận
  8. Đọc tiếp
  9. 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.KhachHang bằng FOR SYSTEM_TIME AS OF vớ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ằng TemporalAsOf(gioVn.UtcDateTime) với gioVn là DateTimeOffset có độ 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.Now và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 OF giờ 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 UPDATE hà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.

Khách 42: ba phiên bản và hai câu AS OF (SysStart, SysEnd theo giờ UTC) AS OF 03:34:43 (UTC) AS OF 10:34:43 (giờ máy) Đại lý A 0901000042 Đại lý A - Chi nhánh 3 0901000042 Đại lý A - Chi nhánh 3 0909000042 :41 :42 :43 :44 :45 :46 :47 :48 10:34:43 Giây của 03:34 UTC. Hai khối trên ở KhachHang_LichSu, khối dưới là dòng hiện tại.
Engine so mốc với SysStart và SysEnd như hai số, không biết mốc đó là giờ nào. Truyền giờ máy là hỏi về 7 giờ sau.

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:

  1. Mọi mốc vào TemporalAsOf là giờ UTC. Mốc nhập theo giờ Việt Nam đi qua DateTimeOffset có độ lệch +07:00, rồi lấy .UtcDateTime.
  2. Thêm chỉ mục (KhachHangId, SysEnd, SysStart) cho dbo.KhachHang_LichSu.
  3. Tài khoản ứng dụng có quyền SELECT trên bảng lịch sử. Thiếu quyền này thì câu AS OF bị 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)C# · 210 dòng
#: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í tra17 dòng
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

Tra khách 42 trong lịch sử 1.000.000 dòng: chỉ mục theo khách đưa 9.742 logical reads xuống 9

Chỉ mục mặc định (SysEnd, SysStart)9.742 pageThêm (KhachHangId, SysEnd, SysStart)9 page
LocalDB SQL Server 2019, lần chạy thứ hai của mỗi cấu hình. Đếm bằng logical_reads của phiên trước và sau câu tra. Trục log.
Bảng số liệu
Giá trị
Chỉ mục mặc định (SysEnd, SysStart)9.742 page
Thêm (KhachHangId, SysEnd, SysStart)9 page

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.

Một lệnh UPDATE 200.000 khách: bảng temporal chậm khoảng 3,6 lần vì phải ghi thêm 200.000 dòng lịch sử

Bản sao không lịch sử542 msBảng temporal dbo.KhachHang1.947 ms
Trung vị 5 lượt trên LocalDB SQL Server 2019. Cập nhật từng dòng lẻ không được đo vì thời gian commit trên máy dùng chung dao động quá lớn.
Bảng số liệu
Giá trị
Bản sao không lịch sử542 ms
Bảng temporal dbo.KhachHang1.947 ms

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 IsTemporal và chỉ truyền giờ UTC vào TemporalAsOf; mốc nhập theo giờ Việt Nam đi qua DateTimeOffset(..., 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ên FG_ARCHIVE trước khi bật SYSTEM_VERSIONING.
  • Cấp SELECT trên dbo.KhachHang_LichSu cho 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

  • "SysStart là 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

Nguồn

Đọc tiếp

Bài tiếp theo trong series

File backup bị mang ra ngoài: mã hóa file trên đĩa bằng TDE

File backup 286 MB của database thử chứa nguyên văn 200.000 số điện thoại khách, dò ra trong 4,5 giây và restore được trên instance khác trong 3,5 giây mà không cần khóa nào. TDE mã hóa file dữ liệu, log và backup bằng một khóa mà certificate trong master bảo vệ, nên certificate phải được sao lưu ra máy khác ngay lúc bật. LocalDB không có TDE, phần sau khi bật chưa chạy thử.

10 phút đọc