問(wèn)題描述
最近在排查一個(gè)問(wèn)題,為了方便說(shuō)明,我們假設(shè)現(xiàn)在有如下一個(gè)API:
@app.route("/sqlalchemy/test", methods=['GET'])
def sqlalchemy_test_api():
data = {}
# 獲取商品價(jià)格
product = Product.query.get(1)
data['old_price'] = product.present_price
# 休眠10秒,等待外部修改價(jià)格
time.sleep(10)
product = Product.query.get(1)
data['new_price'] = product.present_price
return jsonify(status='ok', data=data)
這里我們的后臺(tái)使用了Flask作為服務(wù)端框架,SQLAlchemy作為數(shù)據(jù)庫(kù)ORM框架。Product是一張商品表的ORM模型,假設(shè)原來(lái)id=1的商品價(jià)格為10,在程序休眠的10秒內(nèi)價(jià)格被修改為20,那么你覺(jué)得返回的結(jié)果是多少?
old_price顯然是10,那么new_price呢?講道理的話由于外部修改價(jià)格為20了,同時(shí)程序在sleep后立刻又query了一次,你可能覺(jué)得new_price應(yīng)該是20。但結(jié)果并不是,真實(shí)測(cè)試的結(jié)果是10,給人感覺(jué)就像是SQLAlchemy“緩存”了上一次的結(jié)果。
另外在測(cè)試的過(guò)程還發(fā)現(xiàn)一個(gè)現(xiàn)象,雖然在第一次API調(diào)用時(shí)兩個(gè)price都是10,但是在第二次調(diào)用API時(shí),讀到的price是20。也就是說(shuō),在一個(gè)新的API開(kāi)始時(shí),之前“緩存”的結(jié)果被清除了。
SQLAlchemy的session狀態(tài)管理
之前我們提出了一個(gè)猜測(cè):第二次查詢是否“緩存”了第一次查詢。為了驗(yàn)證這個(gè)猜想,我們可以把SQLALCHEMY_ECHO這個(gè)配置項(xiàng)打開(kāi),這是個(gè)全局配置項(xiàng),官方文檔定義如下:
| 配置項(xiàng) | 說(shuō)明 |
|---|---|
SQLALCHEMY_ECHO |
If set to True SQLAlchemy will log all the statements issued to stderr which can be useful for debugging. |
在這個(gè)配置項(xiàng)打開(kāi)的情況下,我們可以看到查詢語(yǔ)句輸出到終端下。我們?cè)俅握{(diào)用API,可以發(fā)現(xiàn)第一次查詢會(huì)輸出類似SELECT * FROM product WHERE id = 1的語(yǔ)句,而第二次查詢則沒(méi)有這樣的輸出。如此看來(lái),SQLAlchemy確實(shí)緩存了上次的結(jié)果,在第二次查詢的時(shí)候直接使用了上次的結(jié)果。
實(shí)際上,當(dāng)執(zhí)行第一句product = Product.query.get(1)時(shí),product這個(gè)對(duì)象處于持久狀態(tài)(persistent)了,我們可以通過(guò)一些工具看到ORM對(duì)象目前處于的狀態(tài)。詳細(xì)的狀態(tài)列表可在官方文檔中找到。
>>> from sqlalchemy import inspect
>>> insp = inspect(product)
>>> insp.persistent
True
>>> product.__dict__
{
'id': 1, 'present_price': 10,
'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x1106a3350>,
}
為了清除該對(duì)象的緩存,程度從低到高有下面幾種做法。expire會(huì)清除對(duì)象里緩存的數(shù)據(jù),這樣下次查詢時(shí)會(huì)直接從數(shù)據(jù)庫(kù)進(jìn)行查詢。refresh不僅清除對(duì)象里緩存的數(shù)據(jù),還會(huì)立刻觸發(fā)一次數(shù)據(jù)庫(kù)查詢更新數(shù)據(jù)。expire_all的效果和expire一樣,只不過(guò)會(huì)清除session里所有對(duì)象的緩存。flush會(huì)把所有本地修改寫入到數(shù)據(jù)庫(kù),但沒(méi)有提交。commit不僅把所有本地修改寫入到數(shù)據(jù)庫(kù),同時(shí)也提交了該事務(wù)。
db.session.expire(product)
db.session.refresh(product)
db.session.expire_all()
db.session.flush()
db.session.commit()
我們對(duì)這幾種方法依次做實(shí)驗(yàn),結(jié)果發(fā)現(xiàn)這5個(gè)操作都會(huì)讓下次查詢直接從數(shù)據(jù)庫(kù)進(jìn)行查詢,但只有commit會(huì)讀到最新的price。那這個(gè)又是什么原因呢,我們已經(jīng)強(qiáng)制每次查詢走數(shù)據(jù)庫(kù),為何還是讀到“緩存”的數(shù)據(jù)。這個(gè)就要用數(shù)據(jù)庫(kù)的事務(wù)隔離機(jī)制來(lái)解釋了。
事務(wù)隔離
在數(shù)據(jù)庫(kù)系統(tǒng)中,事務(wù)隔離級(jí)別(isolation level)決定了數(shù)據(jù)在系統(tǒng)中的可見(jiàn)性。隔離級(jí)別從低到高分為四種:未提交讀(Read uncommitted),已提交讀(Read committed),可重復(fù)讀(Repeatable read),可串行化(Serializable)。他們的區(qū)別如下表所示。
| 隔離級(jí)別 | 臟讀 | 不可重復(fù)讀 | 幻讀 |
|---|---|---|---|
| 未提交讀(RU) | 可能 | 可能 | 可能 |
| 已提交讀(RC) | 不可能 | 可能 | 可能 |
| 可重復(fù)讀(RR) | 不可能 | 不可能 | 可能 |
| 可串行化 | 不可能 | 不可能 | 不可能 |
臟讀(dirty read)是指一個(gè)事務(wù)可以讀到其他事務(wù)還未提交的數(shù)據(jù)。不可重復(fù)讀(non-repeatable read)是指在一個(gè)事務(wù)中同一行被讀取了多次,可以讀到不同的值?;米x(phantom read)是指在一個(gè)事務(wù)中執(zhí)行同一個(gè)語(yǔ)句多次,讀到的數(shù)據(jù)行發(fā)生了改變,即可能行數(shù)增加了或減少了。
前面提到的問(wèn)題其實(shí)就涉及到不可重復(fù)讀這個(gè)特性,即在一個(gè)事務(wù)中我們query了product.id=1的數(shù)據(jù)多次,但讀到了重復(fù)的數(shù)據(jù)。對(duì)于MySQL來(lái)說(shuō),默認(rèn)的事務(wù)隔離級(jí)別是RR,通過(guò)上表我們可知RR是可重復(fù)讀的,因此可以解釋這個(gè)現(xiàn)象。
| 事務(wù)A | 事務(wù)B |
|---|---|
BEGIN; |
BEGIN; |
SELECT present_price FROM product WHERE id = 1; /* id=1的商品價(jià)格為10 */ |
|
UPDATE product SET present_price = 20 WHERE id = 1; /* 修改id=1的商品價(jià)格為20 */ |
|
COMMIT; |
|
SELECT present_price FROM product WHERE id = 1; /* 再次查詢id=1的商品價(jià)格 */ |
|
COMMIT; |
對(duì)于前面的問(wèn)題,我們可以把兩個(gè)事務(wù)的執(zhí)行時(shí)序圖畫出來(lái)如上所示。因此為了使第二次查詢得到正確的值,我們可以把隔離級(jí)別設(shè)為RC,或者在第二次查詢前進(jìn)行COMMIT新起一個(gè)事務(wù)。
Flask-SQLAlchemy的自動(dòng)提交
前面還遺留一個(gè)問(wèn)題沒(méi)有搞清楚:在一個(gè)新的API開(kāi)始時(shí),之前“緩存”的結(jié)果似乎被清除了。由于打開(kāi)了SQLALCHEMY_ECHO配置項(xiàng),我們可以觀察到每次API結(jié)束的時(shí)候都會(huì)自動(dòng)觸發(fā)一次COMMIT,而正是這個(gè)自動(dòng)提交清空了所有的“緩存”。通過(guò)查找源代碼,我們發(fā)現(xiàn)是下面這段代碼在起作用:
@teardown
def shutdown_session(response_or_exc):
if app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN']:
if response_or_exc is None:
self.session.commit()
self.session.remove()
return response_or_exc
如果配置項(xiàng)SQLALCHEMY_COMMIT_ON_TEARDOWN為True,那么首先觸發(fā)COMMIT,最后統(tǒng)一執(zhí)行session.remove()操作,即釋放連接并回滾事務(wù)操作。
有意思的是,這個(gè)配置項(xiàng)在Flask2.0版本的Changelog中被移除了。

關(guān)于刪除的原因,作者在stackoverflow的一個(gè)帖子里進(jìn)行了說(shuō)明。這個(gè)帖子同時(shí)也解釋了為什么在我們的生產(chǎn)環(huán)境中經(jīng)常報(bào)這個(gè)錯(cuò)誤:
InvalidRequestError: This session is in 'prepared' state; no further SQL can be emitted within this transaction.,而且只有重啟才能解決問(wèn)題。有興趣的同學(xué)可以深入閱讀一下。
總結(jié)
在MySQL的同一個(gè)事務(wù)中,多次查詢同一行的數(shù)據(jù)得到的結(jié)果是相同的,這里既有SQLAlchemy本身“緩存”結(jié)果的原因,也受到數(shù)據(jù)庫(kù)隔離級(jí)別的影響。如果要強(qiáng)制讀取最新的結(jié)果,最簡(jiǎn)單的辦法就是在查詢前手動(dòng)COMMIT一次。根據(jù)這個(gè)原則,我們可以再仔細(xì)閱讀下自己項(xiàng)目中的代碼,看看會(huì)不會(huì)有一些隱藏的問(wèn)題。