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

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ễ.

Mục lục
  1. 1. Ba bản sao lưu cốt lõi
  2. 2. Recovery model quyết định mất bao nhiêu
  3. 3. Lịch backup, RPO và RTO
  4. 4. Lệnh backup
  5. 5. Thứ tự restore
  6. 6. Xóa nhầm đơn cũ lúc 12:07
  7. 7. Hỏng ổ dữ liệu, hoặc mất cả máy
  8. 8. Một lần COMMIT đi qua mọi tầng
  9. 9. Áp dụng trong .NET
  10. Những chỗ hay hiểu sai
  11. Kết luận
  12. Đọc tiếp
  13. Nguồn

Lúc 12:07, một lệnh DELETE xóa nhầm toàn bộ đơn cũ của BanHang và đã commit. Có đúng chuỗi backup thì database về được thời điểm ngay trước lệnh đó bằng ba file; thiếu một mắt xích thì mất hẳn dữ liệu hoặc mất hàng giờ restore. Đọc xong, bạn chọn được lịch backup theo RPO, restore đúng thứ tự, và cho API .NET báo động khi log backup trễ.

Đọc nhanh

  • Full và differential không cắt log; trong FULL, chỉ log backup mới cắt log và cho về một thời điểm.
  • Khoảng cách giữa hai log backup xấp xỉ lượng dữ liệu mất khi mất cả máy lẫn log.
  • Restore đi theo thứ tự full, differential mới nhất, rồi log theo LSN, giữ NORECOVERY đến bản cuối.
  • Tail-log lấy nốt phần log chưa sao lưu, nên ổ log còn đọc được thì không mất giao dịch đã commit.

1. Ba bản sao lưu cốt lõi

Bài trước, Write-ahead log, VLF và recovery model, cho thấy log giữ mọi thay đổi theo thứ tự. Sao lưu dựa trên đúng chuỗi đó.

Bản Chứa Vai trò trong chuỗi
Full Toàn bộ dữ liệu tại thời điểm backup, kèm log cần để đưa bản đó về trạng thái nhất quán Mốc gốc. Mọi differential sau đó bám vào full này, trừ bản full copy-only
Differential Các extent đổi kể từ full gốc, đọc từ DCM Rút ngắn số log backup phải áp lúc restore. Chỉ cần differential mới nhất của cùng full gốc
Transaction log Log record kể từ log backup trước, theo chuỗi LSN Cắt log trong FULL và BULK_LOGGED, và đưa database về một thời điểm

Chuỗi log độc lập với lịch full và differential. Một log backup lấy lúc 20:00 vẫn chứa log từ log backup lúc 16:00, kể cả khi ở giữa có một full backup lúc 18:00. Ngoài ba loại trên còn có bản dùng trong vận hành:

  • Copy-only full không trở thành gốc mới của differential. Dùng khi cần một bản mang đi mà không làm gãy lịch differential đang chạy.
  • Copy-only log không đẩy điểm cắt log, nên các log backup thường vẫn nối tiếp nhau.
  • File và filegroup backup sao lưu một phần database.
  • Tail-log backup lấy nốt log chưa sao lưu ngay trước khi restore, để không mất phần giao dịch sau log backup cuối.

2. Recovery model quyết định mất bao nhiêu

Cùng một sự cố lúc 14:00 ngày 2026-10-02, ba cách cấu hình cho ba kết cục khác nhau. Mốc sao lưu dùng chung: full lúc 22:00 hôm trước, differential lúc 12:00 cùng ngày.

Cách để BanHang Còn lại sau sự cố Phần đơn hàng mất
SIMPLE, chỉ có full và differential Khôi phục đến differential 12:00 Mọi đơn từ 12:00 đến 14:00
FULL, log backup 15 phút một lần, mất cả ổ L: lẫn máy Khôi phục đến log backup 13:45 Đơn từ 13:45 đến 14:00, khoảng 15 phút
FULL, ổ L: còn đọc được Tail-log rồi áp hết chuỗi Không mất giao dịch đã commit

Cùng sự cố 14:00: SIMPLE mất 120 phút đơn hàng, log backup 15 phút mất 15 phút, tail-log không mất phút nào

