Backup خودکار SQL Server؛ از ساخت Job تا تضمین بازیابی سرویس

وقتی دیتابیس Jira از دسترس خارج می‌شود، مشکل فقط توقف ثبت تیکت نیست؛ تاریخچهٔ تغییرات، گردش کار تیم‌ها و مستندات مرتبط با پروژه نیز در معرض خطر قرار می‌گیرند. برای Confluence هم سالم بودن سرویس وب کافی نیست: اگر دیتابیس و فایل‌های پیوست با یکدیگر سازگار نباشند، بازگرداندن سرویس کامل نخواهد بود. بنابراین Backup را باید بخشی از طراحی بازیابی سرویس دید، با مسئول مشخص، زمان‌بندی، محل نگهداری و تست عملی.

Backup دستی برای کنترل یک تغییر یا گرفتن نسخهٔ موردی مناسب است؛ اما به حضور اپراتور وابسته است و اجرای فراموش‌شده معمولاً تا روز حادثه دیده نمی‌شود. Automated Backup این وابستگی را کم می‌کند، به شرط آنکه شکست Job و عقب‌افتادن آخرین Backup هر دیتابیس مانیتور شود. فایل با پسوند .bak یا Job سبزرنگ، به‌تنهایی مدرک قابل‌بازیابی‌بودن اطلاعات نیست.

RPO مقدار داده‌ای است که کسب‌وکار حاضر است از دست بدهد؛ برای مثال RPO پانزده دقیقه یعنی در بدترین وضعیت، پذیرش ازدست‌رفتن پانزده دقیقه تغییرات. RTO مدت زمان مجاز برای بازگرداندن سرویس است؛ از آماده‌کردن سرور و دریافت Backup تا Restore، کنترل صحت و راه‌اندازی برنامه. این دو عدد را با مالک سرویس توافق کنید؛ زمان‌بندی فنی باید از آن‌ها پیروی کند.

بدون Backup Strategy، یک حذف اشتباه، خرابی Storage، باج‌افزار یا شکست تغییر Schema می‌تواند به ازدست‌رفتن غیرقابل‌جبران داده منجر شود. Snapshot ماشین مجازی و Replica نیز جایگزین تاریخچهٔ قابل‌بازیابی نیستند؛ تغییر خراب یا حذف داده می‌تواند به Replica منتقل شود.

طراحی Backup Strategy برای Jira و Confluence

فرض کنید دیتابیس‌های DB-Jira و DB-Confluence روی یک SQL Server مستقل اجرا می‌شوند. هدف طراحی، RPO حدود پانزده دقیقه و RTO اولیهٔ شصت دقیقه است. RTO یک هدف قراردادی است و فقط با اندازه‌گیری Restore واقعی تأیید می‌شود؛ اندازهٔ دیتابیس، سرعت شبکه و تعداد Logها ممکن است این زمان را تغییر دهند.

نوعزمان اجراکاربرد
Full Backupهر روز 01:00نسخهٔ پایهٔ بازیابی و مبنای Differential
Differential Backup07:00، 13:00، 19:00تغییرات از آخرین Full معمولی؛ کاهش حجم کار هنگام Restore
Transaction Log Backupهر ۱۵ دقیقه، تمام شبانه‌روزپوشش تغییرات بین Backupهای داده و بازیابی به زمان مشخص
Retention و Cleanupپنجرهٔ ۳۰ روزه؛ Cleanup روزانه 05:00حذف نسخه‌های منقضی با حفظ زنجیرهٔ لازم برای کل پنجره

Differential افزایشی نسبت به Differential قبلی نیست؛ هر بار تغییرات از Full پایه را پوشش می‌دهد. برای بازیابی، Full مناسب، آخرین Differential سازگار و سپس Logهای لازم به ترتیب LSN استفاده می‌شوند. برای نمونه در حادثهٔ ساعت 14:10، Full ساعت 01:00، Differential ساعت 13:00 و Logهای بعد از آن را بررسی می‌کنیم؛ آخرین نقطهٔ قابل‌بازیابی به آخرین Log سالم و در صورت امکان Tail-log بستگی دارد، نه صرفاً ساعت حادثه. مستندات توالی Restore در Microsoft Official documentation

زمان‌بندی پانزده‌دقیقه‌ای به‌تنهایی RPO را تضمین نمی‌کند. مدت Job، شکست Backup، انتقال دیرهنگام به محل دوم و Log گم‌شده روی RPO واقعی اثر دارند. Full و Log در SQL Server می‌توانند هم‌زمان اجرا شوند؛ اسکریپت این مقاله Full و Differential را برای هر دیتابیس هماهنگ می‌کند، ولی Log را پشت Full طولانی متوقف نمی‌کند.

همهٔ زمان‌های Schedule بر اساس ساعت محلی سرور SQL هستند؛ نام فایل‌ها و Audit بر اساس UTC تولید می‌شوند. NTP و منطقهٔ زمانی سرور را در Runbook ثبت کنید. برای Jira و Confluence، نسخهٔ هماهنگ از Application Home، Shared Home، Attachmentها و تنظیمات نیز لازم است؛ روش دقیق هماهنگی را با مستندات نسخهٔ نصب‌شدهٔ برنامه تعیین کنید.

پیش‌نیازها و حدود اجرای راهکار

  • سناریو برای SQL Server 2019 یا 2022 روی Windows، نسخهٔ Standard، Enterprise یا Developer و Instance مستقل است. Developer فقط برای محیط غیرتولیدی است. SQL Server Express فاقد SQL Server Agent است؛ برای آن باید زمان‌بند دیگری مانند Windows Task Scheduler طراحی شود.
  • SQL Server Agent باید Running باشد و Startup Type مناسب داشته باشد. این کد برای Azure SQL Database، Availability Group یا مسیر UNC نوشته نشده است؛ در AG باید سیاست Replica و محدودیت نسخهٔ SQL Server جداگانه بررسی شود.
  • بکاپ با هویت سرویس Database Engine روی دیسک نوشته می‌شود. ساخت فولدر و Cleanup با هویت CmdExec Proxy اجرا می‌شوند. مجوز یکی، جایگزین مجوز دیگری نیست.
  • روی مسیر اختصاصی Backup، به سرویس Database Engine مجوز لازم برای خواندن و نوشتن و به حساب Proxy مجوز ساخت فولدر و حذف فایل بدهید؛ از Everyone و مجوزهای گسترده پرهیز کنید. پوشهٔ اسکریپت‌ها برای Proxy فقط Read/Execute باشد و تغییر آن در اختیار DBA یا مدیر سیستم بماند.
  • اسکریپت‌های PowerShell از Windows PowerShell 5.1 و کتابخانهٔ داخلی SqlClient استفاده می‌کنند و به نصب ماژول SqlServer نیاز ندارند. اتصال Integrated Security، رمزگذاری TLS و اعتبارسنجی Certificate فعال است. مقدار localhost را در محیط واقعی با نام Instance/FQDN مطابق Certificate معتبر جایگزین کنید؛ TrustServerCertificate را برای رفع خطا خاموش و روشن نکنید.
  • هشت نام Whitelist مثال هستند. دیتابیس‌های ناموجود را پیش از تست غیرفعال کنید؛ اسکریپت آن‌ها را بی‌صدا رد نمی‌کند. System Databaseها در این Job ممنوع‌اند؛ برای master، model و msdb برنامهٔ جداگانه لازم است. tempdb بکاپ‌پذیر نیست.

COMPRESSION حجم و I/O Backup را کم می‌کند، ولی هزینهٔ CPU دارد. پیش از فعال‌سازی روی ساعات پرترافیک، مدت اجرا و بار CPU را اندازه بگیرید. محدودیت‌ها و پشتیبانی Compression Official documentation

Recovery Model در SQL Server: SIMPLE، FULL و BULK_LOGGED

مدلLog Backupمحدودهٔ بازیابی
SIMPLEپشتیبانی نمی‌شودتا Full یا Differential موجود؛ بازیابی به زمان دلخواه ندارد
FULLضروری برای سیاست این مقالهبا زنجیرهٔ سالم Log، بازیابی به نقطهٔ زمانی مشخص
BULK_LOGGEDپشتیبانی می‌شوداگر Log شامل عملیات minimally logged باشد، توقف در نقطه‌ای داخل همان Log ممکن نیست

در SIMPLE هم تراکنش‌ها در Log ثبت می‌شوند؛ تفاوت این است که بخش غیرفعال Log، در صورت نبود مانع، پس از Checkpoint قابل استفادهٔ مجدد می‌شود و زنجیرهٔ Transaction Log Backup نگهداری نمی‌شود. بنابراین SQL Server دستور BACKUP LOG را در این مدل نمی‌پذیرد. FULL برای Jira و Confluence این سناریو انتخاب شده، چون بازیابی تغییرات بین Backupهای داده لازم است. مقایسهٔ Recovery Modelها Official documentation

