同事发来一张截图
上周三下午,同事甩来一张截图:页面顶部红条,「服务器内部错误」,底下是刚提交完的入库单列表,单据在,数量对。
他补了一句:「点了没反应,但数据进去了。」
这种描述听多了会有条件反射------先怀疑网络重试,再怀疑前端重复提交。但这次两条都不成立:接口只调了一次,返回的是 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 先别改配置。
- 看落在哪个字段 ------
inspect(obj).unloaded里有它,且不是关系名 → 「从未加载」,refresh。 - 看是不是 commit 之后 ------
expire_on_commit=True且访问普通标量字段 → 「已过期」,refresh 或关自动过期。 - 看是不是关系属性 ------
order.items这类,报错跟 commit 无关,改多少遍配置都没用 → 用selectinload预加载。
三条的报错文本一模一样,成因完全不同。选错方向的结果是:配置动了,代码厚了,故障照旧。
这几个坑后来在一套给小微工厂用的生产管理 SaaS 里陆续复现,产品名是摩杰斯 PMS5。冒出来的有四个:丢数据、commit 后过期、关系惰性加载、深翻页。另外两个(server_default 未加载、UPDATE 后 updated_at 失效)反倒没出现------库是 PostgreSQL,RETURNING 把这层洞补掉了。
规模再往上一档,commit 边界就该从「每个方法自己决定」收成显式的 Unit of Work------那是另一个话题了。
有没有人踩过同一个坑但成因不同的?评论区聊聊,尤其是
expire_on_commit派和refresh派的分歧。