SIMPLE, chỉ full và differential120 phútFULL, log 15 phút, mất cả máy15 phútFULL, ổ L: còn, lấy tail-log0 phút
Full 22:00 hôm trước, differential 12:00, sự cố 14:00 ngày 2026-10-02. Số phút đọc từ bảng trên: 12:00 đến 14:00, 13:45 đến 14:00, và không mất.
Bảng số liệu
Giá trị
SIMPLE, chỉ full và differential120 phút
FULL, log 15 phút, mất cả máy15 phút
FULL, ổ L: còn, lấy tail-log0 phút

15 phút ở hàng thứ hai chính là RPO của lịch log backup. Muốn RPO 5 phút thì log backup cách nhau 5 phút, và ổ log vẫn phải sống sót độc lập với ổ dữ liệu nếu muốn kết cục ở hàng thứ ba.

3. Lịch backup, RPO và RTO

Với database ở recovery model FULL, một lịch khởi đầu thường là:

  • Full mỗi ngày hoặc mỗi tuần, tùy kích thước và cửa sổ backup.
  • Differential vài lần trong ngày nếu full lớn và lượng đổi trong ngày nhiều.
  • Log backup từ 5 đến 15 phút một lần.

RPO là lượng dữ liệu chấp nhận mất. RPO 15 phút nghĩa là log backup cách nhau không quá 15 phút, và tệp log nên nằm trên lưu trữ chịu lỗi vì phần log chưa backup sẽ mất nếu đĩa log hỏng. RTO là thời gian chấp nhận ngừng. RTO quyết định full và differential phải restore xong trong bao lâu, nên ảnh hưởng tới kích thước bản backup, tốc độ đĩa backup, và việc có cần filegroup read-only để khỏi restore phần lịch sử hay không.

Minh họa kích thước, không phải số đo: BanHang dữ liệu 500 GB, full backup nén đêm 2026-10-01 còn khoảng vài trăm GB tùy dữ liệu có nén sẵn hay không. Trong ngày 2026-10-02 chỉ 8 GB extent đổi so với full, nên differential đọc DCM và xấp xỉ 8 GB chứ không xấp xỉ 500 GB. Nếu mỗi ngày đổi thêm khoảng 8 GB mà không lấy full mới, differential ngày thứ sáu mang phần lớn extent đã đổi từ ngày một đến ngày sáu. Khi restore nó chẳng nhanh hơn restore một chuỗi log dài, lấy full mới.

Việc cần làm cùng lịch backup

  • Restore thử lên máy khác theo lịch. Bản backup chưa restore thành công thì chưa biết là dùng được.
  • RESTORE VERIFYONLY FROM DISK = N'...' WITH CHECKSUM kiểm tra checksum và cấu trúc bản backup, không thay cho một lần restore thật.
  • Giữ bản backup trên máy khác và trên một bản offline hoặc bất biến. Đĩa backup nằm cùng máy SQL Server mất cùng lúc với dữ liệu.
  • Quyền thư mục backup tách khỏi quyền người dùng ứng dụng. Tài khoản dịch vụ SQL Server cần ghi được nơi backup và đọc được lúc restore.
  • Theo dõi lần backup cuối trong msdb.dbo.backupset và cảnh báo khi full hoặc log backup trễ hơn ngưỡng RPO. Mục 9 làm việc này bằng health check.

4. Lệnh backup

Đặt tên tệp có ngày giờ. Bật CHECKSUM để ghi checksum và để lần restore sau kiểm tra được. COMPRESSION giảm dung lượng và thường giảm cả thời gian nếu nút cổ chai là đường ghi backup. Ba lệnh dưới lấy full lúc 22:00, differential lúc 12:00 và log lúc 12:15, đúng các tệp mà mục 6 dùng.

Full 22:00, differential 12:00, log 12:15 cho BanHangSQL · 27 dòng
BACKUP DATABASE BanHang
TO DISK = N'B:\Backup\BanHang_full_20261001_2200.bak'
WITH
    INIT,
    COMPRESSION,
    CHECKSUM,
    STATS = 10;
GO

