fix(备份): 修复快照位点与轮转期间的增量遗漏

统一跨平台备份入口,校验快照位点、完整性及增量链后隔离恢复。
末个 binlog 按读取位点归档,避免轮转后并发事务被续接跳过。
修复 Windows 安装器的编码、默认路径和花括号路径参数。
This commit is contained in:
liuwenxin 2026-11-03 17:18:52 +08:00
parent b552f1e567
commit ae12403c0b
8 changed files with 700 additions and 364 deletions

View File

@ -0,0 +1,144 @@
"""备份失败不得伪装成功,损坏文件及断链不得进入恢复。"""
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
spec = importlib.util.spec_from_file_location(
"payroll_backup", Path(__file__).resolve().parents[3] / "scripts/payroll_backup.py"
)
backup = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backup)
@pytest.fixture
def chain(tmp_path):
data = tmp_path / "full.sql.gz"
data.write_bytes(b"fixture")
baseline = {"kind": "FULL", "database": "payroll", "server_uuid": "server-a",
"point": {"file": "binlog.000001", "position": 100},
"entries": [{"file": data.name, "sha256": backup.checksum(data)}]}
backup.json_write(tmp_path / "full.json", baseline)
state = {"full": "full.json", "point": baseline["point"], "increments": []}
backup.json_write(tmp_path / "state.json", state)
return SimpleNamespace(directory=tmp_path, container="test", database="payroll"), baseline, state
def test_备份篡改与路径越界必须拒绝(chain):
args, _, _ = chain
(args.directory / "full.sql.gz").write_bytes(b"corrupted")
with pytest.raises(backup.BackupError, match="SHA-256"):
backup.checked_chain(args)
with pytest.raises(backup.BackupError, match="文件名"):
backup.load_manifest(args.directory, "../external.json")
def test_跨全量与位点断链拒绝恢复(chain):
args, _, state = chain
change = {"full": "other.json", "start": state["point"], "end": state["point"], "entries": []}
backup.json_write(args.directory / "increment.json", change)
state["increments"] = ["increment.json"]
backup.json_write(args.directory / "state.json", state)
with pytest.raises(backup.BackupError, match="不连续"):
backup.checked_chain(args)
def test_无更新不轮转日志也不生成增量(chain, monkeypatch):
args, baseline, state = chain
calls = []
def sql(*values):
calls.append(values)
return baseline["server_uuid"]
monkeypatch.setattr(backup, "mysql", sql)
monkeypatch.setattr(backup, "position", lambda _: state["point"])
assert backup.incremental(args)["status"] == "UNCHANGED"
assert len(calls) == 1
assert not list(args.directory.glob("increment*"))
def test_服务器更换后不续接旧链(chain, monkeypatch):
args, _, _ = chain
monkeypatch.setattr(backup, "mysql", lambda *a: "server-b")
with pytest.raises(backup.BackupError, match="身份已变化"):
backup.incremental(args)
def test_备份检查拒绝同服务器上的其他数据库(chain, monkeypatch):
args, _, _ = chain
args.database = "other_payroll"
calls = []
monkeypatch.setattr(backup, "mysql", lambda *a: calls.append(a))
with pytest.raises(backup.BackupError, match="检查目标不符"):
backup.check(args)
assert not calls
def test_轮转后的并发事务归档到末位点且续接不跳过(chain, monkeypatch):
args, baseline, state = chain
start = state["point"]
end = {"file": "binlog.000002", "position": 400}
monkeypatch.setattr(backup, "mysql", lambda *a: baseline["server_uuid"])
monkeypatch.setattr(backup, "position", lambda _: end)
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000001", "binlog.000002"])
commands = []
def archive(command, path):
commands.append(command)
path.write_bytes(b"archived")
return {"file": path.name, "sha256": backup.checksum(path)}
monkeypatch.setattr(backup, "stream_gzip", archive)
first = backup.incremental(args)
manifest = backup.load_manifest(args.directory, first["manifest"])
assert manifest["start"] == start
assert manifest["end"] == end
assert [entry["binlog"] for entry in manifest["entries"]] == [
"binlog.000001", "binlog.000002"
]
assert commands[-1][-4:] == ["head", "-c", "400", "/var/lib/mysql/binlog.000002"]
# 下一轮仍从上一轮已归档的末位点续接,跨轮不会遗漏或重复计算事务。
next_end = {"file": "binlog.000003", "position": 157}
monkeypatch.setattr(backup, "position", lambda _: next_end)
monkeypatch.setattr(backup, "logs", lambda _: ["binlog.000002", "binlog.000003"])
second = backup.incremental(args)
next_manifest = backup.load_manifest(args.directory, second["manifest"])
assert next_manifest["start"] == end
assert [entry["binlog"] for entry in next_manifest["entries"]] == [
"binlog.000002", "binlog.000003"
]
assert backup.checked_chain(args)[1] == [manifest, next_manifest]
def test_损坏清单不得启动任何恢复容器(chain, monkeypatch):
args, _, _ = chain
(args.directory / "full.sql.gz").write_bytes(b"broken")
commands = []
monkeypatch.setattr(backup, "run", lambda command, **kw: commands.append(command))
with pytest.raises(backup.BackupError):
backup.restore(args)
assert commands == []
def test_清单原子落盘不留下半成品(tmp_path):
target = tmp_path / "manifest.json"
backup.json_write(target, {"status": "SUCCEEDED"})
assert json.loads(target.read_text())["status"] == "SUCCEEDED"
assert not list(tmp_path.glob("*.partial"))
def test_MySQL探活使用TCP避开镜像初始化临时服务(monkeypatch):
commands = []
def run(command, **kwargs):
commands.append(command)
assert kwargs["data"] == b"SELECT 1"
return b"1\n"
monkeypatch.setattr(backup, "run", run)
assert backup.mysql("isolated-test", "SELECT 1") == "1"
command = commands[0]
assert "--host=127.0.0.1 --protocol=TCP" in command[command.index("-c") + 1]

