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

Write-ahead log, VLF và recovery model

Vì sao COMMIT chỉ chờ log, ai ghi page dữ liệu xuống đĩa, recovery làm gì sau sự cố, cấp log thế nào để tránh hàng nghìn VLF, recovery model quyết định khi nào log được cắt, và một BackgroundService .NET theo dõi điều đó.

Mục lục
  1. 1. Đường ghi dữ liệu
  2. 2. Sửa đơn 10042, theo từng thời điểm
  3. 3. Ai ghi page xuống đĩa, và recovery lúc khởi động
  4. 4. Tăng trưởng log và VLF
  5. 5. Recovery model
  6. 6. Áp dụng trong .NET
  7. Những chỗ hay hiểu sai
  8. Kết luận
  9. Đọc tiếp
  10. Nguồn

COMMIT trả về khi log record đã xuống tệp .ldf, không phải khi page dữ liệu đã nằm trên đĩa. Không hiểu điều này thì một giao dịch quên đóng, hay một database FULL thiếu log backup, sẽ làm log phình đến lúc không đơn nào commit được. Đọc xong, bạn biết log được giữ đến khi nào, cấp log ra sao để tránh hàng nghìn VLF, và theo dõi điều đó từ một dịch vụ .NET.

Đọc nhanh

  • Quy tắc write-ahead logging: log của một lần sửa phải xuống đĩa trước page dữ liệu của lần sửa đó.
  • Mất điện sau COMMIT không mất giao dịch, vì recovery dùng log để redo phần đã commit và undo phần chưa commit.
  • Nới log từng bước nhỏ sinh hàng nghìn VLF, nên cấp sẵn log đủ lớn ngay lúc tạo database.
  • Trong FULL, chỉ log backup mới cắt log, và một giao dịch mở lâu giữ log dù lịch backup đều.

1. Đường ghi dữ liệu

Sửa một dòng không có nghĩa là ghi ngay page đó xuống .mdf. Đường đi gồm bộ nhớ, nhật ký, rồi mới tới tệp dữ liệu.

Page dữ liệu được đọc vào buffer pool trong RAM. Sửa dòng là một lần ghi logic trên page trong bộ nhớ, và page đó được đánh dấu dirty. Nhiều lần sửa có thể dồn trên cùng một dirty page trước khi page được ghi vật lý ra đĩa.

Mỗi lần sửa tạo log record trong log cache. Quy tắc write-ahead logging (WAL): log record của lần sửa phải đã nằm trên đĩa trước khi dirty page tương ứng được ghi xuống tệp dữ liệu. Khi giao dịch COMMIT, engine làm cứng log của giao dịch xuống .ldf rồi mới báo thành công cho client. Dirty page có thể vẫn nằm trong RAM sau commit. Mất điện lúc này không mất giao dịch đã commit, vì recovery đọc log và làm lại thay đổi trên page.

Thứ tự đúng của một UPDATE đã commit:

  1. Page dữ liệu nằm trong buffer pool, hoặc được đọc lên nếu chưa có.
  2. Log record được ghi vào log cache.
  3. Page trong bộ nhớ bị sửa và trở thành dirty page.
  4. Lúc COMMIT, log record được flush xuống .ldf.
  5. Client nhận kết quả thành công.
  6. Sau đó checkpoint, lazy writer, hoặc eager writer mới ghi dirty page xuống .mdf hoặc .ndf.
sequenceDiagram
  participant App as Ứng dụng
  participant BP as Buffer pool
  participant LC as Log cache
  participant LDF as Tệp log trên L
  participant NDF as Tệp dữ liệu trên E hoặc G
  App->>BP: UPDATE đơn 10042
  BP->>LC: log record
  BP->>BP: page thành dirty
  App->>LC: COMMIT
  LC->>LDF: flush log
  LDF-->>App: thành công
  BP->>NDF: checkpoint hoặc lazy writer ghi page, sau đó

Bước 6 được phép xảy ra trước commit nếu log của lần sửa đó đã ở trên đĩa. Điều WAL cấm là ghi dirty page khi log tương ứng chưa bền.

2. Sửa đơn 10042, theo từng thời điểm

Đơn 10042 tạo lúc 11:58 ngày 2026-10-02, TongTien = 1500000.00, nằm trên FG_DATA (xem bài tệp và filegroup). Lúc 12:04 ứng dụng chạy:

