<# .SYNOPSIS 灾备演练脚本(Windows PowerShell 版本) .DESCRIPTION 自动化灾备演练:从备份恢复到测试数据库,验证数据完整性 .EXAMPLE .\dr-drill.ps1 .EXAMPLE .\dr-drill.ps1 -BackupFile "backups\db_backup_20260617_020000.sql.gz" -TestDb "next_edu_dr_drill" .EXAMPLE .\dr-drill.ps1 -NoCleanup .PARAMETER BackupFile 指定备份文件(不指定则使用最新备份) .PARAMETER TestDb 测试数据库名(默认 next_edu_dr_drill) .PARAMETER NoCleanup 演练后不清理测试数据库 .PARAMETER ReportDir 报告输出目录(默认 docs\dr\reports) .PARAMETER Help 显示帮助信息 #> [CmdletBinding()] param( [Parameter(Position = 0)] [string]$BackupFile = "", [Parameter()] [string]$TestDb = "", [Parameter()] [switch]$NoCleanup, [Parameter()] [string]$ReportDir = "", [Parameter()] [switch]$Help ) # 显示帮助 if ($Help) { Get-Help $MyInvocation.MyCommand.Path -Detailed exit 0 } # 配置 $ErrorActionPreference = "Stop" $DatabaseUrl = $env:DATABASE_URL if ([string]::IsNullOrEmpty($DatabaseUrl)) { Write-Host "ERROR: DATABASE_URL not set" -ForegroundColor Red exit 1 } $BackupDir = if ($env:BACKUP_DIR) { $env:BACKUP_DIR } else { ".\backups" } if ([string]::IsNullOrEmpty($TestDb)) { $TestDb = if ($env:DR_DRILL_TEST_DB) { $env:DR_DRILL_TEST_DB } else { "next_edu_dr_drill" } } if ([string]::IsNullOrEmpty($ReportDir)) { $ReportDir = if ($env:DR_DRILL_REPORT_DIR) { $env:DR_DRILL_REPORT_DIR } else { "docs\dr\reports" } } $Timestamp = Get-Date -Format "yyyyMMdd_HHmmss" $ReportFile = Join-Path $ReportDir "dr_drill_${Timestamp}.md" # 解析 DATABASE_URL # 格式: mysql://user:password@host:port/dbname function Parse-DatabaseUrl { param([string]$Url) try { $uri = [System.Uri]$Url $userInfo = $uri.UserInfo -split ':', 2 return @{ User = $userInfo[0] Pass = if ($userInfo.Length -gt 1) { $userInfo[1] } else { "" } Host = $uri.Host Port = if ($uri.Port -gt 0) { $uri.Port } else { 3306 } DbName = $uri.AbsolutePath.TrimStart('/') } } catch { Write-Host "ERROR: Invalid DATABASE_URL format" -ForegroundColor Red exit 1 } } $db = Parse-DatabaseUrl $DatabaseUrl # 创建报告目录 if (-not (Test-Path $ReportDir)) { New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null } # 初始化报告 function Init-Report { $content = @" # 灾备演练报告 - **演练时间**: $(Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") - **测试数据库**: $TestDb - **源数据库**: $($db.DbName) - **数据库主机**: $($db.Host):$($db.Port) - **备份文件**: $BackupFile ## 演练步骤 "@ Set-Content -Path $ReportFile -Value $content -Encoding UTF8 } function Append-Report { param([string]$Content) Add-Content -Path $ReportFile -Value $Content -Encoding UTF8 } function Step-Result { param( [string]$Step, [string]$Status, [string]$Detail ) Append-Report "### 步骤 $Step`: $Status" Append-Report "" Append-Report $Detail Append-Report "" if ($Status -eq "FAILED") { Append-Report "❌ 步骤失败" } else { Append-Report "✅ 步骤成功" } Append-Report "" Write-Host "---" } # MySQL 执行函数 function Invoke-MySql { param( [string]$Query, [string]$Database = "", [switch]$Silent, [switch]$Scalar ) $mysqlArgs = @("-h", $db.Host, "-P", $db.Port, "-u", $db.User, "-p$($db.Pass)") if (-not [string]::IsNullOrEmpty($Database)) { $mysqlArgs += $Database } $mysqlArgs += @("-e", $Query) if ($Scalar) { $mysqlArgs += @("-s", "-N") } if ($Silent) { $result = & mysql @mysqlArgs 2>$null } else { $result = & mysql @mysqlArgs 2>&1 } return $result } # 检查 mysql 命令 if (-not (Get-Command mysql -ErrorAction SilentlyContinue)) { Write-Host "ERROR: mysql client not found in PATH" -ForegroundColor Red Write-Host "Please install MySQL client tools" -ForegroundColor Red exit 1 } Write-Host "=== Disaster Recovery Drill ===" -ForegroundColor Cyan Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" Write-Host "Test DB: $TestDb" Write-Host "Source DB: $($db.DbName)@$($db.Host):$($db.Port)" Write-Host "Report: $ReportFile" Write-Host "" Init-Report $drillStart = Get-Date $overallStatus = "SUCCESS" # 步骤 1: 查找备份文件 Write-Host "[1/6] Locating backup file..." if ([string]::IsNullOrEmpty($BackupFile)) { $backupPattern = Join-Path $BackupDir "db_backup_*.sql.gz" $latestBackup = Get-ChildItem -Path $backupPattern -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($latestBackup) { $BackupFile = $latestBackup.FullName } else { Write-Host " FAIL: No backup file found in $BackupDir" -ForegroundColor Red Step-Result "1 - 定位备份文件" "FAILED" "未找到备份文件于 $BackupDir" Append-Report "## 演练结果: ❌ FAILED`n`n演练失败,未找到备份文件" exit 1 } } if (-not (Test-Path $BackupFile)) { Write-Host " FAIL: Backup file not found: $BackupFile" -ForegroundColor Red Step-Result "1 - 定位备份文件" "FAILED" "备份文件不存在: $BackupFile" exit 1 } $backupSize = (Get-Item $BackupFile).Length Write-Host " PASS: Found backup: $BackupFile ($backupSize bytes)" -ForegroundColor Green Step-Result "1 - 定位备份文件" "PASSED" "备份文件: ``$BackupFile`` ($backupSize bytes)" # 步骤 2: 创建测试数据库 Write-Host "[2/6] Creating test database..." try { Invoke-MySql -Query "DROP DATABASE IF EXISTS ``$TestDb``;" -Silent Invoke-MySql -Query "CREATE DATABASE ``$TestDb`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" -Silent Write-Host " PASS: Test database created: $TestDb" -ForegroundColor Green Step-Result "2 - 创建测试数据库" "PASSED" "测试数据库 ``$TestDb`` 创建成功" } catch { Write-Host " FAIL: Could not create test database" -ForegroundColor Red Step-Result "2 - 创建测试数据库" "FAILED" "创建测试数据库 ``$TestDb`` 失败" $overallStatus = "FAILED" Append-Report "## 演练结果: ❌ FAILED" exit 1 } # 步骤 3: 从备份恢复到测试数据库 Write-Host "[3/6] Restoring backup to test database..." $restoreStart = Get-Date try { # 使用 7z 或 gunzip 解压,然后管道到 mysql # Windows 上可能需要使用 7z 或 .NET GZipStream $tempSqlFile = [System.IO.Path]::GetTempFileName() # 尝试使用 gunzip(如果可用) if (Get-Command gunzip -ErrorAction SilentlyContinue) { $process = Start-Process -FilePath "gunzip" -ArgumentList "-c", "`"$BackupFile`"" -NoNewWindow -RedirectStandardOutput $tempSqlFile -Wait -PassThru } # 尝试使用 7z elseif (Get-Command 7z -ErrorAction SilentlyContinue) { & 7z e -so "$BackupFile" | Out-File -FilePath $tempSqlFile -Encoding ASCII } # 使用 .NET GZipStream else { $inStream = [System.IO.File]::OpenRead($BackupFile) $gzStream = New-Object System.IO.Compression.GZipStream($inStream, [System.IO.Compression.CompressionMode]::Decompress) $reader = New-Object System.IO.StreamReader($gzStream, [System.Text.Encoding]::UTF8) $content = $reader.ReadToEnd() $reader.Close() $gzStream.Close() $inStream.Close() Set-Content -Path $tempSqlFile -Value $content -Encoding UTF8 -NoNewline } # 执行恢复 $mysqlArgs = @("-h", $db.Host, "-P", $db.Port, "-u", $db.User, "-p$($db.Pass)", $TestDb) Get-Content $tempSqlFile -Raw | & mysql @mysqlArgs 2>$null Remove-Item $tempSqlFile -Force -ErrorAction SilentlyContinue $restoreEnd = Get-Date $restoreDuration = ($restoreEnd - $restoreStart).TotalSeconds Write-Host " PASS: Restore completed in $([int]$restoreDuration)s" -ForegroundColor Green Step-Result "3 - 从备份恢复" "PASSED" "恢复完成,耗时 $([int]$restoreDuration) 秒" } catch { Write-Host " FAIL: Restore failed: $_" -ForegroundColor Red Step-Result "3 - 从备份恢复" "FAILED" "从备份恢复失败: $_" $overallStatus = "FAILED" if (-not $NoCleanup) { try { Invoke-MySql -Query "DROP DATABASE IF EXISTS ``$TestDb``;" -Silent } catch {} } Append-Report "## 演练结果: ❌ FAILED" exit 1 } # 步骤 4: 数据完整性检查 Write-Host "[4/6] Running data integrity checks..." $testTables = Invoke-MySql -Query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TestDb';" -Silent -Scalar $sourceTables = Invoke-MySql -Query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.DbName)';" -Silent -Scalar Write-Host " Test DB tables: $testTables" Write-Host " Source DB tables: $sourceTables" $testRecords = Invoke-MySql -Query "SELECT SUM(table_rows) FROM information_schema.tables WHERE table_schema='$TestDb';" -Silent -Scalar $sourceRecords = Invoke-MySql -Query "SELECT SUM(table_rows) FROM information_schema.tables WHERE table_schema='$($db.DbName)';" -Silent -Scalar Write-Host " Test DB records: $testRecords" Write-Host " Source DB records: $sourceRecords" $integrityDetail = @" | 指标 | 测试库 | 源库 | |------|--------|------| | 表数量 | $testTables | $sourceTables | | 记录数(近似) | $testRecords | $sourceRecords | "@ if ([int]$testTables -ge [int]$sourceTables) { Write-Host " PASS: Table count matches" -ForegroundColor Green Step-Result "4 - 数据完整性检查" "PASSED" $integrityDetail } else { Write-Host " WARN: Test DB has fewer tables than source" -ForegroundColor Yellow Step-Result "4 - 数据完整性检查" "WARN" "$integrityDetail`n`n⚠️ 测试库表数量少于源库" } # 步骤 5: 冒烟测试 Write-Host "[5/6] Running smoke tests..." $smokePassed = 0 $smokeFailed = 0 $smokeDetail = "" # 测试 1: 检查 users 表 try { $userCount = Invoke-MySql -Query "SELECT COUNT(*) FROM users;" -Database $TestDb -Silent -Scalar $smokePassed++ $smokeDetail += "- ✅ users 表查询成功: $userCount 条记录`n" Write-Host " PASS: users table query: $userCount records" -ForegroundColor Green } catch { $smokeDetail += "- ⚠️ users 表不存在或查询失败`n" Write-Host " WARN: users table not found or query failed" -ForegroundColor Yellow } # 测试 2: 检查 schools 表 try { $schoolCount = Invoke-MySql -Query "SELECT COUNT(*) FROM schools;" -Database $TestDb -Silent -Scalar $smokePassed++ $smokeDetail += "- ✅ schools 表查询成功: $schoolCount 条记录`n" Write-Host " PASS: schools table query: $schoolCount records" -ForegroundColor Green } catch { $smokeDetail += "- ⚠️ schools 表不存在或查询失败`n" Write-Host " WARN: schools table not found or query failed" -ForegroundColor Yellow } # 测试 3: 基础表查询 try { $baseTableCount = Invoke-MySql -Query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$TestDb' AND table_type='BASE TABLE';" -Silent -Scalar if ([int]$baseTableCount -gt 0) { $smokePassed++ $smokeDetail += "- ✅ 基础表查询成功: $baseTableCount 个基础表`n" Write-Host " PASS: Base table query: $baseTableCount tables" -ForegroundColor Green } else { $smokeFailed++ $smokeDetail += "- ❌ 基础表查询失败`n" Write-Host " FAIL: Base table query failed" -ForegroundColor Red } } catch { $smokeFailed++ $smokeDetail += "- ❌ 基础表查询失败`n" Write-Host " FAIL: Base table query failed" -ForegroundColor Red } Step-Result "5 - 冒烟测试" "PASSED" "通过: $smokePassed, 失败: $smokeFailed`n`n$smokeDetail" # 步骤 6: 清理测试数据库 Write-Host "[6/6] Cleaning up test database..." if ($NoCleanup) { Write-Host " SKIP: Cleanup skipped (--NoCleanup)" -ForegroundColor Yellow Step-Result "6 - 清理测试数据库" "SKIPPED" "演练后保留测试数据库 ``$TestDb``" } else { try { Invoke-MySql -Query "DROP DATABASE IF EXISTS ``$TestDb``;" -Silent Write-Host " PASS: Test database dropped: $TestDb" -ForegroundColor Green Step-Result "6 - 清理测试数据库" "PASSED" "测试数据库 ``$TestDb`` 已删除" } catch { Write-Host " WARN: Could not drop test database (manual cleanup required)" -ForegroundColor Yellow Step-Result "6 - 清理测试数据库" "WARN" "⚠️ 无法删除测试数据库 ``$TestDb``,需手动清理" } } # 生成总结 $drillEnd = Get-Date $drillDuration = ($drillEnd - $drillStart).TotalSeconds Append-Report "## 演练结果" Append-Report "" if ($overallStatus -eq "SUCCESS") { Append-Report "**状态**: ✅ 成功" } else { Append-Report "**状态**: ❌ 失败" } Append-Report "**总耗时**: $([int]$drillDuration) 秒" Append-Report "**备份文件**: ``$BackupFile``" Append-Report "**测试数据库**: ``$TestDb``" Append-Report "" Append-Report "## RTO/RPO 评估" Append-Report "" Append-Report "- **RTO 目标**: 4 小时" Append-Report "- **本次恢复耗时**: $([int]$restoreDuration) 秒 ($([int]($restoreDuration / 60)) 分钟)" if ($restoreDuration -lt 14400) { Append-Report "- **RTO 评估**: ✅ 达标" } else { Append-Report "- **RTO 评估**: ⚠️ 需关注" } Append-Report "- **RPO 目标**: 24 小时(取决于备份频率)" Append-Report "" Write-Host "" Write-Host "=== Drill Summary ===" -ForegroundColor Cyan Write-Host "Status: $overallStatus" Write-Host "Duration: $([int]$drillDuration)s" Write-Host "Report: $ReportFile" Write-Host "" if ($overallStatus -eq "SUCCESS") { exit 0 } else { exit 1 }