cm_bot_v2/app/db.py
yiekheng a42fdf54b0 perf(api): pool MySQL connections + drop per-request schema check
Two wins, one root cause: every API request was opening TWO fresh MySQL
connections plus four wasted round-trips before the real query.

Old per-request shape (GET /acc/):
  1. DB() constructor → open conn, SHOW TABLES LIKE 'acc',
     SHOW TABLES LIKE 'user', close
  2. db.query() → open conn, run SELECT, close

That's ~4 round-trips for ~10 ms of useful work. With the dashboard's
30 s auto-refresh and two open tabs (accounts + users), the api-server
churned through ~10 fresh MySQL connections every minute even when
nothing changed.

Changes:
- app/db.py: introduce a process-wide MySQLConnectionPool (size 8 by
  default, override with DB_POOL_SIZE). DB() now just touches the cached
  pool — no schema check, no fresh handshake. query()/execute() rent a
  connection from the pool and return it via conn.close().
- app/db.py: extract the schema check into verify_tables_once() — runs
  once at WSGI boot inside create_app() so a misconfigured DB still
  fails fast at startup.
- app/cm_api.py: _close_database_connection() removed; the finally
  blocks that wrapped every route are gone too. Pool reclamation lives
  inside DB now.
- app/cm_api.py: create_app() and run() invoke verify_tables_once()
  once at startup instead of CM_API.__init__ doing nothing useful.

Net: ~4× round-trip reduction per request, no MySQL handshake on the
hot path. With two gunicorn workers × pool_size 8 = 16 max in-flight
connections, well under MySQL's default max_connections=151.

(The user asked about 'batching the queries' — but the queries already
return the full row set in one shot. The bottleneck was connection
churn, not query shape. If row count grows past the comfortable single-
fetch range later, swap to LIMIT/OFFSET pagination at the API + table
component layer.)
2026-05-03 10:54:11 +08:00

135 lines
4.4 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.

import os
import threading
import time
import mysql.connector
from mysql.connector import Error, pooling
def _get_required_env(name: str) -> str:
value = os.getenv(name)
if value is None or value == "":
raise RuntimeError(f"Missing required environment variable: {name}")
return value
# Process-wide MySQL connection pool. Gunicorn forks workers; each worker
# gets its own pool (the global is rebuilt per process at first use).
_pool: "pooling.MySQLConnectionPool | None" = None
_pool_lock = threading.Lock()
def _build_pool() -> "pooling.MySQLConnectionPool":
config = {
"host": _get_required_env("DB_HOST"),
"user": _get_required_env("DB_USER"),
"password": _get_required_env("DB_PASSWORD"),
"database": _get_required_env("DB_NAME"),
"port": int(_get_required_env("DB_PORT")),
"connection_timeout": int(_get_required_env("DB_CONNECTION_TIMEOUT")),
"pool_name": "cm_pool",
"pool_size": int(os.getenv("DB_POOL_SIZE", "8")),
"pool_reset_session": True,
}
return pooling.MySQLConnectionPool(**config)
def _get_pool() -> "pooling.MySQLConnectionPool":
"""Lazily build the per-process pool with retry, then memoize."""
global _pool
if _pool is not None:
return _pool
with _pool_lock:
if _pool is not None:
return _pool
retries = max(1, int(_get_required_env("DB_CONNECT_RETRIES")))
delay = float(_get_required_env("DB_CONNECT_RETRY_DELAY"))
last_err: "Exception | None" = None
for attempt in range(1, retries + 1):
try:
_pool = _build_pool()
return _pool
except Error as e:
last_err = e
if attempt < retries:
print(
f"MySQL pool init failed ({e}); "
f"retry {attempt}/{retries} in {delay}s..."
)
time.sleep(delay)
raise RuntimeError(
f"Failed to build MySQL pool after {retries} attempts: {last_err}"
)
def verify_tables_once() -> None:
"""Run once at app startup to confirm schema is present.
Previously the DB() constructor ran two SHOW TABLES LIKE queries on
every request — wasted round-trips. Now the check happens once when
create_app() boots the WSGI app; subsequent requests just rent a
connection from the pool.
"""
conn = _get_pool().get_connection()
try:
cursor = conn.cursor()
try:
cursor.execute("SHOW TABLES LIKE 'acc'")
if not cursor.fetchone():
raise Exception("Table 'acc' does not exist")
cursor.execute("SHOW TABLES LIKE 'user'")
if not cursor.fetchone():
raise Exception("Table 'user' does not exist")
finally:
cursor.close()
finally:
conn.close()
class DB:
"""Thin handle backed by the process-wide MySQL pool.
Constructing DB() is now ~free — it just touches the (cached) pool.
Each query()/execute() rents a connection from the pool and returns
it on completion via conn.close() (which the pool intercepts and
releases the connection back instead of actually closing it).
Pool size caps in-flight queries per worker; tune with DB_POOL_SIZE
(default 8). Two gunicorn workers × pool_size 8 = 16 max
connections — comfortably under MySQL's default max_connections.
"""
def __init__(self):
_get_pool()
def query(self, sql, params=None):
conn = _get_pool().get_connection()
try:
cursor = conn.cursor(dictionary=True)
try:
cursor.execute(sql, params or ())
return cursor.fetchall()
finally:
cursor.close()
except Error as e:
print(f"Error executing query: {e}")
return []
finally:
conn.close()
def execute(self, sql, params=None):
conn = _get_pool().get_connection()
try:
cursor = conn.cursor()
try:
cursor.execute(sql, params or ())
conn.commit()
return True
finally:
cursor.close()
except Error as e:
print(f"Error executing query: {e}")
return False
finally:
conn.close()