从一次「报错但成功」查起:SQLAlchemy async 的对象过期与 commit 时机

同事发来一张截图

上周三下午,同事甩来一张截图:页面顶部红条,「服务器内部错误」,底下是刚提交完的入库单列表,单据在,数量对。

他补了一句:「点了没反应,但数据进去了。」

这种描述听多了会有条件反射------先怀疑网络重试,再怀疑前端重复提交。但这次两条都不成立:接口只调了一次,返回的是 500。

翻日志,一行堆栈翻来覆去:

scss 复制代码
MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here

下面那行指向一个谁都没写过的地方:读 updated_at 的时候。

第一次判断:事务没提交

第一反应是事务没提交。数据在库里是上一版留下的,新写入被回滚了。

查下去发现这个假设有一半对一半错。

对的那一半:这套代码确实没有任何一层负责 commit。会话依赖长这样:

python 复制代码
async def get_session():
    async with session_factory() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise

异常时回滚,正常路径什么都没做。仓储基类也只到 flush():

python 复制代码
async def create(self, session, payload):
    obj = self.model(**payload)
    session.add(obj)
    await session.flush()
    return obj

错的那一半:这次数据真的在库里。也就是说,某个上层用例确实调了 commit------问题不在丢数据,在提交之后发生的事。

第二次判断:以为是漏了 await

greenlet_spawn has not been called 这句话很容易把人带向「有同步代码混进了异步流」。

于是沿着调用链找裸 session.execute()、找同步 Session 的残留、找 time.sleep,全都没有。

机制得再往下钻一层:SQLAlchemy 在 commit 之后,默认会把实例上所有属性标记为过期 (expire_on_commit=True)。所谓过期,是「值可能变了,下次读的时候重新查一遍」。

同步驱动下这一步是透明的:读属性 → 发一条 SELECT → 返回。异步驱动下,读属性这个动作不允许发 IO------Python 的属性访问没有 await 的位置。SQLAlchemy 只好抛异常,告诉你「这里需要一个 await,但语法上给不了」。

所以堆栈指向一个从没写过的字段,不是因为那里有代码,而是因为那里有一次隐式 IO。

第三次判断:关掉自动过期,好了一阵

知道成因之后,修法看着很简单------建会话时关掉自动过期:

python 复制代码
session_factory = async_sessionmaker(engine, expire_on_commit=False)

改完跑了一遍,好了。

好了一周。第二个模块又炸了,同一行报错。这次不是时间戳,是一个明细列表------order.items,读它的时候炸的。

关系属性。

expire_on_commit 管的是标量字段,管不到关系。relationship 默认 lazy="select",第一次访问就去查数据库,同样是一次隐式 IO,同样在 greenlet 之外,同样抛 MissingGreenlet。这跟 commit 一点关系都没有,把 expire_on_commit 翻来覆去改是没用的。

顺便把流传很广的另一条说法也验了:「设了 expire_on_commit=False 也没用,server_default 列从未加载,照样炸」。

在 PostgreSQL 上跑了一遍,没炸。

原因在 eager_defaults 的默认值是 auto:方言支持 INSERT ... RETURNING 时,带 server_default 的列会被一并放进 RETURNING,flush 那一刻值就回到对象上了,压根不存在「从未加载」。PostgreSQL 支持,所以没事。

换到不支持 RETURNING 的库,这条说法立刻成立。所以它不是错,是缺了前提------网上多数结论都缺这个前提,于是同一段代码在 A 项目稳、在 B 项目炸,谁也说不清。

判断自己遇到的是哪一种,打印两个属性就够了:

python 复制代码
from sqlalchemy import inspect
state = inspect(obj)
print(state.expired_attributes)   # 过期:有值被清掉,重读即可
print(state.unloaded)             # 从未加载:含关系属性与未取回的列

unloaded 里有关系名,就别 refresh 了,那个查询次数会很难看------用 selectinload 预加载,或者干脆显式查一次明细。

修法收敛成两条

后来把修法收成两条规则,写进 Code Review 清单:

规则一:写操作返回给序列化层之前,必须补一次读。

python 复制代码
await session.commit()
await session.refresh(obj)
return obj

对 ORM 对象重复刷不会出问题,非 ORM 对象直接忽略;那些自己写的构建函数(只做 add 再 flush 那种)也得挂上,跟仓储基类保持一致。

规则二:状态变更方法返回重新查过的对象,不管方言替不替你 RETURNING。

