Cơ sở dữ liệuSQL Server, phần 7/34

Dọn cả năm đơn cũ mà không khóa bảng: SWITCH partition

Job dọn đơn cũ xóa 3,0 triệu đơn trước 2025 bằng ExecuteDeleteAsync mất 40,7 giây và ghi 710 MB log; SWITCH thường thì chặn API 3.393 ms khi gặp một phiên báo cáo đang mở. SWITCH với WAIT_AT_LOW_PRIORITY dọn cùng số đơn trong 225 ms, 24 KB log, API không lệnh nào quá 500 ms.

Mục lục
  1. 1. Vấn đề: xóa một năm đơn làm API đứng 35,7 giây
  2. 2. Mục đích: đơn trước 2025 rời bảng trong dưới 1 giây, API không đứng
  3. 3. Cơ sở lý thuyết: DELETE ghi từng dòng, SWITCH chỉ đổi metadata
  4. 4. Cách giải quyết: SWITCH ra staging, chờ khóa ở hàng ưu tiên thấp
  5. 5. Cách cài đặt: bảng staging và job C#
  6. 6. Chứng minh: 225 ms thay vì 40,7 giây, API không lệnh nào quá 500 ms
  7. 7. Kết luận
  8. Đọc tiếp
  9. Nguồn

Đọc nhanh

  • Vấn đề: Job dọn đơn cũ gọi ExecuteDeleteAsync xóa 3,0 triệu đơn trước 2025: chạy 40,7 giây, ghi 710 MB log, và API ghi đơn mới đứng tới 35,7 giây; đổi sang SWITCH thường thì gặp phiên báo cáo đang mở vẫn làm API đứng 3.393 ms.
  • Cách giải: SWITCH partition 1 sang bảng staging cùng filegroup rồi TRUNCATE, với WAIT_AT_LOW_PRIORITY để lệnh đang chờ khóa không chặn API.
  • Chứng minh: Cùng 3,0 triệu đơn rời bảng trong 225 ms với 24 KB log; khi có phiên báo cáo giữ khóa, API chậm nhất 86 ms thay vì 3.393 ms.
  • Trong .NET: Một job Microsoft.Data.SqlClient chạy SWITCH ... WITH (WAIT_AT_LOW_PRIORITY ...) và bắt lỗi 1222 để thử lại lần sau, thay cho ExecuteDeleteAsync trên cả một năm.

1. Vấn đề: xóa một năm đơn làm API đứng 35,7 giây

Kế toán đã chốt sổ và chuyển đơn năm 2024 sang kho lưu trữ, nên dbo.DonHang chỉ cần giữ đơn từ 2025. Job dọn dữ liệu của BanHang viết bằng EF Core, chạy lúc 23:00:

await db.DonHang.Where(d => d.NgayTao < new DateTime(2025, 1, 1)).ExecuteDeleteAsync(ct);

EF Core dịch câu này thành một lệnh DELETE FROM [d] FROM [dbo].[DonHang] AS [d] WHERE [d].[NgayTao] < @moc cho cả 3,0 triệu đơn. Chạy thử trên bản sao 10 triệu đơn, trong lúc một tiến trình đóng vai API ghi một đơn mới mỗi 100 ms: lệnh xóa chạy 40,7 giây, ghi 710 MB vào file log và đẩy file log lên 3.208 MB. BanHang chạy recovery model FULL với log backup mỗi 15 phút, nên chừng ấy log còn phải đi qua lượt log backup kế tiếp. Lệnh ghi đơn chậm nhất của API trong lúc đó mất 35.716 ms: hơn nửa phút không đơn mới nào vào được bảng, dù đơn mới thuộc partition 2026 còn lệnh xóa chỉ đụng partition 1. Lần chạy trước của cùng chương trình, API chậm nhất chỉ 1.262 ms; lần nào lệnh xóa giữ khóa trên cả bảng thì lần đó API đứng.

Nhóm vận hành thử cách nhanh: SWITCH partition 1 ra một bảng staging rồi TRUNCATE. Lần chạy đó trùng lúc một phiên báo cáo đang mở giao dịch đọc dbo.DonHang. Lệnh SWITCH đứng chờ phiên báo cáo, và các lệnh ghi đơn của API xếp hàng sau nó: lệnh chậm nhất mất 3.393 ms.

2. Mục đích: đơn trước 2025 rời bảng trong dưới 1 giây, API không đứng

  • 3,0 triệu đơn trước 2025 rời dbo.DonHang trong dưới 1 giây.
  • Log ghi cho việc dọn dưới 1 MB.
  • API ghi một đơn mỗi 100 ms trong lúc dọn: không lệnh nào quá 500 ms.
  • Khi có phiên khác đang giữ khóa trên bảng: API vẫn không lệnh nào quá 500 ms, và job tự bỏ cuộc sau 1 phút thay vì chờ mãi.
  • Ngoài phạm vi: chép đơn sang kho lưu trữ, và thêm hay gộp partition cho năm mới (SPLIT RANGE, MERGE RANGE).

3. Cơ sở lý thuyết: DELETE ghi từng dòng, SWITCH chỉ đổi metadata

DELETE ghi log từng dòng và leo thang khóa

DELETE xóa từng dòng và ghi một bản ghi log cho mỗi dòng bị xóa, ở mọi chỉ mục của bảng. Với dbo.DonHang, mỗi đơn mất một dòng ở PK_DonHang và một dòng ở IX_DonHang_KhachHang. Cả giao dịch phải nằm trong log đến khi commit, nên file log nới ra đủ chứa nó dù recovery model là gì.

