feat(mysql): 添加MySQL全量备份与还原脚本及文档

新增备份脚本backup-sync.sh和还原脚本restore-sync.sh,用于处理严重复制冲突场景
更新故障分析文档,补充全量备份还原的操作流程和注意事项
添加测试流程模板文件_TEST_FLOW_TEMPLATE.md
更新SKILL.md文档,增加测试流程分析相关内容
This commit is contained in:
2026-02-02 14:40:46 +08:00
parent 3525aee49e
commit 3808192fd8
5 changed files with 511 additions and 2 deletions
@@ -0,0 +1,15 @@
# Test Flow Template
## Business Flow: [Flow Name]
| 步骤 | 涉及服务 | 关键接口/代码逻辑 | 测试内容/预期结果 |
| :--- | :--- | :--- | :--- |
| 1. [步骤名称] | `[服务名]` | `[类名.方法名()]` | [测试要点及预期行为描述] |
| 2. [步骤名称] | `[服务名]` | `[类名.方法名()]` | [测试要点及预期行为描述] |
### Testing Notes
- **Prerequisites**: [e.g., User must be logged in, Balance > 0]
- **Key Data Points**: [e.g., OrderID, TransactionID]
- **Edge Cases to Cover**:
- [Edge Case 1]
- [Edge Case 2]
@@ -0,0 +1,132 @@
#!/bin/bash
TAG=""
while getopts "t:" opt; do
case $opt in
t) TAG="$OPTARG" ;;
*) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
esac
done
shift $((OPTIND-1))
cd /data/backup/mysql || exit 1
if [ -n "$TAG" ]; then
BACKUP_DIR="./$(date +%Y%m%d%H%M%S)_${TAG}"
else
BACKUP_DIR="./$(date +%Y%m%d%H%M%S)"
fi
mkdir -p "$BACKUP_DIR"
MYSQL_USER="root"
MYSQL_PASSWORD="ENC(VN13kACOdhyl7xYqbgDMTMP0fK4dwoCkFe0AQW0VEt7/tpwx96GzRVY6eIpwlASM)"
MYSQL_HOST="127.0.0.1"
if [[ "$MYSQL_PASSWORD" =~ ^ENC\(.*\)$ ]]; then
encrypted_pwd="${MYSQL_PASSWORD:4:-1}"
if ! MYSQL_PASSWORD=$(java -cp /data/secure/jasypt-agent.jar com.jasypt.agent.DecryptionCLI $encrypted_pwd \
2>&1); then
echo -e "\033[31m[Fatal Error] Password decryption failed. Reason:"
echo "$MYSQL_PASSWORD" | grep -iE "error|exception"
exit 1
fi
fi
# Function to execute MySQL command
mysql_exec() {
docker exec -i mysql mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -h "$MYSQL_HOST" -N -e "$1" 2>/dev/null
}
# Function to restore MySQL state (called on exit)
restore_mysql_state() {
if [ "$READ_ONLY_SET" = "true" ]; then
echo "Restoring MySQL state..."
mysql_exec "SET GLOBAL read_only = OFF;" || echo "[Warning] Failed to set read_only = OFF"
echo "MySQL state restored"
fi
}
# Set trap to ensure MySQL state is restored even on error
trap restore_mysql_state EXIT ERR
# Pre-backup: Set read-only and stop slave
echo "Setting MySQL to read-only mode..."
if ! mysql_exec "SET GLOBAL read_only = ON;"; then
echo "[Critical Error] Failed to set read_only = ON"
exit 1
fi
READ_ONLY_SET="true"
echo "Stopping slave replication..."
if ! mysql_exec "STOP REPLICA;"; then
echo "[Warning] Failed to stop replica (may not be a slave server or already stopped)"
fi
# Get GTID position using SELECT @@global.gtid_executed;
echo "Getting GTID position..."
GTID_EXECUTED=$(mysql_exec "SELECT @@global.gtid_executed;")
if [ -n "$GTID_EXECUTED" ]; then
# Remove newlines and literal \n strings
GTID_EXECUTED=$(echo "$GTID_EXECUTED" | tr -d '\n' | sed 's/\\n//g' | tr -s ' ')
echo "GTID Executed: $GTID_EXECUTED"
# Write GTID to file for restore use
echo "$GTID_EXECUTED" > "$BACKUP_DIR/gtid_executed.txt"
else
echo "[Warning] Failed to get GTID executed information"
fi
# Get database list for backup
if [ $# -eq 0 ]; then
# When no arguments, get all non-system databases
databases=$(mysql_exec "SHOW DATABASES;" | grep -Ev "(Database|information_schema|performance_schema|mysql|skywalking|sys)")
# Convert to array and check empty values
readarray -t db_list <<< "$databases" || {
echo "ERROR: No valid databases found or failed to retrieve database list"
restore_mysql_state
exit 1
}
else
# Use arguments as database list when provided
db_list=("$@")
fi
# Check if database list is empty
if [ ${#db_list[@]} -eq 0 ]; then
echo "ERROR: No databases to backup"
restore_mysql_state
exit 1
fi
backup_list_str="$BACKUP_DIR.7z"
# Perform backup
for db in "${db_list[@]}"; do
echo "Backing up database: $db"
backup_list_str="$backup_list_str $db"
if ! docker exec -i mysql mysqldump -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -h "$MYSQL_HOST" --set-gtid-purged=OFF --single-transaction --databases "$db" > "$BACKUP_DIR/$db.sql"; then
echo "[Critical Error] Failed to backup database $db, aborting"
restore_mysql_state
exit 1
fi
done
# Post-backup: Restore MySQL state (before compression)
echo "Restoring MySQL to read-write mode..."
mysql_exec "SET GLOBAL read_only = OFF;" || echo "[Warning] Failed to set read_only = OFF"
# Clear the trap since we've successfully restored
READ_ONLY_SET="false"
trap - EXIT ERR
# Compress backup files
if ! 7zz a "$BACKUP_DIR.7z" "$BACKUP_DIR" >/dev/null; then
echo "Compression failed. Please check disk space or 7z installation"
exit 1
fi
# Cleanup temporary files
rm -rf "$BACKUP_DIR"
echo "Backup completed: $BACKUP_DIR.7z"
echo $backup_list_str >> backup_list.txt
echo "Backup process completed successfully"
@@ -99,3 +99,122 @@ BEGIN; COMMIT;
SET GTID_NEXT = 'AUTOMATIC';
START REPLICA;
```
## 2. 全量备份还原解决冲突(适用于严重冲突场景)
当复制冲突过多、数据不一致严重,或手动修复成本过高时,可以使用全量备份还原的方式彻底解决冲突问题。此方法通过从主库(或备份库)全量备份数据,然后在从库上完全还原,实现数据一致性。
### 2.1 相关脚本
- **备份脚本**: [`backup-sync.sh`](./backup-sync.sh) - 在主库或备份库上执行全量备份
- **还原脚本**: [`restore-sync.sh`](./restore-sync.sh) - 在从库上执行全量还原
### 2.2 操作流程
#### 步骤 1: 在备份机上执行备份
在需要备份的 MySQL 服务器(通常是主库或数据一致的节点)上执行备份脚本:
```bash
# 备份所有数据库(默认)
cd /data/backup/mysql
./backup-sync.sh
# 或指定标签备份
./backup-sync.sh -t conflict_resolve
# 或备份指定数据库
./backup-sync.sh database1 database2
```
备份脚本会自动:
- 设置 MySQL 为只读模式
- 停止复制(如果是从库)
- 记录当前 GTID 位置
- 执行全量备份并压缩为 `.7z` 文件
- 恢复 MySQL 为读写模式
备份完成后,会在 `/data/backup/mysql/` 目录下生成 `YYYYMMDDHHMMSS.7z` 或 `YYYYMMDDHHMMSS_TAG.7z` 格式的备份文件。
#### 步骤 2: 传输备份文件到还原机
使用 `scp` 将备份文件推送到需要还原的 DB 机的 `/data/backup/mysql/` 目录:
```bash
# 从备份机执行(将备份文件推送到还原机)
scp /data/backup/mysql/YYYYMMDDHHMMSS.7z user@restore-server:/data/backup/mysql/
# 示例
scp /data/backup/mysql/20250127120000.7z root@192.168.1.100:/data/backup/mysql/
```
**注意**: 确保还原机的 `/data/backup/mysql/` 目录存在且有写入权限。
#### 步骤 3: 在还原机上执行还原
在需要还原的 MySQL 服务器(从库)上执行还原脚本:
```bash
cd /data/backup/mysql
# 还原所有数据库
./restore-sync.sh YYYYMMDDHHMMSS.7z
# 或仅还原指定数据库
./restore-sync.sh YYYYMMDDHHMMSS.7z database1.sql
```
还原脚本会自动:
- 设置 MySQL 为只读模式
- 停止复制
- 重置二进制日志和 GTID
- 导入备份数据(导入时禁用 binlog,避免循环复制)
- 设置 GTID purged(从备份时的 GTID 位置)
- 恢复 MySQL 为读写模式
- **自动启动复制并检查状态**
#### 步骤 4: 在备份机上启动同步(重要)
还原完成后,还原脚本会自动启动复制并显示同步状态。确认看到同步成功后,**需要在备份机(主库)上执行以下操作**:
**A. Docker 执行模式 (包含 -h127.0.0.1):**
```bash
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
START REPLICA;
"
```
**B. 纯 SQL 模式:**
```sql
START REPLICA;
```
等待 3 秒后,检查同步状态:
**A. Docker 执行模式 (包含 -h127.0.0.1):**
```bash
sleep 3
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "SHOW REPLICA STATUS\G"
```
**B. 纯 SQL 模式:**
```sql
-- 等待 3 秒后执行
SHOW REPLICA STATUS\G;
```
检查关键指标:
- `Replica_IO_Running`: 应为 `Yes`
- `Replica_SQL_Running`: 应为 `Yes`
- `Last_IO_Error`: 应为空
- `Last_SQL_Error`: 应为空
- `Seconds_Behind_Source`: 延迟秒数(应逐渐减小)
### 2.3 注意事项
1. **备份时机**: 建议在业务低峰期执行备份,减少对业务的影响
2. **网络传输**: 确保备份机和还原机之间网络畅通,备份文件较大时传输可能需要较长时间
3. **磁盘空间**: 确保还原机有足够的磁盘空间存放备份文件和临时解压文件
4. **权限要求**: 脚本需要 MySQL root 权限,以及 `/data/backup/mysql/` 目录的读写权限
5. **GTID 一致性**: 还原脚本会自动处理 GTID,确保复制能够正常继续
6. **备份机同步**: 还原完成后务必在备份机(主库)上执行 `START REPLICA` 并检查状态,确保双向复制正常
@@ -0,0 +1,227 @@
#!/bin/bash
# Define variables
MYSQL_USER="root"
MYSQL_PASSWORD="ENC(VN13kACOdhyl7xYqbgDMTMP0fK4dwoCkFe0AQW0VEt7/tpwx96GzRVY6eIpwlASM)"
MYSQL_HOST="127.0.0.1"
BACKUP_DIR="/data/backup/mysql"
TEMP_DIR="/tmp/mysql_restore_temp"
if [[ "$MYSQL_PASSWORD" =~ ^ENC\(.*\)$ ]]; then
encrypted_pwd="${MYSQL_PASSWORD:4:-1}"
# Decrypt password
if ! MYSQL_PASSWORD=$(java -cp /data/secure/jasypt-agent.jar com.jasypt.agent.DecryptionCLI $encrypted_pwd \
2>&1); then
echo -e "\033[31m[Fatal Error] Password decryption failed. Reason:"
echo "$MYSQL_PASSWORD" | grep -iE "error|exception"
exit 1
fi
fi
# Function to execute MySQL command
mysql_exec() {
docker exec -i mysql mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -h "$MYSQL_HOST" -N -e "$1" 2>/dev/null
}
# Function to restore MySQL state (called on exit)
restore_mysql_state() {
if [ "$READ_ONLY_SET" = "true" ]; then
echo "Restoring MySQL state..."
mysql_exec "SET GLOBAL read_only = OFF;" || echo "[Warning] Failed to set read_only = OFF"
echo "MySQL state restored"
fi
}
# Check parameters
if [ $# -lt 1 ]; then
echo "Usage: $0 <backup_file.7z> [sql_file]"
echo "Example: $0 20250414105525.7z nacos.sql"
exit 1
fi
BACKUP_FILE="$1"
SQL_FILE="$2"
# Verify backup file existence
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
echo "Error: Backup file $BACKUP_DIR/$BACKUP_FILE not found"
exit 1
fi
# Extract base backup name
BACKUP_NAME=$(basename "$BACKUP_FILE" .7z)
# Create temporary directory
mkdir -p "$TEMP_DIR"
echo "Creating temporary directory: $TEMP_DIR"
# Extract backup file
echo "Extracting $BACKUP_FILE to $TEMP_DIR..."
7zz x "$BACKUP_DIR/$BACKUP_FILE" -o"$TEMP_DIR" -y
# Check extraction status
if [ $? -ne 0 ]; then
echo "Error: Decompression failed"
rm -rf "$TEMP_DIR"
exit 1
fi
echo "Extraction completed"
# Handle nested directory
NESTED_DIR="$TEMP_DIR/$BACKUP_NAME"
if [ -d "$NESTED_DIR" ]; then
echo "Detected nested directory: $NESTED_DIR"
SQL_DIR="$NESTED_DIR"
else
echo "No nested directory found, using root extraction path"
SQL_DIR="$TEMP_DIR"
fi
# Verify SQL files existence
SQL_COUNT=$(find "$SQL_DIR" -name "*.sql" | wc -l)
if [ "$SQL_COUNT" -eq 0 ]; then
echo "Error: No SQL files found in extracted directory"
rm -rf "$TEMP_DIR"
exit 1
fi
# Pre-restore: Set read-only, stop replica, and reset GTID
echo "Setting MySQL to read-only mode..."
if ! mysql_exec "SET GLOBAL read_only = ON;"; then
echo "[Critical Error] Failed to set read_only = ON"
rm -rf "$TEMP_DIR"
exit 1
fi
READ_ONLY_SET="true"
# Set trap to ensure MySQL state is restored even on error
trap restore_mysql_state EXIT ERR
echo "Stopping replica replication..."
if ! mysql_exec "STOP REPLICA;"; then
echo "[Warning] Failed to stop replica (may not be a replica server or already stopped)"
fi
echo "Resetting binary logs and GTIDs..."
if ! mysql_exec "RESET BINARY LOGS AND GTIDS;"; then
echo "[Critical Error] Failed to reset binary logs and GTIDs"
restore_mysql_state
rm -rf "$TEMP_DIR"
exit 1
fi
# Database import logic
if [ -n "$SQL_FILE" ]; then
# Import specific SQL file
if [ -f "$SQL_DIR/$SQL_FILE" ]; then
echo "Importing $SQL_FILE..."
db_name=$(basename "$SQL_FILE" .sql)
echo "Using database: $db_name"
# Create database if not exists
docker exec -i mysql mysql --init-command="SET SQL_LOG_BIN=0;" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -h"$MYSQL_HOST" --connect-expired-password -e "CREATE DATABASE IF NOT EXISTS \`$db_name\`;"
# Import SQL file
docker exec -i mysql mysql --init-command="SET SQL_LOG_BIN=0;" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -h"$MYSQL_HOST" --connect-expired-password "$db_name" < "$SQL_DIR/$SQL_FILE"
if [ $? -eq 0 ]; then
echo "Successfully imported $SQL_FILE"
else
echo "Error: Failed to import $SQL_FILE"
restore_mysql_state
rm -rf "$TEMP_DIR"
exit 1
fi
else
echo "Error: SQL file $SQL_DIR/$SQL_FILE not found"
restore_mysql_state
rm -rf "$TEMP_DIR"
exit 1
fi
else
# Import all SQL files
echo "Importing all SQL files..."
for sql_file in $(find "$SQL_DIR" -name "*.sql" | sort); do
db_name=$(basename "$sql_file" .sql)
echo "Importing $sql_file to database $db_name..."
# Create database if not exists
docker exec -i mysql mysql --init-command="SET SQL_LOG_BIN=0;" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -h"$MYSQL_HOST" --connect-expired-password -e "CREATE DATABASE IF NOT EXISTS \`$db_name\`;"
# Import SQL file
docker exec -i mysql mysql --init-command="SET SQL_LOG_BIN=0;" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -h"$MYSQL_HOST" --connect-expired-password "$db_name" < "$sql_file"
if [ $? -eq 0 ]; then
echo "Successfully imported $sql_file to $db_name"
else
echo "Error: Failed to import $sql_file to $db_name"
restore_mysql_state
rm -rf "$TEMP_DIR"
exit 1
fi
done
fi
# Post-restore: Set GTID purged from backup file
GTID_FILE="$SQL_DIR/gtid_executed.txt"
if [ -f "$GTID_FILE" ]; then
echo "Reading GTID from $GTID_FILE..."
GTID_EXECUTED=$(cat "$GTID_FILE" | tr -d '\n' | sed 's/\\n//g' | tr -s ' ' | xargs)
if [ -n "$GTID_EXECUTED" ]; then
echo "Setting GTID purged to: $GTID_EXECUTED"
if ! mysql_exec "SET GLOBAL gtid_purged = '$GTID_EXECUTED';"; then
echo "[Critical Error] Failed to set gtid_purged"
restore_mysql_state
rm -rf "$TEMP_DIR"
exit 1
fi
echo "GTID purged set successfully"
else
echo "[Warning] GTID file is empty"
fi
else
echo "[Warning] GTID file $GTID_FILE not found, skipping GTID purged setting"
fi
# Restore MySQL to read-write mode
echo "Restoring MySQL to read-write mode..."
mysql_exec "SET GLOBAL read_only = OFF;" || echo "[Warning] Failed to set read_only = OFF"
READ_ONLY_SET="false"
# Start replica
echo "Starting replica replication..."
if ! mysql_exec "START REPLICA;"; then
echo "[Warning] Failed to start replica (may not be a replica server)"
else
echo "Waiting 3 seconds for replica to initialize..."
sleep 3
echo "Checking replica status..."
REPLICA_STATUS=$(mysql_exec "SHOW REPLICA STATUS\G")
if [ -n "$REPLICA_STATUS" ]; then
echo "Replica Status:"
echo "$REPLICA_STATUS"
# Check for errors in replica status
ERROR_COUNT=$(echo "$REPLICA_STATUS" | grep -i "Last_Error\|Last_IO_Error\|Last_SQL_Error" | grep -v "No error" | wc -l)
if [ "$ERROR_COUNT" -gt 0 ]; then
echo "[Warning] Replica status shows errors, please check the output above"
else
echo "Replica status appears normal"
fi
else
echo "[Warning] Failed to get replica status"
fi
fi
# Clear the trap since we've successfully restored
trap - EXIT ERR
# Cleanup
echo "Cleaning temporary directory..."
rm -rf "$TEMP_DIR"
echo "Database restore operation completed"
exit 0