hy-app-monitor/monitors/yumi/mysql_metrics.py

250 lines
9.1 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 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)