标题剧透预警,然而已经晚了 :)
事情是这样的:这两天在写某 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
的做法则有点本末倒置的意思
- 个人觉得,将
以上。