diff --git a/deploy/README.md b/deploy/README.md index bc3e37d..addfa99 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -72,7 +72,7 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要 ### 数据库在线备份与时间点恢复 -生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,每分钟轮转并归档已关闭的 binlog。备份对象先做客户端加密,再由 Bucket 的服务器端加密保护;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 +生产 MySQL 开启了 `ROW` 格式 binlog 和崩溃安全刷盘。备份期间业务持续读写:每日用 XtraBackup 做物理全量备份,调度器每分钟检查 binlog 位点,仅在有新增已提交事务时轮转并归档。备份对象先做客户端加密,再由 Bucket 的服务器端加密保护;远端仅在 payload、checksum 和 manifest 都上传成功后写入 `complete.env`。 部署脚本会创建或更新 `BACKUP_MYSQL_USER`,该用户只有 XtraBackup 所需的全局备份权限和业务库只读权限,不开放远程 root。 @@ -84,7 +84,7 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要 # 每日全量热备,本地留存并上传 OSS 备份桶 ./scripts/backup-online.sh full -# binlog 分钟级归档,配合全量实现最近保留窗口内的时间点恢复 +# 检查并归档有新增事务的关闭 binlog,配合全量实现时间点恢复 ./scripts/archive-binlog.sh archive # 校验本地状态对应的远端完整对象 / 清理过期本地备份 diff --git a/scripts/archive-binlog.sh b/scripts/archive-binlog.sh index 80d7c1d..29bbe98 100755 --- a/scripts/archive-binlog.sh +++ b/scripts/archive-binlog.sh @@ -2,8 +2,8 @@ set -euo pipefail umask 077 -# 分钟级 binlog 归档。每次轮转后只归档已关闭文件;每个对象以 -# server_uuid、原始文件名和原始 sha256 命名,避免实例重建后的编号碰撞。 +# 分钟级 binlog 归档。每分钟检查活动 binlog 的 Position,仅在有新增已提交事务时 +# 轮转;每个对象以 server_uuid、原始文件名和原始 sha256 命名,避免实例重建后的编号碰撞。 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=backup-common.sh @@ -31,6 +31,29 @@ closed_binlogs() { 2>/dev/null | awk 'NR > 1 { print previous } { previous = $1 } END { }' } +master_status() { + local mysql_cid="$1" + docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW MASTER STATUS;"' \ + 2>/dev/null | awk 'NR == 1 { print $1 "\t" $2 }' +} + +state_active_binlog() { + local state_dir="$1" key="$2" value + [[ -f "${state_dir}/active-binlog.env" ]] || return + value="$(awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "${state_dir}/active-binlog.env")" + printf '%s' "${value}" +} + +write_active_binlog_state() { + local state_dir="$1" filename="$2" position="$3" + { + printf 'filename=%s\n' "${filename}" + printf 'position=%s\n' "${position}" + printf 'observed_at=%s\n' "$(date -Iseconds)" + } > "${state_dir}/active-binlog.env" +} + state_last_index() { local state_dir="$1" local value @@ -103,7 +126,12 @@ archive() { backup_require_cmd ossutil backup_require_cmd openssl require_archive_config - local backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index + local force="${1:-}" backup_dir oss_uri mysql_cid server_uuid state_dir first_binlog last_index first_index expected_index filename index + local current_status current_file current_pos previous_file previous_pos final_status final_file final_pos + if [[ -n "${force}" && "${force}" != '--force' ]]; then + printf '用法: %s archive [--force]\n' "$0" >&2 + exit 1 + fi backup_dir="$(backup_require_env BACKUP_DIR)" backup_validate_dir "${backup_dir}" oss_uri="$(backup_require_env BACKUP_OSS_URI)" @@ -115,8 +143,19 @@ archive() { work_dir="${backup_dir}/.tmp-binlog-$$" mkdir -p "${state_dir}" "${work_dir}" - docker exec "${mysql_cid}" sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -e "FLUSH BINARY LOGS;"' >/dev/null + current_status="$(master_status "${mysql_cid}")" + IFS=$'\t' read -r current_file current_pos <<< "${current_status}" + [[ -n "${current_file}" && "${current_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取当前 binlog 位点'; exit 1; } + backup_binlog_index "${current_file}" >/dev/null + previous_file="$(state_active_binlog "${state_dir}" filename || true)" + previous_pos="$(state_active_binlog "${state_dir}" position || true)" + + if [[ "${force}" == '--force' || -z "${previous_file}" || -z "${previous_pos}" || "${current_file}" != "${previous_file}" || "${current_pos}" != "${previous_pos}" ]]; then + docker exec "${mysql_cid}" sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -e "FLUSH BINARY LOGS;"' >/dev/null + else + backup_log "活动 binlog 无新增已提交事务,跳过轮转" + fi first_binlog="$(docker exec "${mysql_cid}" sh -c \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket -N -B -e "SHOW BINARY LOGS;"' | head -1 | awk '{print $1}')" [[ -n "${first_binlog}" ]] || { backup_error '无法读取 binlog 列表'; exit 1; } @@ -141,6 +180,10 @@ archive() { archive_one "${mysql_cid}" "${state_dir}" "${server_uuid}" "${oss_uri}" "${filename}" expected_index="${index}" done < <(closed_binlogs "${mysql_cid}") + final_status="$(master_status "${mysql_cid}")" + IFS=$'\t' read -r final_file final_pos <<< "${final_status}" + [[ -n "${final_file}" && "${final_pos}" =~ ^[0-9]+$ ]] || { backup_error '无法读取归档后的 binlog 位点'; exit 1; } + write_active_binlog_state "${state_dir}" "${final_file}" "${final_pos}" backup_log "binlog 归档完成,实例 ${server_uuid}" } @@ -165,7 +208,7 @@ verify() { } case "${1:-archive}" in - archive) archive ;; + archive) archive "${2:-}" ;; verify) verify ;; *) printf '用法: %s {archive|verify}\n' "$0" >&2 diff --git a/scripts/backup-online.sh b/scripts/backup-online.sh index 710ff15..4672710 100755 --- a/scripts/backup-online.sh +++ b/scripts/backup-online.sh @@ -123,7 +123,7 @@ EOF # 关闭包含 XtraBackup 位点的当前 binlog 并归档。全量备份只有在该文件 # 已有远端 complete 标识后才会被标记完成。 - BACKUP_LOCK_WAIT_SECONDS=120 "${SCRIPT_DIR}/archive-binlog.sh" archive + BACKUP_LOCK_WAIT_SECONDS=120 "${SCRIPT_DIR}/archive-binlog.sh" archive --force if ! find "${backup_dir}/binlog-state/${server_uuid}" -maxdepth 1 -type f \ -name "${binlog_file}.*.state" -print -quit | grep -q .; then backup_error "XtraBackup 位点 ${binlog_file}:${binlog_pos} 尚未完成 binlog 归档"