從SQLAlchemy的“緩存”問(wèn)題說(shuō)起

問(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_TEARDOWNTrue,那么首先觸發(fā)COMMIT,最后統(tǒng)一執(zhí)行session.remove()操作,即釋放連接并回滾事務(wù)操作。

有意思的是,這個(gè)配置項(xiàng)在Flask2.0版本的Changelog中被移除了。

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)題。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容