بعد از تغییر SIMPLE به FULL یا BULK_LOGGED، یک Backup داده، یعنی Full یا Differential معتبر، برای شروع زنجیرهٔ Log لازم است. در این Runbook یک Full معمولی و جدید می‌گیریم تا پایهٔ Differential و مسیر بازیابی روشن باشد. این الزام به معنی «Full بعد از هر تغییر مدل» نیست: جابه‌جایی FULL و BULK_LOGGED زنجیره را مانند رفتن به SIMPLE قطع نمی‌کند. پیش از خروج از FULL یا BULK_LOGGED، Log Backup بگیرید؛ بازگشت از SIMPLE نیازمند شروع زنجیرهٔ جدید است. تغییر Recovery Model و شروع زنجیره Official documentation

SELECT name,recovery_model_desc,log_reuse_wait_desc
FROM sys.databases WHERE name IN (N'DB-Jira',N'DB-Confluence');
GO
ALTER DATABASE [DB-Jira] SET RECOVERY FULL;
ALTER DATABASE [DB-Confluence] SET RECOVERY FULL;
GO
-- After installing the procedure and preparing folders:
EXEC DBAOperations.dbo.usp_BackupWhitelist @BackupType='FULL';
EXEC DBAOperations.dbo.usp_BackupWhitelist @BackupType='LOG';
GO

این دستورات عمداً فقط دو دیتابیس سناریو را تغییر می‌دهند؛ مدل سایر دیتابیس‌های Whitelist را جداگانه تصمیم‌گیری کنید. BULK_LOGGED راه‌حل دائمی برای کوچک‌کردن Log نیست. برای عملیات Bulk باید محدودیت بازیابی همان بازه را از قبل پذیرفته باشید.

ساختار فولدر Backup و تفکیک دیتابیس‌ها

D:\SQLBackup
├── DB-Jira
│   ├── FULL
│   ├── DIFF
│   └── LOG
└── DB-Confluence
    ├── FULL
    ├── DIFF
    └── LOG

تفکیک مسیر هر دیتابیس، پیدا کردن فایل‌های Restore، کنترل حجم و ممیزی Cleanup را ساده می‌کند. Full و Differential با پسوند bak و Log با پسوند trn ذخیره می‌شوند. نام فایل شامل نام دیتابیس، نوع Backup، تاریخ و زمان UTC با میلی‌ثانیه و GUID است؛ تکرار دستی یا Retry فایل قبلی را بازنویسی نمی‌کند.

حرف D در این مثال فقط یک مسیر است؛ جدا بودن Drive Letter به معنی مستقل بودن Storage نیست. اگر دیتابیس و Backup روی همان دیسک، LUN یا Storage خرابی‌پذیر قرار دارند، همچنان یک نقطهٔ شکست دارید. برای چند Instance روی یک سرور، Root و دیتابیس مدیریتی مستقل تعریف کنید تا Audit و فایل‌ها مخلوط نشوند.

اسکریپت Backup چند دیتابیس با Whitelist و Audit

فایل زیر دیتابیس مدیریتی DBAOperations، جدول Whitelist، جدول ثبت نتیجه و Stored Procedure را ایجاد می‌کند. Whitelist دقیقاً نام‌های درخواست‌شده را دارد و به‌صورت خودکار تمام دیتابیس‌های سرور را انتخاب نمی‌کند. Compression و CHECKSUM همیشه فعال‌اند؛ TRY/CATCH خطای هر دیتابیس را ثبت می‌کند و پس از تلاش برای بقیه، در صورت هر شکست، Step را Failed می‌کند.

Stored Procedure مسیر را کنترل می‌کند، دیتابیس‌های سیستمی، Snapshot، دیتابیس Offline و AG را رد می‌کند و Log Backup در SIMPLE را خطا می‌داند. این راهکار فولدر را با یک Step PowerShell ایجاد می‌کند؛ نیازی به xp_cmdshell یا xp_create_subdir ندارد. حساب T-SQL Job در این نمونه یک حساب مورد اعتماد DBA با دسترسی لازم برای Backup و VERIFYONLY است؛ مجوزدهی محدودتر باید با Module Signing و آزمون دسترسی طراحی شود.

01-backup-procedure.sql
-- SQL Server 2019/2022 on Windows, Standard/Enterprise/Developer.
-- DBA installation. No xp_cmdshell or undocumented folder procedures.
USE master;
GO
IF DB_ID(N'DBAOperations') IS NULL CREATE DATABASE [DBAOperations];
GO
USE [DBAOperations];
GO
IF OBJECT_ID(N'dbo.BackupWhitelist', N'U') IS NULL
CREATE TABLE dbo.BackupWhitelist
(
    DatabaseName sysname NOT NULL PRIMARY KEY,
    Enabled bit NOT NULL CONSTRAINT DF_BackupWhitelist_Enabled DEFAULT (1)
);
IF OBJECT_ID(N'dbo.BackupAudit', N'U') IS NULL
BEGIN
    CREATE TABLE dbo.BackupAudit
    (
        AuditId bigint IDENTITY PRIMARY KEY,
        RunId uniqueidentifier NOT NULL,
        DatabaseName sysname NOT NULL,
        BackupType varchar(4) NOT NULL,
        StartedUtc datetime2(3) NOT NULL,
        FinishedUtc datetime2(3) NULL,
        BackupRoot nvarchar(200) NOT NULL,
        BackupFile nvarchar(260) NULL,
        Status varchar(12) NOT NULL,
        Verified bit NOT NULL CONSTRAINT DF_BackupAudit_Verified DEFAULT (0),
        ErrorNumber int NULL,
        ErrorMessage nvarchar(4000) NULL
    );
    CREATE INDEX IX_BackupAudit_Retention
        ON dbo.BackupAudit(DatabaseName, BackupType, Status, FinishedUtc);
END;
INSERT dbo.BackupWhitelist(DatabaseName)
SELECT v.DatabaseName FROM (VALUES
    (N'DB-Confluence'), (N'DB-Jira'), (N'DWConfiguration'), (N'DWDiagnostics'),
    (N'DWQueue'), (N'ORACLE_VIEW'), (N'RAYDANA_DB'), (N'STLA')
) v(DatabaseName)
WHERE NOT EXISTS
    (SELECT 1 FROM dbo.BackupWhitelist w WHERE w.DatabaseName = v.DatabaseName);
GO
CREATE OR ALTER PROCEDURE dbo.usp_BackupWhitelist
    @BackupType varchar(4),
    @BackupRoot nvarchar(200) = N'D:\SQLBackup',
    @Verify bit = 1,
    @EncryptionCertificate sysname = NULL