Khóa thì khó đoán hơn. Khi một lệnh giữ từ 5.000 khóa trên một bảng, SQL Server thử leo thang (lock escalation) thành một khóa trên cả bảng; LOCK_ESCALATION mặc định là TABLE, nên khóa X đó phủ cả partition 2026 đang nhận đơn và chặn mọi lệnh INSERT cần khóa IX. Trên LocalDB, DELETE TOP (20000) đơn trước 2025 trong một giao dịch kết thúc với khóa X trên dbo.DonHang. Lệnh xóa cả 3,0 triệu đơn thì kết thúc chỉ với khóa IX trên bảng và 4 khóa page. Job không nên dựa vào việc lệnh của mình có leo thang hay không.

TRUNCATE và SWITCH không đụng từng dòng

TRUNCATE TABLE gỡ cả page khỏi bảng và chỉ ghi log việc giải phóng page. Từ SQL Server 2016, TRUNCATE TABLE ... WITH (PARTITIONS (1)) làm việc đó cho riêng một partition.

ALTER TABLE ... SWITCH PARTITION 1 TO dbo.DonHang_Staging trao toàn bộ page của partition 1 cho một bảng khác. Không dòng nào bị chép: SQL Server chỉ đổi metadata để page đó thuộc về bảng staging. Sau đó TRUNCATE bảng staging, hoặc giữ nó lại để kiểm, chép đi nơi khác, rồi mới xóa.

dbo.DonHang FG_ARCHIVE FG_DATA Partition 1 trước 2025 Partition 2 2025 Partition 3 2026 Partition 4 từ 2027 1. SWITCH PARTITION 1 chỉ đổi metadata DonHang_Staging FG_ARCHIVE 2. TRUNCATE Staging trống có thể DROP TABLE So với DELETE cùng năm đó: ghi log cho từng dòng
Bảng staging phải nằm trên đúng filegroup của partition nguồn. SWITCH không chép dòng nào.

Khóa Sch-M và hàng chờ

Cả TRUNCATE lẫn SWITCH cần khóa sửa schema (Sch-M) trên bảng; SWITCH cần nó trên cả bảng nguồn lẫn bảng đích. Sch-M chặn mọi truy cập khác khi đang giữ, và chính nó phải chờ phiên khác nhả khóa trên bảng, kể cả khóa IS của một câu đọc đang mở giao dịch. Đổi metadata thì nhanh, nên Sch-M được giữ không lâu; thời gian chờ để được cấp thì tùy phiên khác.

Chỗ nguy hiểm là lúc chờ. Đo ở mục 6: khi một phiên báo cáo đang giữ khóa IS trên dbo.DonHang, lệnh SWITCH thường đứng chờ, và lệnh INSERT của API đến sau xếp hàng sau SWITCH dù IX tương thích với IS. Tùy chọn WAIT_AT_LOW_PRIORITY (từ SQL Server 2014) cho SWITCH chờ ở hàng ưu tiên thấp: phiên khác vẫn được cấp khóa trong lúc nó chờ. Hết MAX_DURATION phút, ABORT_AFTER_WAIT = SELF hủy chính lệnh SWITCH với lỗi 1222.

sequenceDiagram
  participant R as Phiên báo cáo
  participant T as Khóa trên dbo.DonHang
  participant S as Job SWITCH
  participant A as API ghi đơn
  R->>T: BEGIN TRAN, giữ IS
  S->>T: xin Sch-M, phải chờ báo cáo
  alt SWITCH thường
    A->>T: xin IX
    Note over A,T: IX xếp hàng sau Sch-M đang chờ, API đứng
  else WAIT_AT_LOW_PRIORITY
    A->>T: xin IX
    T-->>A: cấp ngay, Sch-M chờ ở hàng ưu tiên thấp
  end
  R->>T: COMMIT, nhả IS
  T-->>S: cấp Sch-M, đổi metadata rồi nhả

Điều kiện để SWITCH chạy

Các mã lỗi dưới đã thử trên LocalDB SQL Server 2019; vi phạm thì SWITCH báo lỗi và không đổi gì:

Điều kiện Lỗi khi vi phạm
Staging đã tồn tại và trống, cùng cột, cùng khóa clustered Theo tài liệu ALTER TABLE
Staging nằm trên đúng filegroup của partition nguồn (FG_ARCHIVE cho partition 1 và 2) 4939
Staging cùng mức nén với partition nguồn, ví dụ PAGE sau bài nén dữ liệu 11406
CHECK trên staging, nếu có, phải chứa trọn miền của partition nguồn 4972
Không bảng nào có khóa ngoại trỏ vào dbo.DonHang 4967
Chỉ mục nào staging có thì nguồn phải có bản giống hệt, cả cột INCLUDE; IX_DonHang_KhachHang thêm INCLUDE như ở bài chỉ mục phủ thì staging phải đổi theo 4947

Chiều ra, staging thiếu chỉ mục phụ vẫn chạy được. Chiều ngược lại, SWITCH từ staging trở vào partition 1 để hoàn tác, thì staging phải có đủ mọi chỉ mục của dbo.DonHang và CHECK khớp miền partition. Vì thế bảng staging ở mục 5 có cả IX_DonHang_Staging_KhachHang lẫn CHECK (NgayTao < '20250101'). Mỗi chỉ mục thêm vào dbo.DonHang sau này, như NCCI_DonHang của bài columnstore, cần thêm bản giống hệt vào staging nếu muốn giữ đường lùi.

4. Cách giải quyết: SWITCH ra staging, chờ khóa ở hàng ưu tiên thấp

