database.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. """
  2. 数据库连接管理模块(SQLAlchemy 版本)
  3. """
  4. import asyncio
  5. import time
  6. import traceback
  7. from typing import Optional
  8. from urllib.parse import quote_plus
  9. import redis.asyncio as aioredis
  10. from sqlalchemy.ext.asyncio import (
  11. create_async_engine,
  12. AsyncSession,
  13. async_sessionmaker,
  14. AsyncEngine
  15. )
  16. from sqlalchemy.orm import declarative_base
  17. from config import settings
  18. import logging
  19. logger = logging.getLogger(__name__)
  20. class DatabaseManager:
  21. """数据库连接管理器(MySQL + Redis)"""
  22. def __init__(self):
  23. self.engine: Optional[AsyncEngine] = None
  24. self.async_session_maker: Optional[async_sessionmaker] = None
  25. self.redis_client: Optional[aioredis.Redis] = None
  26. self._redis_lock = asyncio.Lock()
  27. self._last_redis_healthcheck: float = 0.0
  28. self._healthcheck_task: Optional[asyncio.Task] = None
  29. self._healthcheck_running = False
  30. async def connect(self):
  31. """初始化数据库连接"""
  32. try:
  33. await self._create_mysql_engine()
  34. await self._create_redis_client()
  35. # 🔥 启动后台健康检查任务
  36. self._start_healthcheck_task()
  37. except Exception as e:
  38. logger.error(f"❌ 数据库连接失败: {e}")
  39. raise
  40. async def _create_mysql_engine(self):
  41. """创建 SQLAlchemy 异步引擎"""
  42. # 构建 MySQL 连接 URL
  43. encoded_user = quote_plus(settings.MYSQL_USER)
  44. encoded_password = quote_plus(settings.MYSQL_PASSWORD)
  45. mysql_url = (
  46. f"mysql+aiomysql://{encoded_user}:{encoded_password}"
  47. f"@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
  48. f"?charset=utf8mb4"
  49. )
  50. # 创建异步引擎(自动连接池 + 健康检查 + 超时配置)
  51. self.engine = create_async_engine(
  52. mysql_url,
  53. pool_size=settings.MYSQL_POOL_SIZE,
  54. max_overflow=settings.MYSQL_MAX_OVERFLOW,
  55. pool_pre_ping=True, # 🔥 自动健康检查(类似 ping)
  56. pool_recycle=settings.MYSQL_POOL_RECYCLE, # 连接回收
  57. pool_timeout=5.0, # 🔥 连接池获取连接超时(秒),避免长时间等待
  58. echo=False, # 生产环境关闭 SQL 日志
  59. future=True,
  60. connect_args={
  61. "connect_timeout": settings.MYSQL_CONNECT_TIMEOUT, # 🔥 连接超时(aiomysql 支持)
  62. # ⚠️ read_timeout 已移除:aiomysql 不支持此参数,读取超时由 pool_timeout 控制
  63. },
  64. )
  65. # 创建异步 Session 工厂
  66. self.async_session_maker = async_sessionmaker(
  67. self.engine,
  68. class_=AsyncSession,
  69. expire_on_commit=False,
  70. autoflush=False,
  71. autocommit=False,
  72. )
  73. logger.info(
  74. f"✅ MySQL连接池已创建: {settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
  75. )
  76. async def _create_redis_client(self):
  77. """创建 Redis 客户端"""
  78. async with self._redis_lock:
  79. if self.redis_client:
  80. await self.redis_client.close()
  81. self.redis_client = None
  82. redis_protocol = "rediss" if settings.REDIS_SSL else "redis"
  83. redis_url = (
  84. f"{redis_protocol}://:{settings.REDIS_PASSWORD}@"
  85. f"{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}"
  86. )
  87. self.redis_client = await aioredis.from_url(
  88. redis_url,
  89. encoding="utf-8",
  90. decode_responses=True,
  91. max_connections=settings.REDIS_MAX_CONNECTIONS,
  92. socket_connect_timeout=10,
  93. socket_timeout=30,
  94. retry_on_timeout=True,
  95. health_check_interval=30,
  96. socket_keepalive=True,
  97. )
  98. await self.redis_client.ping()
  99. logger.info(
  100. f"✅ Redis连接成功: {settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}"
  101. )
  102. async def disconnect(self):
  103. """关闭数据库连接"""
  104. try:
  105. # 🔥 停止健康检查任务
  106. self._stop_healthcheck_task()
  107. if self.engine:
  108. await self.engine.dispose()
  109. self.engine = None
  110. self.async_session_maker = None
  111. logger.info("✅ MySQL连接池已关闭")
  112. if self.redis_client:
  113. await self.redis_client.close()
  114. self.redis_client = None
  115. logger.info("✅ Redis连接已关闭")
  116. except Exception as e:
  117. logger.error(f"❌ 关闭数据库连接失败: {e}")
  118. def get_session(self) -> AsyncSession:
  119. """
  120. 获取异步 Session(上下文管理器)
  121. 使用方式:
  122. async with db_manager.get_session() as session:
  123. user = await session.get(User, user_id)
  124. """
  125. if not self.async_session_maker:
  126. raise RuntimeError("MySQL连接池未初始化")
  127. return self.async_session_maker()
  128. async def _ensure_redis_client(self):
  129. """确保Redis连接可用(定期健康检查,自动重连)"""
  130. start_time = time.time()
  131. if not self.redis_client:
  132. logger.info("🔧 Redis客户端不存在,创建新连接...")
  133. await self._create_redis_client()
  134. elapsed = (time.time() - start_time) * 1000
  135. logger.info(f"✅ Redis客户端创建完成,耗时: {elapsed:.1f}ms")
  136. return
  137. loop = asyncio.get_running_loop()
  138. now = loop.time()
  139. time_since_last_check = now - self._last_redis_healthcheck
  140. # 🔥 Redis健康检查间隔(从配置读取)
  141. if time_since_last_check < settings.REDIS_HEALTHCHECK_INTERVAL:
  142. logger.debug(f"⏭️ Redis健康检查跳过(距离上次检查: {time_since_last_check:.1f}s < {settings.REDIS_HEALTHCHECK_INTERVAL}s)")
  143. return
  144. logger.info(f"🏥 开始Redis健康检查(距离上次检查: {time_since_last_check:.1f}s)...")
  145. # 🔥 执行健康检查(使用超时,避免长时间阻塞)
  146. try:
  147. # 使用asyncio.wait_for设置超时,避免健康检查阻塞太久
  148. ping_start = time.time()
  149. await asyncio.wait_for(self.redis_client.ping(), timeout=1.0)
  150. ping_elapsed = (time.time() - ping_start) * 1000
  151. self._last_redis_healthcheck = now
  152. elapsed = (time.time() - start_time) * 1000
  153. logger.info(f"✅ Redis健康检查成功,PING耗时: {ping_elapsed:.1f}ms,总耗时: {elapsed:.1f}ms")
  154. except asyncio.TimeoutError:
  155. elapsed = (time.time() - start_time) * 1000
  156. # 健康检查超时,可能连接有问题,但不在关键路径上阻塞
  157. logger.warning(f"⚠️ Redis健康检查超时(耗时: {elapsed:.1f}ms),将在后台重建连接")
  158. # 🔥 在后台重建连接,不阻塞当前请求
  159. asyncio.create_task(self._recreate_redis_client_background())
  160. self._last_redis_healthcheck = loop.time()
  161. except Exception as exc:
  162. elapsed = (time.time() - start_time) * 1000
  163. # 🔥 健康检查失败,在后台重建连接,不阻塞当前请求
  164. logger.warning(f"⚠️ Redis健康检查失败(耗时: {elapsed:.1f}ms),将在后台重建连接: {exc}")
  165. asyncio.create_task(self._recreate_redis_client_background())
  166. self._last_redis_healthcheck = loop.time()
  167. async def _recreate_redis_client_background(self):
  168. """后台重建Redis连接(不阻塞主流程)"""
  169. start_time = time.time()
  170. logger.info("🔧 开始后台重建Redis连接...")
  171. old_client = None
  172. try:
  173. old_client = self.redis_client
  174. self.redis_client = None # 先清空,避免其他请求使用旧连接
  175. await self._create_redis_client()
  176. elapsed = (time.time() - start_time) * 1000
  177. logger.info(f"✅ Redis连接已在后台重建完成,耗时: {elapsed:.1f}ms")
  178. except asyncio.CancelledError:
  179. # 🔥 任务被取消时,确保清理资源
  180. logger.info("Redis重建任务被取消")
  181. raise # 重新抛出 CancelledError
  182. except Exception as e:
  183. elapsed = (time.time() - start_time) * 1000
  184. logger.error(f"❌ 后台重建Redis连接失败,耗时: {elapsed:.1f}ms, 错误: {e}")
  185. logger.error(traceback.format_exc())
  186. finally:
  187. # 🔥 确保旧连接被关闭
  188. if old_client:
  189. try:
  190. await old_client.close()
  191. except Exception:
  192. pass
  193. def _start_healthcheck_task(self):
  194. """启动后台健康检查任务"""
  195. if self._healthcheck_running:
  196. return
  197. self._healthcheck_running = True
  198. async def healthcheck_loop():
  199. """定期健康检查循环"""
  200. while self._healthcheck_running:
  201. try:
  202. await asyncio.sleep(300) # 每5分钟检查一次
  203. if not self._healthcheck_running:
  204. break
  205. # 检查Redis(MySQL由SQLAlchemy的pool_pre_ping自动处理)
  206. await self._ensure_redis_client()
  207. except asyncio.CancelledError:
  208. # 🔥 任务被取消时,确保清理资源
  209. logger.info("数据库健康检查任务被取消")
  210. raise # 重新抛出 CancelledError
  211. except Exception as e:
  212. logger.error(f"❌ 健康检查任务异常: {e}")
  213. self._healthcheck_task = asyncio.create_task(healthcheck_loop())
  214. logger.info("✅ 数据库健康检查任务已启动")
  215. def _stop_healthcheck_task(self):
  216. """停止后台健康检查任务"""
  217. self._healthcheck_running = False
  218. if self._healthcheck_task and not self._healthcheck_task.done():
  219. self._healthcheck_task.cancel()
  220. logger.info("✅ 数据库健康检查任务已停止")
  221. async def get_redis(self) -> aioredis.Redis:
  222. """获取Redis客户端(自动健康检查)"""
  223. await self._ensure_redis_client()
  224. if not self.redis_client:
  225. raise RuntimeError("Redis客户端未初始化")
  226. return self.redis_client
  227. # 全局数据库管理器实例
  228. db_manager = DatabaseManager()
  229. async def init_database():
  230. """初始化数据库连接"""
  231. await db_manager.connect()
  232. async def close_database():
  233. """关闭数据库连接"""
  234. await db_manager.disconnect()