BACKUP DATABASE BanHang
TO DISK = N'B:\Backup\BanHang_diff_20261002_1200.bak'
WITH
    DIFFERENTIAL,
    INIT,
    COMPRESSION,
    CHECKSUM,
    STATS = 10;
GO

BACKUP LOG BanHang
TO DISK = N'B:\Backup\BanHang_log_20261002_1215.trn'
WITH
    INIT,
    COMPRESSION,
    CHECKSUM,
    STATS = 10;
GO

INIT ghi đè các backup set trong đúng tệp đích, giữ media header nếu còn tương thích. FORMAT tạo media header mới và xóa nội dung backup cũ trên tệp đó; chỉ dùng khi cố ý bỏ toàn bộ bản đang nằm trong tệp. Một tệp .bak có thể chứa nhiều backup set nếu không dùng INIT, và restore lúc đó phải chỉ đúng FILE = n. Với vận hành, mỗi lần backup một tệp riêng, tên có thời điểm, dễ kiểm kê hơn nhiều set chồng trong một file.

5. Thứ tự restore

Khôi phục về cuối chuỗi, database đang ở FULL:

  1. Tail-log backup nếu tệp log còn đọc được. Tail-log để lại database ở trạng thái restoring (WITH NORECOVERY) khi mục tiêu là thay thế chính database đó.
  2. Full backup gần nhất, WITH NORECOVERY.
  3. Differential mới nhất dựa trên full đó, nếu có, WITH NORECOVERY.
  4. Mọi log backup sau mốc của differential (hoặc sau full nếu không dùng differential), theo đúng thứ tự, không được đứt LSN.
  5. Log backup cuối cùng WITH RECOVERY để mở database. Thêm STOPAT khi cần dừng ở một thời điểm.

NORECOVERY giữ database đóng để còn áp được bản tiếp theo. RECOVERY chạy undo và đưa database online. Gọi RECOVERY quá sớm thì không áp thêm log được nữa, và phải restore lại từ full. REPLACE chỉ nằm ở lệnh full đầu tiên, vì database BanHang vẫn còn trên máy sau tail-log. STOPAT lấy giờ đồng hồ của máy SQL Server, không phải giờ UTC. Trong SIMPLE, chuỗi dừng ở full cộng differential mới nhất: không có log backup và không có STOPAT.

Chuỗi restore tổng quát: tail-log, full, differential, log, và biến thể STOPATSQL · 32 dòng
BACKUP LOG BanHang
TO DISK = N'B:\Backup\BanHang_tail.trn'
WITH NORECOVERY, INIT, CHECKSUM;
GO

RESTORE DATABASE BanHang
FROM DISK = N'B:\Backup\BanHang_full_20261001_2200.bak'
WITH NORECOVERY, REPLACE, CHECKSUM, STATS = 10;
GO

RESTORE DATABASE BanHang
FROM DISK = N'B:\Backup\BanHang_diff_20261002_1200.bak'
WITH NORECOVERY, CHECKSUM, STATS = 10;
GO

RESTORE LOG BanHang
FROM DISK = N'B:\Backup\BanHang_log_20261002_1215.trn'
WITH NORECOVERY, CHECKSUM, STATS = 10;
GO

RESTORE LOG BanHang
FROM DISK = N'B:\Backup\BanHang_tail.trn'
WITH RECOVERY, CHECKSUM, STATS = 10;
GO

-- Về một thời điểm: log cuối dùng STOPAT và RECOVERY.
RESTORE LOG BanHang
FROM DISK = N'B:\Backup\BanHang_log_20261002_1215.trn'
WITH RECOVERY,
     STOPAT = '2026-10-02T12:07:00',
     CHECKSUM,
     STATS = 10;

6. Xóa nhầm đơn cũ lúc 12:07

Bối cảnh: recovery model FULL. Full lúc 22:00 ngày 2026-10-01. Log backup mỗi 15 phút; lịch bỏ lượt 12:00 vì giờ đó dành cho differential, nên bản cuối là 11:45 ngày 2026-10-02. Differential chạy từ 12:00 đến 12:05. Lúc 12:07 một phiên chạy:

DELETE FROM dbo.DonHang
WHERE NgayTao < CAST('20260101' AS datetime2(0));

