Files
opsxsql/backend/app/services/impact_analyzer.py
T
logan 4c9427e546 sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
2026-05-23 14:41:06 +08:00

316 lines
13 KiB
Python
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.
"""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 行