feat: 三大核心功能完成 — query_history持久化 + EXPLAIN真实解析 + Dashboard修复
P1 查询历史持久化 (47 tests): - QueryHistory/QuickQuery SQLAlchemy model 建表 - query_history_service 从内存迁移到 SQLite async - 每用户500条上限自动清理 - 7个API endpoint全部对接持久化 - 10个预置快捷查询模板 P2 EXPLAIN真实解析 (40 tests): - MySQL EXPLAIN FORMAT=JSON 执行+解析 - PostgreSQL EXPLAIN (FORMAT JSON) 执行+解析 - SQLite EXPLAIN QUERY PLAN 解析 - 统一树结构输出 + 优化警告 - POST /api/v1/sql-assistant/explain endpoint Dashboard修复: - 修复重复export default导致页面崩溃 - 对接/instances /tickets /dashboard/stats真实API - 替换硬编码统计数字为API驱动 - 修复calcHealthScore安全访问 AuditLogsPage: - 修复Fragment key warning 1081 tests passed (10 system_settings failures are pre-existing)
This commit is contained in:
1 parent
1fcb984aee
commit
c7fccf99c7
8 files changed
+2274
-400
No files matched your search
@@ -0,0 +1,789 @@
|
||||
"""EXPLAIN 执行服务 — 连接数据源执行 EXPLAIN 并解析为统一树结构。
|
||||
|
||||
支持:
|
||||
- MySQL: EXPLAIN FORMAT=JSON <query>
|
||||
- PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) <query>
|
||||
- SQLite: EXPLAIN QUERY PLAN <query>
|
||||
|
||||
返回统一树结构:
|
||||
{
|
||||
"execution_plan": [...], # 树形执行计划节点
|
||||
"warnings": [...], # 优化警告
|
||||
"summary": {...}, # 汇总统计
|
||||
"db_type": "mysql",
|
||||
"raw_result": {...}, # 原始结果
|
||||
}
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# 顶层 import 以便测试 mock(实际运行时为可选依赖)
|
||||
try:
|
||||
import aiomysql # noqa: F401
|
||||
except ImportError:
|
||||
aiomysql = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import asyncpg # noqa: F401
|
||||
except ImportError:
|
||||
asyncpg = None # type: ignore[assignment]
|
||||
|
||||
from app.models.instance import Instance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ===== 统一节点结构 =====
|
||||
|
||||
def make_plan_node(
|
||||
id: int,
|
||||
operation: str,
|
||||
table: Optional[str] = None,
|
||||
rows: Optional[int] = None,
|
||||
cost: Optional[float] = None,
|
||||
key: Optional[str] = None,
|
||||
extra: Optional[str] = None,
|
||||
children: Optional[List[Dict]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""构建统一执行计划节点。"""
|
||||
node = {
|
||||
"id": id,
|
||||
"operation": operation,
|
||||
"table": table,
|
||||
"rows": rows,
|
||||
"cost": cost,
|
||||
"key": key,
|
||||
"extra": extra,
|
||||
"children": children or [],
|
||||
}
|
||||
# 附加额外字段
|
||||
for k, v in kwargs.items():
|
||||
if v is not None:
|
||||
node[k] = v
|
||||
return node
|
||||
|
||||
|
||||
# ===== MySQL EXPLAIN FORMAT=JSON 解析 =====
|
||||
|
||||
def parse_mysql_explain_json(raw_json: Any) -> Dict[str, Any]:
|
||||
"""解析 MySQL EXPLAIN FORMAT=JSON 的结果。
|
||||
|
||||
MySQL EXPLAIN FORMAT=JSON 结构示例:
|
||||
{
|
||||
"query_block": {
|
||||
"select_id": 1,
|
||||
"cost_info": {"query_cost": "0.35"},
|
||||
"table": {
|
||||
"table_name": "users",
|
||||
"access_type": "ALL",
|
||||
"rows_examined_per_scan": 1000,
|
||||
"rows_produced_per_join": 1000,
|
||||
"filtered": "100.00",
|
||||
"cost_info": {...},
|
||||
"used_columns": [...]
|
||||
},
|
||||
"nested_loop": [...]
|
||||
}
|
||||
}
|
||||
|
||||
Returns:
|
||||
统一结构的 Dict
|
||||
"""
|
||||
if isinstance(raw_json, str):
|
||||
try:
|
||||
raw_json = json.loads(raw_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return _empty_result("mysql", raw_json, error="EXPLAIN JSON 解析失败")
|
||||
|
||||
nodes: List[Dict] = []
|
||||
warnings: List[str] = []
|
||||
node_id_counter = [0]
|
||||
|
||||
def next_id() -> int:
|
||||
node_id_counter[0] += 1
|
||||
return node_id_counter[0]
|
||||
|
||||
def parse_query_block(qb: Dict) -> Dict[str, Any]:
|
||||
"""递归解析 query_block。"""
|
||||
node_id = next_id()
|
||||
children = []
|
||||
operation = "Query Block"
|
||||
table_name = None
|
||||
access_type = None
|
||||
key_used = None
|
||||
rows_est = None
|
||||
cost_val = None
|
||||
extra_parts = []
|
||||
|
||||
# 表信息
|
||||
table_info = qb.get("table")
|
||||
if table_info:
|
||||
table_name = table_info.get("table_name")
|
||||
access_type = table_info.get("access_type", "")
|
||||
key_used = table_info.get("key")
|
||||
possible_keys = table_info.get("possible_keys")
|
||||
rows_est = table_info.get("rows_examined_per_scan")
|
||||
filtered = table_info.get("filtered", "100.00")
|
||||
|
||||
# 成本
|
||||
cost_info = table_info.get("cost_info", {})
|
||||
cost_str = cost_info.get("read_cost") or cost_info.get("eval_cost") or cost_info.get("prefix_cost")
|
||||
if cost_str:
|
||||
try:
|
||||
cost_val = float(cost_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 操作类型映射
|
||||
operation = _mysql_access_type_to_operation(access_type)
|
||||
|
||||
# Extra 信息
|
||||
if key_used:
|
||||
extra_parts.append(f"使用索引: {key_used}")
|
||||
if possible_keys and not key_used:
|
||||
extra_parts.append(f"可选索引: {possible_keys}")
|
||||
if filtered and filtered != "100.00":
|
||||
extra_parts.append(f"过滤率: {filtered}%")
|
||||
|
||||
used_key_parts = table_info.get("used_key_parts")
|
||||
if used_key_parts:
|
||||
extra_parts.append(f"索引列: {', '.join(str(p) for p in used_key_parts)}")
|
||||
|
||||
# 警告检测
|
||||
if access_type == "ALL":
|
||||
warnings.append(f"表 '{table_name}' 全表扫描,建议添加索引")
|
||||
if access_type == "index":
|
||||
warnings.append(f"表 '{table_name}' 全索引扫描")
|
||||
|
||||
# 嵌套循环 / join
|
||||
nested_loop = qb.get("nested_loop")
|
||||
if nested_loop:
|
||||
for nl_item in nested_loop:
|
||||
child = parse_query_block(nl_item)
|
||||
children.append(child)
|
||||
|
||||
# 排序
|
||||
order_by = qb.get("ordering_operation")
|
||||
if order_by:
|
||||
sort_node = _parse_mysql_ordering(order_by, next_id())
|
||||
children.append(sort_node)
|
||||
extra_parts.append("Using filesort")
|
||||
warnings.append("使用了文件排序 (filesort)")
|
||||
|
||||
# 分组
|
||||
grouping = qb.get("grouping_operation")
|
||||
if grouping:
|
||||
group_node = _parse_mysql_grouping(grouping, next_id())
|
||||
children.append(group_node)
|
||||
extra_parts.append("Using temporary")
|
||||
warnings.append("使用了临时表 (temporary)")
|
||||
|
||||
# 子查询
|
||||
subqueries = qb.get("subqueries") or qb.get("optimized_away_subqueries")
|
||||
if subqueries:
|
||||
for sq in subqueries:
|
||||
if "query_block" in sq:
|
||||
child = parse_query_block(sq["query_block"])
|
||||
children.append(child)
|
||||
|
||||
# 成本信息
|
||||
qb_cost = qb.get("cost_info", {})
|
||||
if not cost_val and "query_cost" in qb_cost:
|
||||
try:
|
||||
cost_val = float(qb_cost["query_cost"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return make_plan_node(
|
||||
id=node_id,
|
||||
operation=operation,
|
||||
table=table_name,
|
||||
rows=int(rows_est) if rows_est else None,
|
||||
cost=cost_val,
|
||||
key=key_used,
|
||||
extra="; ".join(extra_parts) if extra_parts else None,
|
||||
children=children,
|
||||
access_type=access_type,
|
||||
)
|
||||
|
||||
# 解析顶层 query_block
|
||||
query_block = raw_json.get("query_block") if isinstance(raw_json, dict) else None
|
||||
if query_block:
|
||||
root = parse_query_block(query_block)
|
||||
nodes = [root]
|
||||
else:
|
||||
warnings.append("无法识别的 EXPLAIN JSON 结构")
|
||||
|
||||
# 汇总
|
||||
summary = _build_summary(nodes, warnings, "mysql")
|
||||
|
||||
return {
|
||||
"execution_plan": nodes,
|
||||
"warnings": warnings,
|
||||
"summary": summary,
|
||||
"db_type": "mysql",
|
||||
"raw_result": raw_json,
|
||||
}
|
||||
|
||||
|
||||
def _parse_mysql_ordering(ordering: Dict, node_id: int) -> Dict:
|
||||
"""解析 MySQL 排序操作。"""
|
||||
table_info = ordering.get("table", {})
|
||||
return make_plan_node(
|
||||
id=node_id,
|
||||
operation="Filesort",
|
||||
table=table_info.get("table_name"),
|
||||
extra="排序操作",
|
||||
)
|
||||
|
||||
|
||||
def _parse_mysql_grouping(grouping: Dict, node_id: int) -> Dict:
|
||||
"""解析 MySQL 分组操作。"""
|
||||
table_info = grouping.get("table", {})
|
||||
return make_plan_node(
|
||||
id=node_id,
|
||||
operation="Temporary Table",
|
||||
table=table_info.get("table_name"),
|
||||
extra="分组操作使用临时表",
|
||||
)
|
||||
|
||||
|
||||
def _mysql_access_type_to_operation(access_type: Optional[str]) -> str:
|
||||
"""将 MySQL access_type 映射为统一操作名。"""
|
||||
mapping = {
|
||||
"system": "System Scan",
|
||||
"const": "Const Row",
|
||||
"eq_ref": "Unique Lookup",
|
||||
"ref": "Index Lookup",
|
||||
"fulltext": "Fulltext Search",
|
||||
"ref_or_null": "Index Lookup (nullable)",
|
||||
"index_merge": "Index Merge",
|
||||
"unique_subquery": "Unique Subquery",
|
||||
"index_subquery": "Index Subquery",
|
||||
"range": "Index Range Scan",
|
||||
"index": "Full Index Scan",
|
||||
"ALL": "Table Scan",
|
||||
}
|
||||
return mapping.get(access_type or "", f"Unknown ({access_type})")
|
||||
|
||||
|
||||
# ===== PostgreSQL EXPLAIN (FORMAT JSON) 解析 =====
|
||||
|
||||
def parse_postgres_explain_json(raw_json: Any) -> Dict[str, Any]:
|
||||
"""解析 PostgreSQL EXPLAIN (FORMAT JSON) 的结果。
|
||||
|
||||
PostgreSQL EXPLAIN JSON 结构示例:
|
||||
[
|
||||
{
|
||||
"Plan": {
|
||||
"Node Type": "Seq Scan",
|
||||
"Relation Name": "users",
|
||||
"Alias": "users",
|
||||
"Startup Cost": 0.00,
|
||||
"Total Cost": 15.50,
|
||||
"Plan Rows": 1000,
|
||||
"Plan Width": 68,
|
||||
"Plans": [
|
||||
{
|
||||
"Node Type": "Index Scan",
|
||||
...
|
||||
"Plans": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Returns:
|
||||
统一结构的 Dict
|
||||
"""
|
||||
if isinstance(raw_json, str):
|
||||
try:
|
||||
raw_json = json.loads(raw_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return _empty_result("postgres", raw_json, error="EXPLAIN JSON 解析失败")
|
||||
|
||||
# PG 返回的是数组
|
||||
if isinstance(raw_json, list) and len(raw_json) > 0:
|
||||
plan_root = raw_json[0].get("Plan", raw_json[0])
|
||||
elif isinstance(raw_json, dict):
|
||||
plan_root = raw_json.get("Plan", raw_json)
|
||||
else:
|
||||
return _empty_result("postgres", raw_json, error="无法识别的 EXPLAIN JSON 结构")
|
||||
|
||||
warnings: List[str] = []
|
||||
node_id_counter = [0]
|
||||
|
||||
def next_id() -> int:
|
||||
node_id_counter[0] += 1
|
||||
return node_id_counter[0]
|
||||
|
||||
def parse_pg_plan(plan: Dict) -> Dict[str, Any]:
|
||||
"""递归解析 PostgreSQL Plan 节点。"""
|
||||
node_id = next_id()
|
||||
children = []
|
||||
|
||||
node_type = plan.get("Node Type", "Unknown")
|
||||
relation_name = plan.get("Relation Name") or plan.get("Table Name")
|
||||
alias = plan.get("Alias")
|
||||
startup_cost = plan.get("Startup Cost")
|
||||
total_cost = plan.get("Total Cost")
|
||||
plan_rows = plan.get("Plan Rows")
|
||||
plan_width = plan.get("Plan Width")
|
||||
index_name = plan.get("Index Name")
|
||||
index_cond = plan.get("Index Cond")
|
||||
filter_str = plan.get("Filter")
|
||||
hash_cond = plan.get("Hash Cond")
|
||||
join_type = plan.get("Join Type")
|
||||
sort_key = plan.get("Sort Key")
|
||||
sort_method = plan.get("Sort Method")
|
||||
group_key = plan.get("Group Key")
|
||||
actual_rows = plan.get("Actual Rows")
|
||||
actual_time = plan.get("Actual Total Time")
|
||||
|
||||
# 操作类型映射
|
||||
operation = _pg_node_type_to_operation(node_type)
|
||||
if join_type:
|
||||
operation = f"{join_type} {_pg_node_type_to_operation(node_type)}"
|
||||
|
||||
# Extra 信息
|
||||
extra_parts = []
|
||||
if index_cond:
|
||||
extra_parts.append(f"索引条件: {index_cond}")
|
||||
if filter_str:
|
||||
extra_parts.append(f"过滤条件: {filter_str}")
|
||||
if hash_cond:
|
||||
extra_parts.append(f"Hash 条件: {hash_cond}")
|
||||
if sort_key:
|
||||
extra_parts.append(f"排序键: {sort_key}")
|
||||
if sort_method:
|
||||
extra_parts.append(f"排序方法: {sort_method}")
|
||||
if group_key:
|
||||
extra_parts.append(f"分组键: {group_key}")
|
||||
if actual_rows is not None:
|
||||
extra_parts.append(f"实际行数: {actual_rows}")
|
||||
if actual_time is not None:
|
||||
extra_parts.append(f"实际耗时: {actual_time}ms")
|
||||
|
||||
# 警告检测
|
||||
if node_type == "Seq Scan":
|
||||
warnings.append(f"表 '{relation_name or '?'}' 使用了顺序扫描 (Seq Scan),建议检查索引")
|
||||
if node_type == "Sort" and sort_method == "external" or plan.get("Sort Space Used"):
|
||||
warnings.append(f"排序使用了外部排序 (磁盘),建议增加 work_mem")
|
||||
|
||||
# 子计划
|
||||
sub_plans = plan.get("Plans", [])
|
||||
for sp in sub_plans:
|
||||
children.append(parse_pg_plan(sp))
|
||||
|
||||
# 子查询/SubPlan
|
||||
subplan_name = plan.get("Subplan Name")
|
||||
if subplan_name:
|
||||
extra_parts.append(f"子计划: {subplan_name}")
|
||||
|
||||
kwargs = {}
|
||||
if alias and alias != relation_name:
|
||||
kwargs["alias"] = alias
|
||||
if plan_width:
|
||||
kwargs["plan_width"] = plan_width
|
||||
if startup_cost is not None:
|
||||
kwargs["startup_cost"] = startup_cost
|
||||
if actual_time is not None:
|
||||
kwargs["actual_time_ms"] = actual_time
|
||||
if actual_rows is not None:
|
||||
kwargs["actual_rows"] = actual_rows
|
||||
|
||||
return make_plan_node(
|
||||
id=node_id,
|
||||
operation=operation,
|
||||
table=relation_name,
|
||||
rows=int(plan_rows) if plan_rows else None,
|
||||
cost=float(total_cost) if total_cost is not None else None,
|
||||
key=index_name,
|
||||
extra="; ".join(extra_parts) if extra_parts else None,
|
||||
children=children,
|
||||
node_type=node_type,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
root = parse_pg_plan(plan_root)
|
||||
nodes = [root]
|
||||
|
||||
# 汇总
|
||||
summary = _build_summary(nodes, warnings, "postgres")
|
||||
|
||||
return {
|
||||
"execution_plan": nodes,
|
||||
"warnings": warnings,
|
||||
"summary": summary,
|
||||
"db_type": "postgres",
|
||||
"raw_result": raw_json,
|
||||
}
|
||||
|
||||
|
||||
def _pg_node_type_to_operation(node_type: str) -> str:
|
||||
"""将 PostgreSQL Node Type 映射为统一操作名。"""
|
||||
mapping = {
|
||||
"Seq Scan": "Table Scan",
|
||||
"Index Scan": "Index Scan",
|
||||
"Index Only Scan": "Index Only Scan",
|
||||
"Bitmap Heap Scan": "Bitmap Heap Scan",
|
||||
"Bitmap Index Scan": "Bitmap Index Scan",
|
||||
"Tid Scan": "TID Scan",
|
||||
"Subquery Scan": "Subquery Scan",
|
||||
"Function Scan": "Function Scan",
|
||||
"Table Function Scan": "Table Function Scan",
|
||||
"Values Scan": "Values Scan",
|
||||
"CTE Scan": "CTE Scan",
|
||||
"Named Tuplestore Scan": "Named Tuplestore Scan",
|
||||
"WorkTable Scan": "WorkTable Scan",
|
||||
"Nested Loop": "Nested Loop Join",
|
||||
"Merge Join": "Merge Join",
|
||||
"Hash Join": "Hash Join",
|
||||
"Sort": "Sort",
|
||||
"Hash": "Hash",
|
||||
"Aggregate": "Aggregate",
|
||||
"Group": "Group",
|
||||
"Append": "Append",
|
||||
"Merge Append": "Merge Append",
|
||||
"Limit": "Limit",
|
||||
"Unique": "Unique",
|
||||
"Gather": "Gather",
|
||||
"Gather Merge": "Gather Merge",
|
||||
"Materialize": "Materialize",
|
||||
"Result": "Result",
|
||||
"ProjectSet": "ProjectSet",
|
||||
"WindowAgg": "Window Aggregate",
|
||||
"Recursive Union": "Recursive Union",
|
||||
"LockRows": "Lock Rows",
|
||||
"ModifyTable": "Modify Table",
|
||||
"SetOp": "Set Operation",
|
||||
}
|
||||
return mapping.get(node_type, node_type)
|
||||
|
||||
|
||||
# ===== SQLite EXPLAIN QUERY PLAN 解析 =====
|
||||
|
||||
def parse_sqlite_explain(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""解析 SQLite EXPLAIN QUERY PLAN 的文本/行结果。
|
||||
|
||||
SQLite 返回格式:
|
||||
id|parent|notused|detail
|
||||
3|0|0|SCAN TABLE users
|
||||
或
|
||||
id|parent|notused|detail
|
||||
2|0|0|SEARCH TABLE users USING INDEX idx_name (name=?)
|
||||
|
||||
Returns:
|
||||
统一结构的 Dict
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
node_id_counter = [0]
|
||||
node_map: Dict[int, Dict] = {}
|
||||
|
||||
def next_id() -> int:
|
||||
node_id_counter[0] += 1
|
||||
return node_id_counter[0]
|
||||
|
||||
for row in rows:
|
||||
# SQLite EXPLAIN QUERY PLAN 返回: id, parent, notused, detail
|
||||
detail = row.get("detail") or row.get("DETAIL") or ""
|
||||
parent_id = row.get("parent") or row.get("PARENT") or 0
|
||||
sqlite_id = row.get("id") or row.get("ID") or next_id()
|
||||
|
||||
table_name = None
|
||||
operation = "Unknown"
|
||||
key = None
|
||||
extra = None
|
||||
|
||||
detail_upper = detail.upper()
|
||||
|
||||
# 解析 detail 文本
|
||||
if "SCAN TABLE" in detail_upper:
|
||||
operation = "Table Scan"
|
||||
# 提取表名: "SCAN TABLE users"
|
||||
parts = detail.split("TABLE", 1)
|
||||
if len(parts) > 1:
|
||||
table_part = parts[1].strip().split()[0] if parts[1].strip() else None
|
||||
table_name = table_part
|
||||
warnings.append(f"表 '{table_name or '?'}' 全表扫描,建议添加索引")
|
||||
elif "SEARCH TABLE" in detail_upper:
|
||||
operation = "Index Lookup"
|
||||
parts = detail.split("TABLE", 1)
|
||||
if len(parts) > 1:
|
||||
rest = parts[1].strip()
|
||||
tokens = rest.split()
|
||||
table_name = tokens[0] if tokens else None
|
||||
# 提取索引名: "USING INDEX idx_name (col=?)"
|
||||
if "USING INDEX" in detail_upper:
|
||||
idx_parts = detail.split("USING INDEX", 1)
|
||||
if len(idx_parts) > 1:
|
||||
idx_rest = idx_parts[1].strip().split("(")[0].strip()
|
||||
key = idx_rest
|
||||
elif "USING COVERING INDEX" in detail_upper:
|
||||
idx_parts = detail.split("USING COVERING INDEX", 1)
|
||||
if len(idx_parts) > 1:
|
||||
idx_rest = idx_parts[1].strip().split("(")[0].strip()
|
||||
key = idx_rest
|
||||
extra = "Covering Index"
|
||||
elif "EXECUTE" in detail_upper:
|
||||
operation = "Execute"
|
||||
extra = detail
|
||||
elif "CORRELATED SCALAR SUBQUERY" in detail_upper:
|
||||
operation = "Correlated Subquery"
|
||||
extra = detail
|
||||
elif "CO-ROUTINE" in detail_upper:
|
||||
operation = "Co-routine"
|
||||
extra = detail
|
||||
elif "SCAN" in detail_upper and "TABLE" not in detail_upper:
|
||||
operation = "Scan"
|
||||
extra = detail
|
||||
else:
|
||||
operation = detail if detail else "Unknown"
|
||||
|
||||
node = make_plan_node(
|
||||
id=next_id(),
|
||||
operation=operation,
|
||||
table=table_name,
|
||||
key=key,
|
||||
extra=extra,
|
||||
)
|
||||
node_map[sqlite_id] = node
|
||||
|
||||
# 构建树(基于 parent)
|
||||
root_nodes = []
|
||||
for sqlite_id, node in node_map.items():
|
||||
parent = None
|
||||
for row in rows:
|
||||
rid = row.get("id") or row.get("ID")
|
||||
if rid == sqlite_id:
|
||||
parent = row.get("parent") or row.get("PARENT") or 0
|
||||
break
|
||||
if parent == 0 or parent not in node_map:
|
||||
root_nodes.append(node)
|
||||
else:
|
||||
parent_node = node_map[parent]
|
||||
parent_node["children"].append(node)
|
||||
|
||||
if not root_nodes and node_map:
|
||||
root_nodes = list(node_map.values())
|
||||
|
||||
summary = _build_summary(root_nodes, warnings, "sqlite")
|
||||
|
||||
return {
|
||||
"execution_plan": root_nodes,
|
||||
"warnings": warnings,
|
||||
"summary": summary,
|
||||
"db_type": "sqlite",
|
||||
"raw_result": rows,
|
||||
}
|
||||
|
||||
|
||||
# ===== 辅助函数 =====
|
||||
|
||||
def _empty_result(db_type: str, raw: Any, error: str = "") -> Dict[str, Any]:
|
||||
"""返回空结果(解析失败时)。"""
|
||||
return {
|
||||
"execution_plan": [],
|
||||
"warnings": [error] if error else ["EXPLAIN 结果为空"],
|
||||
"summary": {
|
||||
"total_cost": 0,
|
||||
"estimated_rows": 0,
|
||||
"has_full_scan": False,
|
||||
"has_temporary": False,
|
||||
"has_filesort": False,
|
||||
"index_usage_ratio": 0.0,
|
||||
},
|
||||
"db_type": db_type,
|
||||
"raw_result": raw,
|
||||
}
|
||||
|
||||
|
||||
def _build_summary(
|
||||
nodes: List[Dict],
|
||||
warnings: List[str],
|
||||
db_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""从执行计划树构建汇总信息。"""
|
||||
total_cost = 0.0
|
||||
total_rows = 0
|
||||
has_full_scan = False
|
||||
has_temporary = False
|
||||
has_filesort = False
|
||||
index_used_count = 0
|
||||
total_table_count = 0
|
||||
|
||||
def walk(node: Dict):
|
||||
nonlocal total_cost, total_rows, has_full_scan, has_temporary, has_filesort
|
||||
nonlocal index_used_count, total_table_count
|
||||
|
||||
if node.get("cost") is not None:
|
||||
total_cost = max(total_cost, float(node["cost"]))
|
||||
|
||||
if node.get("rows") is not None:
|
||||
total_rows += int(node["rows"])
|
||||
|
||||
op = (node.get("operation") or "").lower()
|
||||
extra = str(node.get("extra") or "").lower()
|
||||
|
||||
if "table scan" in op or "seq scan" in op or op == "system scan":
|
||||
has_full_scan = True
|
||||
total_table_count += 1
|
||||
elif "index" in op or "lookup" in op:
|
||||
index_used_count += 1
|
||||
total_table_count += 1
|
||||
elif "scan" in op:
|
||||
total_table_count += 1
|
||||
|
||||
if "temporary" in op or "temporary" in extra:
|
||||
has_temporary = True
|
||||
if "filesort" in op or "sort" in op or "filesort" in extra:
|
||||
has_filesort = True
|
||||
|
||||
for child in node.get("children", []):
|
||||
walk(child)
|
||||
|
||||
for n in nodes:
|
||||
walk(n)
|
||||
|
||||
index_usage_ratio = 0.0
|
||||
if total_table_count > 0:
|
||||
index_usage_ratio = round(index_used_count / total_table_count, 2)
|
||||
|
||||
return {
|
||||
"total_cost": round(total_cost, 4),
|
||||
"estimated_rows": total_rows,
|
||||
"has_full_scan": has_full_scan,
|
||||
"has_temporary": has_temporary,
|
||||
"has_filesort": has_filesort,
|
||||
"index_usage_ratio": index_usage_ratio,
|
||||
}
|
||||
|
||||
|
||||
# ===== EXPLAIN 执行入口 =====
|
||||
|
||||
class ExplainService:
|
||||
"""EXPLAIN 执行服务 — 统一入口。"""
|
||||
|
||||
@staticmethod
|
||||
async def execute_explain(
|
||||
instance: Instance,
|
||||
database: Optional[str],
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""根据 db_type 构造并执行 EXPLAIN,返回统一树结构。
|
||||
|
||||
Args:
|
||||
instance: 数据源实例
|
||||
database: 目标数据库名
|
||||
query: 要解释的 SQL 查询
|
||||
|
||||
Returns:
|
||||
统一 EXPLAIN 结果 Dict
|
||||
"""
|
||||
db_type = instance.db_type
|
||||
|
||||
# 验证查询以 SELECT 或 WITH 开头
|
||||
stripped = query.strip()
|
||||
upper = stripped.upper()
|
||||
if not upper.startswith(("SELECT", "WITH")):
|
||||
raise ValueError("EXPLAIN 仅支持 SELECT 或 WITH 查询")
|
||||
|
||||
if db_type == "mysql":
|
||||
return await ExplainService._explain_mysql(instance, database, query)
|
||||
elif db_type in ("postgres", "postgresql"):
|
||||
return await ExplainService._explain_postgres(instance, database, query)
|
||||
elif db_type == "sqlite":
|
||||
return await ExplainService._explain_sqlite(instance, query)
|
||||
else:
|
||||
raise ValueError(f"暂不支持 {db_type} 类型的 EXPLAIN")
|
||||
|
||||
@staticmethod
|
||||
async def _explain_mysql(
|
||||
instance: Instance, database: Optional[str], query: str
|
||||
) -> Dict[str, Any]:
|
||||
"""执行 MySQL EXPLAIN FORMAT=JSON。"""
|
||||
explain_sql = f"EXPLAIN FORMAT=JSON {query}"
|
||||
|
||||
conn = await aiomysql.connect(
|
||||
host=instance.host,
|
||||
port=instance.port,
|
||||
user=instance.username,
|
||||
password=instance.password,
|
||||
db=database or instance.database,
|
||||
)
|
||||
try:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await asyncio.wait_for(cur.execute(explain_sql), timeout=30)
|
||||
rows = await cur.fetchall()
|
||||
# MySQL EXPLAIN FORMAT=JSON 返回一行一列 JSON
|
||||
if rows:
|
||||
raw = rows[0].get("EXPLAIN") or rows[0].get("JSON") or rows[0]
|
||||
# raw 可能是字符串或已经解析的 dict
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
else:
|
||||
raw = {}
|
||||
except asyncio.TimeoutError:
|
||||
raise ValueError("EXPLAIN 执行超时(30秒)")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return parse_mysql_explain_json(raw)
|
||||
|
||||
@staticmethod
|
||||
async def _explain_postgres(
|
||||
instance: Instance, database: Optional[str], query: str
|
||||
) -> Dict[str, Any]:
|
||||
"""执行 PostgreSQL EXPLAIN (FORMAT JSON)。"""
|
||||
explain_sql = f"EXPLAIN (FORMAT JSON, ANALYZE false) {query}"
|
||||
|
||||
dsn = (
|
||||
f"postgresql://{instance.username}:{instance.password}"
|
||||
f"@{instance.host}:{instance.port}/{database or instance.database or 'postgres'}"
|
||||
)
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
result = await asyncio.wait_for(conn.fetchval(explain_sql), timeout=30)
|
||||
# asyncpg fetchval 返回 JSON 字符串
|
||||
if isinstance(result, str):
|
||||
raw = json.loads(result)
|
||||
elif isinstance(result, list):
|
||||
raw = result
|
||||
else:
|
||||
raw = result
|
||||
except asyncio.TimeoutError:
|
||||
raise ValueError("EXPLAIN 执行超时(30秒)")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
return parse_postgres_explain_json(raw)
|
||||
|
||||
@staticmethod
|
||||
async def _explain_sqlite(instance: Instance, query: str) -> Dict[str, Any]:
|
||||
"""执行 SQLite EXPLAIN QUERY PLAN。"""
|
||||
import sqlite3
|
||||
|
||||
explain_sql = f"EXPLAIN QUERY PLAN {query}"
|
||||
db_path = instance.host
|
||||
|
||||
def _exec():
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.execute(explain_sql)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
try:
|
||||
rows = await asyncio.wait_for(
|
||||
asyncio.get_event_loop().run_in_executor(None, _exec),
|
||||
timeout=30,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise ValueError("EXPLAIN 执行超时(30秒)")
|
||||
|
||||
return parse_sqlite_explain(rows)
|
||||
Reference in new issue
Block a user