Lệnh xóa toàn bộ đơn trước năm 2026 và đã COMMIT. Người trực phát hiện lúc 12:10, trước kỳ log backup 12:15. Phần xóa đang nằm trong log đang mở trên L:, chưa có trong file .trn nào. Việc đầu tiên là tail-log, để giữ phần log từ sau bản 11:45 đến 12:10, gồm cả lệnh xóa. NORECOVERY đưa BanHang sang trạng thái restoring.

BACKUP LOG BanHang
TO DISK = N'B:\Backup\BanHang_tail_20261002_1210.trn'
WITH NORECOVERY, INIT, COMPRESSION, CHECKSUM;

Chuỗi áp vào là full, differential 12:05, rồi đúng một file tail-log. Bản tail-log bắt đầu từ mốc sau log 11:45, nên nó phủ cả LSN cuối của differential (khoảng 12:05) lẫn lệnh xóa lúc 12:07. STOPAT dừng trước lệnh xóa. Không áp lại hàng chục file log từ 22:15 đến 11:45: differential đã mang dữ liệu tới 12:05.

Full 22:00 01/10 Log mỗi 15 phút 22:15 11:45 02/10 Differential 12:00–12:05 STOPAT 12:06 DELETE 12:07 Tail-log 12:10 chưa chạy 12:15 Full 22:00 NORECOVERY Differential NORECOVERY Tail-log 12:10 STOPAT 12:06, RECOVERY bỏ qua log 22:15–11:45 Mở ở 12:06: đơn trước 2026 còn, đơn 10042 là 1.750.000
Ba file đủ để về 12:06. Differential thay cho chuỗi log từ 22:15 đến 11:45.
RESTORE DATABASE BanHang
FROM DISK = N'B:\Backup\BanHang_full_20261001_2200.bak'
WITH NORECOVERY, REPLACE, CHECKSUM, STATS = 10;
GO

RESTORE DATABASE BanHang
FROM DISK = N'B:\Backup\BanHang_diff_20261002_1200.bak'
WITH NORECOVERY, CHECKSUM, STATS = 10;
GO

RESTORE LOG BanHang
FROM DISK = N'B:\Backup\BanHang_tail_20261002_1210.trn'
WITH RECOVERY,
     STOPAT = '2026-10-02T12:06:00',
     CHECKSUM,
     STATS = 10;
GO

Sau lệnh cuối, đơn 10042 với TongTien = 1750000.00 (đã commit lúc 12:04) còn. Các đơn trước năm 2026 cũng còn. Giao dịch commit sau 12:06:00, gồm lệnh DELETE lúc 12:07, không còn trong database đã mở.

Đường dài, nếu không có differential: full 22:00, rồi từng file log từ 22:15 ngày 01/10 đến tail-log, vẫn STOPAT ở 12:06 trên file cuối. Kết quả dữ liệu giống đường ngắn, nhưng số file phải áp nhiều hơn và RTO dài hơn. Đó là lý do lấy differential giữa ngày.

Đếm file phải áp cho sự cố 12:07

Log backup chạy mỗi 15 phút, từ 22:15 ngày 01/10 đến 11:45 ngày 02/10. Khoảng đó dài 13 giờ 30 phút, tức 810 phút. 810 / 15 = 54 bước, nên có 55 file .trn.

  • Đường ngắn: full 22:00, differential 12:00, tail-log 12:10. 3 file, 3 lệnh RESTORE.
  • Đường dài: full 22:00, 55 file log, tail-log 12:10. 57 file, 57 lệnh RESTORE, và thiếu một file .trn bất kỳ là đứt LSN.

Hai đường cho cùng dữ liệu lúc 12:06. Đường ngắn chỉ redo phần log từ cuối differential, khoảng 12:05, tới 12:06. Đường dài redo toàn bộ log từ sau full 22:00 tới 12:06, khoảng 14 giờ giao dịch.

Ba lỗi hay gặp với đúng bộ file này:

Việc làm Kết quả
RESTORE DATABASE full với RECOVERY rồi mới nhớ ra còn differential Database đã online. Không áp thêm differential hay log được. Phải restore lại từ full với NORECOVERY
Full + differential, rồi nhảy tới một log backup lúc 12:30, bỏ tail-log vốn bắt đầu từ 11:45 Đứt LSN. Log 12:30 không nối vào mốc cuối của differential
STOPAT = '2026-10-02T12:08:00' Mốc này sau lệnh xóa. Các đơn trước năm 2026 mất, vì DELETE đã commit lúc 12:07 nằm trong phần được redo