AS
BEGIN
    SET NOCOUNT ON;
    IF @@TRANCOUNT > 0 THROW 51000, 'Run backups outside a user transaction.', 1;
    SET @BackupType = UPPER(@BackupType);
    IF @BackupType IS NULL OR @BackupType NOT IN ('FULL','DIFF','LOG')
        THROW 51001, 'BackupType must be FULL, DIFF or LOG.', 1;
    IF @Verify IS NULL THROW 51002, 'Verify must be 0 or 1.', 1;
    IF @BackupRoot IS NULL OR LEN(@BackupRoot) > 100
       OR @BackupRoot NOT LIKE N'[A-Za-z]:\%'
       OR CHARINDEX(N'..', @BackupRoot) > 0 OR CHARINDEX(N'/', @BackupRoot) > 0
        THROW 51003, 'Use a local absolute backup root, up to 100 characters.', 1;
    WHILE RIGHT(@BackupRoot, 1) = N'\'
        SET @BackupRoot = LEFT(@BackupRoot, LEN(@BackupRoot)-1);
    IF LEN(@BackupRoot) < 4 THROW 51004, 'Do not use a drive root.', 1;
    IF NOT EXISTS (SELECT 1 FROM dbo.BackupWhitelist WHERE Enabled = 1)
        THROW 51005, 'Whitelist is empty; no backups were taken.', 1;
    IF @EncryptionCertificate IS NOT NULL AND NOT EXISTS
       (SELECT 1 FROM master.sys.certificates WHERE name = @EncryptionCertificate)
        THROW 51006, 'Encryption certificate is missing in master.', 1;

    DECLARE @RunId uniqueidentifier = NEWID(), @Database sysname,
            @AuditId bigint, @File nvarchar(260), @Sql nvarchar(max),
            @Stamp varchar(20), @Recovery nvarchar(60), @DatabaseId int,
            @State nvarchar(60), @SourceId int, @Failures int = 0,
            @LockResult int, @LockResource nvarchar(255), @Locked bit;
    DECLARE databases CURSOR LOCAL FAST_FORWARD FOR
        SELECT DatabaseName FROM dbo.BackupWhitelist WHERE Enabled = 1
        ORDER BY DatabaseName;
    OPEN databases;
    FETCH NEXT FROM databases INTO @Database;
    WHILE @@FETCH_STATUS = 0
    BEGIN
        SET @File = NULL;
        SET @Locked = 0;
        INSERT dbo.BackupAudit(RunId,DatabaseName,BackupType,StartedUtc,BackupRoot,Status)
        VALUES(@RunId,@Database,@BackupType,SYSUTCDATETIME(),@BackupRoot,'RUNNING');
        SET @AuditId = SCOPE_IDENTITY();
        BEGIN TRY
            IF @Database COLLATE Latin1_General_100_BIN2 LIKE N'%[^A-Za-z0-9_-]%'
               OR UPPER(@Database) IN
                  (N'CON',N'PRN',N'AUX',N'NUL',N'COM1',N'COM2',N'COM3',N'COM4',
                   N'COM5',N'COM6',N'COM7',N'COM8',N'COM9',N'LPT1',N'LPT2',N'LPT3',
                   N'LPT4',N'LPT5',N'LPT6',N'LPT7',N'LPT8',N'LPT9')
                THROW 51007, 'Whitelist name is not safe for this folder layout.', 1;
            SELECT @DatabaseId = NULL, @Recovery = NULL, @State = NULL, @SourceId = NULL;
            SELECT @DatabaseId=database_id, @Recovery=recovery_model_desc,
                   @State=state_desc, @SourceId=source_database_id
            FROM sys.databases WHERE name = @Database;
            IF @DatabaseId IS NULL THROW 51008, 'Whitelist database does not exist.', 1;
            IF @DatabaseId <= 4 THROW 51009, 'System databases are excluded.', 1;
            IF @State <> N'ONLINE' OR @SourceId IS NOT NULL
                THROW 51010, 'Database must be online and not a snapshot.', 1;
            IF @BackupType = 'LOG' AND @Recovery = N'SIMPLE'
                THROW 51011, 'SIMPLE recovery does not support log backups.', 1;
            IF EXISTS (SELECT 1 FROM sys.databases
                       WHERE database_id=@DatabaseId AND group_database_id IS NOT NULL)
                THROW 51012, 'Availability Group needs a replica-aware backup policy.', 1;
            -- FULL/DIFF serialize; LOG may run concurrently with a data backup.
            SET @LockResource = N'MeetAJ.Backup.' + @Database +
                CASE WHEN @BackupType='LOG' THEN N'.LOG' ELSE N'.DATA' END;
            EXEC @LockResult = sys.sp_getapplock
                @Resource=@LockResource, @LockMode='Exclusive',
                @LockOwner='Session', @LockTimeout=60000;
            IF @LockResult < 0 THROW 51013, 'Could not acquire the database backup lock.', 1;
            SET @Locked = 1;
            SET @Stamp = CONVERT(char(8),GETUTCDATE(),112) + '_' +
                         REPLACE(CONVERT(char(12),GETUTCDATE(),114),':','');
            IF LEN(@BackupRoot)+LEN(@Database)*2+LEN(@BackupType)+65 > 259
                THROW 51014, 'Backup path would exceed 259 characters.', 1;
            SET @File = @BackupRoot + N'\' + @Database + N'\' + @BackupType +
                        N'\' + @Database + N'_' + @BackupType + N'_' + @Stamp + N'_' +
                        CONVERT(nvarchar(36),NEWID()) +
                        CASE WHEN @BackupType='LOG' THEN N'.trn' ELSE N'.bak' END;
            UPDATE dbo.BackupAudit SET BackupFile=@File WHERE AuditId=@AuditId;
            SET @Sql = CASE WHEN @BackupType='LOG' THEN N'BACKUP LOG ' ELSE N'BACKUP DATABASE ' END
                + QUOTENAME(@Database) + N' TO DISK=@Path WITH '
                + CASE WHEN @BackupType='DIFF' THEN N'DIFFERENTIAL, ' ELSE N'' END
                + N'COMPRESSION, CHECKSUM, STOP_ON_ERROR, STATS=10'
                + CASE WHEN @EncryptionCertificate IS NULL THEN N'' ELSE
                    N', ENCRYPTION (ALGORITHM=AES_256, SERVER CERTIFICATE='
                    + QUOTENAME(@EncryptionCertificate) + N')' END + N';';
            EXEC sys.sp_executesql @Sql, N'@Path nvarchar(260)', @Path=@File;
            IF @Verify=1
            BEGIN
                SET @Sql = N'RESTORE VERIFYONLY FROM DISK=@Path WITH CHECKSUM, STOP_ON_ERROR;';
                EXEC sys.sp_executesql @Sql, N'@Path nvarchar(260)', @Path=@File;
            END;
            UPDATE dbo.BackupAudit
                SET Status='SUCCEEDED',FinishedUtc=SYSUTCDATETIME(),Verified=@Verify
                WHERE AuditId=@AuditId;
        END TRY
        BEGIN CATCH
            SET @Failures += 1;
            UPDATE dbo.BackupAudit SET Status='FAILED',FinishedUtc=SYSUTCDATETIME(),
                ErrorNumber=ERROR_NUMBER(),ErrorMessage=ERROR_MESSAGE()
                WHERE AuditId=@AuditId;
        END CATCH;
        IF @Locked=1
            EXEC sys.sp_releaseapplock @Resource=@LockResource, @LockOwner='Session';
        FETCH NEXT FROM databases INTO @Database;
    END;
    CLOSE databases;
    DEALLOCATE databases;
    IF @Failures > 0
        THROW 51015, 'One or more backups failed. Check DBAOperations.dbo.BackupAudit.', 1;
END;
GO

مدیریت فهرست دیتابیس‌ها پیش از اجرا

ابتدا فهرست را با sys.databases مقایسه کنید. اگر مثلاً STLA در این Instance نیست، آن را غیرفعال کنید؛ خطای نام اشتباه را با انتخاب همهٔ دیتابیس‌ها پنهان نکنید. غیرفعال‌کردن یک دیتابیس به معنای پایان خودکار سیاست Retention آن نیست؛ آرشیو و حذف باقی‌ماندهٔ آن نیاز به تصمیم مستقل دارد.

SELECT w.DatabaseName,w.Enabled,d.state_desc,d.recovery_model_desc
FROM DBAOperations.dbo.BackupWhitelist w
LEFT JOIN sys.databases d ON d.name=w.DatabaseName;
-- Example only: disable a database that is intentionally absent.
UPDATE DBAOperations.dbo.BackupWhitelist SET Enabled=0 WHERE DatabaseName=N'STLA';
-- Add an approved database explicitly:
-- INSERT DBAOperations.dbo.BackupWhitelist(DatabaseName) VALUES(N'NewShop');

ساخت خودکار فولدرها با PowerShell

فایل زیر را با نام C:\DBA\Prepare-SqlBackupFolders.ps1 ذخیره کنید. این فایل Whitelist را می‌خواند و برای هر عضو فعال، FULL، DIFF و LOG را ایجاد می‌کند. نام‌های نامناسب برای Windows و مسیرهای Reparse Point مانند Junction پذیرفته نمی‌شوند.

02-prepare-folders.ps1
# Windows PowerShell 5.1. Provision folders without enabling xp_cmdshell.
param([string]$ServerInstance = 'localhost', [string]$BackupRoot = 'D:\SQLBackup')
$ErrorActionPreference = 'Stop'
$rootPath = [IO.Path]::GetFullPath($BackupRoot).TrimEnd('\')
if ($rootPath -notmatch '^[A-Za-z]:\\.+' -or $rootPath.Length -gt 100) {
    throw 'Use a local absolute backup folder, not a drive root.'
}
function Assert-NoReparsePath([string]$Path) {
    $currentPath = [IO.Path]::GetFullPath($Path)
    while ($currentPath) {
        if (Test-Path -LiteralPath $currentPath) {
            $item = Get-Item -LiteralPath $currentPath -Force
            if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
                throw "Reparse points are not permitted: $currentPath"
            }
        }
        $parent = [IO.Directory]::GetParent($currentPath)
        if ($null -eq $parent) { break }
        $currentPath = $parent.FullName
    }
}
Assert-NoReparsePath $rootPath
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder.DataSource = $ServerInstance
$builder.InitialCatalog = 'DBAOperations'
$builder.IntegratedSecurity = $true
$builder.Encrypt = $true
$builder.TrustServerCertificate = $false
$connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString
try {
    $connection.Open()
    $command = $connection.CreateCommand()
    $command.CommandText = 'SELECT DatabaseName FROM dbo.BackupWhitelist WHERE Enabled=1;'
    $reader = $command.ExecuteReader()
    $databases = @()
    while ($reader.Read()) { $databases += $reader.GetString(0) }
    $reader.Close()
    if ($databases.Count -eq 0) { throw 'Whitelist is empty.' }
    foreach ($database in $databases) {
        if ($database -notmatch '^[A-Za-z0-9_-]+$' -or
            $database -match '^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$' -or
            $database -in @('master','model','msdb','tempdb')) {
            throw "Unsupported database folder name: $database"
        }
        foreach ($kind in @('FULL','DIFF','LOG')) {
            $directory = Join-Path (Join-Path $rootPath $database) $kind
            Assert-NoReparsePath $directory
            [IO.Directory]::CreateDirectory($directory) | Out-Null
        }
    }
} finally { $connection.Dispose() }