7
docker/Dockerfile.backup Normal file
View File

@ -0,0 +1,7 @@
# 官方最小 MySQL 镜像缺少 mysqlbinlog;仅在独立恢复演练镜像内补齐同版本工具。
ARG MYSQL_IMAGE=mysql:8.0.46
FROM ${MYSQL_IMAGE}
ADD https://repo.mysql.com/yum/mysql-8.0-community/el/9/x86_64/mysql-community-client-8.0.46-1.el9.x86_64.rpm /tmp/mysql-client.rpm
RUN echo "decc2c06fac430b7987cfae4e4039cb49bdce56dfadfd0a28738bf0757f04f17 /tmp/mysql-client.rpm" | sha256sum -c - \
&& rpm -i --nodeps --replacefiles /tmp/mysql-client.rpm \
&& mysqlbinlog --version

View File

@ -1,64 +1,5 @@
#!/usr/bin/env bash
# 衍水工资系统 —— MySQL 全量备份(⑨ 备份恢复与运维检查)
#
# 思路:在跑中的容器内执行 mysqldump,免暴露 3306 端口给宿主机工具,
# 也避免宿主机装 MySQL 客户端。凭据从 docker/.env 读取(不入脚本、不入版本库)。
#
# 用法:在项目根目录执行 bash scripts/backup_full.sh
# 产物:db/backups/payroll_YYYYMMDD_HHMMSS.sql.gz,永久保留,不自动清理。
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f docker/.env ]]; then
# shellcheck disable=SC1091
source docker/.env
else
echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2
exit 1
fi
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}"
BACKUP_DIR="$ROOT/db/backups"
mkdir -p "$BACKUP_DIR"
STAMP="$(date +%Y%m%d_%H%M%S)"
OUT="$BACKUP_DIR/payroll_${STAMP}.sql.gz"
# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致
CONTAINER="ys-mysql-dev"
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2
exit 1
fi
echo "开始备份 $MYSQL_DATABASE → $(basename "$OUT")"
docker exec "$CONTAINER" sh -c \
"mysqldump -uroot -p\"\$MYSQL_ROOT_PASSWORD\" --single-transaction --routines --triggers \"$MYSQL_DATABASE\"" \
| gzip > "$OUT"
if [[ ! -s "$OUT" ]]; then
echo "ERROR: 备份文件为空(数据库名或凭据可能不对)" >&2
rm -f "$OUT"
exit 1
fi
SIZE="$(du -h "$OUT" | cut -f1)"
echo "备份完成:$(basename "$OUT") ($SIZE)"
# 记录 binlog 位点:增量备份(backup_incremental.sh)与恢复重放的对齐锚点——
# 从「此位点之后」的 binlog 开始重放即为完整增量链
docker exec "$CONTAINER" sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW MASTER STATUS"' > "${OUT}.pos" 2>/dev/null
if [[ -s "${OUT}.pos" ]]; then
echo "binlog 位点:$(awk '{print $1, "pos="$2}' "${OUT}.pos") → $(basename "$OUT").pos"
fi
echo "永久留存:未自动清理任何备份"
echo ""
echo "最新备份:"
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -3
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" full "$@"

View File