7. Hỏng ổ dữ liệu, hoặc mất cả máy

14:00 cùng ngày, volume E: và G: hỏng. L: vẫn đọc được. Log backup cuối là 13:45, và phần đơn từ 13:45 đến 14:00 chỉ có trên log đang mở. Lấy tail-log trước:

BACKUP LOG BanHang
TO DISK = N'B:\Backup\BanHang_tail_20261002_1400.trn'
WITH NORECOVERY, INIT, COMPRESSION, CHECKSUM;

Rồi full 22:00, differential mới nhất, mọi log backup sau differential theo đúng giờ, và tail-log cuối cùng với RECOVERY, không STOPAT. Giao dịch đã commit đến sát 14:00 được redo, phần chưa commit bị undo. Đây là hàng thứ ba trong bảng ở mục 2.

Nếu mất cả máy, bản backup chỉ còn trên B:, là máy khác. Log backup cuối 13:45, không lấy được tail-log. Restore full, differential, và log đến file 13:45, file cuối WITH RECOVERY. Đơn insert và commit từ 13:45 đến 14:00 không còn ở đâu để lấy lại: khoảng mất đúng bằng chu kỳ log backup.

8. Một lần COMMIT đi qua mọi tầng

Lấy đúng lần COMMIT lúc 12:04 của đơn 10042, TongTien từ 1.500.000 lên 1.750.000, qua cả bốn bài của phần lưu trữ:

  1. Dòng 35 byte nằm trên một data page 8 KB trong clustered index (NgayTao, DonHangId), partition năm 2026, allocation unit IN_ROW_DATA.
  2. Partition năm 2026 nằm trên FG_DATA; proportional fill đã đặt page này trong tệp trên E: hoặc G:.
  3. Log record được flush xuống L:\SqlLog\BanHang_log.ldf trước khi COMMIT trả về. Lúc này file .ndf có thể chưa đổi.
  4. DCM bật bit của extent chứa page đó. Differential 12:00 đến 12:05 kết thúc sau commit 12:04, nên lần sửa này nằm trong bản differential đó.
  5. Checkpoint sau đó ghi dirty page xuống .ndf.
  6. Log backup 12:15, hoặc tail-log nếu sự cố đến trước, copy phần log này ra B: và, nếu không còn giao dịch cũ hơn giữ VLF, cho phép tái sử dụng đoạn log đó.
  7. Full backup đêm 2026-10-01 là mốc gốc, không có nó thì differential và mọi file .trn không mở được một lần restore.

Thiếu bước 6, tệp log trên L: phình đến khi đầy và đơn hàng mới không commit được. Thiếu bản full trên B:, sự cố mất E: và G: không có mốc để áp log.

9. Áp dụng trong .NET

Lịch log backup chạy trong SQL Server Agent hay công cụ sao lưu, ngoài tầm nhìn của đội ứng dụng. Khi job hỏng, RPO 15 phút âm thầm thành vài giờ. API của BanHang vì vậy có một health check ASP.NET Core đọc msdb.dbo.backupset và đặt trạng thái theo tuổi của log backup cuối:

SELECT DATEDIFF(SECOND, MAX(CASE WHEN type = 'D' THEN backup_finish_date END), SYSDATETIME()),
       DATEDIFF(SECOND, MAX(CASE WHEN type = 'L' THEN backup_finish_date END), SYSDATETIME()),
       (SELECT recovery_model_desc FROM sys.databases WHERE name = @db)
FROM msdb.dbo.backupset
WHERE database_name = @db -- bỏ lịch sử của database cùng tên đã xóa trước đó:
  AND backup_start_date >= (SELECT create_date FROM sys.databases WHERE name = @db);

