Files
MyGoNavi/internal/app/explain_parse_mysql.go
Syngnat 04019135a0 🐛 fix(sql-diagnose): 兼容新版 MySQL JSON EXPLAIN 解析
- 兼容 query_plan 包装和新版 JSON V2 执行计划结构
- 补齐 covering index、table scan 等节点类型与统计归一化
- 增加新版 MySQL EXPLAIN 解析测试并修正 TotalCost 汇总逻辑
2026-06-20 14:08:02 +08:00

622 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
// MySQL FORMAT=JSON 解析。
//
// 典型结构8.0+
//
// {
// "query_block": {
// "select_id": 1,
// "cost_info": {"query_cost": "100.00"},
// "table": { ... }, // 单表
// "nested_loop": [{"table": {...}}], // 多表 JOIN
// "ordering_operation": { ... }, // ORDER BY 包装
// "grouping_operation": { ... }, // GROUP BY 包装
// "duplicates_removal": { ... }
// }
// }
//
// 单个 table 节点字段:
// - table_name / alias
// - access_typesystem/const/eq_ref/ref/range/index/ALL
// - rows_examined_per_scan / rows_produced_per_join / filtered
// - possible_keys / key / used_key_parts / key_length
// - attached_condition / used_columns
//
// OceanBase MySQL 协议输出与 MySQL 8.0 几乎一致(可能多 range_info 列)。
//
// 5.7 不支持 FORMAT=JSON 时走 vanilla EXPLAIN返回 8 列表格id/select_type/table/type/
// possible_keys/key/key_len/ref/rows/ExtraOceanBase 可能多 range_info由 parseMySQLTableExplain 处理。
func parseMySQLExplain(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
result := connection.ExplainResult{
DBType: dbType,
SourceSQL: sourceSQL,
}
resetExplainNodeID()
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return result, fmt.Errorf("MySQL EXPLAIN 返回空内容")
}
// FORMAT=JSON 模式
if format == connection.ExplainFormatJSON || strings.HasPrefix(trimmed, "{") {
plan, warnings, err := parseMySQLJSONExplain(trimmed)
if err != nil {
// JSON 解析失败但确实是 JSON 开头:报错让上层决定降级
return result, fmt.Errorf("解析 MySQL FORMAT=JSON 失败:%w", err)
}
result.Nodes = plan.Nodes
result.Edges = plan.Edges
result.Warnings = warnings
result.RawFormat = connection.ExplainFormatJSON
result.RawPayload = raw
finalizeExplainStats(&result)
return result, nil
}
// 表格模式5.7 fallback 或 Doris/StarRocks
parsed, err := parseMySQLTableExplain(raw)
if err != nil {
result.RawFormat = connection.ExplainFormatText
result.RawPayload = raw
result.Warnings = []string{fmt.Sprintf("表格解析失败:%v保留原文供调试", err)}
return result, nil
}
result.Nodes = parsed.Nodes
result.Edges = parsed.Edges
result.RawFormat = connection.ExplainFormatTable
result.RawPayload = raw
finalizeExplainStats(&result)
return result, nil
}
// mysqlQueryBlock 对应 MySQL FORMAT=JSON 顶层 query_block。
type mysqlQueryBlock struct {
SelectID json.Number `json:"select_id"`
CostInfo map[string]string `json:"cost_info"`
Table *mysqlTableNode `json:"table"`
NestedLoop []map[string]json.RawMessage `json:"nested_loop"`
OrderingOperation *map[string]any `json:"ordering_operation"`
GroupingOperation *map[string]any `json:"grouping_operation"`
DuplicatesRemoval *map[string]any `json:"duplicates_removal"`
Windowing *map[string]any `json:"windowing"`
Distinct *map[string]any `json:"distinct"`
Message string `json:"message"`
}
type mysqlTableNode struct {
TableName string `json:"table_name"`
Alias string `json:"alias"`
AccessType string `json:"access_type"`
RowsExaminedPerScan json.Number `json:"rows_examined_per_scan"`
RowsProducedPerJoin json.Number `json:"rows_produced_per_join"`
Filtered string `json:"filtered"`
PossibleKeys []string `json:"possible_keys"`
Key string `json:"key"`
UsedKeyParts []string `json:"used_key_parts"`
KeyLength json.Number `json:"key_length"`
Ref []string `json:"ref"`
RowsExaminedPerJoin json.Number `json:"rows_examined_per_join"`
CostInfo map[string]string `json:"cost_info"`
AttachedCondition string `json:"attached_condition"`
AttachedSubqueries []map[string]any `json:"attached_subqueries"`
UsingIntersection []map[string]any `json:"using_intersect"`
Message string `json:"message"`
}
// parseMySQLJSONExplain 递归解析 MySQL FORMAT=JSON 输出。
// 返回扁平的节点列表 + 解析过程中的警告(用于前端提示不识别的字段)。
func parseMySQLJSONExplain(raw string) (*connection.ExplainResult, []string, error) {
var top map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &top); err != nil {
return nil, nil, fmt.Errorf("顶层 JSON 解析失败:%w", err)
}
qbRaw, ok := top["query_block"]
if ok {
result := &connection.ExplainResult{}
var warnings []string
// query_block 总成本
var qb map[string]json.RawMessage
if err := json.Unmarshal(qbRaw, &qb); err != nil {
return nil, nil, fmt.Errorf("query_block 解析失败:%w", err)
}
if costRaw, ok := qb["cost_info"]; ok {
var ci map[string]string
if err := json.Unmarshal(costRaw, &ci); err == nil {
result.Stats.TotalCost = parseExplainFloat64(ci["query_cost"])
}
}
// 递归 query_block可能套 ordering/grouping/distinct 等操作层)
parseMySQLQueryBlock(qbRaw, "", result, &warnings)
return result, warnings, nil
}
if rootRaw, ok := resolveMySQLJSONV2Root(top, raw); ok {
return parseMySQLJSONExplainV2(rootRaw)
}
return nil, nil, fmt.Errorf("缺少 query_block 字段")
}
type mysqlJSONV2Node struct {
Query string `json:"query"`
Operation string `json:"operation"`
AccessType string `json:"access_type"`
TableName string `json:"table_name"`
Alias string `json:"alias"`
SchemaName string `json:"schema_name"`
IndexName string `json:"index_name"`
IndexAccessType string `json:"index_access_type"`
Condition string `json:"condition"`
LookupCondition string `json:"lookup_condition"`
JoinType string `json:"join_type"`
JoinAlgorithm string `json:"join_algorithm"`
EstimatedRows json.Number `json:"estimated_rows"`
EstimatedTotalCost json.Number `json:"estimated_total_cost"`
ActualRows json.Number `json:"actual_rows"`
ActualLoops json.Number `json:"actual_loops"`
ActualFirstRowMs json.Number `json:"actual_first_row_ms"`
ActualLastRowMs json.Number `json:"actual_last_row_ms"`
Covering *bool `json:"covering"`
UsedColumns []string `json:"used_columns"`
KeyColumns []string `json:"key_columns"`
Ranges []string `json:"ranges"`
Inputs []json.RawMessage `json:"inputs"`
}
func resolveMySQLJSONV2Root(top map[string]json.RawMessage, raw string) (json.RawMessage, bool) {
if queryPlanRaw, ok := top["query_plan"]; ok && len(strings.TrimSpace(string(queryPlanRaw))) > 0 {
return queryPlanRaw, true
}
if isMySQLJSONV2Root(top) {
return json.RawMessage(raw), true
}
return nil, false
}
func isMySQLJSONV2Root(top map[string]json.RawMessage) bool {
if len(top) == 0 {
return false
}
for _, key := range []string{"operation", "inputs", "access_type", "table_name", "estimated_total_cost"} {
if _, ok := top[key]; ok {
return true
}
}
return false
}
func parseMySQLJSONExplainV2(rootRaw json.RawMessage) (*connection.ExplainResult, []string, error) {
var rawMap map[string]json.RawMessage
if err := json.Unmarshal(rootRaw, &rawMap); err != nil {
return nil, nil, fmt.Errorf("新版根节点解析失败:%w", err)
}
var root mysqlJSONV2Node
if err := json.Unmarshal(rootRaw, &root); err != nil {
return nil, nil, fmt.Errorf("新版根节点反序列化失败:%w", err)
}
result := &connection.ExplainResult{}
var warnings []string
if root.EstimatedTotalCost != "" {
result.Stats.TotalCost = parseExplainFloat64(root.EstimatedTotalCost.String())
}
parseMySQLJSONV2Node(rootRaw, "", result, &warnings, true)
if len(result.Nodes) == 0 {
return nil, nil, fmt.Errorf("新版 JSON 结构未解析出有效节点")
}
return result, warnings, nil
}
// parseMySQLQueryBlock 递归解析 query_block 内部结构。
// MySQL FORMAT=JSON 是深度嵌套的"操作层"结构,每层可能包含 table、nested_loop、ordering_operation 等。
func parseMySQLQueryBlock(qbRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) {
var qb mysqlQueryBlock
if err := json.Unmarshal(qbRaw, &qb); err != nil {
*warnings = append(*warnings, fmt.Sprintf("query_block JSON 反序列化失败:%v", err))
return
}
// 单表:直接挂一个 table 节点
if qb.Table != nil {
node := buildMySQLTableNode(qb.Table)
appendExplainChild(result, parentID, node)
}
// nested_loop每个元素含 table作为 parent 的子节点
for _, item := range qb.NestedLoop {
if tableRaw, ok := item["table"]; ok {
var t mysqlTableNode
if err := json.Unmarshal(tableRaw, &t); err == nil {
node := buildMySQLTableNode(&t)
appendExplainChild(result, parentID, node)
}
}
}
// 递归操作层ordering_operation / grouping_operation / duplicates_removal / windowing
type opLayer struct {
raw json.RawMessage
opType string
}
layers := []opLayer{}
if qb.OrderingOperation != nil {
// 反向取原始 JSON结构体已 unmarshal但用 raw 更通用)
}
// 直接遍历原始 qb map 更省事
var qbMap map[string]json.RawMessage
_ = json.Unmarshal(qbRaw, &qbMap)
for key, val := range qbMap {
switch key {
case "ordering_operation":
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpSort})
case "grouping_operation":
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate})
case "duplicates_removal":
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpOther})
case "windowing":
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpWindow})
case "distinct":
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate})
}
}
for _, layer := range layers {
// 操作层本身作为一个节点(供前端展示层次)
layerNode := connection.ExplainNode{
OpType: layer.opType,
OpDetail: strings.Title(strings.ReplaceAll(layer.opType, "_", " ")),
}
layerID := appendExplainChild(result, parentID, layerNode)
// 递归:操作层可能含 table、nested_loop、子操作层
parseMySQLQueryBlock(layer.raw, layerID, result, warnings)
}
}
func parseMySQLJSONV2Node(raw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string, isRoot bool) {
var node mysqlJSONV2Node
if err := json.Unmarshal(raw, &node); err != nil {
*warnings = append(*warnings, fmt.Sprintf("新版 JSON 节点反序列化失败:%v", err))
return
}
nextParentID := parentID
if shouldAppendMySQLJSONV2Node(&node, isRoot) {
nextParentID = appendExplainChild(result, parentID, buildMySQLJSONV2ExplainNode(&node, isRoot))
}
for _, child := range node.Inputs {
parseMySQLJSONV2Node(child, nextParentID, result, warnings, false)
}
}
func shouldAppendMySQLJSONV2Node(node *mysqlJSONV2Node, isRoot bool) bool {
if node == nil {
return false
}
if strings.TrimSpace(node.Operation) != "" {
return true
}
if strings.TrimSpace(node.AccessType) != "" {
return true
}
if strings.TrimSpace(node.TableName) != "" || strings.TrimSpace(node.IndexName) != "" {
return true
}
if len(node.Inputs) == 0 && isRoot {
return true
}
return false
}
func buildMySQLJSONV2ExplainNode(node *mysqlJSONV2Node, isRoot bool) connection.ExplainNode {
if node == nil {
return connection.ExplainNode{OpType: connection.ExplainOpOther}
}
lowerOperation := strings.ToLower(strings.TrimSpace(node.Operation))
explainNode := connection.ExplainNode{
OpType: classifyMySQLJSONV2OpType(node.AccessType, node.Operation, node.Covering),
OpDetail: strings.TrimSpace(node.Operation),
Table: strings.TrimSpace(node.TableName),
Index: strings.TrimSpace(node.IndexName),
EstRows: parseExplainInt64(node.EstimatedRows.String()),
ActualRows: parseExplainInt64(node.ActualRows.String()),
Loops: parseExplainInt64(node.ActualLoops.String()),
DurationMs: parseExplainFloat64(node.ActualLastRowMs.String()),
}
if explainNode.DurationMs == 0 {
explainNode.DurationMs = parseExplainFloat64(node.ActualFirstRowMs.String())
}
if cost := parseExplainFloat64(node.EstimatedTotalCost.String()); cost > 0 {
if isRoot {
explainNode.Cost = cost
} else {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["estimatedTotalCost"] = cost
}
}
if strings.TrimSpace(node.Alias) != "" && node.Alias != node.TableName {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["alias"] = node.Alias
}
if strings.TrimSpace(node.SchemaName) != "" {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["schemaName"] = node.SchemaName
}
if strings.TrimSpace(node.Query) != "" {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["query"] = node.Query
}
if strings.TrimSpace(node.Condition) != "" {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["condition"] = node.Condition
}
if strings.TrimSpace(node.LookupCondition) != "" {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["lookupCondition"] = node.LookupCondition
}
if strings.TrimSpace(node.JoinType) != "" {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["joinType"] = node.JoinType
}
if strings.TrimSpace(node.JoinAlgorithm) != "" {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["joinAlgorithm"] = node.JoinAlgorithm
}
if strings.TrimSpace(node.IndexAccessType) != "" {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["indexAccessType"] = node.IndexAccessType
}
if node.Covering != nil {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["covering"] = *node.Covering
}
if len(node.UsedColumns) > 0 {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["usedColumns"] = node.UsedColumns
}
if len(node.KeyColumns) > 0 {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["keyColumns"] = node.KeyColumns
}
if len(node.Ranges) > 0 {
if explainNode.Extra == nil {
explainNode.Extra = map[string]any{}
}
explainNode.Extra["ranges"] = node.Ranges
}
if explainNode.OpType == connection.ExplainOpScan || strings.Contains(lowerOperation, "table scan") {
explainNode.Flags = append(explainNode.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
}
if explainNode.OpType == connection.ExplainOpSort || strings.Contains(lowerOperation, "filesort") {
explainNode.Flags = append(explainNode.Flags, connection.ExplainFlagFilesort)
}
if strings.Contains(lowerOperation, "temporary") || strings.Contains(lowerOperation, "temp table") || strings.Contains(lowerOperation, "materialize") {
explainNode.Flags = append(explainNode.Flags, connection.ExplainFlagTempTable)
}
return explainNode
}
func classifyMySQLJSONV2OpType(accessType, operation string, covering *bool) string {
switch strings.ToLower(strings.TrimSpace(accessType)) {
case "table":
return connection.ExplainOpScan
case "index":
if covering != nil && *covering {
return connection.ExplainOpIndexOnly
}
return connection.ExplainOpIndexScan
case "filter":
return connection.ExplainOpFilter
case "join":
return connection.ExplainOpJoin
case "sort":
return connection.ExplainOpSort
case "aggregate":
return connection.ExplainOpAggregate
case "limit":
return connection.ExplainOpLimit
case "materialize":
return connection.ExplainOpMaterialize
case "window":
return connection.ExplainOpWindow
case "union":
return connection.ExplainOpUnion
case "subquery":
return connection.ExplainOpSubquery
}
lowerOperation := strings.ToLower(strings.TrimSpace(operation))
switch {
case strings.Contains(lowerOperation, "table scan"):
return connection.ExplainOpScan
case strings.Contains(lowerOperation, "covering index"):
return connection.ExplainOpIndexOnly
case strings.Contains(lowerOperation, "index lookup"),
strings.Contains(lowerOperation, "index range"),
strings.Contains(lowerOperation, "index scan"):
return connection.ExplainOpIndexScan
case strings.Contains(lowerOperation, "filter"):
return connection.ExplainOpFilter
case strings.Contains(lowerOperation, "join"):
return connection.ExplainOpJoin
case strings.Contains(lowerOperation, "sort"):
return connection.ExplainOpSort
case strings.Contains(lowerOperation, "aggregate"),
strings.Contains(lowerOperation, "group"):
return connection.ExplainOpAggregate
case strings.Contains(lowerOperation, "limit"):
return connection.ExplainOpLimit
case strings.Contains(lowerOperation, "materialize"):
return connection.ExplainOpMaterialize
case strings.Contains(lowerOperation, "window"):
return connection.ExplainOpWindow
case strings.Contains(lowerOperation, "union"):
return connection.ExplainOpUnion
case strings.Contains(lowerOperation, "subquery"):
return connection.ExplainOpSubquery
default:
return connection.ExplainOpOther
}
}
// buildMySQLTableNode 把 mysqlTableNode 转成归一化的 ExplainNode并探测 Flags。
func buildMySQLTableNode(t *mysqlTableNode) connection.ExplainNode {
node := connection.ExplainNode{
OpType: classifyMySQLAccessType(t.AccessType),
OpDetail: fmt.Sprintf("access_type=%s", strings.ToLower(strings.TrimSpace(t.AccessType))),
Table: t.TableName,
Index: t.Key,
EstRows: parseExplainInt64(string(t.RowsExaminedPerScan)),
Cost: parseExplainFloat64(t.CostInfo["read_cost"]),
}
if t.Alias != "" && t.Alias != t.TableName {
node.Extra = map[string]any{"alias": t.Alias}
}
if t.AttachedCondition != "" {
if node.Extra == nil {
node.Extra = map[string]any{}
}
node.Extra["attachedCondition"] = t.AttachedCondition
}
if len(t.UsedKeyParts) > 0 {
if node.Extra == nil {
node.Extra = map[string]any{}
}
node.Extra["usedKeyParts"] = t.UsedKeyParts
}
// 探测 Flags
if node.OpType == connection.ExplainOpScan {
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
}
return node
}
// classifyMySQLAccessType 把 MySQL access_type 归一化到通用 OpType。
// ALL → SCANrange/eq_ref/ref/index → INDEX_SCAN 或 INDEX_ONLY其他 → OTHER。
func classifyMySQLAccessType(accessType string) string {
switch strings.ToLower(strings.TrimSpace(accessType)) {
case "all":
return connection.ExplainOpScan
case "index":
return connection.ExplainOpIndexOnly // 仅扫索引不回表
case "range":
return connection.ExplainOpIndexScan
case "eq_ref", "ref", "ref_or_null", "unique_subquery", "index_subquery":
return connection.ExplainOpIndexScan
case "const", "system":
return connection.ExplainOpOther // 单行命中,性能极佳
default:
return connection.ExplainOpOther
}
}
// parseMySQLTableExplain 解析 MySQL 5.7 表格 / Doris / StarRocks 的 EXPLAIN 输出。
// 标准 MySQL 表格列id|select_type|table|type|possible_keys|key|key_len|ref|rows|Extra
// OceanBase 可能多 range_infoDoris/StarRocks 是完全不同的结构化文本PR2 优化)。
func parseMySQLTableExplain(raw string) (*connection.ExplainResult, error) {
header, rows := parseExplainTSVRows(raw)
if len(header) == 0 || len(rows) == 0 {
return nil, fmt.Errorf("MySQL 表格 EXPLAIN 无有效行")
}
result := &connection.ExplainResult{}
colID := lookupTSVColumn(header, "id")
colType := lookupTSVColumn(header, "type")
colTable := lookupTSVColumn(header, "table")
colKey := lookupTSVColumn(header, "key")
colRows := lookupTSVColumn(header, "rows")
colExtra := lookupTSVColumn(header, "extra")
// MySQL 的 id 字段表达父子:相同 id 是同一 SELECT 内的 joinid 不同代表子查询
// 简化处理每行作为独立节点无父子PR2 增强)
var lastID string
for _, row := range rows {
var idStr string
if colID >= 0 && colID < len(row) {
idStr = strings.TrimSpace(row[colID])
}
if idStr == "" {
idStr = lastID
}
lastID = idStr
var accessType string
if colType >= 0 && colType < len(row) {
accessType = strings.TrimSpace(row[colType])
}
node := connection.ExplainNode{
OpType: classifyMySQLAccessType(accessType),
OpDetail: fmt.Sprintf("id=%s type=%s", idStr, strings.ToLower(accessType)),
}
if colTable >= 0 && colTable < len(row) {
node.Table = strings.TrimSpace(row[colTable])
}
if colKey >= 0 && colKey < len(row) {
node.Index = strings.TrimSpace(row[colKey])
}
if colRows >= 0 && colRows < len(row) {
node.EstRows = parseExplainInt64(row[colRows])
}
if colExtra >= 0 && colExtra < len(row) {
extra := strings.TrimSpace(row[colExtra])
if extra != "" {
node.Extra = map[string]any{"extra": extra}
lower := strings.ToLower(extra)
if strings.Contains(lower, "using filesort") {
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
}
if strings.Contains(lower, "using temporary") {
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
}
}
}
if node.OpType == connection.ExplainOpScan {
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
}
appendExplainChild(result, "", node)
}
return result, nil
}