database.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. """
  2. 数据库连接管理模块
  3. """
  4. import asyncio
  5. import aiomysql
  6. import redis.asyncio as aioredis
  7. from typing import Optional, Callable, Awaitable
  8. from contextlib import asynccontextmanager
  9. from config import settings
  10. import logging
  11. logger = logging.getLogger(__name__)
  12. MYSQL_RETRY_EXCEPTIONS = (
  13. aiomysql.OperationalError,
  14. aiomysql.InterfaceError,
  15. aiomysql.InternalError,
  16. ConnectionError,
  17. ConnectionResetError,
  18. ConnectionAbortedError,
  19. BrokenPipeError,
  20. TimeoutError,
  21. asyncio.TimeoutError,
  22. )
  23. class DatabaseManager:
  24. """数据库连接管理器(MySQL + Redis)"""
  25. def __init__(self):
  26. self.mysql_pool: Optional[aiomysql.Pool] = None
  27. self.redis_client: Optional[aioredis.Redis] = None
  28. self._mysql_lock = asyncio.Lock()
  29. self._redis_lock = asyncio.Lock()
  30. self._last_mysql_healthcheck: float = 0.0
  31. self._last_redis_healthcheck: float = 0.0
  32. self._healthcheck_task: Optional[asyncio.Task] = None
  33. self._healthcheck_running = False
  34. async def connect(self):
  35. """初始化数据库连接"""
  36. try:
  37. await self._create_mysql_pool()
  38. await self._create_redis_client()
  39. # 🔥 启动后台健康检查任务
  40. self._start_healthcheck_task()
  41. except Exception as e:
  42. logger.error(f"❌ 数据库连接失败: {e}")
  43. raise
  44. async def _create_mysql_pool(self):
  45. async with self._mysql_lock:
  46. if self.mysql_pool:
  47. self.mysql_pool.close()
  48. await self.mysql_pool.wait_closed()
  49. self.mysql_pool = None
  50. self.mysql_pool = await aiomysql.create_pool(
  51. host=settings.MYSQL_HOST,
  52. port=settings.MYSQL_PORT,
  53. user=settings.MYSQL_USER,
  54. password=settings.MYSQL_PASSWORD,
  55. db=settings.MYSQL_DATABASE,
  56. charset='utf8mb4',
  57. minsize=5,
  58. maxsize=settings.MYSQL_POOL_SIZE,
  59. autocommit=True,
  60. echo=False,
  61. connect_timeout=settings.MYSQL_CONNECT_TIMEOUT,
  62. # 注意:aiomysql 不支持 read_timeout 参数,已移除
  63. pool_recycle=settings.MYSQL_POOL_RECYCLE,
  64. )
  65. self._last_mysql_healthcheck = 0.0
  66. logger.info(
  67. f"✅ MySQL连接池已创建: {settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
  68. )
  69. async def _create_redis_client(self):
  70. async with self._redis_lock:
  71. if self.redis_client:
  72. await self.redis_client.close()
  73. self.redis_client = None
  74. redis_protocol = "rediss" if settings.REDIS_SSL else "redis"
  75. redis_url = (
  76. f"{redis_protocol}://:{settings.REDIS_PASSWORD}@"
  77. f"{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}"
  78. )
  79. self.redis_client = await aioredis.from_url(
  80. redis_url,
  81. encoding="utf-8",
  82. decode_responses=True,
  83. max_connections=settings.REDIS_MAX_CONNECTIONS,
  84. socket_connect_timeout=10,
  85. socket_timeout=30, # 🔥 增加超时时间,避免短时网络波动断开连接
  86. retry_on_timeout=True,
  87. health_check_interval=30,
  88. socket_keepalive=True, # 🔥 启用 TCP keepalive,保持长连接(Redis 3.2+ 默认启用)
  89. )
  90. await self.redis_client.ping()
  91. logger.info(
  92. f"✅ Redis连接成功: {settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}"
  93. )
  94. async def disconnect(self):
  95. """关闭数据库连接"""
  96. try:
  97. # 🔥 停止健康检查任务
  98. self._stop_healthcheck_task()
  99. if self.mysql_pool:
  100. self.mysql_pool.close()
  101. await self.mysql_pool.wait_closed()
  102. self.mysql_pool = None
  103. logger.info("✅ MySQL连接池已关闭")
  104. if self.redis_client:
  105. await self.redis_client.close()
  106. self.redis_client = None
  107. logger.info("✅ Redis连接已关闭")
  108. except Exception as e:
  109. logger.error(f"❌ 关闭数据库连接失败: {e}")
  110. async def _ensure_mysql_pool(self):
  111. """确保MySQL连接池可用(定期健康检查,保持长连接)"""
  112. if not self.mysql_pool:
  113. await self._create_mysql_pool()
  114. return
  115. loop = asyncio.get_running_loop()
  116. now = loop.time()
  117. # 🔥 健康检查间隔:只在超过间隔时检查,避免频繁检查
  118. if now - self._last_mysql_healthcheck < settings.MYSQL_HEALTHCHECK_INTERVAL:
  119. return
  120. # 🔥 只做健康检查,不重建连接(保持长连接)
  121. try:
  122. async with self.mysql_pool.acquire() as conn:
  123. await conn.ping()
  124. self._last_mysql_healthcheck = now
  125. # 健康检查成功,连接正常,不重建
  126. except Exception as exc:
  127. # 🔥 只有在健康检查失败时才重建连接(连接已断开)
  128. logger.warning(f"⚠️ MySQL健康检查失败,连接可能已断开,准备重建: {exc}")
  129. await self._create_mysql_pool()
  130. self._last_mysql_healthcheck = loop.time()
  131. async def _ensure_redis_client(self):
  132. """确保Redis连接可用(定期健康检查,自动重连)"""
  133. if not self.redis_client:
  134. await self._create_redis_client()
  135. return
  136. loop = asyncio.get_running_loop()
  137. now = loop.time()
  138. # 🔥 Redis健康检查间隔(从配置读取)
  139. if now - self._last_redis_healthcheck < settings.REDIS_HEALTHCHECK_INTERVAL:
  140. return
  141. # 🔥 执行健康检查
  142. try:
  143. await self.redis_client.ping()
  144. self._last_redis_healthcheck = now
  145. # 健康检查成功,连接正常
  146. except Exception as exc:
  147. # 🔥 健康检查失败,重建连接
  148. logger.warning(f"⚠️ Redis健康检查失败,连接可能已断开,准备重建: {exc}")
  149. await self._create_redis_client()
  150. self._last_redis_healthcheck = loop.time()
  151. def _start_healthcheck_task(self):
  152. """启动后台健康检查任务"""
  153. if self._healthcheck_running:
  154. return
  155. self._healthcheck_running = True
  156. async def healthcheck_loop():
  157. """定期健康检查循环"""
  158. while self._healthcheck_running:
  159. try:
  160. await asyncio.sleep(300) # 每5分钟检查一次
  161. if not self._healthcheck_running:
  162. break
  163. # 检查MySQL
  164. await self._ensure_mysql_pool()
  165. # 检查Redis
  166. await self._ensure_redis_client()
  167. except Exception as e:
  168. logger.error(f"❌ 健康检查任务异常: {e}")
  169. self._healthcheck_task = asyncio.create_task(healthcheck_loop())
  170. logger.info("✅ 数据库健康检查任务已启动")
  171. def _stop_healthcheck_task(self):
  172. """停止后台健康检查任务"""
  173. self._healthcheck_running = False
  174. if self._healthcheck_task and not self._healthcheck_task.done():
  175. self._healthcheck_task.cancel()
  176. logger.info("✅ 数据库健康检查任务已停止")
  177. async def _run_with_retry(self, operation: Callable[[], Awaitable], description: str):
  178. """执行MySQL操作并在连接异常时重试一次"""
  179. for attempt in range(2):
  180. try:
  181. await self._ensure_mysql_pool()
  182. return await operation()
  183. except MYSQL_RETRY_EXCEPTIONS as exc:
  184. if attempt == 1:
  185. logger.error(f"❌ {description} 失败,已重试: {exc}")
  186. raise
  187. logger.warning(f"⚠️ {description} 连接异常,尝试重建连接: {exc}")
  188. await self._create_mysql_pool()
  189. @asynccontextmanager
  190. async def get_mysql_connection(self):
  191. """获取MySQL连接(上下文管理器)"""
  192. await self._ensure_mysql_pool()
  193. if not self.mysql_pool:
  194. raise RuntimeError("MySQL连接池未初始化")
  195. async with self.mysql_pool.acquire() as conn:
  196. async with conn.cursor(aiomysql.DictCursor) as cursor:
  197. yield cursor
  198. async def execute_query(self, query: str, params: dict = None):
  199. """执行查询(SELECT)"""
  200. params = params or {}
  201. async def _operation():
  202. async with self.get_mysql_connection() as cursor:
  203. await cursor.execute(query, params)
  204. return await cursor.fetchall()
  205. return await self._run_with_retry(_operation, "执行查询")
  206. async def execute_one(self, query: str, params: dict = None):
  207. """执行查询并返回一条记录"""
  208. params = params or {}
  209. async def _operation():
  210. async with self.get_mysql_connection() as cursor:
  211. await cursor.execute(query, params)
  212. return await cursor.fetchone()
  213. return await self._run_with_retry(_operation, "执行单条查询")
  214. async def execute_update(self, query: str, params: dict = None):
  215. """执行更新(INSERT/UPDATE/DELETE)"""
  216. params = params or {}
  217. async def _operation():
  218. async with self.get_mysql_connection() as cursor:
  219. await cursor.execute(query, params)
  220. return cursor.lastrowid
  221. return await self._run_with_retry(_operation, "执行更新")
  222. async def get_redis(self) -> aioredis.Redis:
  223. """获取Redis客户端(自动健康检查)"""
  224. await self._ensure_redis_client()
  225. if not self.redis_client:
  226. raise RuntimeError("Redis客户端未初始化")
  227. return self.redis_client
  228. # 全局数据库管理器实例
  229. db_manager = DatabaseManager()
  230. async def init_database():
  231. """初始化数据库连接"""
  232. await db_manager.connect()
  233. async def close_database():
  234. """关闭数据库连接"""
  235. await db_manager.disconnect()