Cách Ưu Nhược Khi nào dùng
Một lệnh DELETE (ExecuteDeleteAsync) Một dòng code, xóa được theo điều kiện bất kỳ Log từng dòng: đo 40,7 giây, 710 MB log; có thể leo thang khóa lên cả bảng Vài nghìn dòng
DELETE theo lô 4.000 dòng Mỗi lệnh dưới ngưỡng 5.000 khóa nên không leo thang, mỗi giao dịch nhỏ Vẫn ghi log từng dòng; 3,0 triệu đơn là 750 lệnh Bảng không phân vùng, hoặc xóa không theo ranh giới partition
TRUNCATE ... WITH (PARTITIONS (1)) Nhanh như SWITCH: đo 737 ms, log 68 KB Xóa ngay, không còn bản để kiểm; cú pháp không có WAIT_AT_LOW_PRIORITY Dữ liệu bỏ hẳn, không cần kiểm trước
SWITCH thường ra staging rồi TRUNCATE Đo 225 ms, log vài chục KB; staging còn đó để kiểm Lúc chờ Sch-M chặn lệnh đến sau: API đứng 3.393 ms khi có phiên báo cáo đang mở Cửa sổ bảo trì không còn phiên nào khác
SWITCH ra staging rồi TRUNCATE, với WAIT_AT_LOW_PRIORITY Đo 225 ms; staging còn đó để đếm, sao lưu, hay SWITCH ngược vào; không chặn API khi phải chờ Cần bảng staging khớp điều kiện ở mục 3 Dọn hoặc lưu trữ theo ranh giới partition

Chọn dòng cuối. Đơn trước 2025 nằm trọn trong partition 1, nên ranh giới dọn trùng ranh giới partition và không cần xóa từng dòng. So với TRUNCATE partition, SWITCH cho thêm hai thứ: một bảng staging để đếm và đối chiếu trước khi xóa thật, và tùy chọn chờ khóa ở hàng ưu tiên thấp. Đổi LOCK_ESCALATION sang AUTO thì DELETE leo thang lên partition thay vì cả bảng, nhưng vẫn ghi log từng dòng; bài này không đo cách đó.

Các bước:

  1. Tạo dbo.DonHang_Staging một lần, trên FG_ARCHIVE, khớp các điều kiện ở mục 3.
  2. Job kiểm mốc là một ranh giới partition và staging đang trống.
  3. SWITCH với WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF); gặp lỗi 1222 thì ghi log và để lần chạy sau.
  4. Đếm staging, sao lưu hay chép đi nếu chính sách yêu cầu, rồi TRUNCATE staging.

5. Cách cài đặt: bảng staging và job C#

Partition có ở mọi edition từ SQL Server 2016 SP1; WAIT_AT_LOW_PRIORITY cho SWITCH có từ SQL Server 2014; TRUNCATE ... WITH (PARTITIONS ...) từ SQL Server 2016. Mọi lệnh trong bài đã chạy trên LocalDB SQL Server 2019, bản Express. Bảng staging cho partition 1:

CREATE TABLE dbo.DonHang_Staging (
    DonHangId bigint NOT NULL,
    NgayTao datetime2(0) NOT NULL,
    KhachHangId int NOT NULL,
    TrangThai tinyint NOT NULL,
    TongTien decimal(18, 2) NOT NULL,
    CONSTRAINT PK_DonHang_Staging PRIMARY KEY CLUSTERED (NgayTao, DonHangId),
    CONSTRAINT CK_DonHang_Staging_Truoc2025 CHECK (NgayTao < CAST('20250101' AS datetime2(0)))
) ON FG_ARCHIVE;
CREATE INDEX IX_DonHang_Staging_KhachHang ON dbo.DonHang_Staging (KhachHangId, NgayTao) ON FG_ARCHIVE;

Nếu partition 1 đã nén PAGE theo bài nén dữ liệu, thêm WITH (DATA_COMPRESSION = PAGE) cho cả khóa chính lẫn chỉ mục của staging. Job dọn, viết bằng Microsoft.Data.SqlClient trên .NET 10; chương trình đo dùng bản 6.1.6 mà EF Core 10.0.12 kéo theo:

public static async Task<long> ChayAsync(SqlConnection cn, DateTime moc, Action<string> log)
{
    // Mốc phải là ranh giới của pf_DonHang_Ngay: partition ngay trước nó chứa đúng các đơn trước mốc.
    var p = await Scalar<int?>(cn, """
        SELECT $PARTITION.pf_DonHang_Ngay(@moc) - 1
        WHERE EXISTS (SELECT 1 FROM sys.partition_range_values AS rv
                      JOIN sys.partition_functions AS f ON f.function_id = rv.function_id
                      WHERE f.name = N'pf_DonHang_Ngay' AND CAST(rv.value AS datetime2(0)) = @moc);
        """, moc) ?? throw new InvalidOperationException($"{moc:yyyy-MM-dd} không phải ranh giới partition");
    if (await Scalar<long>(cn, "SELECT COUNT_BIG(*) FROM dbo.DonHang_Staging;", moc) > 0)
        throw new InvalidOperationException("dbo.DonHang_Staging chưa trống: lần chạy trước dừng giữa chừng");
    try
    {
        // Chờ khóa ở hàng ưu tiên thấp: phiên khác vẫn chạy trong lúc chờ; quá 1 phút thì tự hủy, lỗi 1222.
        await using var sw = new SqlCommand($"""
            ALTER TABLE dbo.DonHang SWITCH PARTITION {p} TO dbo.DonHang_Staging
            WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF));
            """, cn) { CommandTimeout = 120 };
        await sw.ExecuteNonQueryAsync();
    }
    catch (SqlException e) when (e.Number == 1222)
    {
        log($"  dbo.DonHang bận quá 1 phút, bỏ qua lần này: {e.Message}");
        return -1;
    }
    var so = await Scalar<long>(cn, "SELECT COUNT_BIG(*) FROM dbo.DonHang_Staging;", moc);
    // Chỗ này sao lưu hoặc chép staging đi nơi khác nếu cần giữ, trước khi TRUNCATE.
    await using (var tr = new SqlCommand("TRUNCATE TABLE dbo.DonHang_Staging;", cn)) await tr.ExecuteNonQueryAsync();
    log($"  đã dọn {so:N0} đơn trước {moc:yyyy-MM-dd} (partition {p})");
    return so;
}