python 复制代码
obj.status = "approved"
await session.flush()
return obj                 # PostgreSQL 上能跑,换个库就炸

# 改成重新查
return await self._reload(session, id)

说实话,这条在 PostgreSQL 上不是「修 bug」,是「上保险」。UPDATE ... RETURNING 会把 onupdate 生成的新值一并带回来,flush 之后读 updated_at 是好的------这条实测过,值跟库里一致。

但同一套代码要出一个 MySQL 版,或者哪天某个 mapper 把 eager_defaults 关掉了,它立刻变成坑。多一次 SELECT,买的是「不依赖方言默认值」。

顺带撞上的第三个坑

同一周还踩了一个更基础的:select 的 where 条件里写成了 :id::uuid,直接语法错误。

go 复制代码
syntax error at or near ":"

不是类型错,是解析错。SQLAlchemy 的 text() 认占位符的正则末尾带一个 (?!:):冒号后面不能紧跟冒号。写 :id::uuid 时,:id 后面正好是 :,这个占位符根本不被识别成参数,原样透传到数据库,PG 收到带裸冒号的 SQL 就报语法错。

这个坑之所以流传成「uuid 必须 CAST 否则类型报错」,是因为两个不同的错被讲成了一件事。实测四种写法:

写法 结果
where id = :id,传 str 通过
where id = CAST(:id AS uuid),传 str 通过
where id = :id::uuid,传 str 语法错误
where id = :id,传 UUID 对象 通过

裸字符串能过,是因为 PostgreSQL 会拿左边的 uuid 列去推断参数类型,再由驱动按 uuid 的格式发过去。会走到 operator does not exist: uuid = character varying 这一步的,是给参数显式标了 String 类型,或者同一个参数名在两处以不同类型出现。

但项目里还是统一写 CAST,理由不神秘:别让类型依赖上下文推断 。哪天有人给参数补个 type_=String,或者这段 SQL 挪到推断不友好的地方,裸字符串就转成线上故障。多敲九个字符,挡掉一个看不见的前提。

python 复制代码
# 统一这么写
await session.execute(text("select * from accounts where id = CAST(:id AS uuid)"),
                      {"id": account_id})

三条判断顺序

如果只记一件事,记这个顺序:遇到 MissingGreenlet 先别改配置。

  1. 看落在哪个字段 ------inspect(obj).unloaded 里有它,且不是关系名 → 「从未加载」,refresh。
  2. 看是不是 commit 之后 ------expire_on_commit=True 且访问普通标量字段 → 「已过期」,refresh 或关自动过期。
  3. 看是不是关系属性 ------order.items 这类,报错跟 commit 无关,改多少遍配置都没用 → 用 selectinload 预加载。

三条的报错文本一模一样,成因完全不同。选错方向的结果是:配置动了,代码厚了,故障照旧。

这几个坑后来在一套给小微工厂用的生产管理 SaaS 里陆续复现,产品名是摩杰斯 PMS5。冒出来的有四个:丢数据、commit 后过期、关系惰性加载、深翻页。另外两个(server_default 未加载、UPDATE 后 updated_at 失效)反倒没出现------库是 PostgreSQL,RETURNING 把这层洞补掉了。

规模再往上一档,commit 边界就该从「每个方法自己决定」收成显式的 Unit of Work------那是另一个话题了。

有没有人踩过同一个坑但成因不同的?评论区聊聊,尤其是 expire_on_commit 派和 refresh 派的分歧。

相关推荐
用户360055579002 小时前
Kestrel_VLLM - 22 · MoE FFN 位级镜像收口:整模型逐层 L2 全等达成
后端
前端小万2 小时前
想做个人副业别乱买服务器!我的 5 个月真实开销分享
前端·后端·github
小蒜学长3 小时前
基于 Spring Boot 的英语单词背诵系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端
用户69371750013843 小时前
我买的电视,我还不能研究它吗?
前端·后端·ai编程
利威尔xu3 小时前
RAG 检索面试十连问:召回率从 92% 掉到 61%,答案句被拦腰截断怎么排查
人工智能·后端·大模型·向量数据库·rag
老王IT4 小时前
Go开发环境搭建(window操作系统)
开发语言·后端·golang
萧瑟余晖4 小时前
Spring Data JDBC与R2DBC详解
java·后端·spring
花间相见4 小时前
【计算基础|MySQL18】Buffer Pool 与内存管理
后端·sql·mysql
花间相见6 小时前
【计算基础|Redis01】【上手】安装连接、配置与通用命令
数据库·后端·面试