Tuổi tính bằng SYSDATETIME() của chính SQL Server, vì backup_finish_date là giờ địa phương của máy SQL Server, giống STOPAT. CheckHealthAsync chạy câu trên, đưa hai tuổi vào data, rồi quyết định trạng thái:

    if (full is null) return HealthCheckResult.Unhealthy("Chưa có full backup", data: data);
    if (log is null) return HealthCheckResult.Unhealthy("Chưa có log backup nào", data: data);
    if (log > rpo.TotalSeconds * 2) return HealthCheckResult.Unhealthy($"Log backup trễ quá 2 lần RPO {rpo.TotalMinutes} phút", data: data);
    if (log > rpo.TotalSeconds) return HealthCheckResult.Degraded($"Log backup trễ hơn RPO {rpo.TotalMinutes} phút", data: data);
    return HealthCheckResult.Healthy("Log backup trong RPO", data);

Đăng ký bằng AddHealthChecks().AddCheck("sao-luu", new SaoLuuHealthCheck(..., rpo: TimeSpan.FromMinutes(15))) và MapHealthChecks("/health"). Kịch bản thử trên LocalDB SQL Server 2019: database FULL mới có full backup thì /health trả Unhealthy vì chưa có log backup; sau một BACKUP LOG thì trả Healthy. Chạy bằng .NET 10.0.12, Microsoft.Data.SqlClient 7.1.1, Intel Core Ultra 5 125U, Windows 11:

Mới có full backup:
  {"status":"Unhealthy","checks":{"sao-luu":{"status":"Unhealthy","description":"Chưa có log backup nào","data":{"recovery_model":"FULL","full_cach_day_phut":0,"log_cach_day_phut":-1}}}}
Sau BACKUP LOG:
  {"status":"Healthy","checks":{"sao-luu":{"status":"Healthy","description":"Log backup trong RPO","data":{"recovery_model":"FULL","full_cach_day_phut":0,"log_cach_day_phut":0}}}}

Tài khoản của API cần quyền SELECT trên msdb.dbo.backupset, không cần quyền backup. Cho check này chạy ở endpoint giám sát, không ở readiness probe của từng pod: backup trễ không phải lý do rút pod khỏi load balancer. Bản đầy đủ có #:property PublishAot=false vì file-based app của .NET 10 bật PublishAot mặc định, làm tắt JsonSerializer dựa trên reflection mà hàm ghi JSON dùng.

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

// dotnet run SaoLuuHealth.cs   (.NET 10, LocalDB SQL Server 2019)
// Health check đọc msdb.dbo.backupset: log backup cuối cách đây bao lâu so với RPO.
// Kịch bản: database FULL mới có full backup -> /health báo Unhealthy; lấy log backup -> Healthy.
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Diagnostics.HealthChecks;

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