CommandTimeout = 120 phải dài hơn MAX_DURATION, nếu không SqlClient hủy lệnh trước khi SQL Server kịp trả lỗi 1222. Gọi job bằng await DonDonCu.ChayAsync(cn, new DateTime(2025, 1, 1), m => logger.LogInformation("{ThongBao}", m)) từ một BackgroundService hay một tác vụ hẹn giờ. Chạy lại an toàn: lần sau partition đã trống thì job dọn 0 đơn.

Chương trình đo dựng dbo.DonHang 10 triệu đơn trên database thử Kumeo_B, chạy ba cách dọn trong lúc một tác vụ ghi đơn mỗi 100 ms, nạp lại 3,0 triệu đơn năm 2024 giữa các lần, rồi thử SWITCH khi một phiên báo cáo giữ khóa trên bảng 5 giây và 70 giây. Đầu file có #:property PublishAot=false vì cách cũ chạy bằng EF Core, mà EF Core không dựng model lúc chạy khi bật PublishAot, chế độ mặc định của file-based app.

DonNamCu.cs — chương trình đo, chạy bằng dotnet run DonNamCu.csC# · 293 dòng
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:property PublishAot=false
// Dọn 3,0 triệu đơn trước 2025 khỏi dbo.DonHang trong lúc "API" ghi đơn mới mỗi 100 ms:
// ExecuteDeleteAsync của EF Core, TRUNCATE ... WITH (PARTITIONS (1)), và job SWITCH ra bảng staging rồi TRUNCATE.
// Sau đó cho một phiên báo cáo giữ khóa trên bảng, rồi SWITCH thường và job dùng WAIT_AT_LOW_PRIORITY.
// Chạy: dotnet run DonNamCu.cs   (LocalDB, database Kumeo_B; lần đầu nạp 10 triệu đơn, khoảng 4 phút)
using System.Diagnostics;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_B;Integrated Security=true;TrustServerCertificate=true;Command Timeout=1800";
var moc = new DateTime(2025, 1, 1);
await DuLieu.DamBao();
await Chay("DROP INDEX IF EXISTS NCCI_DonHang ON dbo.DonHang;");
await Chay(DonDonCu.TaoStaging);
Console.WriteLine($".NET {Environment.Version}, SQL Server {await Scalar<string>("SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(20))")}");

// 1. Cách cũ: job .NET xóa bằng ExecuteDeleteAsync.
await DoKichBan("ExecuteDeleteAsync, đơn trước 2025", async cn =>
{
    await using var db = new BanHangDb(cn);
    return await db.DonHang.Where(d => d.NgayTao < moc).ExecuteDeleteAsync();
});
Console.WriteLine($"  file log sau khi xóa: {await Scalar<int>("SELECT size / 128 FROM sys.database_files WHERE type = 1")} MB");
await DuLieu.NapLai2024();

// 2. TRUNCATE đúng partition 1.
await DoKichBan("TRUNCATE WITH (PARTITIONS (1))", async cn =>
{
    var so = await Scalar<int>("SELECT COUNT(*) FROM dbo.DonHang WHERE NgayTao < '20250101'");
    await new SqlCommand("TRUNCATE TABLE dbo.DonHang WITH (PARTITIONS (1));", cn).ExecuteNonQueryAsync();
    return so;
});
await DuLieu.NapLai2024();

// 3. Job dọn đơn cũ: SWITCH partition 1 ra dbo.DonHang_Staging, rồi TRUNCATE bảng staging.
await DoKichBan("Job SWITCH rồi TRUNCATE", async cn => (int)await DonDonCu.ChayAsync(cn, moc, Console.WriteLine));

// 4. Một phiên báo cáo giữ khóa trên dbo.DonHang; SWITCH cần khóa Sch-M trong lúc đó. Partition 1 lúc này đã trống.
foreach (var (ten, giay, lam) in new (string, int, Func<SqlConnection, Task<int>>)[]
{
    ("SWITCH thường, báo cáo giữ khóa 5 s", 5, async cn =>
    {
        await new SqlCommand("ALTER TABLE dbo.DonHang SWITCH PARTITION 1 TO dbo.DonHang_Staging;", cn).ExecuteNonQueryAsync();
        return 0;
    }),
    ("Job WAIT_AT_LOW_PRIORITY, báo cáo giữ khóa 5 s", 5, async cn => (int)await DonDonCu.ChayAsync(cn, moc, Console.WriteLine)),
    ("Job WAIT_AT_LOW_PRIORITY, báo cáo giữ khóa 70 s", 70, async cn => (int)await DonDonCu.ChayAsync(cn, moc, Console.WriteLine)),
})
{
    var baoCao = GiuKhoa(giay);
    await Task.Delay(1500);
    await DoKichBan(ten, lam, choTruoc: 0);
    await baoCao;
}
await DuLieu.NapLai2024();

// Chạy một kịch bản trong lúc API ghi đơn mới mỗi 100 ms; in thời gian, log ghi ra file, độ trễ ghi của API.
async Task DoKichBan(string ten, Func<SqlConnection, Task<int>> lam, int choTruoc = 1000)
{
    var dung = new CancellationTokenSource();
    var api = GhiDonMoi(dung.Token);
    await Task.Delay(choTruoc);
    var logTruoc = await LogDaGhi();
    await using var cn = new SqlConnection(Cs);
    await cn.OpenAsync();
    var sw = Stopwatch.StartNew();
    var so = await lam(cn);
    var ms = sw.ElapsedMilliseconds;
    var log = await LogDaGhi() - logTruoc;
    await Task.Delay(1000);
    dung.Cancel();
    var tre = await api;
    Console.WriteLine($"{ten}: {(so < 0 ? "không dọn" : $"{so:N0} đơn")}, {ms:N0} ms, log ghi {log / 1024.0:N0} KB; " +
                      $"API ghi {tre.Count} đơn, chậm nhất {tre.Max():N0} ms, {tre.Count(t => t > 500)} lần trên 500 ms");
    await Chay("DELETE dbo.DonHang WHERE NgayTao >= '20261003';");
}

