神刀安全网

SQLAlchemy 的 Identity Map 和 Cache 造成的 add 失败

标题剧透预警,然而已经晚了 :)

事情是这样的:这两天在写某 Flask App 的用户模块,访问邮箱验证邮件里的链接时,SQLAlchemy 在 db.session.add(user) 时有一定几率抛出异常:

AssertionError: A conflicting state is already present in the identity map for key (<class 'project.models.UserModel'>, (UUID('12345678-1234-1234-1234-123456789abc'),)) 

重现模式也很奇怪,同一个链接,首次访问时几乎都是异常,而刷新一下重新访问,一切就平平稳稳通过了。但是如果开新的浏览器(新的隐身窗口)访问,似乎无论是不是首次访问都没有问题。

问题是什么

断言内容直译是「目前的 Identity Map 中已存在与之冲突的状态」。

SQLAlchemy 是数据库和 Python 的中间层,Identity Map 即数据库对象与 Python 对象的映射表。我们之所以可以做这样的判断:

new_user = User(id=42) db.session.add(new_user) user = User.query.get(42) assert user is new_user 

就是 Identity Map 的功劳。

如果试图往 Identity Map 里加入两个不同的 Python 对象,但这两个 Python 对象都映射到同一个数据库对象,自然就不科学了,因为打破了其中的不变量。

foo = Model(id=42) db.session.add(foo)  # OK bar = Model(id=42) db.session.add(bar)  # Fail 

问题的成因

系统中有一个 get_user_by_id 的函数用以从数据库查找用户。这个函数额外加了个自己实现的 @cache 装饰器,用 Redis 做了缓存。

发送验证邮件时,因为其它部分的逻辑对用户信息做了修改,导致缓存被清空。于是下一次对 get_user_by_id 的调用一定会走数据库。

点击邮件中的验证链接后,访问验证页面。由于在 app.before_request 中插入了用 Session 中的 user_id 通过 get_user_by_id 获取用户信息的操作,做了数据库访问, Identity Map 中就保存了这一份 UserModel ,暂且称作 A。

正式的验证页面逻辑,需要从验证链接中提取到这条链接对应的 user_id ,继而通过 get_user_by_id 获得对应的 UserModel ,此时的 UserModel 是从 Redis 中反序列化而来的,暂且称作 B。

A 和 B 虽然指向同一个数据库对象,但其实是不同的 Python 对象。通过 db.session.add 把 B 加入 Session,就会与 Identity Map 里原有的 A 发生冲突。

所以第二次访问该链接,由于都是从 Cache 加载的对象,Identity Map 一直是空的,就不会有如何问题;打开新浏览器,由于没有登录,也不会触发 app.before_request 中的数据库操作,所以也不会有问题。

问题的解决

目前的做法是新开一个 get_user_by_id_for_update(user_id) 从数据库加载,与此同时,正好可以在该函数内部给数据库请求加上 with_for_update() 给该记录加锁。

其它的选项还包括:

  • @cache 里为带 Cache 的函数都加上 force 参数的支持
    • 个人觉得, force 参数的「跳过缓存」语义暴露了实现细节,相比之下, get_model_by_id_for_update 的「修改」语义比它高到不知道哪里去了
  • 统一把 update_model(model, **kwargs) 改成 update_model(model_id, **kwargs) 的形式,在内部做数据库查询
    • 个人觉得,将 update_model 改为接受 model_id 的做法则有点本末倒置的意思

以上。

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » SQLAlchemy 的 Identity Map 和 Cache 造成的 add 失败

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址