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 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]: 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_global_variables(connection) status = collect_global_status(connection) replication = collect_replication_payload(connection, variables) throughput = collect_throughput_payload(status) databases = list_database_summaries(connection) 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, "error": "", } def get_mysql_overview_payload() -> dict[str, Any]: # 概览卡片和表浏览 sidebar 共用缓存,避免每次点 tab 都重查 information_schema。 return MYSQL_OVERVIEW_CACHE.get_or_build(build_mysql_overview_payload)