// "API" của BanHang: mỗi 100 ms ghi một đơn mới vào partition năm 2026, đo thời gian từng lệnh.
async Task<List<long>> GhiDonMoi(CancellationToken dung)
{
    var tre = new List<long>();
    await using var cn = new SqlConnection(Cs);
    await cn.OpenAsync();
    for (long id = 20_000_001; !dung.IsCancellationRequested; id++)
    {
        var sw = Stopwatch.StartNew();
        await using var cmd = new SqlCommand("INSERT dbo.DonHang (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien) VALUES (@id, @ngay, 1507, 1, 1500000);", cn);
        cmd.Parameters.AddWithValue("@id", id);
        cmd.Parameters.Add(new SqlParameter("@ngay", System.Data.SqlDbType.DateTime2) { Scale = 0, Value = new DateTime(2026, 10, 3).AddSeconds(id - 20_000_000) });
        await cmd.ExecuteNonQueryAsync();
        tre.Add(sw.ElapsedMilliseconds);
        await Task.Delay(100);
    }
    return tre;
}

// Phiên báo cáo: mở giao dịch, đọc một đơn với REPEATABLEREAD (giữ khóa IS trên bảng), chờ rồi mới COMMIT.
Task GiuKhoa(int giay) => Task.Run(async () =>
{
    await using var bc = new SqlConnection(Cs);
    await bc.OpenAsync();
    await new SqlCommand($"""
        BEGIN TRAN;
        SELECT TongTien FROM dbo.DonHang WITH (REPEATABLEREAD) WHERE NgayTao = '20260101' AND DonHangId = 6400001;
        WAITFOR DELAY '{TimeSpan.FromSeconds(giay):hh\:mm\:ss}';
        COMMIT;
        """, bc) { CommandTimeout = 300 }.ExecuteNonQueryAsync();
});

async Task<long> LogDaGhi() => await Scalar<long>("SELECT num_of_bytes_written FROM sys.dm_io_virtual_file_stats(DB_ID(), 2)");


async Task<T> Scalar<T>(string sql)
{
    await using var cn = new SqlConnection(Cs);
    await cn.OpenAsync();
    return (T)(await new SqlCommand(sql, cn).ExecuteScalarAsync())!;
}

async Task Chay(string sql)
{
    await using var cn = new SqlConnection(Cs);
    await cn.OpenAsync();
    await new SqlCommand(sql, cn).ExecuteNonQueryAsync();
}

// Job dọn đơn cũ của BanHang: SWITCH partition chứa đơn trước mốc ra dbo.DonHang_Staging, rồi TRUNCATE.
static class DonDonCu
{
    // Cùng cột, cùng khóa clustered, cùng filegroup FG_ARCHIVE và cùng mức nén với partition nguồn.
    // Có đủ chỉ mục và CHECK khớp miền partition 1 để còn SWITCH ngược vào nếu cần.
    public const string TaoStaging = """
        IF OBJECT_ID(N'dbo.DonHang_Staging') IS NULL
        BEGIN
            CREATE TABLE dbo.DonHang_Staging (
                DonHangId bigint NOT NULL,
                NgayTao datetime2(0) NOT NULL,
                KhachHangId int NOT NULL,
                TrangThai tinyint NOT NULL,
                TongTien decimal(18, 2) NOT NULL,
                CONSTRAINT PK_DonHang_Staging PRIMARY KEY CLUSTERED (NgayTao, DonHangId),
                CONSTRAINT CK_DonHang_Staging_Truoc2025 CHECK (NgayTao < CAST('20250101' AS datetime2(0)))
            ) ON FG_ARCHIVE;
            CREATE INDEX IX_DonHang_Staging_KhachHang ON dbo.DonHang_Staging (KhachHangId, NgayTao) ON FG_ARCHIVE;
        END
        """;

    public static async Task<long> ChayAsync(SqlConnection cn, DateTime moc, Action<string> log)
    {
        // Mốc phải là một ranh giới của pf_DonHang_Ngay: partition ngay trước nó chứa đúng các đơn trước mốc.
        var p = await Scalar<int?>(cn, """
            SELECT $PARTITION.pf_DonHang_Ngay(@moc) - 1
            WHERE EXISTS (SELECT 1 FROM sys.partition_range_values AS rv
                          JOIN sys.partition_functions AS f ON f.function_id = rv.function_id
                          WHERE f.name = N'pf_DonHang_Ngay' AND CAST(rv.value AS datetime2(0)) = @moc);
            """, moc) ?? throw new InvalidOperationException($"{moc:yyyy-MM-dd} không phải ranh giới partition");
        if (await Scalar<long>(cn, "SELECT COUNT_BIG(*) FROM dbo.DonHang_Staging;", moc) > 0)
            throw new InvalidOperationException("dbo.DonHang_Staging chưa trống: lần chạy trước dừng giữa chừng");
        try
        {
            // Chờ khóa ở hàng ưu tiên thấp: phiên khác vẫn chạy trong lúc chờ; quá 1 phút thì tự hủy, lỗi 1222.
            await using var sw = new SqlCommand($"""
                ALTER TABLE dbo.DonHang SWITCH PARTITION {p} TO dbo.DonHang_Staging
                WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF));
                """, cn) { CommandTimeout = 120 };
            await sw.ExecuteNonQueryAsync();
        }
        catch (SqlException e) when (e.Number == 1222)
        {
            log($"  dbo.DonHang bận quá 1 phút, bỏ qua lần này: {e.Message}");
            return -1;
        }
        var so = await Scalar<long>(cn, "SELECT COUNT_BIG(*) FROM dbo.DonHang_Staging;", moc);
        // Chỗ này sao lưu hoặc chép staging đi nơi khác nếu cần giữ, trước khi TRUNCATE.
        await using (var tr = new SqlCommand("TRUNCATE TABLE dbo.DonHang_Staging;", cn)) await tr.ExecuteNonQueryAsync();
        log($"  đã dọn {so:N0} đơn trước {moc:yyyy-MM-dd} (partition {p})");
        return so;
    }

