From 8ceb249274ddeeff1d8f9c1d5c5c14e6677e1f77 Mon Sep 17 00:00:00 2001 From: logan Date: Wed, 3 Jun 2026 02:40:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20MySQL=20DDL+DML=E5=90=8C=E6=89=B9EXPLAIN?= =?UTF-8?q?=E9=A2=84=E6=A3=80bug=20+=20Redis=E9=9B=86=E7=BE=A4MOVED?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=20+=20=E6=B8=85=E6=99=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL修复: - 批次含DDL时跳过整个EXPLAIN预检(避免CREATE TABLE未执行时INSERT的EXPLAIN失败) - 纯DML批次仍正常做EXPLAIN预检 Redis集群修复: - 单节点客户端遇到MOVED时自动映射外部IP+端口重试 - 重试失败给出清晰提示(Docker端口未暴露) - 新增 isClusterInstance/parseMovedTarget 辅助函数 测试验证通过: - MySQL 5条DDL+DML ✅ (建表+INSERT+UPDATE+加索引) - MySQL 纯DML+EXPLAIN预检 ✅ - MySQL dba(logan)执行 ✅ - Redis单机 SET写入 ✅ - Redis集群 ⚠️ Docker内部端口未暴露(基础设施限制) --- backend/internal/handler/ticket.go | 93 +++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index 93efe68..66a93c3 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -14,6 +14,7 @@ import ( "sql-platform/internal/model" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) // SqlExecItem records the execution result of a single SQL statement @@ -571,6 +572,33 @@ func ExecuteTicket(c *gin.Context) { } execStart := time.Now() result, err := client.Do(ctx, args...).Result() + // Cluster MOVED redirect: retry on the correct node with external IP mapping + if err != nil && isClusterInstance(&instance) { + errStr := err.Error() + if moved := parseMovedTarget(errStr); moved != "" { + // Map internal IP to external host: replace host part, keep port + if idx := strings.LastIndex(moved, ":"); idx >= 0 { + port := moved[idx+1:] + externalAddr := instance.Host + ":" + port + password := instance.Password + if strings.HasPrefix(password, "enc:") { + if dec, e := decryptPassword(strings.TrimPrefix(password, "enc:")); e == nil { + password = dec + } + } + targetClient := redis.NewClient(&redis.Options{ + Addr: externalAddr, + Username: instance.Username, + Password: password, + }) + defer targetClient.Close() + result, err = targetClient.Do(ctx, args...).Result() + if err != nil { + err = fmt.Errorf("集群节点重定向失败(MOVED→%s): %w — 集群节点端口(%s)可能未暴露给外部,需检查Docker端口映射", moved, err, port) + } + } + } + } stmtMs := time.Since(execStart).Milliseconds() execRes := SqlExecResult{ Status: "success", @@ -659,36 +687,42 @@ func ExecuteTicket(c *gin.Context) { execTimeout := getSettingInt("executor_timeout", 300) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second) defer cancel() - // Dry-run: EXPLAIN pre-check (skip DDL statements) + // Dry-run: EXPLAIN pre-check if getSetting("executor_dry_run", "true") == "true" { ddlPrefixes := []string{"CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME"} statements := splitSQL(ticket.SQLContent) + // If batch contains ANY DDL, skip EXPLAIN precheck entirely — + // DDL changes the schema so subsequent DML can't be EXPLAINed beforehand + hasDDL := false for _, stmt := range statements { - stmt = strings.TrimSpace(stmt) - if stmt == "" { - continue - } - upperStmt := strings.ToUpper(stmt) - if strings.HasPrefix(upperStmt, "EXPLAIN") { - continue - } - // Skip DDL — EXPLAIN does not work on DDL statements - isDDL := false + upper := strings.ToUpper(strings.TrimSpace(stmt)) for _, prefix := range ddlPrefixes { - if strings.HasPrefix(upperStmt, prefix) { - isDDL = true + if strings.HasPrefix(upper, prefix) { + hasDDL = true break } } - if isDDL { - continue - } - rows, err := db.QueryContext(ctx, "EXPLAIN "+stmt) - if err != nil { - execErr = fmt.Sprintf("预检查失败(EXPLAIN): %v", err) + if hasDDL { break } - rows.Close() + } + if !hasDDL { + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + upperStmt := strings.ToUpper(stmt) + if strings.HasPrefix(upperStmt, "EXPLAIN") { + continue + } + rows, err := db.QueryContext(ctx, "EXPLAIN "+stmt) + if err != nil { + execErr = fmt.Sprintf("预检查失败(EXPLAIN): %v", err) + break + } + rows.Close() + } } if execErr != "" { failStatus := "failed" @@ -1026,3 +1060,22 @@ func getApplicantName(userID int) string { } return "" } + +// isClusterInstance checks if a Redis instance is configured as cluster mode +func isClusterInstance(instance *model.Instance) bool { + return instance.ClusterMode != nil && *instance.ClusterMode +} + +// parseMovedTarget extracts the target address from a MOVED response +// Input: "MOVED 8480 172.19.0.4:9002" → Output: "172.19.0.4:9002" +func parseMovedTarget(errMsg string) string { + // MOVED : + if !strings.HasPrefix(errMsg, "MOVED ") { + return "" + } + parts := strings.SplitN(errMsg, " ", 3) + if len(parts) == 3 { + return parts[2] + } + return "" +}