BEGIN TRAN;

UPDATE dbo.DonHang
SET TongTien = 1750000.00
WHERE DonHangId = 10042
  AND NgayTao = CAST('2026-10-02T11:58:00' AS datetime2(0));

-- Chưa COMMIT. Page trong RAM đã là 1.750.000 và được đánh dấu dirty.
-- Log record đang ở log cache. Tệp .ndf trên E: hoặc G: vẫn là 1.500.000.
-- Giao dịch khác ở READ COMMITTED vẫn đọc thấy 1.500.000.

COMMIT;

-- Log record đã được ghi xuống L:\SqlLog\BanHang_log.ldf.
-- Client nhận thành công. Phiên khác đọc thấy 1.750.000 từ buffer pool.
-- Tệp .ndf có thể vẫn đang chứa 1.500.000 cho đến checkpoint hoặc lazy writer.

Ba cách mất điện ngay sau đó:

Thời điểm mất điện Trên đĩa dữ liệu Trên đĩa log Sau khi SQL Server mở lại
Sau UPDATE, trước COMMIT, log chưa flush 1.500.000 Không có bản ghi hoàn chỉnh của lần sửa Đơn vẫn là 1.500.000. Giao dịch không tồn tại
Sau UPDATE, trước COMMIT, nhưng log buffer đã flush vì một commit khác 1.500.000 hoặc đã là dirty page được ghi Có log record của giao dịch chưa kết thúc Undo trả TongTien về 1.500.000
Sau COMMIT, trước checkpoint Vẫn có thể là 1.500.000 Có log record đã commit Redo ghi 1.750.000 lên page. Khách không mất lần sửa

Trường hợp thứ ba là lý do commit có thể trả về trước khi .ndf đổi. Độ bền nằm ở L:\SqlLog\BanHang_log.ldf, không nằm ở việc page dữ liệu đã xuống E: hay chưa.

3. Ai ghi page xuống đĩa, và recovery lúc khởi động

Tiến trình Việc làm
Log flush Đẩy log cache xuống .ldf khi commit, khi checkpoint, hoặc khi buffer log đầy
Checkpoint Quét dirty page của database và ghi xuống tệp dữ liệu, tạo một mốc mà recovery không phải làm lại từ quá khứ xa
Lazy writer Khi buffer pool thiếu trang trống, ghi bớt dirty page và giải phóng bộ đệm
Eager writer Ghi các trang mới của thao tác minimal logging, chẳng hạn bulk insert đủ điều kiện, song song với chính thao tác đó

Checkpoint tự chạy theo mục tiêu thời gian recovery, theo lượng log phát sinh, và khi có thay đổi cấu trúc như thêm hoặc gỡ tệp. Người quản trị có thể gọi CHECKPOINT. Database tạo trên SQL Server 2016 trở đi dùng indirect checkpoint theo mặc định: engine dàn việc ghi dirty page để thời gian recovery gần với TARGET_RECOVERY_TIME.

Checkpoint ghi dirty page và ghi một mốc vào log. Việc phần log cũ có được tái sử dụng hay không thì phụ thuộc recovery model, nói ở mục 5.

Sau sự cố, recovery đi từ checkpoint gần nhất. Redo áp lại log record của thay đổi đã ghi log nhưng dirty page chưa kịp xuống đĩa. Undo hoàn tác giao dịch chưa commit. Thời gian recovery tỷ lệ với lượng log phải redo và undo, nên giữ log nhỏ bằng log backup đều và giữ mục tiêu recovery hợp lý trực tiếp rút ngắn lúc SQL Server mở lại database.

4. Tăng trưởng log và VLF

Tệp dữ liệu được nới nhanh nhờ instant file initialization. Tệp log thì luôn được điền số 0 khi nới, nên autogrowth của log làm giao dịch đang chờ ghi log phải dừng cho đến khi phần mới sẵn sàng. Nới log 1 GB ghi số 0 trên toàn bộ phần mới, và trên đĩa chậm có thể giữ commit trong nhiều giây.

Autogrowth nhỏ lặp lại nhiều lần còn cắt log thành rất nhiều VLF. Quá nhiều VLF làm chậm recovery lúc khởi động, chậm log backup, và chậm các thao tác quét log. Với thuật toán dùng từ SQL Server 2014 đến 2019, mỗi lần tạo hoặc nới log sinh số VLF theo kích thước lần nới đó:

Kích thước lần tạo hoặc nới Số VLF thêm vào
Đến 64 MB 4
Trên 64 MB đến 1 GB 8
Trên 1 GB 16

Từ SQL Server 2022, nếu bước nới nhỏ hơn 1/8 dung lượng log hiện tại thì lần nới đó chỉ thêm 1 VLF.

Ví dụ xấu trên SQL Server 2019: tạo log 64 MB, FILEGROWTH = 64MB, rồi để autogrowth đưa log lên 64 GB. Có 1.024 lần cấp 64 MB, mỗi lần 4 VLF, tổng khoảng 4.096 VLF. Khởi động sau sự cố phải mở hàng nghìn VLF trước khi redo.

Nếu tạo log 8 GB ngay từ đầu, như cột mục tiêu của BanHang, lần tạo lớn hơn 1 GB nên ra khoảng 16 VLF, mỗi VLF khoảng 512 MB. FILEGROWTH = 1GB đúng bằng 1 GB nên mỗi lần nới, đến SQL Server 2019, thêm 8 VLF. Log lên 16 GB thì còn khoảng 80 VLF. Script tạo database của series để log 4 GB cho máy thử: vẫn lớn hơn 1 GB, nên cũng khoảng 16 VLF, mỗi VLF khoảng 256 MB.

Nới log từng bước 64 MB sinh khoảng 4.096 VLF, cấp sẵn 8 GB rồi nới 1 GB chỉ khoảng 80

Tạo 64 MB, nới 64 MB, lên 64 GB4.096 VLFTạo 8 GB, nới 1 GB, lên 16 GB80 VLFTạo 4 GB một lần, máy thử16 VLF
Ước lượng theo thuật toán VLF của SQL Server 2014 đến 2019, đúng các ví dụ trong hai đoạn trên.
Bảng số liệu
Giá trị
Tạo 64 MB, nới 64 MB, lên 64 GB4.096 VLF
Tạo 8 GB, nới 1 GB, lên 16 GB80 VLF
Tạo 4 GB một lần, máy thử16 VLF

Đếm VLF từ SQL Server 2017 (và SQL Server 2016 SP2). Phép đo ở mục 6 dùng đúng truy vấn này: log 64 MB ra 4 VLF, sau một lần autogrowth 64 MB ra 8 VLF, khớp bảng trên.

SELECT
    COUNT(*) AS vlf_count,
    SUM(vlf_size_mb) AS log_mb,
    SUM(CASE WHEN vlf_active = 1 THEN 1 ELSE 0 END) AS active_vlf_count
FROM sys.dm_db_log_info(DB_ID(N'BanHang'));

Nếu vlf_count lên vài nghìn, tạo lại log ở kích thước đủ dùng trong cửa sổ bảo trì thay vì tiếp tục nới từng bước nhỏ.

5. Recovery model

Recovery model quyết định log được giữ đến khi nào và bản sao lưu nào có nghĩa.

SIMPLE FULL BULK_LOGGED
Log backup Không dùng được Bắt buộc nếu muốn giữ log và muốn phục hồi theo thời điểm Bắt buộc
Cắt log Tự cắt khi checkpoint Khi log backup thành công Khi log backup thành công
Mất dữ liệu sau sự cố Mọi thay đổi sau full hoặc differential gần nhất Về được một thời điểm trong chuỗi log nếu log còn Có khoảng không phục hồi theo thời điểm nếu log backup chứa thao tác bulk-logged
Log shipping Không dùng Dùng được Dùng được, với hạn chế quanh bulk log
Always On, database mirroring Không dùng Bắt buộc FULL Không dùng. Cần chuyển về FULL
Phiên bản mặc định Express Enterprise, Standard, và các bản đầy đủ khác Không phải mặc định

BULK_LOGGED là biến thể của FULL cho lúc nạp dữ liệu lớn. Thao tác bulk đủ điều kiện được ghi log tối thiểu, nên nhanh hơn và log nhỏ hơn, nhưng log backup phải kéo theo các extent đã đổi (đọc từ BCM) để vẫn restore được. Trong khoảng có bulk-logged change, không restore về một giây nằm giữa khoảng đó.

Đổi từ SIMPLE sang FULL hoặc BULK_LOGGED chưa mở chuỗi log. Hãy lấy một full backup ngay sau khi đổi. Bản full đó là mốc đầu, và log backup lấy sau mốc này mới tạo được chuỗi khôi phục theo thời điểm.