    static async Task<T> Scalar<T>(SqlConnection cn, string sql, DateTime moc)
    {
        await using var cmd = new SqlCommand(sql, cn);
        cmd.Parameters.Add(new SqlParameter("@moc", System.Data.SqlDbType.DateTime2) { Scale = 0, Value = moc });
        var v = await cmd.ExecuteScalarAsync();
        return v is null or DBNull ? default! : (T)v;
    }
}
class DonHang
{
    public long DonHangId { get; set; }
    public DateTime NgayTao { get; set; }
    public int KhachHangId { get; set; }
    public byte TrangThai { get; set; }
    public decimal TongTien { get; set; }
}

class BanHangDb(SqlConnection ketNoi) : DbContext
{
    public DbSet<DonHang> DonHang => Set<DonHang>();
    protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseSqlServer(ketNoi, s => s.CommandTimeout(1800))
        .LogTo(s => { var i = s.IndexOf("DELETE"); if (i >= 0) Console.WriteLine("  SQL của EF Core: " + string.Join(" ", s[i..].Split('\n', StringSplitOptions.TrimEntries))); },
               new[] { DbLoggerCategory.Database.Command.Name }, Microsoft.Extensions.Logging.LogLevel.Information);
    protected override void OnModelCreating(ModelBuilder m) => m.Entity<DonHang>(e =>
    {
        e.ToTable("DonHang", "dbo");
        e.HasKey(d => new { d.NgayTao, d.DonHangId });
        e.Property(d => d.NgayTao).HasColumnType("datetime2(0)");
        e.Property(d => d.TongTien).HasColumnType("decimal(18, 2)");
    });
}

static class DuLieu
{
    const string Master = @"Server=(localdb)\MSSQLLocalDB;Database=master;Integrated Security=true;TrustServerCertificate=true;Command Timeout=1800";

    // Dựng Kumeo_B như database BanHang: hai filegroup, dbo.DonHang phân vùng theo năm, 10 triệu đơn.
    public static async Task DamBao()
    {
        await using var cn = new SqlConnection(Master);
        await cn.OpenAsync();
        await Chay(cn, """
            IF DB_ID(N'Kumeo_B') IS NULL
            BEGIN
                DECLARE @dir nvarchar(400) = CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS nvarchar(400));
                CREATE DATABASE Kumeo_B COLLATE Vietnamese_100_CI_AS;
                ALTER DATABASE Kumeo_B SET AUTO_CLOSE OFF;
                ALTER DATABASE Kumeo_B ADD FILEGROUP FG_ARCHIVE;
                ALTER DATABASE Kumeo_B ADD FILEGROUP FG_DATA;
                DECLARE @sql nvarchar(max) =
                    N'ALTER DATABASE Kumeo_B ADD FILE (NAME = Kumeo_B_archive, FILENAME = ''' + @dir + N'Kumeo_B_archive.ndf'', SIZE = 512MB, FILEGROWTH = 256MB) TO FILEGROUP FG_ARCHIVE;
                      ALTER DATABASE Kumeo_B ADD FILE (NAME = Kumeo_B_data, FILENAME = ''' + @dir + N'Kumeo_B_data.ndf'', SIZE = 512MB, FILEGROWTH = 256MB) TO FILEGROUP FG_DATA;';
                EXEC (@sql);
            END
            """);
        await Chay(cn, "USE Kumeo_B;");
        await Chay(cn, """
            IF OBJECT_ID(N'dbo.DonHang') IS NULL
            BEGIN
                CREATE PARTITION FUNCTION pf_DonHang_Ngay (datetime2(0)) AS RANGE RIGHT FOR VALUES ('20250101', '20260101', '20270101');
                CREATE PARTITION SCHEME ps_DonHang_Ngay AS PARTITION pf_DonHang_Ngay TO (FG_ARCHIVE, FG_ARCHIVE, FG_DATA, FG_DATA);
                CREATE TABLE dbo.DonHang (
                    DonHangId bigint NOT NULL,
                    NgayTao datetime2(0) NOT NULL,
                    KhachHangId int NOT NULL,
                    TrangThai tinyint NOT NULL,
                    TongTien decimal(18, 2) NOT NULL,
                    CONSTRAINT PK_DonHang PRIMARY KEY CLUSTERED (NgayTao, DonHangId)
                ) ON ps_DonHang_Ngay (NgayTao);
            END
            """);
        await using (var dem = new SqlCommand("SELECT COUNT_BIG(*) FROM dbo.DonHang;", cn))
            if ((long)(await dem.ExecuteScalarAsync())! > 0) return;
        await Nap(cn, 1, 10_000_000);
        await Chay(cn, "CREATE INDEX IX_DonHang_KhachHang ON dbo.DonHang (KhachHangId, NgayTao) ON ps_DonHang_Ngay (NgayTao);");
    }

    public static async Task NapLai2024()
    {
        await using var cn = new SqlConnection(Master.Replace("master", "Kumeo_B"));
        await cn.OpenAsync();
        await Nap(cn, 1, 3_000_000);
    }

