問題描述
最近在排查一個(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_TEARDOWN
為True
,那么首先觸發(fā)COMMIT
渡嚣,最后統(tǒng)一執(zhí)行session.remove()
操作梢睛,即釋放連接并回滾事務(wù)操作馍悟。
有意思的是狈蚤,這個(gè)配置項(xiàng)在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ì)有一些隱藏的問題。