学习笔记

5.27故障复盘:手写ormar的连接池问题

by 猪皮怪, 2026-06-13


上午,某业务系统的客户端突然「打不开内容、登录不上」。后端进程还在、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=Noneis_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

关键信息提炼:

  1. 报错点在 databases 库的 MySQLBackend.acquire(),断言 self._database._pool is not None 失败。
  2. 也就是说,连接池对象 _pool 已经变成了 None
  3. 这个问题,所有接口持续出现,非偶发,无法自主恢复。

更早的日志里,还能看到另外两类周期性报错,来自每 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.Databaseconnect() / 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_connectedFalse——这两步之间存在一个危险的「时间窗」。

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。只要进程启动时连过一次,它就永远是 Trueensure_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 Nonedatabase.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:
            raise

5.6 扩大重试白名单,把「连接池消失 / 连接被拒」一并纳入

各处的 DB 重试逻辑原本只认 Lost connection / 2013,且判定关键字在 dependencies.pymiddleware/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()
    continue

5.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 asyncTortoise ORMDjango 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)。捕获异常要精准、要分类:该让它向上抛的,绝不要吞。

none
猪皮怪

作者: 猪皮怪

2026 © typecho & esia.asia