從SQLAlchemy的“緩存”問題說起

問題描述

最近在排查一個(gè)問題帮掉,為了方便說明,我們假設(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,那么你覺得返回的結(jié)果是多少掰邢?

old_price顯然是10牺陶,那么new_price呢?講道理的話由于外部修改價(jià)格為20了辣之,同時(shí)程序在sleep后立刻又query了一次掰伸,你可能覺得new_price應(yīng)該是20。但結(jié)果并不是怀估,真實(shí)測(cè)試的結(jié)果是10狮鸭,給人感覺就像是SQLAlchemy“緩存”了上一次的結(jié)果。

另外在測(cè)試的過程還發(fā)現(xiàn)一個(gè)現(xiàn)象多搀,雖然在第一次API調(diào)用時(shí)兩個(gè)price都是10歧蕉,但是在第二次調(diào)用API時(shí),讀到的price是20康铭。也就是說惯退,在一個(gè)新的API開始時(shí),之前“緩存”的結(jié)果被清除了麻削。

SQLAlchemy的session狀態(tài)管理

之前我們提出了一個(gè)猜測(cè):第二次查詢是否“緩存”了第一次查詢蒸痹。為了驗(yàn)證這個(gè)猜想春弥,我們可以把SQLALCHEMY_ECHO這個(gè)配置項(xiàng)打開,這是個(gè)全局配置項(xiàng)叠荠,官方文檔定義如下:

配置項(xiàng) 說明
SQLALCHEMY_ECHO If set to True SQLAlchemy will log all the statements issued to stderr which can be useful for debugging.

在這個(gè)配置項(xiàng)打開的情況下匿沛,我們可以看到查詢語(yǔ)句輸出到終端下。我們?cè)俅握{(diào)用API榛鼎,可以發(fā)現(xiàn)第一次查詢會(huì)輸出類似SELECT * FROM product WHERE id = 1的語(yǔ)句逃呼,而第二次查詢則沒有這樣的輸出。如此看來(lái)者娱,SQLAlchemy確實(shí)緩存了上次的結(jié)果抡笼,在第二次查詢的時(shí)候直接使用了上次的結(jié)果。

實(shí)際上黄鳍,當(dāng)執(zhí)行第一句product = Product.query.get(1)時(shí)推姻,product這個(gè)對(duì)象處于持久狀態(tài)(persistent)了,我們可以通過一些工具看到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一樣,只不過會(huì)清除session里所有對(duì)象的緩存队丝。flush會(huì)把所有本地修改寫入到數(shù)據(jù)庫(kù)靡馁,但沒有提交。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)中的可見性掺冠。隔離級(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ù)中同一行被讀取了多次锌奴,可以讀到不同的值『豆桑幻讀(phantom read)是指在一個(gè)事務(wù)中執(zhí)行同一個(gè)語(yǔ)句多次鹿蜀,讀到的數(shù)據(jù)行發(fā)生了改變,即可能行數(shù)增加了或減少了服球。

前面提到的問題其實(shí)就涉及到不可重復(fù)讀這個(gè)特性茴恰,即在一個(gè)事務(wù)中我們query了product.id=1的數(shù)據(jù)多次,但讀到了重復(fù)的數(shù)據(jù)斩熊。對(duì)于MySQL來(lái)說往枣,默認(rèn)的事務(wù)隔離級(jí)別是RR,通過上表我們可知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ì)于前面的問題,我們可以把兩個(gè)事務(wù)的執(zhí)行時(shí)序圖畫出來(lái)如上所示渣叛。因此為了使第二次查詢得到正確的值,我們可以把隔離級(jí)別設(shè)為RC盯捌,或者在第二次查詢前進(jìn)行COMMIT新起一個(gè)事務(wù)淳衙。

Flask-SQLAlchemy的自動(dòng)提交

前面還遺留一個(gè)問題沒有搞清楚:在一個(gè)新的API開始時(shí),之前“緩存”的結(jié)果似乎被清除了饺著。由于打開了SQLALCHEMY_ECHO配置項(xiàng)箫攀,我們可以觀察到每次API結(jié)束的時(shí)候都會(huì)自動(dòng)觸發(fā)一次COMMIT,而正是這個(gè)自動(dòng)提交清空了所有的“緩存”幼衰。通過查找源代碼靴跛,我們發(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)行了說明腹鹉。這個(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.藏畅,而且只有重啟才能解決問題。有興趣的同學(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ì)有一些隱藏的問題。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末藕届,一起剝皮案震驚了整個(gè)濱河市挪蹭,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌休偶,老刑警劉巖梁厉,帶你破解...
    沈念sama閱讀 206,968評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異踏兜,居然都是意外死亡词顾,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門碱妆,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)肉盹,“玉大人,你說我怎么就攤上這事疹尾∩先蹋” “怎么了?”我有些...
    開封第一講書人閱讀 153,220評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵纳本,是天一觀的道長(zhǎng)窍蓝。 經(jīng)常有香客問我,道長(zhǎng)繁成,這世上最難降的妖魔是什么吓笙? 我笑而不...
    開封第一講書人閱讀 55,416評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮巾腕,結(jié)果婚禮上面睛,老公的妹妹穿的比我還像新娘。我一直安慰自己祠墅,他們只是感情好侮穿,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評(píng)論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著毁嗦,像睡著了一般亲茅。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評(píng)論 1 285
  • 那天克锣,我揣著相機(jī)與錄音茵肃,去河邊找鬼。 笑死袭祟,一個(gè)胖子當(dāng)著我的面吹牛验残,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播巾乳,決...
    沈念sama閱讀 38,432評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼您没,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了胆绊?” 一聲冷哼從身側(cè)響起氨鹏,我...
    開封第一講書人閱讀 37,088評(píng)論 0 261
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎压状,沒想到半個(gè)月后仆抵,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡种冬,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評(píng)論 2 325
  • 正文 我和宋清朗相戀三年镣丑,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片娱两。...
    茶點(diǎn)故事閱讀 38,137評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡莺匠,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出十兢,到底是詐尸還是另有隱情慨蛙,我是刑警寧澤,帶...
    沈念sama閱讀 33,783評(píng)論 4 324
  • 正文 年R本政府宣布纪挎,位于F島的核電站,受9級(jí)特大地震影響跟匆,放射性物質(zhì)發(fā)生泄漏异袄。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評(píng)論 3 307
  • 文/蒙蒙 一玛臂、第九天 我趴在偏房一處隱蔽的房頂上張望烤蜕。 院中可真熱鬧,春花似錦迹冤、人聲如沸讽营。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)橱鹏。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間莉兰,已是汗流浹背挑围。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評(píng)論 1 262
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留糖荒,地道東北人杉辙。 一個(gè)月前我還...
    沈念sama閱讀 45,595評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像捶朵,于是被迫代替她去往敵國(guó)和親蜘矢。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容