SQLAlchemy 回滚后再次 flush,偶发 NoneType 没有 __dict__
SQLite 复用回滚主键时,旧 Session 中过期对象与垃圾回收可能触发 flush 内部加载异常。区分事务失败状态,缩短 Session 范围,并核对已修正的正式实现。
一次事务回滚之后,在同一个 Session 中再插入数据,flush() 偶发报 AttributeError: 'NoneType' object has no attribute '__dict__'。堆栈若经过 _register_persistent、_identity_key_from_state、_load_expired,最后落在 loading._instance,应检查过期 identity state 的替换路径,而不是只寻找业务代码里哪个变量是 None。
在 SQLAlchemy 2.0.50、SQLite 和 CPython 3.11 中,这条异常涉及以下顺序:先在事务中写入并加载关系对象,回滚后 SQLite 再次使用相同主键;旧对象还留在循环引用中,垃圾回收恰好在内部刷新期间发生。并不是任意 rollback 都会触发,也不能仅凭同一句 AttributeError 确认根因。
旧状态为什么会触发新的 SELECT
rollback 会使保留的已加载对象过期;这些对象不一定都像本次新增对象那样被移出会话。下一轮 flush 登记相同 identity key 时,旧实现先读取旧状态的身份属性,再判断对象是否还活着。读取已过期属性可以触发数据库刷新,期间对象又可能被 GC 回收,最终内部加载拿到 None。
简单交换两项判断的顺序仍有竞争窗口:对象可以在判断之后、刷新期间消失。关闭自动垃圾回收也只是挪动触发时间,还会改变整个进程的内存回收行为,不适合当作修复。
先保存第一次异常的完整堆栈、依赖锁文件、Session 创建与关闭位置。区分这里的内部 AttributeError 与 flush 失败后要求显式 rollback 的会话状态错误;后者通常会明确提示 previous exception during flush,处理事务状态不能自动修正本题的内部加载路径。
按一个业务单元管理 Session
如果测试或服务让一个 Session 跨多个请求反复回滚、继续写入,先检查能否让每个业务单元持有自己的 Session。下面只示意边界,engine、Child 和输入仍使用项目实际对象:
from sqlalchemy.orm import Session
with Session(engine) as session:
with session.begin():
session.add(Child(parent_id=parent_id))
成功退出事务块才提交;异常沿原有失败流程传播,外层退出后关闭本次 Session。不要让新的请求继续使用上一轮异常后的对象、待写集合或会话。框架已经管理事务时,应在框架允许的生命周期中调整,不能再叠加一套互相竞争的提交机制。
关闭 Session 后,旧 ORM 对象可能脱离会话。响应需要的数据应在有效范围内按既有契约整理,不要为了绕开异常无限延长会话。测试依赖 SQLite 内存数据库时,还要保留原来的连接与事务隔离设置;换 Session 不等于随意换 engine。
版本核对应看是否禁止了这次刷新
SQLAlchemy 2.1.6 已使用 PASSIVE_NO_FETCH 比较旧 identity,避免在该检查中发 SQL。使用 2.0 分支时,也要选择包含同一修正的版本;不能只按另一个分支的版本号判断自己的环境。
在隔离测试中覆盖回滚后相同主键复用、循环引用仍存在、GC 发生在后续 flush 附近的顺序。既检查不再出现内部加载异常,也要确认回滚的数据没有被当作成功结果返回,新事务只持久化本轮预期记录。升级或改变 Session 范围不会撤销过去已经提交的数据;存在不明确结果时,按业务记录核对。


