限制MySQL备份元数据锁等待并覆盖取消与文件失败边界

This commit is contained in:
xuans 2026-09-27 19:54:57 +08:00
parent 79d70426fd
commit 73df576c1c
3 changed files with 251 additions and 12 deletions

View File

@ -50,6 +50,11 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output
return fmt.Errorf("创建 MySQL 一致性备份事务失败:%w", err)
}
defer tx.Rollback()
// 客户端取消会关闭连接,但服务端等待元数据锁时未必立即感知断开。
// 仅限制本次备份会话的单次锁等待;不限制总导出时间,也不修改全局配置。
if _, err := tx.ExecContext(ctx, "SET SESSION lock_wait_timeout = 5"); err != nil {
return fmt.Errorf("设置 MySQL 备份元数据锁等待上限失败:%w", err)
}
if _, err := fmt.Fprintf(file, "-- Scert MySQL 数据备份\n-- 生成时间:%s\n\nSET FOREIGN_KEY_CHECKS=0;\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return fmt.Errorf("写入 MySQL 备份头失败:%w", err)

View File

@ -0,0 +1,230 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealBackupFailureBoundaries(t *testing.T) {
server := mysqltest.New(t)
var originalGlobalTimeout int
if err := server.Admin().QueryRowContext(t.Context(), "SELECT @@GLOBAL.lock_wait_timeout").Scan(&originalGlobalTimeout); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var currentGlobalTimeout int
if err := server.Admin().QueryRowContext(ctx, "SELECT @@GLOBAL.lock_wait_timeout").Scan(&currentGlobalTimeout); err != nil || currentGlobalTimeout != originalGlobalTimeout {
t.Errorf("备份不能修改数据库全局锁等待配置:%v", err)
}
})
for _, scenario := range []struct {
name string
ddl string
}{
{name: "reject_nontransactional_table", ddl: "CREATE TABLE %s (id INT PRIMARY KEY) ENGINE=MyISAM"},
{name: "reject_view", ddl: "CREATE VIEW %s AS SELECT 1 AS id"},
} {
t.Run(scenario.name, func(t *testing.T) {
cfg := server.Database(t)
parsed, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal("解析隔离数据库连接失败")
}
qualified := quoteMySQLIdentifier(parsed.DBName) + ".`unsupported_object`"
if _, err := server.Admin().ExecContext(t.Context(), fmt.Sprintf(scenario.ddl, qualified)); err != nil {
t.Fatal(err)
}
output := filepath.Join(server.Root(), scenario.name+".sql")
original := []byte("原有备份不能因失败被替换\n")
writeMySQLBackupFixture(t, output, original)
err = backupMySQLDatabase(t.Context(), cfg, output)
if err == nil || !strings.Contains(err.Error(), "仅支持 InnoDB 基础表") {
t.Fatalf("非事务对象不能被当作一致性备份成功导出:%v", err)
}
assertMySQLBackupFile(t, output, original)
assertMySQLBackupAbsent(t, output+".tmp")
})
}
for _, scenario := range []struct {
name string
cancel bool
timeout bool
}{
{name: "cancel_after_first_table", cancel: true},
{name: "query_failure_after_first_table"},
{name: "metadata_lock_timeout", timeout: true},
} {
for _, existing := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/existing_output_%t", scenario.name, existing), func(t *testing.T) {
cfg := server.Database(t)
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, table := range []string{"a_snapshot", "b_snapshot"} {
if _, err := db.DB.ExecContext(t.Context(), "CREATE TABLE "+table+" (id INT PRIMARY KEY) ENGINE=InnoDB"); err != nil {
t.Fatal(err)
}
if _, err := db.DB.ExecContext(t.Context(), "INSERT INTO "+table+" VALUES (1)"); err != nil {
t.Fatal(err)
}
}
parsed, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal("解析隔离数据库连接失败")
}
locker, err := server.Admin().Conn(t.Context())
if err != nil {
t.Fatal(err)
}
defer locker.Close()
qualified := quoteMySQLIdentifier(parsed.DBName) + ".`b_snapshot`"
if _, err := locker.ExecContext(t.Context(), "LOCK TABLES "+qualified+" WRITE"); err != nil {
t.Fatal(err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := locker.ExecContext(ctx, "UNLOCK TABLES"); err != nil {
t.Error(err)
}
}()
output := filepath.Join(server.Root(), fmt.Sprintf("%s-%t.sql", scenario.name, existing))
original := []byte("已完成的历史备份\n")
if existing {
writeMySQLBackupFixture(t, output, original)
}
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
done := make(chan struct{})
var backupErr error
go func() {
defer close(done)
backupErr = backupMySQLDatabase(ctx, cfg, output)
}()
t.Cleanup(func() {
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("备份协程未按期结束")
}
})
connectionID := waitForMySQLBackupLock(t, ctx, server.Admin(), parsed.DBName)
content, err := os.ReadFile(output + ".tmp")
if err != nil || !bytes.Contains(content, []byte("INSERT INTO `a_snapshot`")) {
t.Fatal("失败必须注入在首表已经写入备份、第二张表等待锁之后")
}
if scenario.cancel {
cancel()
} else if !scenario.timeout {
// 只终止本次已确认等待锁的备份语句,不终止连接或共享数据库。
if _, err := server.Admin().ExecContext(ctx, fmt.Sprintf("KILL QUERY %d", connectionID)); err != nil {
t.Fatal(err)
}
}
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("备份失败后未及时退出")
}
if scenario.cancel {
if !errors.Is(backupErr, context.Canceled) {
t.Fatalf("备份未保留取消错误:%v", backupErr)
}
} else {
var sqlErr *mysql.MySQLError
expectedCode := uint16(1317)
if scenario.timeout {
expectedCode = 1205
}
if !errors.As(backupErr, &sqlErr) || sqlErr.Number != expectedCode {
t.Fatalf("备份未保留真实语句中断错误:%v", backupErr)
}
}
if existing {
assertMySQLBackupFile(t, output, original)
} else {
assertMySQLBackupAbsent(t, output)
}
assertMySQLBackupAbsent(t, output+".tmp")
// 备份事务曾读取首表;失败后必须释放元数据锁,不能阻塞后续维护。
verifyCtx, verifyCancel := context.WithTimeout(t.Context(), 10*time.Second)
defer verifyCancel()
if _, err := db.DB.ExecContext(verifyCtx, "ALTER TABLE a_snapshot ADD COLUMN released INT NULL"); err != nil {
t.Fatalf("备份失败后未释放首表元数据锁:%v", err)
}
})
}
}
t.Run("temporary_file_conflict", func(t *testing.T) {
cfg := server.Database(t)
output := filepath.Join(server.Root(), "conflict.sql")
original := []byte("保留原有完整备份\n")
temporary := []byte("其他备份操作持有的临时文件\n")
writeMySQLBackupFixture(t, output, original)
writeMySQLBackupFixture(t, output+".tmp", temporary)
if err := backupMySQLDatabase(t.Context(), cfg, output); !errors.Is(err, os.ErrExist) {
t.Fatalf("临时文件冲突必须拒绝:%v", err)
}
assertMySQLBackupFile(t, output, original)
assertMySQLBackupFile(t, output+".tmp", temporary)
})
t.Run("publish_failure", func(t *testing.T) {
cfg := server.Database(t)
output := filepath.Join(server.Root(), "occupied-output")
if err := os.Mkdir(output, 0o700); err != nil {
t.Fatal(err)
}
marker := filepath.Join(output, "marker.txt")
original := []byte("已有目录与内容必须保留\n")
writeMySQLBackupFixture(t, marker, original)
err := backupMySQLDatabase(t.Context(), cfg, output)
var linkErr *os.LinkError
if !errors.As(err, &linkErr) || linkErr.Op != "rename" {
t.Fatalf("必须在文件发布阶段失败:%v", err)
}
assertMySQLBackupFile(t, marker, original)
assertMySQLBackupAbsent(t, output+".tmp")
})
}
func writeMySQLBackupFixture(t *testing.T, path string, content []byte) {
t.Helper()
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
}
func assertMySQLBackupFile(t *testing.T, path string, expected []byte) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil || !bytes.Equal(content, expected) {
t.Fatalf("已有备份或其他操作的文件被修改:%s,%v", path, err)
}
}
func assertMySQLBackupAbsent(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("不应遗留不完整备份或本次临时文件:%s,%v", path, err)
}
}