SELECT name, recovery_model_desc
FROM sys.databases
WHERE name = N'BanHang';

ALTER DATABASE BanHang SET RECOVERY FULL;

Với database giao dịch, model làm việc là FULL, kèm lịch log backup. SIMPLE phù hợp database có thể nạp lại, hoặc nơi chấp nhận mất phần phát sinh từ bản full hay differential gần nhất. Recovery model còn quyết định mất bao nhiêu đơn khi sự cố; bài sao lưu và khôi phục so ba cấu hình cho cùng sự cố lúc 14:00.

Log trong FULL tăng cho đến khi có log backup. Full backup không cắt log, differential backup cũng không. Kể cả khi log backup chạy đều, log vẫn có thể bị giữ. Nếu phiên giữ BEGIN TRAN từ 09:00 và không commit, các log backup sau 09:00 vẫn chạy nhưng, khi ADR tắt, không giải phóng được VLF cũ hơn giao dịch đó. Log đầy dần dù lịch backup đều. DBCC OPENTRAN('BanHang') cho thấy giao dịch cũ nhất và thời điểm nó bắt đầu. ADR, mặc định tắt trên bản tự cài, đổi cách cắt log này; Kỹ thuật thường dùng: an toàn và sẵn sàng nói riêng phần đó.

6. Áp dụng trong .NET

Giao dịch mở lâu thường không đến từ DBA mà từ code ứng dụng: mở SqlTransaction hoặc TransactionScope, ghi đơn, rồi gọi cổng thanh toán đối tác qua HTTP trong lúc giao dịch còn mở. Cổng chậm 30 giây thì giao dịch, cùng mọi phần log từ lúc nó bắt đầu, bị giữ 30 giây. Cách đúng là ghi đơn ở trạng thái chờ thanh toán, COMMIT, rồi mới gọi cổng, và cập nhật kết quả bằng một giao dịch ngắn thứ hai hoặc qua webhook.

Phía vận hành, một BackgroundService trong API đọc lý do log chưa được tái sử dụng, phần trăm log đã dùng, số VLF và tuổi giao dịch ghi cũ nhất:

SELECT d.log_reuse_wait_desc, u.used_log_space_in_percent, u.total_log_size_in_bytes / 1048576,
       (SELECT COUNT(*) FROM sys.dm_db_log_info(DB_ID())),
       (SELECT DATEDIFF(SECOND, MIN(t.database_transaction_begin_time), SYSDATETIME())
        FROM sys.dm_tran_database_transactions AS t
        WHERE t.database_id = DB_ID() AND t.database_transaction_log_bytes_used > 0)
FROM sys.databases AS d CROSS JOIN sys.dm_db_log_space_usage AS u
WHERE d.database_id = DB_ID();
protected override async Task ExecuteAsync(CancellationToken stop)
{
    var daCanhBao = false;
    using var timer = new PeriodicTimer(chuKy);
    while (await timer.WaitForNextTickAsync(stop))
    {
        await using var cn = new SqlConnection(connectionString);
        await cn.OpenAsync(stop);
        await using var r = await new SqlCommand(Sql, cn).ExecuteReaderAsync(stop);
        await r.ReadAsync(stop);
        var s = new TrangThaiLog(r.GetString(0), r.GetFloat(1), r.GetInt64(2), r.GetInt32(3),
                                 r.IsDBNull(4) ? null : r.GetInt32(4));
        MoiNhat = s.ToString();

        if (s.LyDo == "ACTIVE_TRANSACTION" && s.TuoiGiaoDichCuGiay > nguong.TotalSeconds && !daCanhBao)
        {
            log.LogWarning("Log bị giữ bởi giao dịch mở {Giay} s, log đã dùng {PhanTram:F0}%",
                           s.TuoiGiaoDichCuGiay, s.PhanTramDung);
            daCanhBao = true;
        }
        if (s.TuoiGiaoDichCuGiay is null) daCanhBao = false;
    }
}

Tài khoản chạy truy vấn cần quyền VIEW SERVER STATE để đọc sys.dm_tran_database_transactions. Kịch bản thử trên LocalDB SQL Server 2019 tái hiện giao dịch mở từ 09:00 ở thu nhỏ: database FULL, log 64 MB nới 64 MB, phiên A sửa đơn 10042 rồi để giao dịch mở, phiên B ghi ba đợt 200.000 đơn, mỗi đợt có BACKUP LOG. Worker đọc mỗi giây:

