305 lines
11 KiB
Python
305 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
from core.cache import TTLCache
|
|
from core.config import MYSQL_CACHE_TTL_SECONDS, MYSQL_HOST, MYSQL_PORT, utc_now
|
|
from .mysql_common import build_mysql_connection, list_database_summaries, mysql_error_payload, normalize_mysql_value
|
|
|
|
|
|
# MySQL 概览卡片和基础设施页都复用同一份汇总数据。
|
|
MYSQL_OVERVIEW_CACHE = TTLCache[dict[str, Any]](MYSQL_CACHE_TTL_SECONDS)
|
|
# QPS / TPS 需要用两次采样求差值,这里单独保存最近一次原始计数器。
|
|
MYSQL_RATE_LOCK = threading.Lock()
|
|
MYSQL_RATE_SNAPSHOT: dict[str, Any] | None = None
|
|
|
|
|
|
def clear_mysql_metrics_cache() -> None:
|
|
# 数据写入、DDL 之后清理缓存,避免概览继续显示旧指标。
|
|
MYSQL_OVERVIEW_CACHE.clear()
|
|
|
|
|
|
def coerce_int(mapping: dict[str, Any], key: str) -> int:
|
|
try:
|
|
# MySQL status / variables 大多返回字符串,这里统一转整数。
|
|
return int(mapping.get(key) or 0)
|
|
except Exception: # noqa: BLE001
|
|
return 0
|
|
|
|
|
|
def coerce_bool(mapping: dict[str, Any], key: str) -> bool:
|
|
value = str(mapping.get(key) or "").strip().lower()
|
|
return value in {"1", "on", "yes", "true"}
|
|
|
|
|
|
def default_replication_payload(variables: dict[str, Any]) -> dict[str, Any]:
|
|
# 复制详情拿不到时,至少保留只读状态和基础占位,避免整块直接失败。
|
|
read_only = coerce_bool(variables, "read_only")
|
|
super_read_only = coerce_bool(variables, "super_read_only")
|
|
return {
|
|
"configured": False,
|
|
"role": "replica" if read_only or super_read_only else "primary",
|
|
"readOnly": read_only,
|
|
"superReadOnly": super_read_only,
|
|
"sourceHost": "",
|
|
"secondsBehindSource": None,
|
|
"ioRunning": False,
|
|
"sqlRunning": False,
|
|
"state": "",
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def collect_with_warning(
|
|
label: str,
|
|
builder: Any,
|
|
fallback: Any,
|
|
warnings: list[str],
|
|
) -> Any:
|
|
try:
|
|
# 账号权限不足时保留可用信息,不让整个 MySQL 模块直接退成 ERR。
|
|
return builder()
|
|
except Exception as exc: # noqa: BLE001
|
|
warnings.append(f"{label}: {exc}")
|
|
return fallback
|
|
|
|
|
|
def collect_global_status(connection: Any) -> dict[str, Any]:
|
|
names = (
|
|
"Threads_connected",
|
|
"Threads_running",
|
|
"Questions",
|
|
"Com_commit",
|
|
"Com_rollback",
|
|
"Uptime",
|
|
)
|
|
|
|
# 这里选最小必要状态量,避免每次都拉全量 GLOBAL STATUS。
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SHOW GLOBAL STATUS
|
|
WHERE Variable_name IN (%s, %s, %s, %s, %s, %s)
|
|
""",
|
|
names,
|
|
)
|
|
rows = cursor.fetchall() or []
|
|
|
|
return {str(item.get("Variable_name") or ""): item.get("Value") for item in rows}
|
|
|
|
|
|
def collect_global_variables(connection: Any) -> dict[str, Any]:
|
|
names = (
|
|
"version",
|
|
"version_comment",
|
|
"server_uuid",
|
|
"server_id",
|
|
"max_connections",
|
|
"read_only",
|
|
"super_read_only",
|
|
)
|
|
|
|
# 版本、只读状态和最大连接数都在 GLOBAL VARIABLES 里。
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SHOW GLOBAL VARIABLES
|
|
WHERE Variable_name IN (%s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
names,
|
|
)
|
|
rows = cursor.fetchall() or []
|
|
|
|
return {str(item.get("Variable_name") or ""): item.get("Value") for item in rows}
|
|
|
|
|
|
def collect_replication_payload(connection: Any, variables: dict[str, Any]) -> dict[str, Any]:
|
|
# 先把只读状态打平,这一层即使复制信息拿不到也能稳定展示。
|
|
payload = {
|
|
"configured": False,
|
|
"role": "unknown",
|
|
"readOnly": coerce_bool(variables, "read_only"),
|
|
"superReadOnly": coerce_bool(variables, "super_read_only"),
|
|
"sourceHost": "",
|
|
"secondsBehindSource": None,
|
|
"ioRunning": False,
|
|
"sqlRunning": False,
|
|
"state": "",
|
|
"error": "",
|
|
}
|
|
|
|
# 云 MySQL 版本不同,复制状态命令可能叫 SHOW SLAVE STATUS 或 SHOW REPLICA STATUS。
|
|
commands = ["SHOW REPLICA STATUS", "SHOW SLAVE STATUS"]
|
|
for command in commands:
|
|
try:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(command)
|
|
row = cursor.fetchone()
|
|
except Exception: # noqa: BLE001
|
|
# 云库版本和权限口径不一致时,这两条命令经常会报语法或权限错误。
|
|
# 这里不把它直接当成实例异常,避免主节点或单节点 MySQL 被误标成故障。
|
|
continue
|
|
|
|
# 没有行时说明当前节点没有暴露复制状态,继续尝试下一条命令。
|
|
if not row:
|
|
continue
|
|
|
|
payload["configured"] = True
|
|
payload["sourceHost"] = str(
|
|
row.get("Source_Host")
|
|
or row.get("Master_Host")
|
|
or ""
|
|
).strip()
|
|
payload["secondsBehindSource"] = (
|
|
int(row.get("Seconds_Behind_Source") or row.get("Seconds_Behind_Master") or 0)
|
|
if row.get("Seconds_Behind_Source") is not None or row.get("Seconds_Behind_Master") is not None
|
|
else None
|
|
)
|
|
payload["ioRunning"] = str(
|
|
row.get("Replica_IO_Running")
|
|
or row.get("Slave_IO_Running")
|
|
or ""
|
|
).strip().lower() == "yes"
|
|
payload["sqlRunning"] = str(
|
|
row.get("Replica_SQL_Running")
|
|
or row.get("Slave_SQL_Running")
|
|
or ""
|
|
).strip().lower() == "yes"
|
|
payload["state"] = str(
|
|
row.get("Replica_SQL_Running_State")
|
|
or row.get("Slave_SQL_Running_State")
|
|
or ""
|
|
).strip()
|
|
payload["error"] = str(
|
|
row.get("Last_SQL_Error")
|
|
or row.get("Last_IO_Error")
|
|
or ""
|
|
).strip()
|
|
payload["role"] = "replica"
|
|
return payload
|
|
|
|
# 没拿到复制详情时,退化成基于只读状态的粗略判断。
|
|
payload["role"] = "replica" if payload["readOnly"] or payload["superReadOnly"] else "primary"
|
|
return payload
|
|
|
|
|
|
def collect_throughput_payload(status: dict[str, Any]) -> dict[str, Any]:
|
|
current = {
|
|
"capturedAt": time.time(),
|
|
"questions": coerce_int(status, "Questions"),
|
|
"commits": coerce_int(status, "Com_commit"),
|
|
"rollbacks": coerce_int(status, "Com_rollback"),
|
|
}
|
|
|
|
# 首次采样时没有可对比的上一帧,这里返回 0 但仍记住快照。
|
|
with MYSQL_RATE_LOCK:
|
|
global MYSQL_RATE_SNAPSHOT
|
|
previous = MYSQL_RATE_SNAPSHOT
|
|
MYSQL_RATE_SNAPSHOT = current
|
|
|
|
if previous is None:
|
|
return {
|
|
"qps": 0.0,
|
|
"tps": 0.0,
|
|
"sampleSeconds": 0.0,
|
|
"questionsDelta": 0,
|
|
"transactionsDelta": 0,
|
|
}
|
|
|
|
elapsed = max(current["capturedAt"] - float(previous.get("capturedAt") or 0.0), 0.001)
|
|
questions_delta = max(current["questions"] - int(previous.get("questions") or 0), 0)
|
|
transactions_delta = max(
|
|
(current["commits"] + current["rollbacks"]) - (int(previous.get("commits") or 0) + int(previous.get("rollbacks") or 0)),
|
|
0,
|
|
)
|
|
|
|
return {
|
|
"qps": round(questions_delta / elapsed, 2),
|
|
"tps": round(transactions_delta / elapsed, 2),
|
|
"sampleSeconds": round(elapsed, 2),
|
|
"questionsDelta": questions_delta,
|
|
"transactionsDelta": transactions_delta,
|
|
}
|
|
|
|
|
|
def build_mysql_overview_payload() -> dict[str, Any]:
|
|
warnings: list[str] = []
|
|
|
|
try:
|
|
# 指标和元数据统一走一条连接,减少直连云 MySQL 的并发压力。
|
|
with build_mysql_connection(autocommit=True) as connection:
|
|
with connection.cursor() as cursor:
|
|
# ping 能尽早暴露账号、网络和权限问题。
|
|
cursor.execute("SELECT 1 AS ok")
|
|
cursor.fetchone()
|
|
|
|
variables = collect_with_warning(
|
|
"全局变量读取受限",
|
|
lambda: collect_global_variables(connection),
|
|
{},
|
|
warnings,
|
|
)
|
|
status = collect_with_warning(
|
|
"全局状态读取受限",
|
|
lambda: collect_global_status(connection),
|
|
{},
|
|
warnings,
|
|
)
|
|
replication = collect_with_warning(
|
|
"复制状态读取受限",
|
|
lambda: collect_replication_payload(connection, variables),
|
|
default_replication_payload(variables),
|
|
warnings,
|
|
)
|
|
throughput = collect_throughput_payload(status)
|
|
databases = collect_with_warning(
|
|
"数据库元数据读取受限",
|
|
lambda: list_database_summaries(connection),
|
|
[],
|
|
warnings,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return mysql_error_payload(str(exc))
|
|
|
|
# 库级汇总同时作为基础设施概览卡片的数据源。
|
|
business_databases = [item for item in databases if not item.get("system")]
|
|
summary = {
|
|
"databaseTotal": len(business_databases),
|
|
"tableTotal": sum(int(item.get("tableTotal") or 0) for item in business_databases),
|
|
"dataSizeMb": round(sum(float(item.get("dataSizeMb") or 0.0) for item in business_databases), 2),
|
|
"indexSizeMb": round(sum(float(item.get("indexSizeMb") or 0.0) for item in business_databases), 2),
|
|
"totalSizeMb": round(sum(float(item.get("totalSizeMb") or 0.0) for item in business_databases), 2),
|
|
}
|
|
|
|
# 当前连接数、最大连接数和版本信息都单独拉平,前端展示更直接。
|
|
return {
|
|
"ok": True,
|
|
"updatedAt": utc_now(),
|
|
"info": {
|
|
"host": f"{MYSQL_HOST}:{MYSQL_PORT}",
|
|
"version": str(variables.get("version") or "").strip(),
|
|
"versionComment": str(variables.get("version_comment") or "").strip(),
|
|
"serverUuid": str(variables.get("server_uuid") or "").strip(),
|
|
"serverId": str(variables.get("server_id") or "").strip(),
|
|
"uptimeSeconds": coerce_int(status, "Uptime"),
|
|
},
|
|
"connections": {
|
|
"current": coerce_int(status, "Threads_connected"),
|
|
"running": coerce_int(status, "Threads_running"),
|
|
"max": coerce_int(variables, "max_connections"),
|
|
},
|
|
"throughput": throughput,
|
|
"replication": replication,
|
|
"summary": summary,
|
|
"databases": databases,
|
|
"warnings": warnings,
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def get_mysql_overview_payload() -> dict[str, Any]:
|
|
# 概览卡片和表浏览 sidebar 共用缓存,避免每次点 tab 都重查 information_schema。
|
|
return MYSQL_OVERVIEW_CACHE.get_or_build(build_mysql_overview_payload)
|