یک Windows Credential و Proxy به نام SQLBackupFiles از بخش SQL Server Agent → Proxies → Operating System (CmdExec) ایجاد کنید. حساب Proxy باید به DBAOperations متصل شود، SELECT روی BackupWhitelist و BackupAudit داشته باشد و مجوز مناسب روی مسیر فایل‌ها داشته باشد. Credential Password را در اسکریپت مقاله یا Job ذخیره نکنید؛ آن را در رابط امن مدیریت Credential وارد کنید. Job Owner باید اجازهٔ استفاده از Proxy داشته باشد.

powershell.exe -NoProfile -NonInteractive -File "C:\DBA\Prepare-SqlBackupFolders.ps1" -ServerInstance "localhost" -BackupRoot "D:\SQLBackup"

فولدرها را قبل از اولین Full ایجاد کنید. PowerShell مجوز NTFS سرویس Database Engine را تغییر نمی‌دهد؛ آن مجوز را هنگام Provisioning تنظیم کنید. اجرای مستقیم Stored Procedure بدون آماده‌سازی فولدرها ممکن است با خطای مسیر مواجه شود.

ساخت SQL Server Agent Job، Step و Schedule

  1. در SSMS، SQL Server Agent → Jobs → New Job را باز کنید. نام روشنی مثل MeetAJ - SQL FULL و Owner مورد اعتماد مشخص کنید.
  2. در Steps، Step اول از نوع Operating system (CmdExec) بسازید؛ Run as را SQLBackupFiles قرار دهید و دستور Prepare-SqlBackupFolders.ps1 را وارد کنید. On success به Step بعدی و On failure به Quit with failure برود.
  3. Step دوم از نوع Transact-SQL، با Database برابر DBAOperations باشد. برای Full، دستور EXEC dbo.usp_BackupWhitelist @BackupType='FULL' را قرار دهید؛ برای دو Job دیگر DIFF و LOG. On success برابر Quit with success و On failure برابر Quit with failure باشد.
  4. Schedule Full را Daily ساعت 01:00 تنظیم کنید. Differential هر شش ساعت از 07:00 تا 19:00 اجرا می‌شود؛ دقیقاً 07:00، 13:00 و 19:00. Log هر پانزده دقیقه از 00:00 تا 23:59:59 اجرا شود.
  5. Jobها را ابتدا Disabled نگه دارید. Prepare و Full را دستی اجرا کنید؛ سپس Differential و Log را تست کنید. روی Job راست‌کلیک و Start Job at Step انتخاب کنید. Disabled بودن Schedule مانع شروع دستی Job نیست.
  6. View History را باز کنید و نتیجهٔ هر Step را بررسی کنید. هم‌زمان BackupAudit، وجود فایل و msdb.backupset را کنترل کنید؛ نتیجهٔ کلی Job کافی نیست.

اسکریپت معادل زیر سه Job و Schedule را می‌سازد و Jobهای موجود را بازنویسی نمی‌کند. تمام Jobها Disabled ساخته می‌شوند. در Named Instance یا اتصال دارای Certificate، مقدار ServerInstance داخل دستور Step اول را پیش از اجرا اصلاح کنید. برای ایمیل، پس از تنظیم Database Mail، مقدار @Operator را نام Operator واقعی قرار دهید. مرجع پارامترهای Schedule Official documentation

03-agent-jobs.sql
-- Provision SQLBackupFiles CmdExec proxy first; see article prerequisites.
-- Save 02-prepare-folders.ps1 as C:\DBA\Prepare-SqlBackupFolders.ps1.
USE msdb;
GO
DECLARE @Proxy sysname=N'SQLBackupFiles', @Operator sysname=NULL,
        @Owner sysname=SUSER_SNAME(), @Notify int,
        @Type varchar(4), @Job sysname, @Schedule sysname, @Command nvarchar(max),
        @Start int, @End int, @SubType int, @SubInterval int;
IF NOT EXISTS (SELECT 1 FROM dbo.sysproxies WHERE name=@Proxy AND enabled=1)
    THROW 51100, 'Create the enabled SQLBackupFiles CmdExec proxy first.', 1;
IF @Operator IS NOT NULL AND NOT EXISTS
   (SELECT 1 FROM dbo.sysoperators WHERE name=@Operator AND enabled=1)
    THROW 51101, 'Configured notification operator does not exist.', 1;
IF EXISTS (SELECT 1 FROM dbo.sysjobs
           WHERE name IN (N'MeetAJ - SQL FULL',N'MeetAJ - SQL DIFF',N'MeetAJ - SQL LOG'))
    THROW 51102, 'Jobs already exist. Review/update them; do not overwrite.', 1;