Bước log_reuse_wait_desc Log Đã dùng VLF
Sau full backup NOTHING 63 MB 1% 4
Phiên A mở giao dịch, chưa COMMIT NOTHING 63 MB 1% 4
Đợt 1: ghi đơn, BACKUP LOG ACTIVE_TRANSACTION 63 MB 42% 4
Đợt 2 ACTIVE_TRANSACTION 127 MB 43% 8
Đợt 3 ACTIVE_TRANSACTION 127 MB 64% 8
Phiên A COMMIT, rồi BACKUP LOG NOTHING 127 MB 2% 8

Log backup chạy sau mỗi đợt mà log vẫn nới từ 64 MB lên 128 MB (cột Log in phần nguyên của số MB), vì giao dịch của phiên A giữ mọi VLF từ lúc nó bắt đầu. Một lần COMMIT cộng một log backup đưa phần đã dùng về 2%. log_reuse_wait_desc phản ánh lần checkpoint gần nhất, nên ngay sau BEGIN TRAN nó vẫn là NOTHING; kịch bản gọi CHECKPOINT sau mỗi log backup để đọc ổn định. Đo bằng .NET 10.0.12, Microsoft.Data.SqlClient 7.1.1, Intel Core Ultra 5 125U, Windows 11. Tuổi giao dịch trong output đổi theo từng lần chạy.

GiamSatNhatKy.cs — bản đầy đủ, chạy bằng dotnet run GiamSatNhatKy.csC# · 128 dòng
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.Data.SqlClient@7.1.1

// dotnet run GiamSatNhatKy.cs   (.NET 10, LocalDB SQL Server 2019)
// Kumeo_luutru ở FULL, log 64 MB nới 64 MB. Phiên A mở giao dịch rồi để đó, phiên B ghi đơn đều,
// log backup vẫn chạy. BackgroundService đọc log_reuse_wait_desc, % log đã dùng, số VLF, tuổi giao dịch cũ nhất.
using Microsoft.Data.SqlClient;

const string Master = @"Server=(localdb)\MSSQLLocalDB;Database=master;Integrated Security=true;TrustServerCertificate=true";
const string Db = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_luutru;Integrated Security=true;TrustServerCertificate=true";
var dir = Path.Combine(Path.GetTempPath(), "kumeo-luutru");
Directory.CreateDirectory(dir);

await Exec(Master, "IF DB_ID('Kumeo_luutru') IS NOT NULL BEGIN ALTER DATABASE Kumeo_luutru SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE Kumeo_luutru; END");
await Exec(Master, $"""
    CREATE DATABASE Kumeo_luutru
    ON PRIMARY (NAME = N'BanHang_data', FILENAME = N'{dir}\BanHang.mdf', SIZE = 256MB)
    LOG ON (NAME = N'BanHang_log', FILENAME = N'{dir}\BanHang_log.ldf', SIZE = 64MB, FILEGROWTH = 64MB);
    ALTER DATABASE Kumeo_luutru SET RECOVERY FULL;
    """);
await Exec(Db, """
    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));
    INSERT dbo.DonHang VALUES (10042, '2026-10-02T11:58:00', 1, 1, 1500000.00);
    """);
await Exec(Master, $@"BACKUP DATABASE Kumeo_luutru TO DISK = N'{dir}\full.bak' WITH INIT;"); // mở chuỗi log

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o => o.SingleLine = true).SetMinimumLevel(LogLevel.Warning);
builder.Services.AddHostedService(sp => new GiamSatNhatKy(Db, chuKy: TimeSpan.FromSeconds(1), nguong: TimeSpan.FromSeconds(5),
    sp.GetRequiredService<ILogger<GiamSatNhatKy>>()));
var app = builder.Build();
await app.StartAsync();
await InTrangThai("Sau full backup");

await using var phienA = new SqlConnection(Db);
await phienA.OpenAsync();
var tx = phienA.BeginTransaction();
await new SqlCommand("UPDATE dbo.DonHang SET TongTien = 1750000.00 WHERE DonHangId = 10042", phienA, tx).ExecuteNonQueryAsync();
await InTrangThai("Phiên A: BEGIN TRAN, sửa đơn 10042, chưa COMMIT");

