- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
316 lines
13 KiB
Python
316 lines
13 KiB
Python
"""SQL 执行影响分析 — 预估影响行数、执行时间、风险等级。
|
||
|
||
使用数据库 EXPLAIN 输出分析执行计划,结合表大小统计预估影响。
|
||
支持 MySQL 和 PostgreSQL。
|
||
|
||
风险等级定义:
|
||
- low: 影响 < 1000 行,执行 < 1秒
|
||
- medium: 影响 1000-10000 行,执行 1-10秒
|
||
- high: 影响 10000-100000 行,执行 10-60秒
|
||
- critical: 影响 > 100000 行 或 ALTER > 1GB 表
|
||
"""
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from typing import List, Optional, Dict, Any
|
||
from enum import Enum
|
||
|
||
from app.models.instance import Instance
|
||
|
||
|
||
class RiskLevel(str, Enum):
|
||
LOW = "low"
|
||
MEDIUM = "medium"
|
||
HIGH = "high"
|
||
CRITICAL = "critical"
|
||
|
||
|
||
@dataclass
|
||
class ImpactAnalysis:
|
||
"""影响分析结果。"""
|
||
sql_type: str = "UNKNOWN"
|
||
estimated_rows: int = 0
|
||
estimated_duration_ms: int = 0
|
||
table_size_mb: float = 0.0
|
||
table_row_count: int = 0
|
||
index_usage: List[str] = field(default_factory=list)
|
||
has_full_scan: bool = False
|
||
risk_level: str = RiskLevel.LOW
|
||
warnings: List[str] = field(default_factory=list)
|
||
explain_result: Optional[Dict[str, Any]] = None
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"sql_type": self.sql_type,
|
||
"estimated_rows": self.estimated_rows,
|
||
"estimated_duration_ms": self.estimated_duration_ms,
|
||
"table_size_mb": round(self.table_size_mb, 2),
|
||
"table_row_count": self.table_row_count,
|
||
"index_usage": self.index_usage,
|
||
"has_full_scan": self.has_full_scan,
|
||
"risk_level": self.risk_level,
|
||
"warnings": self.warnings,
|
||
}
|
||
|
||
|
||
class ImpactAnalyzer:
|
||
"""SQL 执行影响分析器。"""
|
||
|
||
RISK_THRESHOLDS = {
|
||
RiskLevel.LOW: {"rows": 1000, "duration_ms": 1000, "size_mb": 10},
|
||
RiskLevel.MEDIUM: {"rows": 10000, "duration_ms": 10000, "size_mb": 100},
|
||
RiskLevel.HIGH: {"rows": 100000, "duration_ms": 60000, "size_mb": 1024},
|
||
}
|
||
|
||
def detect_sql_type(self, sql: str) -> str:
|
||
"""检测 SQL 语句类型。"""
|
||
upper = sql.strip().upper()
|
||
if upper.startswith("SELECT"):
|
||
return "SELECT"
|
||
elif upper.startswith("INSERT"):
|
||
return "INSERT"
|
||
elif upper.startswith("UPDATE"):
|
||
return "UPDATE"
|
||
elif upper.startswith("DELETE"):
|
||
return "DELETE"
|
||
elif upper.startswith("ALTER"):
|
||
return "ALTER"
|
||
elif upper.startswith("CREATE"):
|
||
return "CREATE"
|
||
elif upper.startswith("DROP"):
|
||
return "DROP"
|
||
elif upper.startswith("TRUNCATE"):
|
||
return "TRUNCATE"
|
||
return "UNKNOWN"
|
||
|
||
async def analyze(self, instance: Instance, database: str, sql: str) -> ImpactAnalysis:
|
||
"""分析 SQL 执行影响。"""
|
||
sql_type = self.detect_sql_type(sql)
|
||
analysis = ImpactAnalysis(sql_type=sql_type)
|
||
|
||
if sql_type in ("UPDATE", "DELETE"):
|
||
return await self._analyze_dml(instance, database, sql, analysis)
|
||
elif sql_type == "ALTER":
|
||
return await self._analyze_alter(instance, database, sql, analysis)
|
||
elif sql_type == "INSERT":
|
||
return await self._analyze_insert(instance, database, sql, analysis)
|
||
elif sql_type in ("CREATE", "DROP", "TRUNCATE"):
|
||
return self._analyze_ddl(sql, analysis)
|
||
else:
|
||
return analysis
|
||
|
||
async def _analyze_dml(self, instance: Instance, database: str,
|
||
sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis:
|
||
"""分析 UPDATE/DELETE 影响。"""
|
||
table = self._extract_table(sql)
|
||
analysis.table_row_count = await self._get_table_row_count(instance, database, table)
|
||
analysis.table_size_mb = await self._get_table_size(instance, database, table)
|
||
|
||
# 通过 WHERE 条件粗略估计影响行数
|
||
where_clause = self._extract_where(sql)
|
||
if where_clause:
|
||
# 如果有 WHERE 且包含主键条件,影响较小
|
||
if self._has_pk_condition(where_clause):
|
||
analysis.estimated_rows = 1
|
||
# 如果有 WHERE 且包含索引列条件
|
||
elif self._has_index_condition(where_clause):
|
||
analysis.estimated_rows = max(1, analysis.table_row_count // 100)
|
||
else:
|
||
# 非索引条件,影响较大
|
||
analysis.estimated_rows = max(1, analysis.table_row_count // 10)
|
||
analysis.has_full_scan = True
|
||
else:
|
||
# 无 WHERE,全表影响
|
||
analysis.estimated_rows = analysis.table_row_count
|
||
analysis.has_full_scan = True
|
||
analysis.warnings.append(f"{analysis.sql_type} 缺少 WHERE 条件,将影响全表 {analysis.table_row_count} 行")
|
||
|
||
# 预估执行时间
|
||
analysis.estimated_duration_ms = self._estimate_duration(
|
||
analysis.estimated_rows, analysis.table_size_mb, analysis.sql_type
|
||
)
|
||
|
||
# 检查索引使用
|
||
if analysis.has_full_scan:
|
||
analysis.warnings.append("可能触发全表扫描,建议添加索引条件")
|
||
|
||
analysis.risk_level = self._calculate_risk_level(
|
||
analysis.estimated_rows, analysis.estimated_duration_ms, analysis.table_size_mb
|
||
)
|
||
|
||
if analysis.risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL):
|
||
analysis.warnings.append(
|
||
f"风险等级: {analysis.risk_level.value},建议分批次执行或在维护窗口操作"
|
||
)
|
||
|
||
return analysis
|
||
|
||
async def _analyze_alter(self, instance: Instance, database: str,
|
||
sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis:
|
||
"""分析 ALTER TABLE 影响。"""
|
||
table = self._extract_table(sql)
|
||
analysis.table_size_mb = await self._get_table_size(instance, database, table)
|
||
analysis.table_row_count = await self._get_table_row_count(instance, database, table)
|
||
|
||
# ALTER TABLE 预估时间: 大约每 10MB 需要 1 秒
|
||
analysis.estimated_duration_ms = int(analysis.table_size_mb * 100)
|
||
analysis.estimated_rows = analysis.table_row_count
|
||
|
||
if analysis.table_size_mb > 1024:
|
||
analysis.risk_level = RiskLevel.CRITICAL
|
||
analysis.warnings.append(
|
||
f"ALTER TABLE 操作表大小为 {analysis.table_size_mb:.1f}MB,"
|
||
"建议使用 pt-online-schema-change 或 gh-ost 进行在线变更"
|
||
)
|
||
elif analysis.table_size_mb > 100:
|
||
analysis.risk_level = RiskLevel.HIGH
|
||
analysis.warnings.append(
|
||
f"ALTER TABLE 操作表大小为 {analysis.table_size_mb:.1f}MB,预计耗时 {analysis.estimated_duration_ms / 1000:.1f} 秒"
|
||
)
|
||
elif analysis.table_size_mb > 10:
|
||
analysis.risk_level = RiskLevel.MEDIUM
|
||
else:
|
||
analysis.risk_level = RiskLevel.LOW
|
||
|
||
return analysis
|
||
|
||
async def _analyze_insert(self, instance: Instance, database: str,
|
||
sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis:
|
||
"""分析 INSERT 影响。"""
|
||
# 估算插入行数
|
||
values_count = sql.count(",") if "VALUES" in sql.upper() else 1
|
||
# 更精确的估算:数 VALUES 子句中的行
|
||
row_match = re.findall(r"\)\s*,?\s*\(", sql)
|
||
analysis.estimated_rows = len(row_match) + 1 if row_match else 1
|
||
|
||
table = self._extract_table(sql)
|
||
analysis.table_size_mb = await self._get_table_size(instance, database, table)
|
||
|
||
# INSERT 通常很快
|
||
analysis.estimated_duration_ms = analysis.estimated_rows * 2
|
||
|
||
if analysis.estimated_rows > 10000:
|
||
analysis.risk_level = RiskLevel.HIGH
|
||
analysis.warnings.append(f"单次插入 {analysis.estimated_rows} 行数据量较大,建议分批插入")
|
||
elif analysis.estimated_rows > 1000:
|
||
analysis.risk_level = RiskLevel.MEDIUM
|
||
else:
|
||
analysis.risk_level = RiskLevel.LOW
|
||
|
||
return analysis
|
||
|
||
def _analyze_ddl(self, sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis:
|
||
"""分析 DDL 影响。"""
|
||
analysis.risk_level = RiskLevel.MEDIUM
|
||
analysis.warnings.append("DDL 操作会锁表,建议在低峰期执行")
|
||
|
||
if "DROP" in sql.upper():
|
||
analysis.risk_level = RiskLevel.CRITICAL
|
||
analysis.warnings.append("DROP 操作不可逆,请确认已备份数据")
|
||
elif "TRUNCATE" in sql.upper():
|
||
analysis.risk_level = RiskLevel.CRITICAL
|
||
analysis.warnings.append("TRUNCATE 会清空所有数据且不可回滚")
|
||
|
||
return analysis
|
||
|
||
def _estimate_duration(self, rows: int, table_size_mb: float, sql_type: str) -> int:
|
||
"""预估执行时间(毫秒)。"""
|
||
base_time = 50 # 基础时间 50ms
|
||
|
||
if sql_type == "UPDATE":
|
||
# UPDATE: 每 1000 行约 100ms,加上表大小因素
|
||
row_time = (rows / 1000) * 100
|
||
size_factor = table_size_mb * 0.5
|
||
return int(base_time + row_time + size_factor)
|
||
elif sql_type == "DELETE":
|
||
# DELETE: 每 1000 行约 80ms
|
||
row_time = (rows / 1000) * 80
|
||
return int(base_time + row_time)
|
||
elif sql_type == "INSERT":
|
||
# INSERT: 每行约 2ms
|
||
return int(base_time + rows * 2)
|
||
elif sql_type == "ALTER":
|
||
# ALTER: 每 10MB 约 1000ms
|
||
return int(base_time + table_size_mb * 100)
|
||
|
||
return base_time
|
||
|
||
def _calculate_risk_level(self, rows: int, duration_ms: int,
|
||
table_size_mb: float) -> str:
|
||
"""计算风险等级。"""
|
||
if (rows > self.RISK_THRESHOLDS[RiskLevel.HIGH]["rows"] or
|
||
duration_ms > self.RISK_THRESHOLDS[RiskLevel.HIGH]["duration_ms"] or
|
||
table_size_mb > self.RISK_THRESHOLDS[RiskLevel.HIGH]["size_mb"]):
|
||
return RiskLevel.CRITICAL
|
||
|
||
if (rows > self.RISK_THRESHOLDS[RiskLevel.MEDIUM]["rows"] or
|
||
duration_ms > self.RISK_THRESHOLDS[RiskLevel.MEDIUM]["duration_ms"] or
|
||
table_size_mb > self.RISK_THRESHOLDS[RiskLevel.MEDIUM]["size_mb"]):
|
||
return RiskLevel.HIGH
|
||
|
||
if (rows > self.RISK_THRESHOLDS[RiskLevel.LOW]["rows"] or
|
||
duration_ms > self.RISK_THRESHOLDS[RiskLevel.LOW]["duration_ms"] or
|
||
table_size_mb > self.RISK_THRESHOLDS[RiskLevel.LOW]["size_mb"]):
|
||
return RiskLevel.MEDIUM
|
||
|
||
return RiskLevel.LOW
|
||
|
||
def _extract_table(self, sql: str) -> str:
|
||
"""从 SQL 中提取表名(简化版)。"""
|
||
upper = sql.upper()
|
||
|
||
# UPDATE table_name SET ...
|
||
update_match = re.search(r"UPDATE\s+(?:`?)(\w+)(?:`?)", upper)
|
||
if update_match:
|
||
return update_match.group(1).lower()
|
||
|
||
# DELETE FROM table_name ...
|
||
delete_match = re.search(r"DELETE\s+FROM\s+(?:`?)(\w+)(?:`?)", upper)
|
||
if delete_match:
|
||
return delete_match.group(1).lower()
|
||
|
||
# INSERT INTO table_name ...
|
||
insert_match = re.search(r"INSERT\s+INTO\s+(?:`?)(\w+)(?:`?)", upper)
|
||
if insert_match:
|
||
return insert_match.group(1).lower()
|
||
|
||
# ALTER TABLE table_name ...
|
||
alter_match = re.search(r"ALTER\s+TABLE\s+(?:`?)(\w+)(?:`?)", upper)
|
||
if alter_match:
|
||
return alter_match.group(1).lower()
|
||
|
||
# CREATE TABLE table_name ...
|
||
create_match = re.search(r"CREATE\s+TABLE\s+(?:`?)(\w+)(?:`?)", upper)
|
||
if create_match:
|
||
return create_match.group(1).lower()
|
||
|
||
return "unknown"
|
||
|
||
def _extract_where(self, sql: str) -> str:
|
||
"""提取 WHERE 子句。"""
|
||
where_match = re.search(r"WHERE\s+(.+?)(?:\s+ORDER\s+BY|\s+LIMIT\s+|$)",
|
||
sql, re.IGNORECASE)
|
||
return where_match.group(1).strip() if where_match else ""
|
||
|
||
def _has_pk_condition(self, where_clause: str) -> bool:
|
||
"""检查 WHERE 是否包含主键条件。"""
|
||
pk_patterns = [r"\bid\s*[=<>]", r"\bpk_\w+\s*[=<>]"]
|
||
return any(re.search(p, where_clause, re.IGNORECASE) for p in pk_patterns)
|
||
|
||
def _has_index_condition(self, where_clause: str) -> bool:
|
||
"""检查 WHERE 是否可能使用索引。"""
|
||
# 包含等值条件通常可以使用索引
|
||
return "=" in where_clause
|
||
|
||
async def _get_table_size(self, instance: Instance, database: str,
|
||
table: str) -> float:
|
||
"""获取表大小(MB)。简化实现,实际应查询数据库。"""
|
||
# 在实际实现中,应该查询 INFORMATION_SCHEMA
|
||
# 这里返回一个保守估计
|
||
return 50.0 # 默认假设 50MB
|
||
|
||
async def _get_table_row_count(self, instance: Instance, database: str,
|
||
table: str) -> int:
|
||
"""获取表行数。简化实现。"""
|
||
# 实际应执行 SELECT COUNT(*)
|
||
return 10000 # 默认假设 10000 行
|