SET @Notify=CASE WHEN @Operator IS NULL THEN 0 ELSE 2 END;
BEGIN TRY
    BEGIN TRANSACTION;
    DECLARE types CURSOR LOCAL FAST_FORWARD FOR
        SELECT Kind FROM (VALUES ('FULL'),('DIFF'),('LOG')) t(Kind);
    OPEN types;
    FETCH NEXT FROM types INTO @Type;
    WHILE @@FETCH_STATUS=0
    BEGIN
        SET @Job=N'MeetAJ - SQL '+@Type;
        SET @Schedule=@Job+N' schedule';
        -- Jobs stay disabled until a manual FULL/DIFF/LOG test succeeds.
        EXEC dbo.sp_add_job @job_name=@Job, @enabled=0,
            @owner_login_name=@Owner, @notify_level_email=@Notify,
            @notify_email_operator_name=@Operator;
        EXEC dbo.sp_add_jobstep @job_name=@Job, @step_id=1,
            @step_name=N'Prepare whitelist folders', @subsystem=N'CmdExec',
            @proxy_name=@Proxy,
            @command=N'powershell.exe -NoProfile -NonInteractive -File "C:\DBA\Prepare-SqlBackupFolders.ps1" -ServerInstance "localhost" -BackupRoot "D:\SQLBackup"',
            @on_success_action=3, @on_fail_action=2, @retry_attempts=0;
        SET @Command=N'EXEC dbo.usp_BackupWhitelist @BackupType='''+@Type+
            N''', @BackupRoot=N''D:\SQLBackup'', @Verify=1;';
        EXEC dbo.sp_add_jobstep @job_name=@Job, @step_id=2,
            @step_name=N'Backup and verify', @subsystem=N'TSQL',
            @database_name=N'DBAOperations', @command=@Command,
            @on_success_action=1, @on_fail_action=2, @retry_attempts=0;
        SET @Start=CASE @Type WHEN 'FULL' THEN 010000 WHEN 'DIFF' THEN 070000 ELSE 000000 END;
        SET @End=CASE WHEN @Type='DIFF' THEN 190000 ELSE 235959 END;
        SET @SubType=CASE @Type WHEN 'FULL' THEN 1 WHEN 'DIFF' THEN 8 ELSE 4 END;
        SET @SubInterval=CASE @Type WHEN 'FULL' THEN 1 WHEN 'DIFF' THEN 6 ELSE 15 END;
        EXEC dbo.sp_add_jobschedule @job_name=@Job, @name=@Schedule,
            @enabled=1, @freq_type=4, @freq_interval=1,
            @freq_subday_type=@SubType, @freq_subday_interval=@SubInterval,
            @active_start_time=@Start, @active_end_time=@End;
        EXEC dbo.sp_add_jobserver @job_name=@Job, @server_name=N'(LOCAL)';
        FETCH NEXT FROM types INTO @Type;
    END;
    CLOSE types;
    DEALLOCATE types;
    COMMIT;
END TRY
BEGIN CATCH
    IF @@TRANCOUNT>0 ROLLBACK;
    THROW;
END CATCH;
GO
-- Execute individually and wait for each job to finish before starting the next.
EXEC msdb.dbo.sp_start_job @job_name=N'MeetAJ - SQL FULL';
-- After successful FULL:
EXEC msdb.dbo.sp_start_job @job_name=N'MeetAJ - SQL DIFF';
-- After recovery models and log chain have been checked:
EXEC msdb.dbo.sp_start_job @job_name=N'MeetAJ - SQL LOG';
GO
-- Enable only after validating history, files and restore tests.
EXEC msdb.dbo.sp_update_job @job_name=N'MeetAJ - SQL FULL',@enabled=1;
EXEC msdb.dbo.sp_update_job @job_name=N'MeetAJ - SQL DIFF',@enabled=1;
EXEC msdb.dbo.sp_update_job @job_name=N'MeetAJ - SQL LOG',@enabled=1;
GO

دستورات شروع Job غیرهم‌زمان‌اند؛ اجرای پشت‌سرهم آن‌ها به معنی پایان Full قبل از Differential نیست. هر دستور را جداگانه اجرا و پایان آن را در History مشاهده کنید. اگر Session قطع شود یا Agent متوقف شود، Audit ممکن است روی RUNNING بماند؛ آن را نشانهٔ موفقیت فرض نکنید.

Cleanup Job و Retention سی‌روزه بدون شکستن زنجیره

دستور سادهٔ حذف «تمام فایل‌های قدیمی‌تر از ۳۰ روز» برای تضمین بازیابی کل پنجرهٔ سی‌روزه کافی نیست. Full پایهٔ نزدیک مرز و Logهای بین آن و اولین نقطهٔ داخل پنجره ممکن است کمی قدیمی‌تر از ۳۰ روز باشند. حذف آن‌ها، بخشی از پنجرهٔ وعده‌داده‌شده را غیرقابل‌بازیابی می‌کند.

Cleanup این مقاله آخرین Full تأییدشده قبل از مرز Retention را برای هر دیتابیس محافظت می‌کند و تمام Full، Differential و Logهای بعد از شروع آن را نگه می‌دارد. فقط فایل‌های قدیمی‌تر از آن پایه، با پسوند معتبر، مسیر مطابق Whitelist و سابقهٔ موفق در Audit، حذف می‌شوند. در نتیجه ممکن است چند ساعت یا در صورت شکست Full، چند روز بیشتر از ۳۰ روز نگهداری شود؛ این حاشیه برای حفظ قابلیت Restore است. اگر هیچ پایهٔ قابل اتکایی پیدا نشود، برای آن دیتابیس حذف انجام نمی‌شود.

اسکریپت زیر را در C:\DBA\Cleanup-SqlBackups.ps1 ذخیره کنید. پیش‌فرض Preview است. فایل‌های ناشناخته، ناقص یا FAILED را خودکار حذف نمی‌کند؛ آن‌ها باید بعد از بررسی علت، به‌صورت مستقل مدیریت شوند. Audit و msdb History نیز با این Job پاک نمی‌شوند.

04-cleanup.ps1
# Windows PowerShell 5.1. Default is preview; -Delete authorizes actual cleanup.
# Retain the verified FULL before the 30-day boundary, plus ALL backups since it.
[CmdletBinding(SupportsShouldProcess=$true)]
param(
    [string]$ServerInstance = 'localhost',
    [string]$BackupRoot = 'D:\SQLBackup',
    [ValidateRange(30,3650)][int]$RetentionDays = 30,
    [switch]$Delete
)
$ErrorActionPreference = 'Stop'
$rootPath = [IO.Path]::GetFullPath($BackupRoot).TrimEnd('\')
if ($rootPath -notmatch '^[A-Za-z]:\\.+' -or $rootPath.Length -gt 100) {
    throw 'Use a dedicated local backup root, not a drive root.'
}
function Assert-NoReparsePath([string]$Path) {
    $currentPath = [IO.Path]::GetFullPath($Path)
    while ($currentPath) {
        if (Test-Path -LiteralPath $currentPath) {
            $item = Get-Item -LiteralPath $currentPath -Force
            if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
                throw "Reparse point blocked: $currentPath"
            }
        }
        $parent = [IO.Directory]::GetParent($currentPath)
        if ($null -eq $parent) { break }
        $currentPath = $parent.FullName
    }
}
Assert-NoReparsePath $rootPath
$builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder.DataSource = $ServerInstance
$builder.InitialCatalog = 'DBAOperations'
$builder.IntegratedSecurity = $true
$builder.Encrypt = $true
$builder.TrustServerCertificate = $false
$connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString
$candidates = New-Object System.Data.DataTable
try {
    $connection.Open()
    $command = $connection.CreateCommand()
    $command.CommandText = @'
DECLARE @Cutoff datetime2(3)=DATEADD(day,-@Days,SYSUTCDATETIME());
SELECT a.DatabaseName,a.BackupType,a.BackupFile,f.BackupFile AS AnchorFile
FROM dbo.BackupWhitelist w
CROSS APPLY (
    SELECT TOP (1) b.BackupFile,b.StartedUtc
    FROM dbo.BackupAudit b
    WHERE b.DatabaseName=w.DatabaseName AND b.BackupRoot=@Root
      AND b.BackupType='FULL' AND b.Status='SUCCEEDED' AND b.Verified=1
      AND b.FinishedUtc<=@Cutoff
    ORDER BY b.FinishedUtc DESC,b.AuditId DESC
) f
JOIN dbo.BackupAudit a ON a.DatabaseName=w.DatabaseName
WHERE w.Enabled=1 AND a.BackupRoot=@Root AND a.Status='SUCCEEDED'
  AND a.BackupType IN ('FULL','DIFF','LOG')
  AND a.FinishedUtc<f.StartedUtc AND a.FinishedUtc<@Cutoff
  AND a.BackupFile IS NOT NULL
ORDER BY a.DatabaseName,a.FinishedUtc;
'@
    $command.Parameters.Add('@Days',[Data.SqlDbType]::Int).Value=$RetentionDays
    $command.Parameters.Add('@Root',[Data.SqlDbType]::NVarChar,200).Value=$rootPath
    $reader=$command.ExecuteReader()
    $candidates.Load($reader)
    $reader.Close()
} finally { $connection.Dispose() }
$cutoffUtc=[DateTime]::UtcNow.AddDays(-$RetentionDays)
foreach ($row in $candidates.Rows) {
    $database=[string]$row.DatabaseName
    $kind=[string]$row.BackupType
    if ($database -notmatch '^[A-Za-z0-9_-]+$' -or
        $database -in @('master','model','msdb','tempdb')) {
        throw 'Invalid database name in cleanup manifest.'
    }
    $expectedDir=Join-Path (Join-Path $rootPath $database) $kind
    $filePath=[IO.Path]::GetFullPath([string]$row.BackupFile)
    $anchorPath=[IO.Path]::GetFullPath([string]$row.AnchorFile)
    $anchorDir=Join-Path (Join-Path $rootPath $database) 'FULL'
    if (-not [IO.Path]::GetDirectoryName($filePath).Equals($expectedDir,[StringComparison]::OrdinalIgnoreCase) -or
        -not [IO.Path]::GetDirectoryName($anchorPath).Equals($anchorDir,[StringComparison]::OrdinalIgnoreCase)) {
        throw "Manifest path is outside the expected backup directory: $filePath"
    }
    Assert-NoReparsePath $filePath
    Assert-NoReparsePath $anchorPath
    if (-not (Test-Path -LiteralPath $anchorPath -PathType Leaf)) {
        throw "Protected FULL backup is missing; cleanup stopped: $anchorPath"
    }
    $extension=if ($kind -eq 'LOG') { '.trn' } else { '.bak' }
    if ([IO.Path]::GetExtension($filePath) -ne $extension -or
        -not [IO.Path]::GetFileName($filePath).StartsWith($database+'_'+$kind+'_',[StringComparison]::OrdinalIgnoreCase)) {
        throw "Unexpected backup filename: $filePath"
    }
    if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { continue }
    $file=Get-Item -LiteralPath $filePath -Force
    if ($file.LastWriteTimeUtc -ge $cutoffUtc) { continue }
    if (-not $Delete) { Write-Output "PREVIEW: $filePath"; continue }
    if ($PSCmdlet.ShouldProcess($filePath,'Delete expired SQL backup')) {
        Remove-Item -LiteralPath $filePath -ErrorAction Stop
        Write-Output "DELETED: $filePath"
    }
}
# Preview candidates; no deletion.
powershell.exe -NoProfile -NonInteractive -File "C:\DBA\Cleanup-SqlBackups.ps1" -RetentionDays 30
# After reviewing candidates and the protected FULL:
powershell.exe -NoProfile -NonInteractive -File "C:\DBA\Cleanup-SqlBackups.ps1" -RetentionDays 30 -Delete -WhatIf
# Actual deletion is performed by the reviewed Cleanup Job.

Cleanup فقط روی Root اختصاصی همین Instance اجرا شود. حساب‌های دیگر نباید بتوانند هم‌زمان مسیرها را به Junction تبدیل کنند یا فایل پایه را حذف کنند. وجود Full محافظت‌شده روی فایل‌سیستم بررسی می‌شود، اما سالم‌ماندن آن و پیوستگی LSNها همچنان به تست Restore و کنترل زنجیره نیاز دارد.

05-cleanup-job.sql
-- Save 04-cleanup.ps1 as C:\DBA\Cleanup-SqlBackups.ps1; preview before enabling.
USE msdb;
GO
IF EXISTS (SELECT 1 FROM dbo.sysjobs WHERE name=N'MeetAJ - SQL Cleanup')
    THROW 51200, 'Cleanup job exists; review it instead of overwriting.', 1;
IF NOT EXISTS (SELECT 1 FROM dbo.sysproxies WHERE name=N'SQLBackupFiles' AND enabled=1)
    THROW 51201, 'SQLBackupFiles proxy is missing.', 1;
BEGIN TRY
    BEGIN TRANSACTION;
    DECLARE @Owner sysname=SUSER_SNAME();
    EXEC dbo.sp_add_job @job_name=N'MeetAJ - SQL Cleanup',@enabled=0,@owner_login_name=@Owner;
    EXEC dbo.sp_add_jobstep @job_name=N'MeetAJ - SQL Cleanup',
        @step_name=N'Clean expired backups',@subsystem=N'CmdExec',@proxy_name=N'SQLBackupFiles',
        @command=N'powershell.exe -NoProfile -NonInteractive -File "C:\DBA\Cleanup-SqlBackups.ps1" -ServerInstance "localhost" -BackupRoot "D:\SQLBackup" -RetentionDays 30 -Delete',
        @on_success_action=1,@on_fail_action=2,@retry_attempts=0;
    EXEC dbo.sp_add_jobschedule @job_name=N'MeetAJ - SQL Cleanup',
        @name=N'MeetAJ - cleanup daily 05:00',@freq_type=4,@freq_interval=1,
        @active_start_time=050000;
    EXEC dbo.sp_add_jobserver @job_name=N'MeetAJ - SQL Cleanup',@server_name=N'(LOCAL)';
    COMMIT;
END TRY
BEGIN CATCH
    IF @@TRANCOUNT>0 ROLLBACK;
    THROW;
END CATCH;
GO

پس از مشاهدهٔ Preview و تأیید سیاست، Job Cleanup را از SSMS فعال کنید. ساعت 05:00 زمانی پیشنهادی است؛ اگر Full شما تا آن زمان ادامه دارد یا انتقال Off-site تمام نشده، زمان را تغییر دهید و شرط تأیید انتقال را به Runbook اضافه کنید. Maintenance Cleanup Task هم امکان حذف بر اساس سن و پسوند را دارد، اما زنجیرهٔ وابستگی را خودکار حفظ نمی‌کند؛ در این سناریو Cleanup مبتنی بر Audit انتخاب شده است.

Validation: Backup History، VERIFYONLY و آخرین Backup هر دیتابیس

سه لایه را کنترل کنید: Agent History برای اجرای Step، BackupAudit برای نتیجهٔ تک‌تک دیتابیس‌ها و msdb.backupset برای سابقهٔ Backup در SQL Server. اسکریپت زیر نبودن Backup را با NULL نشان می‌دهد؛ دیتابیس بدون Backup از گزارش حذف نمی‌شود.

06-validation.sql
-- Run on the source instance. msdb timestamps use server local time;
-- BackupAudit timestamps use UTC.
USE msdb;
GO
SELECT TOP (100) b.database_name,
    CASE b.type WHEN 'D' THEN 'FULL' WHEN 'I' THEN 'DIFF' WHEN 'L' THEN 'LOG' END AS BackupType,
    b.backup_start_date,b.backup_finish_date,b.is_copy_only,b.has_backup_checksums,
    CAST(b.backup_size/1048576.0 AS decimal(18,2)) AS OriginalMB,
    CAST(b.compressed_backup_size/1048576.0 AS decimal(18,2)) AS CompressedMB,
    m.physical_device_name,b.first_lsn,b.last_lsn,b.database_backup_lsn
FROM dbo.backupset b
JOIN dbo.backupmediafamily m ON m.media_set_id=b.media_set_id
JOIN DBAOperations.dbo.BackupWhitelist w ON w.DatabaseName=b.database_name AND w.Enabled=1
WHERE b.type IN ('D','I','L')
ORDER BY b.backup_finish_date DESC;
GO
-- OUTER APPLY keeps missing backups visible (NULL), instead of hiding the DB.
SELECT w.DatabaseName,d.recovery_model_desc,
       f.backup_finish_date AS LastFull,
       i.backup_finish_date AS LastDifferential,
       l.backup_finish_date AS LastLog,
       d.log_reuse_wait_desc
FROM DBAOperations.dbo.BackupWhitelist w
LEFT JOIN sys.databases d ON d.name=w.DatabaseName
OUTER APPLY (SELECT TOP (1) backup_finish_date FROM dbo.backupset
    WHERE database_name=w.DatabaseName AND type='D' AND is_copy_only=0
    ORDER BY backup_finish_date DESC) f
OUTER APPLY (SELECT TOP (1) backup_finish_date FROM dbo.backupset
    WHERE database_name=w.DatabaseName AND type='I'
    ORDER BY backup_finish_date DESC) i
OUTER APPLY (SELECT TOP (1) backup_finish_date FROM dbo.backupset
    WHERE database_name=w.DatabaseName AND type='L' AND is_copy_only=0
    ORDER BY backup_finish_date DESC) l
WHERE w.Enabled=1;
GO
SELECT TOP (100) * FROM DBAOperations.dbo.BackupAudit ORDER BY AuditId DESC;
GO
-- Verify the latest successful Jira FULL using its real generated file name.
DECLARE @File nvarchar(260);
SELECT TOP (1) @File=BackupFile FROM DBAOperations.dbo.BackupAudit
WHERE DatabaseName=N'DB-Jira' AND BackupType='FULL' AND Status='SUCCEEDED'
ORDER BY FinishedUtc DESC;
IF @File IS NULL THROW 51300, 'No successful Jira FULL backup found.', 1;
RESTORE VERIFYONLY FROM DISK=@File WITH CHECKSUM,STOP_ON_ERROR;
GO
-- Example freshness thresholds; integrate these rows with your monitoring.
-- A successful Agent execution is not a substitute for per-database checks.
SELECT w.DatabaseName,t.Kind,latest.FinishedUtc,
    CASE WHEN latest.FinishedUtc IS NULL THEN 'MISSING' ELSE 'STALE' END AS AlertReason
FROM DBAOperations.dbo.BackupWhitelist w
-- DIFF has a 12-hour overnight gap (19:00 to 07:00), so 7h would false-alert.
-- Use 13h here, or a schedule-aware check for stricter daytime detection.
CROSS JOIN (VALUES ('FULL',1560),('DIFF',780),('LOG',20)) t(Kind,MaxMinutes)
OUTER APPLY (
    SELECT TOP (1) a.FinishedUtc FROM DBAOperations.dbo.BackupAudit a
    WHERE a.DatabaseName=w.DatabaseName AND a.BackupType=t.Kind AND a.Status='SUCCEEDED'
    ORDER BY a.FinishedUtc DESC
) latest
WHERE w.Enabled=1 AND (latest.FinishedUtc IS NULL OR
    latest.FinishedUtc<DATEADD(minute,-t.MaxMinutes,SYSUTCDATETIME()));
GO

CHECKSUM احتمال کشف خطا هنگام خواندن صفحات و نوشتن Backup را افزایش می‌دهد. RESTORE VERIFYONLY با CHECKSUM خوانایی Backup و Checksumهای موجود را بررسی می‌کند، اما دیتابیس را Restore نمی‌کند و صحت منطقی داده، سازگاری برنامه یا دستیابی به RTO را ثابت نمی‌کند. محدودهٔ واقعی VERIFYONLY Official documentation

تست Restore روی محیط جدا

  1. یک Instance آزمایشی جدا با فضای کافی آماده کنید؛ نام و مسیر مقصد را از Production جدا نگه دارید.
  2. با RESTORE HEADERONLY، نوع Backup، DatabaseBackupLSN و بازهٔ FirstLSN/LastLSN را کنترل کنید. با RESTORE FILELISTONLY نام Logical Fileها را بخوانید.
  3. Full را با MOVE به مسیرهای آزمایشی و NORECOVERY برگردانید. آخرین Differential سازگار و Logهای لازم را به ترتیب و با NORECOVERY اعمال کنید.
  4. برای نقطهٔ زمانی، STOPAT را روی مرحلهٔ Log مناسب اعمال کنید؛ در پایان RECOVERY انجام دهید. در محیط آزمایشی DBCC CHECKDB اجرا کنید.
  5. Jira یا Confluence آزمایشی را با Home و Attachment هماهنگ راه‌اندازی کنید. زمان کل بازیابی و اختلاف آخرین داده را ثبت و با RPO/RTO مقایسه کنید.

گزینهٔ REPLACE یا مسیر فایل‌های Production را به‌عنوان دستور عمومی Restore کپی نکنید. فایل‌های Restore واقعی باید از روی همین Backupهای تولیدشده و Logical Fileهای همان دیتابیس انتخاب شوند. یک Restore موفق ماه قبل، قابل‌بازیابی‌بودن زنجیرهٔ امروز را تضمین نمی‌کند.

عیب‌یابی Backup Job در SQL Server

نشانهبررسیاقدام
Log Backup تولید نمی‌شودRecovery Model، اولین Full پس از SIMPLE، وضعیت Job LOG و ErrorMessage در AuditFULL را برای دیتابیس موردنظر تنظیم کنید، Backup دادهٔ آغازین بگیرید و Log را دستی تست کنید؛ Agent History را بخوانید.
خطای 4214زنجیرهٔ Backup هنوز شروع نشده استپس از آماده‌شدن فولدر، Full معمولی بگیرید؛ صرف ALTER DATABASE کافی نیست.
خطای 3201 / Operating system error 5دسترسی سرویس Database Engine به Root و فولدر دیتابیسمجوز NTFS یا Share را برای هویت درست اصلاح کنید. دسترسی حسابی که SSMS را اجرا کرده، معیار نوشتن Backup نیست.
Operating system error 3وجود Root و FULL/DIFF/LOG؛ نتیجهٔ Step ساخت فولدرPrepare را با همان Proxy اجرا کنید؛ مسیر را روی سرور SQL بررسی کنید، نه سیستم شخصی DBA.
Job اصلاً اجرا نشدهRunning بودن Agent، Enabled بودن Job و Schedule، ساعت سرور، Step شروعسرویس و Schedule را اصلاح کنید؛ نبودن اجرا را با مانیتور Freshness تشخیص دهید.
فضای دیسک کم است / خطای 112حجم Fullها، نرخ رشد Differential و Log، انتقال Off-site و CleanupCapacity را افزایش دهید یا سیاست را بازبینی کنید. Log موردنیاز را برای آزادکردن فضا حذف نکنید.
Full موفق است ولی Differential خطا می‌دهدFull پایهٔ معمولی، تغییر Recovery Model، Fullهای خارج از این Pipelineیک Full معمولی جدید بگیرید و زنجیره را بازبینی کنید. COPY_ONLY پایهٔ Differential جدید ایجاد نمی‌کند.
CHECKSUM یا VERIFYONLY خطا داردخطای دقیق، I/O، Storage، فایل ناقص یا تغییرکردهفایل را قرنطینه کنید، زنجیرهٔ جایگزین سالم را بررسی کنید و سلامت دیتابیس/Storage را ارزیابی کنید؛ CONTINUE_AFTER_ERROR را برای سبزکردن Job فعال نکنید.
فایل Log رشد می‌کندlog_reuse_wait_desc، Log Backup، تراکنش طولانی، Replica یا Replicationعلت نگهداری Log را برطرف کنید؛ Shrink روزانه و تغییر به SIMPLE جایگزین حل علت نیست.
PowerShell به SQL وصل نمی‌شودLogin حساب Proxy، دسترسی جدول‌ها، FQDN، زنجیرهٔ اعتماد TLS و Certificateهویت و Certificate را اصلاح کنید؛ SQL Password را داخل Job ننویسید.
Job Failed است ولی بعضی فایل‌ها ساخته شده‌اندAudit هر دیتابیسProcedure بعد از تلاش برای همهٔ اعضا خطا می‌دهد؛ فقط دیتابیس یا مرحلهٔ شکست‌خورده را بررسی کنید. فایل حاصل از Verify ناموفق هنوز ممکن است روی دیسک بماند.

Backup Encryption و نگهداری Certificate

Backup شامل دادهٔ قابل‌خواندن سازمان است و باید در زمان نگهداری و انتقال محافظت شود. Procedure پارامتر @EncryptionCertificate دارد و با Certificate از master، AES_256 را به BACKUP اضافه می‌کند. ابتدا Database Master Key و Certificate را با سیاست مدیریت کلید سازمان ایجاد کنید؛ سپس Certificate و Private Key را در مقصد امن، جدا از فایل‌های Backup، Export کنید. بدون کلید لازم، Restore روی سرور جایگزین امکان‌پذیر نیست. پیش‌نیازهای Encryption و بازیابی کلید Official documentation

-- After provisioning and securely exporting the certificate/private key:
EXEC DBAOperations.dbo.usp_BackupWhitelist
    @BackupType='FULL',
    @EncryptionCertificate=N'SqlBackupEncryptionCert',
    @Verify=1;
-- Add the SAME encryption parameter to the DIFF and LOG job commands too.

پیش‌فرض نمونه برای سهولت تست، Encryption ندارد؛ قبل از Production، Certificate معتبر را آماده و پارامتر را در هر سه Job اضافه کنید. TDE، Backup Encryption و رمزگذاری Storage کاربردهای متفاوت دارند. دسترسی به کلید و امکان Import آن روی Instance بازیابی را دوره‌ای تست کنید و کلید را همراه با همان Backup قابل‌حذف رها نکنید.

Best Practiceها برای Backup SQL Server

  • Storage مستقل و نسخهٔ Off-site: Backup محلی برای بازیابی سریع است؛ نسخهٔ دوم روی محل مستقل نگهداری شود. حداقل یک نسخهٔ Offline یا Immutable با هویت جدا داشته باشید تا حساب آسیب‌دیده نتواند تمام نسخه‌ها را پاک کند.
  • تست Restore: برای سرویس‌های حیاتی، آزمون منظم Restore و آزمون Point-in-time تعریف کنید؛ نتیجه، مدت و مالک اقدام اصلاحی را ثبت کنید.
  • مانیتورینگ: شکست Job، Backup مفقود یا دیرهنگام برای هر عضو Whitelist، فایل RUNNING مانده، فضای آزاد، مدت اجرا و نتیجهٔ انتقال Off-site را کنترل کنید. نمونهٔ Validation با آستانهٔ Log بیست دقیقه هشدار می‌دهد؛ این آستانه باید متناسب با RPO شما تنظیم شود.
  • Alert: Database Mail را با پروفایل مورد تأیید سازمان تنظیم کنید، آن را در Agent Properties → Alert System انتخاب و Operator بسازید. روی Job Notification، Email When job fails را فعال کنید و یک شکست کنترل‌شده را آزمایش کنید. برای Cleanup هم Notification مستقل لازم است. قطع خود Agent باید از مانیتورینگ خارج از سرور تشخیص داده شود.
  • یک مالک برای زنجیره: Full معمولی خارج از Pipeline می‌تواند مبنای Differential را تغییر دهد. برای Backup موردی از COPY_ONLY استفاده کنید؛ حذف و Restore را با ابزار Backup دیگری بدون هماهنگی این سیاست مخلوط نکنید.
  • System Database و کلیدها: این Job عمداً master، model، msdb و tempdb را کنار می‌گذارد. master، model و msdb، Loginها، Jobها، Certificateها و Runbook باید در برنامهٔ بازیابی جداگانه پوشش داده شوند. Audit مدیریتی نیز به نگهداری و Backup مستقل نیاز دارد.
  • Capacity و Performance: حجم واقعی Full، رشد Differential و تولید Log را اندازه بگیرید. VERIFYONLY تمام فایل را می‌خواند و I/O اضافه دارد؛ برای دیتابیس حجیم می‌توان آن را به Job اعتبارسنجی جدا برد، اما وضعیت Verified و قواعد Cleanup باید با همان فرایند به‌روز شوند.
  • Change Control: افزودن دیتابیس به Whitelist، تغییر Recovery Model، تغییر Root و کاهش Retention باید در تغییرات ثبت شود. یک دیتابیس تازه‌ساخته‌شده تا زمانی که به Whitelist اضافه نشود، پوشش ندارد.

جمع‌بندی: معیار موفقیت، Restore قابل‌اعتماد است

برای Jira و Confluence این سناریو، Full روزانه، سه Differential و Log پانزده‌دقیقه‌ای یک نقطهٔ شروع مشخص می‌دهند. ارزش عملی این طراحی در Whitelist صریح، ثبت شکست هر دیتابیس، کنترل زنجیره، Cleanup محافظه‌کارانه، نسخهٔ مستقل و تست Restore است. پیش از فعال‌کردن Scheduleها، نام دیتابیس‌ها، دسترسی‌ها، Certificate، فضای آزاد و بازیابی آزمایشی را تأیید کنید؛ سپس RPO و RTO واقعی را به مالک سرویس گزارش دهید.

سوالات متداول دربارهٔ Backup خودکار SQL Server

چرا در Recovery Model ساده نمی‌توان Log Backup گرفت؟

در SIMPLE، بخش غیرفعال Transaction Log در صورت نبود مانع پس از Checkpoint قابل استفادهٔ مجدد می‌شود و زنجیرهٔ Log Backup نگهداری نمی‌شود. برای Log Backup باید FULL یا BULK_LOGGED داشته باشید و زنجیره را با Backup داده آغاز کنید.

بعد از تغییر SIMPLE به FULL چه کاری لازم است؟

باید یک Full یا Differential معتبر برای شروع زنجیرهٔ Log گرفته شود. در این سناریو Full معمولی جدید انتخاب می‌شود تا پایهٔ بازیابی روشن باشد؛ سپس Log Backup زمان‌بندی می‌شود. این الزام برای تمام تغییرات Recovery Model یکسان نیست.

آیا Differential تغییرات از Differential قبلی را ذخیره می‌کند؟

خیر. Differential تغییرات از Full پایهٔ معمولی را نگه می‌دارد. برای Restore از Full سازگار، آخرین Differential مناسب و Logهای موردنیاز به ترتیب استفاده می‌شود.

آیا VERIFYONLY جایگزین تست Restore است؟

خیر. VERIFYONLY خوانایی Backup و Checksumهای موجود را بررسی می‌کند، ولی دیتابیس را Restore نمی‌کند. تست Restore، کنترل DBCC CHECKDB، سازگاری برنامه و اندازه‌گیری RTO همچنان لازم‌اند.

چرا بعضی فایل‌های قدیمی‌تر از ۳۰ روز حذف نمی‌شوند؟

برای بازیابی تمام پنجرهٔ سی‌روزه، Full پایهٔ قبل از مرز Retention و Backupهای بعد از آن لازم‌اند. Cleanup این وابستگی را حفظ می‌کند و فایل پایه یا Logهای موردنیاز را صرفاً به خاطر سن حذف نمی‌کند.

چه حسابی باید به فولدر Backup دسترسی داشته باشد؟

سرویس Database Engine فایل Backup را می‌نویسد و می‌خواند. ساخت فولدر و Cleanup در این راهکار با CmdExec Proxy انجام می‌شوند. هر دو هویت به مجوزهای متناسب روی مسیر اختصاصی نیاز دارند.

آیا این Job از master، model، msdb و tempdb بکاپ می‌گیرد؟

خیر. دیتابیس‌های سیستمی حتی اگر به Whitelist اضافه شوند رد می‌شوند. master، model و msdb باید Job جداگانه داشته باشند؛ tempdb بکاپ‌پذیر نیست.

آیا SQL Server Agent به‌تنهایی بکاپ Jira و Confluence را کامل می‌کند؟

خیر. علاوه بر دیتابیس، Home، Shared Home، Attachmentها و تنظیمات برنامه باید با روش سازگار با نسخهٔ نصب‌شده Backup شوند. نسخهٔ مستقل Off-site و تست راه‌اندازی برنامه پس از Restore نیز لازم است.

منابع رسمی و اطلاعات انتشار

Meta Title: بکاپ خودکار SQL Server؛ راهنمای Enterprise با Agent | Meet AJ

Meta Description: طراحی بکاپ SQL Server برای Jira و Confluence؛ Full، Differential و Log، اسکریپت Whitelist، Retention سی‌روزه، Encryption، مانیتورینگ و تست Restore.

URL پیشنهادی و حفظ‌شده: https://meetaj.ir/articles/sql-server-automatic-backup-job

FAQ Schema از همین سوالات قابل‌مشاهده در Head صفحه تولید می‌شود؛ وجود Schema تضمین نمایش Rich Result در موتور جست‌وجو نیست.

مقدمه

Job سبز به معنی قابل بازیابی‌بودن تمام databaseها نیست. برای پذیرش backup باید تازگی هر database، سلامت chain و restore واقعی روی instance ایزوله تأیید شود.

مثال عملی

DB-Jira نمونه database سازمان است؛ RPO هدف ۱۵ دقیقه و RTO هدف ۶۰ دقیقه صرفاً اهداف آموزشی‌اند و باید از اندازه‌گیری restore تأیید شوند.

پیش‌نیازها

SQL Server 2022/2025 با edition پشتیبان SQL Server Agent؛ Windows Server و PowerShell 5.1. compression/encryption با edition/build بررسی شود.

سخت‌افزار و ظرفیت: storage backup جدا از data/log، ظرفیت full+diff+log و کپی off-host؛ throughput restore اندازه‌گیری شود و sizing بر اساس حجم واقعی باشد.

نرم‌افزار و دسترسی: DBA برای backup/restore و Agent proxy محدود برای file operation؛ SQLBackupFiles و whitelist در بخش اجرایی موجود همین مقاله پیاده شده‌اند.

Architecture / Design

Whitelist → FULL/DIFF/LOG jobs → checksum/audit → encrypted off-host → restore drill. VERIFYONLY خوانایی backup را می‌سنجد؛ صحت کامل داده کاربردی و CHECKDB را جایگزین نمی‌کند.

Installation / Configuration

پیاده‌سازی کامل procedure، PowerShell تهیه پوشه، سه Agent Job، cleanup و audit در بخش‌های اجرایی موجود همین مقاله قرار دارد و حفظ شده است. ابتدا scripts را با نام فایل‌های مشخص ذخیره، whitelist و proxy را provision و jobهای disabled را دستی validate کنید. برای Express، SQL Server Agent موجود نیست و scheduler خارجی نیاز workflow مستقل دارد.

در instance منبع recovery model و وضعیت log reuse را بخوانید؛ این query هیچ model را تغییر نمی‌دهد.

SELECT name,state_desc,recovery_model_desc,log_reuse_wait_desc
FROM sys.databases
WHERE database_id > 4;
SELECT TOP (20) database_name,type,backup_finish_date,first_lsn,last_lsn,database_backup_lsn
FROM msdb.dbo.backupset
ORDER BY backup_finish_date DESC;

انتظار FULL/DIFF/LOG مطابق برنامه هر DB؛ NULL یا backup قدیمی alert است. پس از تغییر SIMPLE به FULL برای شروع log chain، full یا differential مناسب لازم است؛ log backup تا بررسی chain فعال نشود. نمونه restore زیر یک FULL مستقل را فقط روی instance تست بازیابی می‌کند.

Security Hardening

backup encryption certificate و private key با password قوی خارج instance backup شوند. service account فقط روی مسیر backup لازم permission داشته باشد. xp_cmdshell برای عملیات فایل فعال نشود. restore سرور تست از outbound integration و کاربران Production جدا باشد.

Monitoring

آخرین FULL/DIFF/LOG هر database، audit failure، فضای دیسک و duration پایش شوند. Queryهای موجود این مقاله missing/stale را مشخص می‌کنند؛ success سطح Agent به‌تنهایی کافی نیست. Alert از مسیر Database Mail/operator یا مانیتورینگ مستقل delivery test داشته باشد.

Troubleshooting

OS error 5: ACL از دید SQL service account، نه کاربر SSMS. 3201: مسیر فایل روی server SQL یا share مجاز؛ مسیر کلاینت معیار نیست. log backup unavailable: recovery model و chain. restore log gap: FirstLSN/LastLSN و FULL پایه بررسی شود؛ chain مفقود با تکرار VERIFYONLY ترمیم نمی‌شود.

Backup / Recovery

نمونه کامل restore یک FULL روی instance ایزوله است؛ این نام‌ها placeholder فایل موجود و logical name واقعی‌اند. ابتدا FILELISTONLY را اجرا و logical names را در MOVE جایگزین کنید؛ D:\RestoreLab باید از قبل وجود داشته و SQL service account permission داشته باشد.

RESTORE FILELISTONLY FROM DISK=N'D:\RestoreLab\DB-Jira_FULL.bak';

پس از بررسی خروجی و نبود database هم‌نام، این batch اجرا شود. اگر backup بیش از دو data/log file دارد برای هر فایل MOVE اضافه کنید؛ تعداد فایل از خروجی قبلی تعیین می‌شود.

IF DB_ID(N'DB_Jira_RestoreTest') IS NOT NULL
    THROW 52000, 'Restore target already exists; choose a fresh isolated name.', 1;
RESTORE DATABASE DB_Jira_RestoreTest
FROM DISK=N'D:\RestoreLab\DB-Jira_FULL.bak'
WITH MOVE N'DB-Jira' TO N'D:\RestoreLab\DB_Jira_RestoreTest.mdf',
     MOVE N'DB-Jira_log' TO N'D:\RestoreLab\DB_Jira_RestoreTest_log.ldf',
     CHECKSUM, RECOVERY, STATS=10;
DBCC CHECKDB (N'DB_Jira_RestoreTest') WITH NO_INFOMSGS, ALL_ERRORMSGS;

این نمونه FULL-only است و PITR نیست. برای PITR از FULL و DIFF سازگار با NORECOVERY، سپس تمام LOGهای پیوسته تا STOPAT هدف و RECOVERY استفاده شود؛ نسخه و chain در بخش اصلی مقاله توضیح داده شده‌اند. CHECKDB بدون error، query کاربردی مجاز و مدت واقعی restore معیار پذیرش‌اند. encryption/TDE certificate پیش از restore روی مقصد با key امن import شود.

Best Practices

Cleanup فقط پس از اثبات backup پایه محافظت‌شده و تمام chain retention انجام شود. برای مورد کوچک این مقاله script اختصاصی whitelist حفظ شده؛ در fleet بزرگ راهکار رسمی سازمانی با coverage همان edition بررسی شود.

نسخه‌های قدیمی و Compatibility

فایل backup از SQL جدیدتر به engine قدیمی‌تر restore نمی‌شود. Agent در Express وجود ندارد. scriptهای موجود و metadata/FAQ هشت‌گانه مقاله حفظ شده‌اند.

منابع رسمی

تاریخ بررسی منابع: 2026-09-30. اعتبارسنجی عملی روی محیط هدف باید پیش از انتشار تغییر زیرساخت انجام شود.

Share