for (var dot = 1; dot <= 3; dot++)
{
    await Exec(Db, $"""
        INSERT dbo.DonHang (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien)
        SELECT TOP (200000) {dot} * 1000000 + ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
               '2026-10-02T12:00:00', 1, 1, 990000.00
        FROM sys.all_columns AS a CROSS JOIN sys.all_columns AS b;
        """);
    await Exec(Master, $@"BACKUP LOG Kumeo_luutru TO DISK = N'{dir}\log_{dot}.trn' WITH INIT;");
    await Exec(Db, "CHECKPOINT;"); // log_reuse_wait_desc phản ánh lần checkpoint gần nhất
    await InTrangThai($"Phiên B: ghi 200.000 đơn rồi BACKUP LOG, đợt {dot}");
}

await tx.CommitAsync();
await Exec(Master, $@"BACKUP LOG Kumeo_luutru TO DISK = N'{dir}\log_4.trn' WITH INIT;");
await InTrangThai("Phiên A: COMMIT, rồi BACKUP LOG");

await app.StopAsync();
await phienA.CloseAsync();
SqlConnection.ClearAllPools();
await Exec(Master, "ALTER DATABASE Kumeo_luutru SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE Kumeo_luutru;");
await Exec(Master, "EXEC msdb.dbo.sp_delete_database_backuphistory @database_name = N'Kumeo_luutru';");
foreach (var f in Directory.GetFiles(dir, "*.bak").Concat(Directory.GetFiles(dir, "*.trn"))) File.Delete(f);

static async Task InTrangThai(string buoc)
{
    await Task.Delay(2500); // chờ worker đọc ít nhất một lần
    Console.WriteLine($"{buoc}\n  {GiamSatNhatKy.MoiNhat}");
}

static async Task Exec(string cs, string sql)
{
    await using var cn = new SqlConnection(cs);
    await cn.OpenAsync();
    await new SqlCommand(sql, cn) { CommandTimeout = 300 }.ExecuteNonQueryAsync();
}

record TrangThaiLog(string LyDo, double PhanTramDung, long LogMb, int SoVlf, int? TuoiGiaoDichCuGiay)
{
    public override string ToString() =>
        $"{LyDo}, log {LogMb} MB dùng {PhanTramDung:F0}%, {SoVlf} VLF, giao dịch ghi cũ nhất: " +
        (TuoiGiaoDichCuGiay is int s ? $"{s} s" : "không có");
}

// ---------- BackgroundService: vì sao log chưa được tái sử dụng ----------
sealed class GiamSatNhatKy(string connectionString, TimeSpan chuKy, TimeSpan nguong, ILogger<GiamSatNhatKy> log)
    : BackgroundService
{
    public static volatile string MoiNhat = "";

    const string Sql = """
        SELECT d.log_reuse_wait_desc, u.used_log_space_in_percent, u.total_log_size_in_bytes / 1048576,
               (SELECT COUNT(*) FROM sys.dm_db_log_info(DB_ID())),
               (SELECT DATEDIFF(SECOND, MIN(t.database_transaction_begin_time), SYSDATETIME())
                FROM sys.dm_tran_database_transactions AS t
                WHERE t.database_id = DB_ID() AND t.database_transaction_log_bytes_used > 0)
        FROM sys.databases AS d CROSS JOIN sys.dm_db_log_space_usage AS u
        WHERE d.database_id = DB_ID();
        """;

    protected override async Task ExecuteAsync(CancellationToken stop)
    {
        var daCanhBao = false;
        using var timer = new PeriodicTimer(chuKy);
        while (await timer.WaitForNextTickAsync(stop))
        {
            await using var cn = new SqlConnection(connectionString);
            await cn.OpenAsync(stop);
            await using var r = await new SqlCommand(Sql, cn).ExecuteReaderAsync(stop);
            await r.ReadAsync(stop);
            var s = new TrangThaiLog(r.GetString(0), r.GetFloat(1), r.GetInt64(2), r.GetInt32(3),
                                     r.IsDBNull(4) ? null : r.GetInt32(4));
            MoiNhat = s.ToString();

            if (s.LyDo == "ACTIVE_TRANSACTION" && s.TuoiGiaoDichCuGiay > nguong.TotalSeconds && !daCanhBao)
            {
                log.LogWarning("Log bị giữ bởi giao dịch mở {Giay} s, log đã dùng {PhanTram:F0}%",
                               s.TuoiGiaoDichCuGiay, s.PhanTramDung);
                daCanhBao = true;
            }
            if (s.TuoiGiaoDichCuGiay is null) daCanhBao = false;
        }
    }
}
Output của GiamSatNhatKy.cs13 dòng
Sau full backup
  NOTHING, log 63 MB dùng 1%, 4 VLF, giao dịch ghi cũ nhất: không có