    // 3,0 triệu đơn năm 2024, 3,4 triệu năm 2025, 3,6 triệu năm 2026 tới hết 2026-10-02; khách 42 chiếm 3%.
    // TrangThai: 1 Mới 1%, 2 Đã thanh toán 2%, 3 Đang giao 2%, 4 Hoàn tất 90%, 5 Đã hủy 5%. Giá trị lấy từ MD5 của số thứ tự.
    static async Task Nap(SqlConnection cn, long tu, long den)
    {
        for (long lo = tu; lo <= den; lo += 1_000_000)
            await Chay(cn, $"""
                WITH n AS (
                    SELECT TOP ({Math.Min(1_000_000, den - lo + 1)}) i = {lo - 1} + ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
                    FROM sys.all_objects a CROSS JOIN sys.all_objects b CROSS JOIN sys.all_objects c)
                INSERT dbo.DonHang WITH (TABLOCK) (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien)
                SELECT i,
                       CASE WHEN i <= 3000000 THEN DATEADD(SECOND, CAST((i - 1) * 31622400 / 3000000 AS int), CAST('20240101' AS datetime2(0)))
                            WHEN i <= 6400000 THEN DATEADD(SECOND, CAST((i - 3000001) * 31536000 / 3400000 AS int), CAST('20250101' AS datetime2(0)))
                            ELSE DATEADD(SECOND, CAST((i - 6400001) * 23760000 / 3600000 AS int), CAST('20260101' AS datetime2(0))) END,
                       CASE WHEN i % 100 < 3 THEN 42 ELSE 1 + ABS(CAST(SUBSTRING(h, 1, 4) AS int) % 200000) END,
                       CASE WHEN r < 1 THEN 1 WHEN r < 3 THEN 2 WHEN r < 5 THEN 3 WHEN r < 95 THEN 4 ELSE 5 END,
                       (100 + ABS(CAST(SUBSTRING(h, 9, 4) AS int) % 49901)) * 1000
                FROM n
                CROSS APPLY (SELECT h = HASHBYTES('MD5', CAST(i AS binary(8)))) k
                CROSS APPLY (SELECT r = ABS(CAST(SUBSTRING(h, 5, 4) AS int) % 100)) t;
                """);
    }

    static async Task Chay(SqlConnection cn, string sql)
    {
        await using var cmd = new SqlCommand(sql, cn);
        await cmd.ExecuteNonQueryAsync();
    }
}

Output:

.NET 10.0.12, SQL Server 15.0.4382.1
  SQL của EF Core: DELETE FROM [d] FROM [dbo].[DonHang] AS [d] WHERE [d].[NgayTao] < @moc
ExecuteDeleteAsync, đơn trước 2025: 3,000,000 đơn, 40,688 ms, log ghi 726,876 KB; API ghi 63 đơn, chậm nhất 35,716 ms, 1 lần trên 500 ms
  file log sau khi xóa: 3208 MB
TRUNCATE WITH (PARTITIONS (1)): 3,000,000 đơn, 737 ms, log ghi 68 KB; API ghi 24 đơn, chậm nhất 56 ms, 0 lần trên 500 ms
  đã dọn 3,000,000 đơn trước 2025-01-01 (partition 1)
Job SWITCH rồi TRUNCATE: 3,000,000 đơn, 225 ms, log ghi 24 KB; API ghi 21 đơn, chậm nhất 32 ms, 0 lần trên 500 ms
SWITCH thường, báo cáo giữ khóa 5 s: 0 đơn, 3,505 ms, log ghi 12 KB; API ghi 11 đơn, chậm nhất 3,393 ms, 1 lần trên 500 ms
  đã dọn 0 đơn trước 2025-01-01 (partition 1)
Job WAIT_AT_LOW_PRIORITY, báo cáo giữ khóa 5 s: 0 đơn, 3,520 ms, log ghi 136 KB; API ghi 38 đơn, chậm nhất 86 ms, 0 lần trên 500 ms
  dbo.DonHang bận quá 1 phút, bỏ qua lần này: Lock request time out period exceeded.
Job WAIT_AT_LOW_PRIORITY, báo cáo giữ khóa 70 s: không dọn, 60,015 ms, log ghi 2,120 KB; API ghi 536 đơn, chậm nhất 136 ms, 0 lần trên 500 ms

6. Chứng minh: 225 ms thay vì 40,7 giây, API không lệnh nào quá 500 ms

Mỗi kịch bản chạy trong lúc tác vụ "API" ghi một đơn mỗi 100 ms vào partition 2026. Log là số byte ghi ra file log trong lúc lệnh chạy, đọc từ sys.dm_io_virtual_file_stats, gồm cả các đơn API ghi cùng lúc. Ba kịch bản cuối chạy sau khi partition 1 đã trống, nên chỉ đo chuyện chờ khóa; phiên báo cáo mở giao dịch, đọc một đơn với REPEATABLEREAD rồi chờ, đủ để giữ khóa IS trên dbo.DonHang.

Kịch bản Đơn dọn Thời gian ms Log ghi KB API: số đơn ghi API chậm nhất ms
ExecuteDeleteAsync (hiện trạng) 3.000.000 40.688 726.876 63 35.716
TRUNCATE partition 1 3.000.000 737 68 24 56
Job: SWITCH rồi TRUNCATE 3.000.000 225 24 21 32
SWITCH thường, báo cáo giữ khóa 5 giây 0 3.505 12 11 3.393
Job, báo cáo giữ khóa 5 giây 0 3.520 136 38 86
Job, báo cáo giữ khóa 70 giây 0, lỗi 1222 60.015 2.120 536 136

Lệnh ghi đơn chậm nhất của API: 35,7 giây khi DELETE, 3,4 giây khi SWITCH thường phải chờ, dưới 0,2 giây với job

ExecuteDeleteAsync35.716 msTRUNCATE partition56 msJob, không có báo cáo32 msSWITCH thường, báo cáo giữ khóa 5 giây3.393 msJob, báo cáo giữ khóa 5 giây86 msJob, báo cáo giữ khóa 70 giây136 ms
API ghi một đơn mỗi 100 ms vào partition 2026. LocalDB SQL Server 2019 (15.0.4382), 10 triệu đơn. Thang log.
Bảng số liệu
Giá trị
ExecuteDeleteAsync35.716 ms
TRUNCATE partition56 ms
Job, không có báo cáo32 ms
SWITCH thường, báo cáo giữ khóa 5 giây3.393 ms
Job, báo cáo giữ khóa 5 giây86 ms
Job, báo cáo giữ khóa 70 giây136 ms

Log ghi khi dọn 3,0 triệu đơn: 726.876 KB với DELETE, 24 KB với SWITCH

