伏雨朝寒悉不胜,那能还傍杏花行。去年高摘斗轻盈。漫惹炉烟双袖紫,空将酒晕一衫青。人间何处问多情。 ———— 纳兰容若
上午,某业务系统的客户端突然「打不开内容、登录不上」。后端进程还在、CPU、内存也正常,但所有依赖该数据库的接口全报错。最终定位到的根因,是 databases 异步连接库的一个状态错位:连接池实例已被销毁(_pool is None),但库内部的 is_connected 标志却还是 True,导致重连逻辑被「幂等保护」永久拦截——服务无法连上数据库,人工重启才能恢复。
一、背景
- 系统:某业务管理系统,含「客户端(小程序)+ Vue 管理后台 + FastAPI 后端」。
- 技术栈:FastAPI + ormar(ORM)+
databases(异步驱动层)+ aiomysql + MySQL 8。 - 部署:云服务器,Conda 环境 + systemd 托管,Nginx 反向代理,单进程(
uvicorn --workers 1)。 - 数据库访问模型:进程启动时(FastAPI
lifespan)建立一次连接池,全生命周期复用。
特征:连接池只在启动时建立一次,运行期不主动重连。 这是本次故障的关键原因。
二、故障现象与影响
- 现象:小程序首页空白、登录报错。
- 影响面:所有依赖数据库的接口全部不可用(列表查询、汇总统计、用户登录……)。纯静态资源、不查库的接口正常。
也就是说:数据库是好的,应用进程是活的,但应用就是连不上数据库。
事件时间线(还原)
| 时间(相对) | 事件 |
|---|---|
| T-数日 | 低峰期空闲连接被 MySQL wait_timeout 静默回收,定时任务偶报 (2013) Lost connection |
| T0 | 一次失效连接的关闭过程在「时间窗」内抛错被吞,连接池进入状态错位(_pool=None 但 is_connected=True) |
| T0+ | 此后所有请求返 500:DatabaseBackend is not running |
| T0+数分钟 | 收到用户反馈:小程序无法正常加载内容 |
| 排查期 | 确认进程存活、MySQL 正常 → 查 app.log → 定位源码 databases 状态错位 |
| 恢复 | 人工 systemctl restart 后立即恢复(治标不治本) |
三、应用日志
后端的 app.log从5.27往前数日满屏都是同一个异常,几乎每个请求都有:
2026-05-27 08:14:00,745 - app.main - ERROR - 未处理的异常: DatabaseBackend is not running
...
File "/opt/app-backend/app/routers/items.py", line 233, in get_summary
active_projects = await BusinessEntity.objects.filter(is_active=True).all()
...
File ".../databases/backends/mysql.py", line 99, in acquire
assert self._database._pool is not None, "DatabaseBackend is not running"
AssertionError: DatabaseBackend is not running用户登录:
File "/opt/app-backend/app/routers/auth.py", line 387, in user_login
user = await User.objects.filter(...).get_or_none()
...
assert self._database._pool is not None, "DatabaseBackend is not running"
AssertionError: DatabaseBackend is not running关键信息提炼:
- 报错点在
databases库的MySQLBackend.acquire(),断言self._database._pool is not None失败。 - 也就是说,连接池对象
_pool已经变成了None。 - 这个问题,所有接口持续出现,非偶发,无法自主恢复。
更早的日志里,还能看到另外两类周期性报错,来自每 2 小时执行一次的「每日数据抓取」定时任务:
pymysql.err.OperationalError: (2013, 'Lost connection to MySQL server during query ([WinError 121] 信号灯超时时间已到)')
...
pymysql.err.IntegrityError: (1062, "Duplicate entry '2026-06-03' for key 'daily_record.ix_daily_record_record_date'")综上:先是连接掉线(2013),而后全站连接池消失(DatabaseBackend is not running),逐解。
四、根因分析
4.1 理解 databases 的连接生命周期
databases.Database 的 connect() / disconnect() 是这次故障的核心。后端实现(节选):
# databases/core.py
async def connect(self) -> None:
if self.is_connected: # ← 幂等保护:已连接就直接返回
logger.debug("Already connected, skipping connection")
return None
await self._backend.connect() # 真正创建连接池
self.is_connected = True
async def disconnect(self) -> None:
if not self.is_connected:
return None
...
await self._backend.disconnect() # ← 这一步会把 _pool 置为 None
self.is_connected = False # ← 之后才把标志位复位# databases/backends/mysql.py
async def connect(self) -> None:
assert self._pool is None, "DatabaseBackend is already running" # 重连前必须 _pool 为 None
self._pool = await aiomysql.create_pool(...)
async def acquire(self):
assert self._database._pool is not None, "DatabaseBackend is not running" # 报错就在这这里有两个「不变式」必须同时成立,系统才正常:
is_connected == True时,_pool必须不是None;disconnect()内部是先把_pool置空,后才把is_connected置False——这两步之间存在一个危险的「时间窗」。
4.2 触发器:连接被悄悄掐断(2013 / WinError 121)
我们的连接池创建时没有设置 pool_recycle:
# 出问题的写法
database = databases.Database(
settings.DATABASE_URL,
min_size=3,
max_size=15,
)后果:连接池里的空闲连接会一直留着。但 MySQL 的 wait_timeout、以及 MySQL 对空闲 TCP 连接的回收,会在后台把这些连接悄悄关掉。应用对此一无所知,下次取用这条已死亡的连接时,报错出现:
(2013, 'Lost connection to MySQL server during query ([WinError 121] 信号灯超时时间已到)')WinError 121(信号灯超时)正是「对端早已关闭、本端读写干等到超时」的典型特征。这是触发器,但还不是致命伤。
4.3 致命伤:状态错位,连接池「再也建不回来」
我本来写了一个「自动重连」的辅助函数,本意是出问题时重连,结果项目时间太紧,紧急上线,没做相关测试:
# 出问题的写法
_is_connected = False
async def ensure_connection():
global _is_connected
if not _is_connected: # ← 永远进不来
await connect_db()
return问题在于:连接「失效」并不会把这个自定义的 _is_connected 标志改成 False。只要进程启动时连过一次,它就永远是 True, ensure_connection() 形同空操作,永远不会触发重连。
而 disconnect_db() 又把异常吞掉了:
# 出问题的写法
async def disconnect_db():
global _is_connected
try:
await database.disconnect() # 这一步内部已经把 _pool 置为 None
_is_connected = False
logger.info("[成功] 数据库连接已断开")
except Exception as e:
logger.warning(f"[警告] 断开数据库连接时出错: {e}") # ← 一旦在这里出错,is_connected 没被复位把 4.1 的源码不变式和这段代码放在一起,「状态错位」就出现了:
如果
database.disconnect()在「_pool已置空、is_connected还没来得及置False」的那个时间窗里抛出了异常(连接处于异常状态时,关闭过程本身就可能报错),那么disconnect_db()的except会把异常吞掉。结果是:
_pool is None但database.is_connected is True。
一旦进入这个状态,再调用 database.connect(),它会因为「is_connected == True」直接幂等早退、根本不会重建连接池。于是连接池永远是 None,之后每一个查询都在 acquire() 处撞上:
AssertionError: DatabaseBackend is not running这就解释了最让人困惑的现象——进程活着、数据库好着,但应用永远连不上,只能人工重启。
4.4 调用链
没有 pool_recycle
│
▼
空闲连接被 MySQL / 防火墙悄悄关闭
│
├──► 直接取用死连接 ──► (2013) Lost connection
│
├──► 抓取服务 SELECT 失败被 except 吞掉 ──► 误判「不存在」──► INSERT ──► (1062) Duplicate
│
└──► 关闭异常连接时 disconnect() 在「时间窗」内抛错并被吞
│
▼
状态错位:_pool=None 但 is_connected=True
│
▼
connect() 幂等早退、永不重建连接池
│
▼
所有接口持续 500:DatabaseBackend is not running(直到人工重启)五、修复
遵循一个原则:既要消除触发器,也要让系统具备自愈能力,从「单点一次性连接」升成「可自我恢复的连接」。
5.1 加上 pool_recycle,从源头杜绝死连接
databases 会把额外参数透传给 aiomysql.create_pool,而 aiomysql 支持 pool_recycle:在「取出连接」时,丢弃存活时间超过阈值的连接并新建。只要这个值小于 MySQL wait_timeout 和中间网络设备的空闲 TCP 超时,就再也不会取到死连接。
# 修复后
POOL_RECYCLE_SECONDS = 280 # 必须小于 MySQL wait_timeout 空闲超时
database = databases.Database(
settings.DATABASE_URL,
min_size=3,
max_size=15,
pool_recycle=POOL_RECYCLE_SECONDS,
)5.2 重写 ensure_connection,按「真实状态」判断而非布尔标志
不再相信任何外部标识,而是同时校验 database.is_connected 与底层连接池是否真实存在,从而能从「状态错位」中自恢复:
# 修复后
def _pool_is_alive() -> bool:
backend = getattr(database, "_backend", None)
return getattr(backend, "_pool", None) is not None
async def ensure_connection():
if database.is_connected and _pool_is_alive():
return
# 关键:检测到「标记已连接、但池已为空」的错位,先解除幂等保护,再重建
if database.is_connected and not _pool_is_alive():
logger.warning("检测到连接状态错位(is_connected=True 但连接池为空),强制重建连接池")
database.is_connected = False
await connect_db()5.3 让 disconnect_db 永远保持状态一致
无论 disconnect() 成功还是抛错,都在 finally 里把 is_connected 与连接池的真实状态对齐,从根上杜绝错位:
# 修复后
async def disconnect_db():
try:
await database.disconnect()
except Exception as e:
logger.warning(f"断开数据库连接时出错,将强制复位连接状态: {e}")
finally:
database.is_connected = _pool_is_alive() # 与真实池状态对齐5.4 主动健康检查 + 强制重建,「分钟级自愈」
仅靠「请求触发重连」不够,因为不是每个接口都有重试逻辑。于是增加一个周期性健康探测(每 60 秒一次),失效就强制重建连接池。注意:当「池还在但连接全死」时,不能直接 connect()(底层会断言 _pool is None),必须先 disconnect 把池置空再重连:
# 修复后
async def force_reconnect():
await disconnect_db() # 先置空旧池(且状态会被一致复位)
await connect_db()
async def health_check() -> bool:
try:
await ensure_connection()
await database.execute("SELECT 1")
return True
except Exception as e:
logger.warning(f"[健康检查] 数据库连接异常,尝试重建连接池: {e}")
try:
await force_reconnect()
logger.info("[健康检查] 连接池已成功重建")
return True
except Exception as e2:
logger.error(f"[健康检查] 连接池重建失败: {e2}")
return False并把它挂到调度器上:
scheduler.add_job(
db_health_check_job,
trigger=IntervalTrigger(seconds=60),
id="db_health_check",
name="数据库连接池健康检查",
replace_existing=True,
)这样,即便发生最坏情况(MySQL 重启、网络抖动、状态错位),服务也能在 1 分钟内自动恢复,无需人工 systemctl restart。
5.5 修复「读失败被误判为不存在」的危险写法
去掉吞异常的 except,并对并发写入做幂等兜底(撞唯一键就转为更新):
# 修复后:让真实异常向上抛出,绝不把「读失败」当成「不存在」
existing = await DailyRecord.objects.filter(record_date=record_date).first()
if existing:
... # 更新
else:
try:
daily_record = await DailyRecord.objects.create(record_date=record_date, ...)
except Exception as create_error:
# 并发场景下仍可能撞唯一键:把 1062 视为「已存在」,转为更新,保证幂等
if "1062" in str(create_error) or "Duplicate entry" in str(create_error):
daily_record = await DailyRecord.objects.filter(record_date=record_date).first()
await daily_record.update()
else:
raise5.6 扩大重试白名单,把「连接池消失 / 连接被拒」一并纳入
各处的 DB 重试逻辑原本只认 Lost connection / 2013,且判定关键字在 dependencies.py、middleware/logging.py 等多处,容易遗漏。现在把判定逻辑上提到 database.py 统一维护,并纳入连接池关闭、连接被拒等场景,让自愈路径对真正的致命错误也生效:
# app/database.py:统一的连接错误判定,各处重试逻辑复用它
_CONNECTION_ERROR_KEYWORDS = (
"Lost connection",
"2013",
"DatabaseBackend is not running",
"Connection refused",
"Can't connect",
)
def is_connection_error(error_msg: str) -> bool:
return any(k in error_msg for k in _CONNECTION_ERROR_KEYWORDS)# 在带重试的查询里(dependencies.py / middleware/logging.py):
from app.database import ensure_connection, is_connection_error
if is_connection_error(str(e)) and attempt < max_retries - 1:
await ensure_connection()
continue5.7 全局兜底:异常处理器拦截致命错误并即时自愈(返 503)
逐个接口加重试覆盖不全。更优雅的做法是在 FastAPI 的全局异常处理器里统一拦截连接池致命错误,触发一次自愈,并返回语义正确的 503(而非 500)引导客户端重试——这样任意接口首次触及故障都能就地恢复:
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
error_msg = str(exc)
if "DatabaseBackend is not running" in error_msg:
logger.error(f"[自愈] 检测到连接池已关闭,尝试恢复: {error_msg}")
try:
from app.database import ensure_connection
await ensure_connection()
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"message": "服务暂时不可用,已自动恢复,请稍后重试"},
)
except Exception as reconnect_exc:
logger.error(f"[自愈失败] 重连失败: {reconnect_exc}", exc_info=True)
logger.error(f"未处理的异常: {error_msg}", exc_info=True)
return JSONResponse(status_code=500, content={"message": "服务器内部错误"})这一层与 5.4 的定时健康检查互补:定时任务负责零流量空闲时的主动自愈,全局处理器负责有流量时首个请求的即时自愈。两者一起,把平均恢复时间压到秒级。
5.8 启动即校验:连接池建立后立刻自检
在 lifespan 启动阶段 connect_db() 之后立即做一次健康探测,让「连不上数据库」的部署在启动期就快速失败,而不是带病上线、运行后才暴露:
await connect_db()
if not await health_check():
raise RuntimeError("数据库连接池启动后不健康,服务拒绝启动")六、延伸:ormar / databases 与 SQLAlchemy 的连接管理差异
这次故障值得追问一句:连接池到底归谁管?要不要自己手写?
6.1 ormar 自己没有连接池
根据 ormar 官方文档,它的依赖构成是:
Ormar 的构建有:用于构建查询的 SQLAlchemy Core、用于跨数据库异步支持的 databases、用于数据验证的 pydantic。
也就是说,ormar 只负责「模型定义 + 查询构建 + 数据校验」,真正的连接与连接池完全委托给 databases(我在 OrmarConfig 里传入的 databases.Database(DATABASE_URL) 就是连接载体),而 databases 又把池化交给底层驱动(MySQL 用 aiomysql、Postgres 用 asyncpg)。
结论是:
- 连接池一直存在,我配置的
min_size/max_size/pool_recycle就是在配它; - 我这次「手写」的并不是连接池本身,而是
databases缺失的那层「连接韧性」——探活、重连、自恢复。
6.2 问题出在 databases 的「极简主义」
databases 是一个非常轻量、近年来基本处于低维护状态的库。它提供了池,但刻意不提供:
- 取连接前探活(pre-ping):不会在使用前检测连接是否还活着;
- 自动重连:连接断了不会自己重建;
- 健康管理:没有任何「池失效自愈」机制。
本次故障本质上就是踩进了这些缺口——所以才不得不手写 pool_recycle 配置 + 健康检查 + 状态错位修复。这是 databases 的局限,不是 ormar 的设计缺陷,但用 ormar 就等于继承了这套局限。
6.3 SQLAlchemy 2.0 async:把这层韧性「内建」了
如果换成 SQLAlchemy 2.0 的 async 引擎,本类问题几乎可以「一个参数」解决:
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(
"mysql+asyncmy://user:pwd@host/db",
pool_size=10,
max_overflow=5,
pool_recycle=280,
pool_pre_ping=True, # ← 取连接前自动探活,失效连接自动丢弃重建
)pool_pre_ping=True 会在每次从池中取出连接前发一个轻量探测,自动识别并替换失效连接——也就意味着本次的 (2013) Lost connection、以及由它衍生的状态错位、DatabaseBackend is not running,在 SQLAlchemy 下根本不会发生,也不需要那套 60s 健康检查补丁。
6.4 各方案横向对比
| 能力 / 库 | ormar(底层 databases) | SQLAlchemy 2.0 async | Tortoise ORM | Django ORM |
|---|---|---|---|---|
| 连接池 | ✅(来自 databases→aiomysql) | ✅(内建 QueuePool) | ✅(自带池管理) | ❌ 无内建池(仅 CONN_MAX_AGE 持久连接,需外置 PgBouncer 等) |
pool_recycle | ✅(需手动透传) | ✅ | ✅ | —— |
pool_pre_ping(取用前探活) | ❌ 没有 | ✅ 一个参数 | ⚠️ 部分支持 | —— |
| 自动重连 / 状态自愈 | ❌ 需自行实现 | ✅ 基本自动 | 较好 | —— |
| 与 FastAPI/pydantic 集成 | ✅ 原生贴合 | ✅(2.0 起完善) | ✅ | 异步支持较新 |
| 维护活跃度 | 偏低 | 高 | 中 | 高(但偏同步) |
七、经验与反思
7.1 连接池的隐形杀手:pool_recycle
很多人配置连接池只盯着 min_size/max_size,却忽略 pool_recycle。云数据库的 wait_timeout、以及防火墙的空闲 TCP 超时可能短至几百秒,一个夜间低峰期就足以让池里的连接全部「假活」。把「连接随时会被对端悄悄掐断」当成常态,是写异步服务的第一课。
7.2 不要用自定义布尔标志去镜像第三方库的内部状态
本次的致命伤,就是一个永远为真的 _is_connected 标志与库内部真实状态错位。要判断,就直接判断真实对象(这里是 _pool),不要维护一份会和真相脱节的影子状态。
7.3 「优雅失败」可能是慢性毒药——但「能自愈就别崩」
全局 except 后默默返回 500,看似稳健,实则掩盖致命错误、拖着残躯服务。这里要把握一个平衡:
- 能自愈的故障(连接池可重建):就地恢复 + 返回 503 引导重试,优于一律 500,也优于直接崩溃;
- 无法自愈的故障(配置错误、依赖彻底不可用):宁可在启动期 / 运行期快速失败,交给 systemd / K8s 重启,也好过持续返回 500 制造「假活」。
判断「能不能自愈」并据此选择「自愈」还是「快死」,比无脑兜底更重要。
7.4 except Exception
except Exception: existing = None 把一次「读失败」悄悄变成了「错误写入」(1062)。捕获异常要精准、要分类:该让它向上抛的,绝不要吞。