Phiên A: BEGIN TRAN, sửa đơn 10042, chưa COMMIT
  NOTHING, log 63 MB dùng 1%, 4 VLF, giao dịch ghi cũ nhất: 3 s
warn: GiamSatNhatKy[0] Log bị giữ bởi giao dịch mở 6 s, log đã dùng 42%
Phiên B: ghi 200.000 đơn rồi BACKUP LOG, đợt 1
  ACTIVE_TRANSACTION, log 63 MB dùng 42%, 4 VLF, giao dịch ghi cũ nhất: 8 s
Phiên B: ghi 200.000 đơn rồi BACKUP LOG, đợt 2
  ACTIVE_TRANSACTION, log 127 MB dùng 43%, 8 VLF, giao dịch ghi cũ nhất: 13 s
Phiên B: ghi 200.000 đơn rồi BACKUP LOG, đợt 3
  ACTIVE_TRANSACTION, log 127 MB dùng 64%, 8 VLF, giao dịch ghi cũ nhất: 20 s
Phiên A: COMMIT, rồi BACKUP LOG
  NOTHING, log 127 MB dùng 2%, 8 VLF, giao dịch ghi cũ nhất: không có

Những chỗ hay hiểu sai

  • “COMMIT xong là page dữ liệu đã nằm trên đĩa.” Sai: COMMIT chỉ chờ log xuống .ldf; page được checkpoint hoặc lazy writer ghi sau.
  • “Full backup cắt log.” Sai: trong FULL, full và differential không cắt log; chỉ log backup cắt.
  • “Lịch log backup đều thì log không thể đầy.” Sai: khi ADR tắt, một giao dịch mở lâu giữ mọi VLF từ lúc nó bắt đầu, như phép đo ở mục 6.
  • “Đổi sang FULL là có ngay phục hồi theo thời điểm.” Sai: cần một full backup ngay sau khi đổi để mở chuỗi log.

Kết luận

Độ bền của một lần sửa nằm ở log, và log chỉ được tái sử dụng khi đã sao lưu và không còn giao dịch nào cần nó. Code .NET quyết định phần sau nhiều hơn người ta nghĩ.

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

  • Không giữ SqlTransaction hay TransactionScope qua lời gọi HTTP, hàng đợi, hay bước chờ người dùng; COMMIT trước rồi mới gọi cổng thanh toán.
  • Chạy một BackgroundService đọc log_reuse_wait_desc, sys.dm_db_log_space_usage và tuổi giao dịch ghi cũ nhất mỗi phút, cảnh báo khi ACTIVE_TRANSACTION kéo dài quá vài phút; cấp VIEW SERVER STATE cho tài khoản giám sát.
  • Đưa truy vấn đếm VLF vào kiểm tra sau triển khai, và tạo log đủ lớn ngay khi tạo database thay vì để autogrowth 64 MB.
  • Database FULL phải có log backup; health check tuổi log backup nằm ở bài sau.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

Sao lưu và khôi phục theo thời điểm

Full, differential và log backup, RPO và RTO, thứ tự restore, cách về 12:06 sau lệnh DELETE nhầm lúc 12:07 bằng ba file, và health check ASP.NET Core cảnh báo khi log backup trễ.

14 phút đọc

Trong SQL Server

Page, dòng và extent

Bên trong một tệp dữ liệu: page 8 KB, dòng DonHang 35 byte và 218 dòng mỗi page, row-overflow và LOB, extent và allocation unit, kèm cách trả file PDF lớn từ .NET mà không kéo cả dòng.

13 phút đọc

Trong SQL Server

Kiến trúc lưu trữ: tệp và filegroup

Một dòng đơn hàng nằm ở tệp nào, trên ổ nào: tệp dữ liệu, tệp log, filegroup, proportional fill, partition theo năm, và cách code .NET sửa đơn khi phần lịch sử bị khóa chỉ đọc.

14 phút đọc