await Exec("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("CREATE DATABASE Kumeo_luutru; ALTER DATABASE Kumeo_luutru SET RECOVERY FULL;");
await Exec($@"BACKUP DATABASE Kumeo_luutru TO DISK = N'{dir}\full.bak' WITH INIT, CHECKSUM;");

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddHealthChecks()
    .AddCheck("sao-luu", new SaoLuuHealthCheck(Master, "Kumeo_luutru", rpo: TimeSpan.FromMinutes(15)),
              tags: ["db"]);
var app = builder.Build();
app.MapHealthChecks("/health", new() { ResponseWriter = GhiJson });
app.Urls.Add("http://127.0.0.1:5464");
await app.StartAsync();

using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5464") };
Console.WriteLine("Mới có full backup:");
Console.WriteLine("  " + await (await http.GetAsync("/health")).Content.ReadAsStringAsync());
await Exec($@"BACKUP LOG Kumeo_luutru TO DISK = N'{dir}\log_1.trn' WITH INIT, CHECKSUM;");
Console.WriteLine("Sau BACKUP LOG:");
Console.WriteLine("  " + await (await http.GetAsync("/health")).Content.ReadAsStringAsync());

await app.StopAsync();
SqlConnection.ClearAllPools();
await Exec("ALTER DATABASE Kumeo_luutru SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE Kumeo_luutru;");
await Exec("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 Task GhiJson(HttpContext ctx, HealthReport report)
{
    ctx.Response.ContentType = "application/json";
    var json = JsonSerializer.Serialize(new
    {
        status = report.Status.ToString(),
        checks = report.Entries.ToDictionary(e => e.Key, e => new
        {
            status = e.Value.Status.ToString(),
            description = e.Value.Description,
            data = e.Value.Data
        })
    }, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
    return ctx.Response.WriteAsync(json);
}

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

// ---------- Health check: tuổi của full và log backup cuối, tính bằng đồng hồ của SQL Server ----------
sealed class SaoLuuHealthCheck(string connectionString, string database, TimeSpan rpo) : IHealthCheck
{
    const string Sql = """
        SELECT DATEDIFF(SECOND, MAX(CASE WHEN type = 'D' THEN backup_finish_date END), SYSDATETIME()),
               DATEDIFF(SECOND, MAX(CASE WHEN type = 'L' THEN backup_finish_date END), SYSDATETIME()),
               (SELECT recovery_model_desc FROM sys.databases WHERE name = @db)
        FROM msdb.dbo.backupset
        WHERE database_name = @db -- bỏ lịch sử của database cùng tên đã xóa trước đó:
          AND backup_start_date >= (SELECT create_date FROM sys.databases WHERE name = @db);
        """;

    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
    {
        await using var cn = new SqlConnection(connectionString);
        await cn.OpenAsync(ct);
        var cmd = new SqlCommand(Sql, cn);
        cmd.Parameters.AddWithValue("@db", database);
        await using var r = await cmd.ExecuteReaderAsync(ct);
        await r.ReadAsync(ct);
        int? full = r.IsDBNull(0) ? null : r.GetInt32(0), log = r.IsDBNull(1) ? null : r.GetInt32(1);
        var data = new Dictionary<string, object>
        {
            ["recovery_model"] = r.IsDBNull(2) ? "?" : r.GetString(2),
            ["full_cach_day_phut"] = full is int f ? f / 60 : -1,
            ["log_cach_day_phut"] = log is int l ? l / 60 : -1,
        };
        if (full is null) return HealthCheckResult.Unhealthy("Chưa có full backup", data: data);
        if (log is null) return HealthCheckResult.Unhealthy("Chưa có log backup nào", data: data);
        if (log > rpo.TotalSeconds * 2) return HealthCheckResult.Unhealthy($"Log backup trễ quá 2 lần RPO {rpo.TotalMinutes} phút", data: data);
        if (log > rpo.TotalSeconds) return HealthCheckResult.Degraded($"Log backup trễ hơn RPO {rpo.TotalMinutes} phút", data: data);
        return HealthCheckResult.Healthy("Log backup trong RPO", data);
    }
}

Những chỗ hay hiểu sai

  • “Full backup cắt transaction log.” Sai: database FULL cần log backup để log ngừng tăng.
  • “Differential chứa thay đổi từ differential trước.” Sai: differential bám vào full gần nhất và chứa mọi extent đổi từ full đó; full copy-only không đổi mốc này.
  • “WITH FORMAT cũng chỉ ghi đè như INIT.” Sai: nó tạo media header mới và xóa mọi bản đang nằm trong tệp đó.
  • “Có backup là restore được.” Sai: bản chưa restore thử thành công thì chưa biết là dùng được, và thiếu một file .trn là đứt chuỗi.

Kết luận

Về được một thời điểm cần chuỗi full, differential, log không đứt, cộng tail-log nếu ổ log còn đọc được. Việc của đội .NET là biết chuỗi đó còn sống hay không trước khi cần đến nó.

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

  • Thêm một IHealthCheck đọc msdb.dbo.backupset, trả Degraded khi log backup trễ hơn RPO và Unhealthy khi trễ gấp đôi, kèm tuổi full backup trong data.
  • Đưa endpoint health check này vào hệ thống cảnh báo, tách khỏi readiness probe của pod.
  • Ghi RPO và RTO của BanHang vào tài liệu vận hành; RPO quyết định chu kỳ log backup, RTO quyết định có cần differential giữa ngày.
  • Trong lệnh chạy migration hay script sửa dữ liệu hàng loạt, ghi lại giờ máy SQL Server trước khi chạy (SELECT SYSDATETIME()), để nếu sai có mốc STOPAT chính xác.

Đọc tiếp

Nguồn

Đọc tiếp

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

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 đó.

14 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