fix: MySQL DDL+DML同批EXPLAIN预检bug + Redis集群MOVED重定向 + 清晰错误提示

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内部端口未暴露(基础设施限制)
This commit is contained in:
logan committed 2026-06-03 02:40:15 +08:00
1 parent 2984f11181
commit 8ceb249274
1 file changed
+73 -20
+73 -20
View File
@@ -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 <slot> <host>:<port>
if !strings.HasPrefix(errMsg, "MOVED ") {
return ""
}
parts := strings.SplitN(errMsg, " ", 3)
if len(parts) == 3 {
return parts[2]
}
return ""
}