TimothyQiu's Blog

keep it simple stupid

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 (, (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() 给该记录加锁。

其它的选项还包括:

还是用 merge 更好啦

距离原来这篇博客发出已经一年多了,期间一直在遵循着上面所说的解决办法。但最近因为需要自己为 SQLAlchemy 封装 Write-through Cache 的轮子,又仔细阅读了 SQLAlchemy 的文档,发现在出现此种状况也可以选择使用 session.merge(obj, load=False) 将对象直接放入本 session 的 identity map 中。

session.merge 会首先根据对象的主键在 identity map 中查找对象,如果没有找到,则从数据库中加载一个进来。然后,会将传入对象的状态同步给 identity map 中的对象。

添加 load=False 参数,就会跳过「从数据库中加载一个」的步骤,直接在 identity map 中新建一个对象。由于对对象的状态有严格要求,官方文档 中只推荐从缓存中加载时使用。不过这正是我们所需要的,哈哈。

以上。

Python

添加新评论 »