View File

@ -3,6 +3,7 @@ package cli
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
@ -61,8 +62,12 @@ func TestMySQLRealBackupSnapshot(t *testing.T) {
output := filepath.Join(server.Root(), scenario.name+".sql")
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- backupMySQLDatabase(ctx, cfg, output) }()
done := make(chan struct{})
var backupErr error
go func() {
defer close(done)
backupErr = backupMySQLDatabase(ctx, cfg, output)
}()
t.Cleanup(func() {
cancel()
select {
@ -88,10 +93,9 @@ func TestMySQLRealBackupSnapshot(t *testing.T) {
t.Fatal(err)
}
select {
case err := <-done:
done <- err
if err != nil {
t.Fatal(err)
case <-done:
if backupErr != nil {
t.Fatal(backupErr)
}
case <-ctx.Done():
t.Fatal("真实备份未按期完成")
@ -116,18 +120,18 @@ func TestMySQLRealBackupSnapshot(t *testing.T) {
}
}
func waitForMySQLBackupLock(t *testing.T, ctx context.Context, admin *sql.DB, name string) {
func waitForMySQLBackupLock(t *testing.T, ctx context.Context, admin *sql.DB, name string) uint64 {
t.Helper()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
var count int
err := admin.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE DB = ? AND STATE LIKE '%lock%' AND (INFO = 'SHOW CREATE TABLE `b_snapshot`' OR INFO = 'SELECT * FROM `b_snapshot`')", name).Scan(&count)
if err != nil {
var connectionID uint64
err := admin.QueryRowContext(ctx, "SELECT ID FROM information_schema.PROCESSLIST WHERE DB = ? AND STATE LIKE '%lock%' AND (INFO = 'SHOW CREATE TABLE `b_snapshot`' OR INFO = 'SELECT * FROM `b_snapshot`')", name).Scan(&connectionID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatal(err)
}
if count > 0 {
return
if err == nil {
return connectionID
}
select {
case <-ctx.Done():