@ -1,89 +1,5 @@
#!/usr/bin/env bash
# 衍水工资系统 —— MySQL 增量备份(binlog 归档)
#
# 口径(澄清 #19 业务答复 2026-10-19):每日全量 + 有更新时增量补充备份。
# 增量 = 归档 MySQL binlog:自上次归档之后产生的新 binlog 文件逐个拷出压缩;
# 没有新变更时不产出文件(「有更新时」才补充)。
#
# 恢复顺序:最新全量(backup_full.sh 产物)→ 依次重放其后的 binlog 增量包
# (用 mysqlbinlog 重放,见 docs/备份恢复手册.md 增量章节)。
#
# 实现说明:mysql:8.0 官方镜像不含 mysqlbinlog 客户端,故不走
# --read-from-remote-server,直接从数据目录 tar 拷出原始 binlog 文件;
# 只归档「已写完」的文件(SHOW BINARY LOGS 列表最后一个为当前写入中,
# 留到下次收取,配合 30 天 binlog 过期窗口余量充足)。
#
# 用法:在项目根目录执行 bash scripts/backup_incremental.sh
# 产物:db/backups/binlog_NNNNNN.gz(NNNNNN 为 binlog 序号),永久保留,不自动清理。
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f docker/.env ]]; then
# shellcheck disable=SC1091
source docker/.env
else
echo "ERROR: docker/.env 不存在(请先创建 docker/.env 并填入 MySQL 凭据)" >&2
exit 1
fi
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
BACKUP_DIR="$ROOT/db/backups"
STATE_FILE="$BACKUP_DIR/.incremental_state"
mkdir -p "$BACKUP_DIR"
# 容器名固定(compose 显式 container_name),与 docker/docker-compose.dev.yml 保持一致
CONTAINER="ys-mysql-dev"
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "ERROR: 容器 $CONTAINER 未运行(先 docker compose up -d)" >&2
exit 1
fi
# 列出 binlog:文件名 序号 大小(mysql -N 无表头,直接出数据行)
mapfile -t BINLOGS < <(
docker exec "$CONTAINER" sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -e "SHOW BINARY LOGS"' 2>/dev/null \
| awk '{print $1}'
)
if [[ ${#BINLOGS[@]} -eq 0 ]]; then
echo "ERROR: SHOW BINARY LOGS 无输出(binlog 未开启或凭据不对)" >&2
exit 1
fi
if [[ ${#BINLOGS[@]} -eq 1 ]]; then
echo "仅有写入中的 binlog(${BINLOGS[0]}),无已写完文件可归档"
exit 0
fi
COMPLETED=("${BINLOGS[@]:0:${#BINLOGS[@]}-1}") # 去掉写入中的最后一个
LAST="$(cat "$STATE_FILE" 2>/dev/null || echo "")"
NEW=()
for f in "${COMPLETED[@]}"; do
# 与状态文件比较序号:无状态(首次)全收;有状态只收更大的
if [[ -z "$LAST" ]] || (( ${f##*.} > ${LAST##*.} )); then
NEW+=("$f")
fi
done
if [[ ${#NEW[@]} -eq 0 ]]; then
echo "无新增变更(上次归档至 ${LAST},无需补充增量)"
exit 0
fi
STAMP="$(date +%Y%m%d_%H%M%S)"
echo "归档 ${#NEW[@]} 个 binlog(${NEW[0]} ~ ${NEW[-1]})"
for f in "${NEW[@]}"; do
OUT="$BACKUP_DIR/${f}.gz"
docker exec "$CONTAINER" sh -c "tar cf - -C /var/lib/mysql '$f'" | gzip > "$OUT"
if [[ ! -s "$OUT" ]]; then
echo "ERROR: $OUT 为空" >&2
rm -f "$OUT"
exit 1
fi
echo " $(basename "$OUT") ($(du -h "$OUT" | cut -f1))"
done
echo "$STAMP ${NEW[-1]}" > "$STATE_FILE"
# 归档文件永久留存;服务器原始 binlog 过期与归档留存是不同配置,部署前验证归档链完整。
echo "增量归档完成,最新至 ${NEW[-1]}"
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" incremental "$@"

View File

@ -1,106 +1,5 @@
#!/usr/bin/env bash
# 运维健康检查 —— 容器/磁盘/备份新鲜度/登录异常(⑨ 输出一份可读报告)
#
# 用法:在项目根目录执行 bash scripts/health_check.sh
# 每项 PASS/WARN/FAIL,最后给出结论。WARN/FAIL 时退出码非零提示运维介入。
set -uo pipefail
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f docker/.env ]]; then
# shellcheck disable=SC1091
source docker/.env
fi
ISSUES=0
report() { printf "%-10s %s\n" "$1" "$2"; }
warn() { report "WARN" "$1"; ISSUES=$((ISSUES+1)); }
fail() { report "FAIL" "$1"; ISSUES=$((ISSUES+1)); }
pass() { report "PASS" "$1"; }
echo "======== 衍水工资系统 健康检查 $(date '+%Y-%m-%d %H:%M:%S') ========"
# 1. 容器健康
for C in ys-mysql-dev ys-redis-dev; do
if ! docker inspect "$C" >/dev/null 2>&1; then
fail "容器 $C 不存在"
continue
fi
STATUS="$(docker inspect -f '{{.State.Health.Status}}' "$C" 2>/dev/null || echo none)"
RUNNING="$(docker inspect -f '{{.State.Running}}' "$C" 2>/dev/null)"
if [[ "$RUNNING" == "true" && "$STATUS" == "healthy" ]]; then
pass "容器 $C 运行中且健康"
elif [[ "$RUNNING" == "true" ]]; then
warn "容器 $C 运行中但健康检查=$STATUS"
else
fail "容器 $C 未运行"
fi
done
# 2. 磁盘水位
DISK_PCT="$(df -h "$ROOT" | awk 'NR==2 {gsub(/%/,"",$5); print $5}')"
if [[ -z "$DISK_PCT" ]]; then
warn "磁盘信息读取失败"
elif (( DISK_PCT < 80 )); then
pass "磁盘使用率 ${DISK_PCT}%(阈值 80%)"
elif (( DISK_PCT < 90 )); then
warn "磁盘使用率 ${DISK_PCT}%(建议清理)"
else
fail "磁盘使用率 ${DISK_PCT}%(高危,立即清理)"
fi
# 3. 备份新鲜度
BACKUP_DIR="$ROOT/db/backups"
LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)"
if [[ -z "$LATEST" ]]; then
warn "未找到任何备份(应每日备份)"
else
AGE_H=$(( ($(date +%s) - $(stat -c %Y "$LATEST")) / 3600 ))
if (( AGE_H < 24 )); then
pass "最新备份 $(basename "$LATEST"),${AGE_H} 小时前"
elif (( AGE_H < 48 )); then
warn "最新备份已 ${AGE_H} 小时前(超过 24 小时)"
else
fail "最新备份已 ${AGE_H} 小时前(备份任务可能已停)"
fi
fi
# 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行;
# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD)
if [[ -n "${REDIS_PASSWORD:-}" ]]; then
if docker exec ys-redis-dev sh -c 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping' 2>/dev/null | grep -q PONG; then
pass "Redis 响应 PONG"
else
warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)"
fi
else
warn "docker/.env 缺 REDIS_PASSWORD,跳过 Redis 探活"
fi
# 5. 登录日志异常(24h 失败次数)
if [[ -n "${MYSQL_ROOT_PASSWORD:-}" ]] && docker inspect ys-mysql-dev >/dev/null 2>&1; then
FAILS=$(docker exec ys-mysql-dev sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" ${MYSQL_DATABASE:-payroll} -N -B -e \"
SELECT COUNT(*) FROM login_logs
WHERE success = 0 AND create_time > NOW() - INTERVAL 24 HOUR;\" 2>/dev/null" || echo "?")
if [[ "$FAILS" == "?" ]]; then
warn "登录日志查询失败"
elif (( FAILS < 10 )); then
pass "24h 登录失败 ${FAILS} 次(阈值 10)"
else
warn "24h 登录失败 ${FAILS} 次(可能有暴力破解,建议核查)"
fi
else
warn "跳过登录日志检查(无凭据或容器未运行)"
fi
echo "=============================================================="
if (( ISSUES == 0 )); then
echo "结论:全部通过,系统健康。"
exit 0
else
echo "结论:发现 $ISSUES 项需关注(见上)。"
exit 1
fi
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" check "$@"

489
scripts/payroll_backup.py Normal file
View File

@ -0,0 +1,489 @@
"""跨平台 MySQL 备份:一致位点、binlog 完整链、独立容器恢复、只读检查。"""
import argparse
import gzip
import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import time
from datetime import UTC, datetime
from itertools import pairwise
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IDENT = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
LOG = re.compile(r"^binlog\.\d{6,}$")
class BackupError(RuntimeError):
pass
def run(args, *, data=None, env=None):
result = subprocess.run(args, input=data, capture_output=True, env=env, check=False)
if result.returncode:
# 原始数据库错误可能含业务内容;只报告操作及退出状态。
raise BackupError(f"{args[0]} 操作失败,退出码 {result.returncode}")
return result.stdout
def mysql(container, sql, database=None):
args = [
"docker",
"exec",
"-i",
container,
"sh",
"-c",
# 官方镜像初始化期间会启动禁用 TCP 的临时服务,不能把它当作就绪实例。
'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; '
'exec mysql -uroot --host=127.0.0.1 --protocol=TCP -N -B "$@"',
"mysql",
]
if database:
args.append(database)
return run(args, data=sql.encode()).decode("utf-8").strip()
def json_write(path, value):
temporary = path.with_suffix(path.suffix + ".partial")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary.replace(path)
def checksum(path):
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def stream_to(args, source):
"""按块恢复大库和日志,避免将全部数据加载进内存。"""
with subprocess.Popen(
args,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
) as process:
shutil.copyfileobj(source, process.stdin, 1024 * 1024)
process.stdin.close()
if process.wait() != 0:
raise BackupError("恢复数据流执行失败")
def logs(container):
rows = [r.split("\t")[0] for r in mysql(container, "SHOW BINARY LOGS").splitlines()]
if not rows or any(not LOG.fullmatch(r) for r in rows):
raise BackupError("binlog 未开启或文件名不受支持")
return rows
def position(container):
parts = mysql(container, "SHOW MASTER STATUS").split("\t")
if len(parts) < 2 or not LOG.fullmatch(parts[0]):
raise BackupError("无法获得 binlog 位点")
return {"file": parts[0], "position": int(parts[1])}
def stream_gzip(args, out):
temporary = out.with_suffix(out.suffix + ".partial")
with subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
) as process:
with gzip.open(temporary, "wb") as dest:
shutil.copyfileobj(process.stdout, dest, 1024 * 1024)
if process.wait() != 0:
raise BackupError("备份流失败,未发布成功清单,临时文件保留供核对")
temporary.replace(out)
return {"file": out.name, "sha256": checksum(out)}
def full(args):
stamp = (
datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
+ "_"
+ secrets.token_hex(3)
)
out = args.directory / f"full_{stamp}.sql.gz"
server = mysql(args.container, "SELECT @@server_uuid")
binary = logs(args.container) # 关闭 binlog 时不生成无法续接的全量。
command = [
"docker",
"exec",
args.container,
"sh",
"-c",
(
'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysqldump -uroot '
"--single-transaction --source-data=2 --set-gtid-purged=OFF "
'--routines --events --triggers --hex-blob --no-tablespaces "$1"'
),
"backup",
args.database,
]
entry = stream_gzip(command, out)
point, completed, tables = None, False, []
with gzip.open(out, "rt", encoding="utf-8") as stream:
for line in stream:
match = re.search(
r"(?:SOURCE|MASTER)_LOG_FILE='([^']+)', (?:SOURCE|MASTER)_LOG_POS=(\d+)",
line,
)
if match:
point = {"file": match[1], "position": int(match[2])}
table = re.match(r"CREATE TABLE `([^`]+)`", line)
if table:
tables.append(table[1])
completed = completed or line.startswith("-- Dump completed on ")
if not completed or not point or not tables or not LOG.fullmatch(point["file"]):
raise BackupError("全量缺少完成标识、业务表或一致快照位点,不能作为恢复基线")
manifest = {
"kind": "FULL",
"database": args.database,
"server_uuid": server,
"created_at": datetime.now(UTC).isoformat(),
"image": args.image,
"point": point,
"entries": [entry],
"tables": sorted(tables),
"logs_seen": binary,
}
name = f"full_{stamp}.json"
json_write(args.directory / name, manifest)
json_write(
args.directory / "state.json",
{
"full": name,
"point": point,
"increments": [],
"checked_at": datetime.now(UTC).isoformat(),
},
)
return {"status": "SUCCEEDED", "manifest": name, "point": point}
def load_manifest(directory, name):
if Path(name).name != name:
raise BackupError("备份清单必须是备份目录内的文件名")
value = json.loads((directory / name).read_text(encoding="utf-8"))
for entry in value["entries"]:
filename = entry["file"]
if (
Path(filename).name != filename
or checksum(directory / filename) != entry["sha256"]
):
raise BackupError("备份文件缺失或 SHA-256 不符")
return value
def incremental(args):
state_path = args.directory / "state.json"
if not state_path.exists():
raise BackupError("请先完成全量备份")
state = json.loads(state_path.read_text(encoding="utf-8"))
baseline = load_manifest(args.directory, state["full"])
if baseline["database"] != args.database or baseline["server_uuid"] != mysql(
args.container, "SELECT @@server_uuid"
):
raise BackupError("数据库或服务器身份已变化,必须先建立新的全量基线")
current = position(args.container)
if state["point"] == current:
state["checked_at"] = datetime.now(UTC).isoformat()
json_write(state_path, state)
return {"status": "UNCHANGED", "point": current}
available = logs(args.container)
if state["point"]["file"] not in available:
raise BackupError("增量链起点已被清理,必须重新全量备份")
mysql(args.container, "FLUSH BINARY LOGS")
end = position(args.container)
available = logs(args.container)
names = available[
available.index(state["point"]["file"]) : available.index(end["file"]) + 1
]
numbers = [int(n.split(".")[1]) for n in names]
if any(b != a + 1 for a, b in pairwise(numbers)):
raise BackupError("binlog 序号存在缺口,拒绝发布增量成功")
stamp = secrets.token_hex(8)
entries = []
for name in names:
# 轮转后读取位点前仍可能有事务写入新文件;末文件也要归档到该位点,
# 否则下一次从 end 续接时会永久跳过这些事务。固定字节边界避免重复重放。
command = ["docker", "exec", args.container]
if name == end["file"]:
command.extend(["head", "-c", str(end["position"]), f"/var/lib/mysql/{name}"])
else:
command.extend(["cat", f"/var/lib/mysql/{name}"])
entry = stream_gzip(
command,
args.directory / f"{stamp}_{name}.gz",
)
entry["binlog"] = name
entries.append(entry)
manifest_name = f"increment_{stamp}.json"
manifest = {
"kind": "INCREMENTAL",
"full": state["full"],
"start": state["point"],
"end": end,
"entries": entries,
"created_at": datetime.now(UTC).isoformat(),
}
json_write(args.directory / manifest_name, manifest)
state["increments"].append(manifest_name)
state["point"] = end
state["checked_at"] = datetime.now(UTC).isoformat()
json_write(state_path, state)
return {"status": "SUCCEEDED", "manifest": manifest_name, "files": len(entries)}
def checked_chain(args):
state = json.loads((args.directory / "state.json").read_text(encoding="utf-8"))
baseline = load_manifest(args.directory, state["full"])
point, increments = baseline["point"], []
for name in state["increments"]:
change = load_manifest(args.directory, name)
if change["full"] != state["full"] or change["start"] != point:
raise BackupError("增量清单不连续或跨全量基线")
increments.append(change)
point = change["end"]
if point != state["point"]:
raise BackupError("增量末位点与状态不一致")
return baseline, increments
def restore(args):
"""只创建随机命名、独立可写层的临时 MySQL;不接触已有数据卷。"""
baseline, increments = checked_chain(args)
target = "payroll-drill-" + secrets.token_hex(8)
env = os.environ | {"MYSQL_ROOT_PASSWORD": secrets.token_urlsafe(32)}
volume = target + "-data"
run(["docker", "volume", "create", "--label", "payroll.restore-drill=true", volume])
run(
[
"docker",
"run",
"-d",
"--name",
target,
"--label",
"payroll.restore-drill=true",
"--mount",
f"type=volume,source={volume},target=/var/lib/mysql",
"--env",
"MYSQL_ROOT_PASSWORD",
args.image,
],
env=env,
)
try:
for _ in range(300):
try:
mysql(target, "SELECT 1")
break
except BackupError:
time.sleep(2)
else:
raise BackupError("隔离恢复容器未就绪")
database = baseline["database"]
if not IDENT.fullmatch(database):
raise BackupError("备份数据库名称无效")
mysql(target, f"CREATE DATABASE `{database}` CHARACTER SET utf8mb4")
command = [
"docker",
"exec",
"-i",
target,
"sh",
"-c",
'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysql -uroot "$1"',
"restore",
database,
]
with subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
) as process:
with gzip.open(
args.directory / baseline["entries"][0]["file"], "rb"
) as source:
shutil.copyfileobj(source, process.stdin, 1024 * 1024)
process.stdin.close()
if process.wait() != 0:
raise BackupError("隔离全量恢复失败")
for change in increments:
paths = []
for entry in change["entries"]:
name = entry["binlog"]
if not LOG.fullmatch(name):
raise BackupError("增量文件名无效")
with gzip.open(args.directory / entry["file"], "rb") as source:
stream_to(
[
"docker",
"exec",
"-i",
target,
"sh",
"-c",
'cat > "/tmp/$1"',
"archive",
name,
],
source,
)
paths.append(f"/tmp/{name}")
if paths:
# mysqlbinlog 必须在同一次调用中按顺序消费多个文件,保留临时表上下文。
decoder_command = [
"docker",
"exec",
target,
"mysqlbinlog",
"--disable-log-bin",
f"--database={database}",
f"--start-position={change['start']['position']}",
*paths,
]
with subprocess.Popen(
decoder_command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
) as decoder:
with subprocess.Popen(
command,
stdin=decoder.stdout,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
) as consumer:
decoder.stdout.close()
consumed = consumer.wait()
if decoder.wait() or consumed:
raise BackupError("增量重放失败,不能将恢复标为成功")
tables = mysql(target, "SHOW TABLES", database).splitlines()
if not increments and sorted(tables) != baseline["tables"]:
raise BackupError("恢复后表清单不符")
counts = {
table: int(mysql(target, f"SELECT COUNT(*) FROM `{table}`", database))
for table in tables
if IDENT.fullmatch(table)
}
result = {
"status": "SUCCEEDED",
"container": target,
"database": database,
"increments": len(increments),
"table_counts": counts,
"scope": "独立容器;生产库未覆盖",
}
json_write(args.directory / f"{target}.json", result)
return result
finally:
# 仅停止本次随机创建且带标签的容器,保留恢复内容供复核,不批量删除历史演练。
run(["docker", "stop", target])
def check(args):
baseline, increments = checked_chain(args)
if baseline["database"] != args.database:
raise BackupError("备份数据库与检查目标不符")
age = (
datetime.now(UTC) - datetime.fromisoformat(baseline["created_at"])
).total_seconds()
if age > 26 * 3600:
raise BackupError("最新全量备份超过 26 小时")
uuid = mysql(args.container, "SELECT @@server_uuid")
if uuid != baseline["server_uuid"]:
raise BackupError("运行服务器与备份身份不符")
if mysql(args.container, "SELECT @@innodb_force_recovery") != "0":
raise BackupError("数据库仍处于强制恢复模式")
state = json.loads((args.directory / "state.json").read_text(encoding="utf-8"))
checked_at = state.get("checked_at", baseline["created_at"])
if (
datetime.now(UTC) - datetime.fromisoformat(checked_at)
).total_seconds() > 30 * 60:
raise BackupError("增量采集检查超过 30 分钟未运行")
redis = getattr(args, "redis_container", None)
if redis:
pong = run(
[
"docker",
"exec",
redis,
"sh",
"-c",
'REDISCLI_AUTH="$REDIS_PASSWORD" redis-cli --no-auth-warning PING',
]
)
if pong.strip() != b"PONG":
raise BackupError("Redis 探活失败")
stat = shutil.disk_usage(args.directory)
if stat.free / stat.total < 0.1:
raise BackupError("备份盘可用空间低于 10%")
return {
"status": "SUCCEEDED",
"full_age_hours": round(age / 3600, 2),
"increment_count": len(increments),
"free_bytes": stat.free,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"action", choices=["full", "incremental", "check", "restore-drill"]
)
parser.add_argument("--container", default="ys-mysql-dev")
parser.add_argument("--database", default="payroll")
parser.add_argument("--directory", type=Path, default=ROOT / "db/backups")
parser.add_argument("--image", default="payroll-mysql-backup:8.0.46")
parser.add_argument("--redis-container", default="ys-redis-dev")
args = parser.parse_args()
if not IDENT.fullmatch(args.database) or not re.fullmatch(
r"[A-Za-z0-9_.-]+", args.container
):
parser.error("容器名或数据库名无效")
args.directory.mkdir(parents=True, exist_ok=True)
lock = args.directory / ".backup.lock"
try:
with lock.open("x", encoding="utf-8") as stream:
stream.write(str(os.getpid()))
except FileExistsError:
print("备份目录已有活动锁,请核实上次进程状态", file=sys.stderr)
return 1
try:
result = {
"full": full,
"incremental": incremental,
"check": check,
"restore-drill": restore,
}[args.action](args)
json_write(
args.directory / f"last-{args.action}-run.json",
{"finished_at": datetime.now(UTC).isoformat(), **result},
)
print(json.dumps(result, ensure_ascii=False))
return 0
except (BackupError, OSError, ValueError, KeyError) as error:
json_write(
args.directory / "last-failure.json",
{
"finished_at": datetime.now(UTC).isoformat(),
"action": args.action,
"status": "FAILED",
"reason": str(error),
},
)
print(f"操作失败:{error}", file=sys.stderr)
return 1
finally:
lock.unlink()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,116 +1,5 @@
#!/usr/bin/env bash
# 恢复演练 —— 把最近一份备份恢复到「临时库」并核对行数(⑨:备份不演练 = 没有备份)
#
# 演练绝不直接覆盖生产库;临时库名 payroll_drill_<时间戳>,核对完由
# 调用方决定是否删除(本脚本默认不 DROP,安全优先)。
#
# 用法:在项目根目录执行 bash scripts/restore_drill.sh
# 产物:临时库 + 终端行数对比报告;--drop-exec 参数在核对通过后删除临时库。
# 统一到跨平台备份工具;成功以完整性清单及退出状态为准。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [[ -f docker/.env ]]; then
# shellcheck disable=SC1091
source docker/.env
else
echo "ERROR: docker/.env 不存在" >&2
exit 1
fi
: "${MYSQL_DATABASE:?docker/.env 缺 MYSQL_DATABASE}"
: "${MYSQL_ROOT_PASSWORD:?docker/.env 缺 MYSQL_ROOT_PASSWORD}"
CONTAINER="ys-mysql-dev"
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
echo "ERROR: 容器 $CONTAINER 未运行" >&2
exit 1
fi
BACKUP_DIR="$ROOT/db/backups"
LATEST="$(ls -t "$BACKUP_DIR"/payroll_*.sql.gz 2>/dev/null | head -1)"
if [[ -z "$LATEST" ]]; then
echo "ERROR: 未找到备份文件(先 bash scripts/backup_full.sh)" >&2
exit 1
fi
DRILL_DB="payroll_drill_$(date +%Y%m%d_%H%M%S)"
# 0. --drop-exec:演练前先清掉**全部**历史遗留 drill 库(含上一轮「保留供
# 核对」的那份——否则每演练一轮净增一份整库拷贝,磁盘被无人认领的
# drill 库吃满),演练完成后本轮临时库也随之删除
if [[ "${1:-}" == "--drop-exec" ]]; then
OLD_DBS=$(docker exec "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SHOW DATABASES LIKE 'payroll_drill_%';\"" 2>/dev/null || true)
for OLD in $OLD_DBS; do
docker exec "$CONTAINER" sh -c \
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$OLD\""
echo "已清理历史演练库 $OLD"
done
fi
echo "演练恢复:$(basename "$LATEST") → 临时库 $DRILL_DB"
# 1. 创建临时库(mysqladmin create 无反引号转义问题)
docker exec "$CONTAINER" sh -c \
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" create \"$DRILL_DB\""
# 2. 解压导入
gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\""
# 3. 行数核对:**全部业务表**(新增表必须同步补入本清单——备份漏表时
# 演练要能发现;alembic_version 不列,行数恒 1 无核对价值)
TABLES=(
adjustment_orders approval_orders approval_steps business_items business_roles
business_secondary_allocations business_status_logs businesses customer_rating_grades
customers login_logs monthly_settlements operation_logs ownership_change_logs
param_change_batches param_definitions param_values performance_ledgers permissions
personnel personnel_plans personnel_regions products receipts role_permissions roles
settlement_items shipment_items shipments system_settings user_roles users
)
echo ""
echo "======== 行数对比(源库 vs 演练库)========"
printf "%-24s %12s %12s %6s\n" "表名" "源库" "演练库" "结果"
ALL_OK=1
for T in "${TABLES[@]}"; do
SRC=$(docker exec "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $MYSQL_DATABASE.$T;\" 2>/dev/null" || echo "?")
DST=$(docker exec "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $DRILL_DB.$T;\" 2>/dev/null" || echo "?")
if [[ "$SRC" == "?" || "$DST" == "?" ]]; then
# 查询失败(表缺失/权限/客户端故障)不可与「数据一致」混淆:?
# 与 ? 相等会误报可恢复——备份真的漏表时演练必须红
MARK="✗"
ALL_OK=0
elif [[ "$SRC" == "$DST" ]]; then
MARK="✓"
else
MARK="✗"
ALL_OK=0
fi
printf "%-24s %12s %12s %6s\n" "$T" "$SRC" "$DST" "$MARK"
done
echo "=========================================="
if [[ "$ALL_OK" == "1" ]]; then
echo ""
echo "结果:全部表行数一致,备份可恢复。"
if [[ "${1:-}" == "--drop-exec" ]]; then
docker exec "$CONTAINER" sh -c \
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$DRILL_DB\""
echo "本轮临时库 $DRILL_DB 已删除(历史遗留亦在演练前清理)。"
else
echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec,"
echo "会先清掉包括它在内的全部演练库再演练,本轮完成后同样即删。"
fi
exit 0
else
echo ""
echo "结果:存在不一致,请检查备份文件与数据库结构。" >&2
echo "临时库 $DRILL_DB 保留供排查。" >&2
exit 1
fi
exec "${PYTHON:-python}" "$ROOT/scripts/payroll_backup.py" restore-drill "$@"

View File

@ -0,0 +1,51 @@
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory = $true)][string]$PythonPath,
[Parameter(Mandatory = $true)][string]$BackupDirectory,
[string]$ProjectRoot,
[string]$Container = 'ys-mysql-dev',
[string]$Database = 'payroll',
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
if (-not $ProjectRoot) {
$ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
}
$project = [IO.Path]::GetFullPath($ProjectRoot)
$python = [IO.Path]::GetFullPath($PythonPath)
$backups = [IO.Path]::GetFullPath($BackupDirectory)
$script = Join-Path $project 'scripts\payroll_backup.py'
if (-not (Test-Path -LiteralPath $python -PathType Leaf) -or -not (Test-Path -LiteralPath $script -PathType Leaf)) {
throw 'Python 或备份程序不存在。'
}
if ($Container -notmatch '^[A-Za-z0-9_.-]+$' -or $Database -notmatch '^[A-Za-z][A-Za-z0-9_]{0,63}$') { throw '容器或数据库标识无效。' }
if (@($python, $script, $backups) | Where-Object { $_.Contains('"') }) { throw '路径不能含双引号。' }
$taskPath = '\PayrollManagementSystem-Backup\'
$common = ' --directory "' + $backups + '" --container ' + $Container + ' --database ' + $Database
$definitions = @(
@{ Name = 'DailyFull'; Action = 'full'; Time = '02:30' },
@{ Name = 'ChangedIncremental'; Action = 'incremental'; Time = '00:10' },
@{ Name = 'BackupCheck'; Action = 'check'; Time = '07:30' }
)
foreach ($definition in $definitions) {
$arguments = '"' + $script + '" ' + $definition.Action + $common
[pscustomobject]@{ TaskPath = $taskPath; Name = $definition.Name; Python = $python; Arguments = $arguments; Enabled = [bool]$Apply }
if (-not $Apply) { continue }
$existing = Get-ScheduledTask -TaskPath $taskPath -TaskName $definition.Name -ErrorAction SilentlyContinue
if ($existing -and (@($existing.Actions).Count -ne 1 -or $existing.Actions[0].Execute -ne $python -or $existing.Actions[0].Arguments -ne $arguments)) {
throw '同名任务已有不同动作,拒绝覆盖。'
}
if ($PSCmdlet.ShouldProcess($definition.Name, '注册当前用户本地开发备份任务')) {
$scheduler = New-Object -ComObject 'Schedule.Service'
$scheduler.Connect()
try { $null = $scheduler.GetFolder($taskPath) }
catch { $null = $scheduler.GetFolder('\').CreateFolder('PayrollManagementSystem-Backup') }
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $project
$trigger = if ($definition.Action -eq 'incremental') {
New-ScheduledTaskTrigger -Once -At ((Get-Date).Date.AddMinutes(10)) -RepetitionInterval (New-TimeSpan -Minutes 15)
} else { New-ScheduledTaskTrigger -Daily -At $definition.Time }
$principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -RestartCount 2 -RestartInterval (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskPath $taskPath -TaskName $definition.Name -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
}
}