ExecuteDeleteAsync726.876 KBTRUNCATE partition68 KBJob SWITCH rồi TRUNCATE24 KB
Byte ghi ra file log trong lúc lệnh chạy, đọc từ sys.dm_io_virtual_file_stats, gồm cả đơn API ghi cùng lúc. Thang log.
Bảng số liệu
Giá trị
ExecuteDeleteAsync726.876 KB
TRUNCATE partition68 KB
Job SWITCH rồi TRUNCATE24 KB

Đối chiếu với mục 2:

Tiêu chí Kết quả Đạt
Đơn trước 2025 rời bảng dưới 1 giây 225 ms cho 3.000.000 đơn Có
Log dưới 1 MB 24 KB, so với 726.876 KB của DELETE Có
API không lệnh nào quá 500 ms lúc dọn Chậm nhất 32 ms Có
Có phiên giữ khóa: API vẫn dưới 500 ms, job tự dừng sau 1 phút Chậm nhất 86 ms và 136 ms; job bỏ cuộc sau 60.015 ms với lỗi 1222 Có

Hai dòng giữa của bảng cho thấy chỗ khác nhau duy nhất là tùy chọn chờ. Cùng một phiên báo cáo giữ khóa 5 giây, SWITCH thường và job đều mất khoảng 3,5 giây để được khóa Sch-M. Với SWITCH thường, API ghi được 11 đơn và có lệnh đứng 3.393 ms; với job, API ghi 38 đơn và lệnh chậm nhất 86 ms. Khi phiên báo cáo giữ khóa quá 1 phút, job trả lỗi 1222, ghi log rồi thoát; trong 60 giây đó API vẫn ghi đủ 536 đơn.

TRUNCATE partition và job cùng chỉ ghi vài chục KB log. DELETE ghi 726.876 KB, tức khoảng 710 MB, và file log nới tới 3.208 MB. Một lần chạy trước của cùng chương trình cho DELETE 44.433 ms, 753.792 KB log, API chậm nhất 1.262 ms: thời gian và log ổn định, còn chuyện API đứng thì không.

Môi trường: LocalDB SQL Server 2019 (15.0.4382, bản Express), recovery model SIMPLE của database thử, .NET 10.0.12, EF Core 10.0.12 kéo theo Microsoft.Data.SqlClient 6.1.6, laptop Intel Core Ultra 5 125U, Windows 11, đang chạy việc khác. Log của DELETE không đổi theo recovery model vì dòng nào bị xóa cũng được ghi log; FULL chỉ giữ phần log đó tới lượt log backup kế tiếp. Chưa đo: LOCK_ESCALATION = AUTO, DELETE theo lô, và SWITCH khi dbo.DonHang có NCCI_DonHang.

7. Kết luận

Khi ranh giới cần dọn trùng ranh giới partition, SWITCH biến việc xóa một năm đơn từ một giao dịch ghi log từng dòng thành một lần đổi metadata. WAIT_AT_LOW_PRIORITY giữ cho lúc chờ khóa không trở thành lúc API đứng.

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

  • Thay ExecuteDeleteAsync trên cả một năm bằng job SWITCH như DonDonCu.ChayAsync, gọi từ BackgroundService hay tác vụ hẹn giờ.
  • Đặt CommandTimeout của lệnh SWITCH dài hơn MAX_DURATION, và bắt SqlException.Number == 1222 để thử lại lần sau.
  • Tạo bảng staging bằng migration cùng lúc với chỉ mục mới trên dbo.DonHang, để staging luôn khớp chỉ mục và mức nén.
  • Không thêm khóa ngoại trỏ vào dbo.DonHang; giữ toàn vẹn trong code ghi đơn, như dbo.ChiTietDonHang đang làm.

Những chỗ hay hiểu sai

  • "ExecuteDeleteAsync xóa theo lô." EF Core sinh một lệnh DELETE duy nhất; 3,0 triệu dòng nằm trong một giao dịch.
  • "SWITCH chỉ mất vài mili giây nên không cần lo khóa." Lệnh đổi metadata nhanh, nhưng lúc chờ Sch-M nó chặn mọi lệnh đến sau.
  • "Staging phải có đủ mọi chỉ mục của bảng nguồn." Chiều ra, staging không cần chỉ mục phụ; đủ chỉ mục là điều kiện để SWITCH ngược vào.
  • "TRUNCATE partition cũng chờ khóa ở hàng ưu tiên thấp được." Cú pháp TRUNCATE TABLE không có WAIT_AT_LOW_PRIORITY.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

Hủy job dài phải chờ rollback: chia lô và Accelerated Database Recovery

Hủy một job đã sửa 1.000.000 đơn thì ROLLBACK chạy 7,6 giây, lâu hơn chính job; khởi động lại máy làm database đóng gần 10 giây. Job chia lô 4.000 dòng có mốc dừng sau 45 ms, và Accelerated Database Recovery bỏ phần hoàn tác này trên SQL Server 2019 Standard trở lên.

12 phút đọc

Trong SQL Server

Job hàng loạt chặn bán hàng: leo thang khóa

Job chốt sổ năm 2024 sửa khoảng 150.000 đơn bằng một câu UPDATE, vượt ngưỡng 5.000 khóa và leo thang lên khóa cả bảng DonHang, chặn mọi đơn mới. Chia lô 4.000 dòng bằng ExecuteUpdateAsync giữ số lần leo thang ở 0 và cho đơn 2026 chèn được sau 19 ms thay vì lỗi 1222 sau 2.015 ms.

12 phút đọc

Trong SQL Server

Timeout để lại giao dịch mở và XACT_ABORT

Lệnh trừ kho timeout sau 30 giây, API trả kết nối về pool, còn giao dịch trên server vẫn giữ khóa sản phẩm 42. Với SET XACT_ABORT ON hoặc SqlTransaction trong await using, phiên khác đọc được tồn sau 17 ms thay vì lỗi 1222 sau 2.022 ms.

11 phút đọc