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ễ.
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 |
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 CHECKSUMkiể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.backupsetvà 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 BanHang
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:
- 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 đó. - Full backup gần nhất,
WITH NORECOVERY. - Differential mới nhất dựa trên full đó, nếu có,
WITH NORECOVERY. - 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.
- Log backup cuối cùng
WITH RECOVERYđể mở database. ThêmSTOPATkhi 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ể STOPAT
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.
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.trnbấ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ữ:
- Dòng 35 byte nằm trên một data page 8 KB trong clustered index
(NgayTao, DonHangId), partition năm 2026, allocation unitIN_ROW_DATA. - Partition năm 2026 nằm trên
FG_DATA; proportional fill đã đặt page này trong tệp trênE:hoặcG:. - Log record được flush xuống
L:\SqlLog\BanHang_log.ldftrước khiCOMMITtrả về. Lúc này file.ndfcó thể chưa đổi. - 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 đó.
- Checkpoint sau đó ghi dirty page xuống
.ndf. - 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 đó. - Full backup đêm 2026-10-01 là mốc gốc, không có nó thì differential và mọi file
.trnkhô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.cs
#: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
FULLcầ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 FORMATcũ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
.trnlà đứ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đọcmsdb.dbo.backupset, trảDegradedkhi log backup trễ hơn RPO vàUnhealthykhi trễ gấp đôi, kèm tuổi full backup trongdata. - Đư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
BanHangvà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ốcSTOPATchính xác.
Đọc tiếp
- Write-ahead log, VLF và recovery model: bài trước trong series, log được giữ và được cắt thế nào.
- Kỹ thuật thường dùng: lưu trữ và truy vấn: bài tiếp theo trong series SQL Server.
- Kỹ thuật thường dùng: an toàn và sẵn sàng: ADR, TDE và Availability Group gắn với chuỗi sao lưu này.
- Azure SQL — Khác gì so với SQL Server tự cài: khi nền tảng chạy chuỗi backup và restore theo thời điểm thay bạn.
- PostgreSQL — Sao lưu và khôi phục theo thời điểm: WAL archive và base backup cho cùng sự cố 12:07.