Skip to content

别再被 SQLAlchemy 的 session.close() 给骗了:我在这儿踩了个大坑!

一、 离奇的“灵异事件”:我明明关了,它怎么还报错?

大家写代码时肯定都有个好习惯:资源用完要释放。比如你为了解耦封装了一个数据库操作函数,写得那叫一个专业:

python
# 案例 1:看起来很专业的“自杀式”写法
def get_user_data():
    s = db_session()  # 获取 session 实例
    user = s.query(User).first()
    s.close()         # 职业操守:用完赶紧关掉,释放连接
    return user

# 在业务里调用
data = get_user_data()

# 报错来了!
# 逻辑走到下面这一行,程序直接崩了
other_data = db_session.query(Order).all()  
# 抛出异常:sqlalchemy.exc.InvalidRequestError: This Session's transaction has been rolled back... 
# 或者直接提示:Session is closed.

这时候你肯定一脸懵逼: “我刚才关的是函数里那个小 s,关你全局大代理 db_session 什么事儿?而且我既然都关了,你这会儿不应该自动给我开个新的吗?”


二、 深度复盘:代理模式背后的“粘性”

要解释这个现象,咱们得掀开 SQLAlchemy scoped_session 的引擎盖看看。

管家 scoped_session 内部维护了一个 Registry(注册表)。它的工作流程是这样的:

  1. **你调用 db_session.query()**
  2. 大管家查表:它先看一眼当前请求的 ID(比如 Flask 的 app_ctx_id12345)。
  3. 发现熟人:表里写着 ID 12345 对应的是 Session_对象_A
  4. 原样返回:管家直接把 Session_对象_A 递给你。

问题的死结就在这儿:

当你执行 s.close() 时,你确实把 Session_对象_A 的门给关了,连接还给了连接池。但是!大管家手里的那张表没擦干净!

表里依然记录着 ID 12345 -> Session_对象_A。只要你的请求(上下文)还没结束,那个 ID 就没变。当你第二次伸手要 Session 时,管家根本不看对象死活,直接把那个“已经咽气”的僵尸对象塞给你。

这就是所谓的代理粘性——它只负责给你找对象,不负责检查对象是不是已经“挂了”。


三、 解药:close() 与 remove() 的致命隔阂

很多兄弟纠结:“我封装了代码,业务层只能拿到 session 实例,它只有 close 没法调 remove 啊!”

这就是最容易搞混的地方。咱们用大白话分一下工:

1. session.close() —— “还连接,不退房”

它是 Session 实例的方法。它只负责把数据库连接放回池子里,把事务回滚掉。但它没本事把自己从大管家的“入住名单”里勾掉。

  • 后果:Session 变成了占着茅坑不拉屎的“僵尸”,把位子(ID)占死了。

2. db_session.remove() —— “毁尸灭迹,彻底退房”

这是大管家(代理对象)的方法。它的逻辑是:

  1. 找到当前 ID 绑定的那个 Session 实例。
  2. 替它调一下 .close()(释放资源)。
  3. 重点:把这个实例从注册表里彻底抹掉。

只有调了 remove(),下次你再找管家要 Session 时,由于表里没记录了,他才会重新去工厂给你生产一个活蹦乱跳的新 Session。


四、 最佳工程实践:到底怎么写才不崩?

既然明白了 Registry 的粘性,咱们就有对策了:

1. 别在中途瞎 close(最省心的写法)

在 Flask 这种框架里,最好的控制感就是“放权”。别在每个小函数里调 close(),而是在请求彻底结束的地方调一次 remove()

python
@app.teardown_appcontext
def shutdown_session(exception=None):
    db_session.remove()  # 统一收尸,整个请求期间 session 都是活的

2. 如果必须在中途重置,必须认准 remove

如果你在一个超长任务里(比如批量处理数据),确实想每隔 1000 条重连一次,请确保你能访问到那个代理对象:

python
# 案例 2:正确的中途重置姿势
db_session.add(some_obj)
db_session.commit()

db_session.remove()  # 关键!用 remove 而不是 close

# 下面这一行会触发管家去创建“全新的” Session 对象
db_session.query(User).all()  # 满血复活,正常运行

3. 追求极致控制:直接用工厂

如果你极度讨厌这种“隐式单例”的魔法,那干脆别用 scoped_session。直接用 sessionmaker,配合 Python 的 with 语句:

python
# 案例 3:完全手动挡,绝对没僵尸
with session_factory() as s:
    s.query(User).all()
# with 一结束,s 自动销毁。因为它压根没进管家的注册表,完全没隐患。

五、 结语

scoped_session 就像一个记性太好的前台,它能帮你省去传参的麻烦,但如果你偷偷把房间搞坏了(close)却不告诉它(remove),下次它还是会把这间破房子租给你。

记住这句顺口溜:close 是给池子看的(归还连接),remove 是给管家看的(清理索引)。 只有大管家点头,你才会有新的开始!


最后更新: