SQLAlchemy剛插入的數據查詢不生效

問題描述

最近在排查一個問題,爲了方便說明,我們假設現在有如下一個API:

@app.route("/sqlalchemy/test", methods=['GET'])
def sqlalchemy_test_api():
    data = {}
    # 獲取商品價格
    product = Product.query.get(1)
    data['old_price'] = product.present_price
    # 休眠10秒,等待外部修改價格
    time.sleep(10)
    product = Product.query.get(1)
    data['new_price'] = product.present_price
    return jsonify(status='ok', data=data)

這裏我們的後臺使用了Flask作爲服務端框架,SQLAlchemy作爲數據庫ORM框架。Product是一張商品表的ORM模型,假設原來id=1的商品價格爲10,在程序休眠的10秒內價格被修改爲20,那麼你覺得返回的結果是多少?

old_price顯然是10,那麼new_price呢?講道理的話由於外部修改價格爲20了,同時程序在sleep後立刻又query了一次,你可能覺得new_price應該是20。但結果並不是,真實測試的結果是10,給人感覺就像是SQLAlchemy“緩存”了上一次的結果。

另外在測試的過程還發現一個現象,雖然在第一次API調用時兩個price都是10,但是在第二次調用API時,讀到的price是20。也就是說,在一個新的API開始時,之前“緩存”的結果被清除了。

SQLAlchemy的session狀態管理

之前我們提出了一個猜測:第二次查詢是否“緩存”了第一次查詢。爲了驗證這個猜想,我們可以把SQLALCHEMY_ECHO這個配置項打開,這是個全局配置項,官方文檔定義如下:

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

在這個配置項打開的情況下,我們可以看到查詢語句輸出到終端下。我們再次調用API,可以發現第一次查詢會輸出類似SELECT * FROM product WHERE id = 1的語句,而第二次查詢則沒有這樣的輸出。如此看來,SQLAlchemy確實緩存了上次的結果,在第二次查詢的時候直接使用了上次的結果。

實際上,當執行第一句product = Product.query.get(1)時,product這個對象處於持久狀態(persistent)了,我們可以通過一些工具看到ORM對象目前處於的狀態。詳細的狀態列表可在官方文檔中找到。

>>> 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>,
}

爲了清除該對象的緩存,程度從低到高有下面幾種做法。expire會清除對象裏緩存的數據,這樣下次查詢時會直接從數據庫進行查詢。refresh不僅清除對象裏緩存的數據,還會立刻觸發一次數據庫查詢更新數據。expire_all的效果和expire一樣,只不過會清除session裏所有對象的緩存。flush會把所有本地修改寫入到數據庫,但沒有提交。commit不僅把所有本地修改寫入到數據庫,同時也提交了該事務。

db.session.expire(product)
db.session.refresh(product)
db.session.expire_all()
db.session.flush()
db.session.commit()

我們對這幾種方法依次做實驗,結果發現這5個操作都會讓下次查詢直接從數據庫進行查詢,但只有commit會讀到最新的price。那這個又是什麼原因呢,我們已經強制每次查詢走數據庫,爲何還是讀到“緩存”的數據。這個就要用數據庫的事務隔離機制來解釋了。

事務隔離

在數據庫系統中,事務隔離級別(isolation level)決定了數據在系統中的可見性。隔離級別從低到高分爲四種:未提交讀(Read uncommitted),已提交讀(Read committed),可重複讀(Repeatable read),可串行化(Serializable)。他們的區別如下表所示。

隔離級別 髒讀 不可重複讀 幻讀
未提交讀(RU) 可能 可能 可能
已提交讀(RC) 不可能 可能 可能
可重複讀(RR) 不可能 不可能 可能
可串行化 不可能 不可能 不可能

髒讀(dirty read)是指一個事務可以讀到其他事務還未提交的數據。不可重複讀(non-repeatable read)是指在一個事務中同一行被讀取了多次,可以讀到不同的值。幻讀(phantom read)是指在一個事務中執行同一個語句多次,讀到的數據行發生了改變,即可能行數增加了或減少了。

前面提到的問題其實就涉及到不可重複讀這個特性,即在一個事務中我們query了product.id=1的數據多次,但讀到了重複的數據。對於MySQL來說,默認的事務隔離級別是RR,通過上表我們可知RR是可重複讀的,因此可以解釋這個現象。

事務A 事務B
BEGIN; BEGIN;
SELECT present_price FROM product WHERE id = 1; /* id=1的商品價格爲10 */  
  UPDATE product SET present_price = 20 WHERE id = 1; /* 修改id=1的商品價格爲20 */
  COMMIT;
SELECT present_price FROM product WHERE id = 1; /* 再次查詢id=1的商品價格 */  
COMMIT;  

對於前面的問題,我們可以把兩個事務的執行時序圖畫出來如上所示。因此爲了使第二次查詢得到正確的值,我們可以把隔離級別設爲RC,或者在第二次查詢前進行COMMIT新起一個事務。

Flask-SQLAlchemy的自動提交

前面還遺留一個問題沒有搞清楚:在一個新的API開始時,之前“緩存”的結果似乎被清除了。由於打開了SQLALCHEMY_ECHO配置項,我們可以觀察到每次API結束的時候都會自動觸發一次COMMIT,而正是這個自動提交清空了所有的“緩存”。通過查找源代碼,我們發現是下面這段代碼在起作用:

@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

如果配置項SQLALCHEMY_COMMIT_ON_TEARDOWNTrue,那麼首先觸發COMMIT,最後統一執行session.remove()操作,即釋放連接並回滾事務操作。

有意思的是,這個配置項在Flask2.0版本的Changelog中被移除了。

Flask2.0 Changelog

關於刪除的原因,作者在stackoverflow的一個帖子裏進行了說明。這個帖子同時也解釋了爲什麼在我們的生產環境中經常報這個錯誤:
InvalidRequestError: This session is in 'prepared' state; no further SQL can be emitted within this transaction.,而且只有重啓才能解決問題。有興趣的同學可以深入閱讀一下。

總結

在MySQL的同一個事務中,多次查詢同一行的數據得到的結果是相同的,這裏既有SQLAlchemy本身“緩存”結果的原因,也受到數據庫隔離級別的影響。如果要強制讀取最新的結果,最簡單的辦法就是在查詢前手動COMMIT一次。根據這個原則,我們可以再仔細閱讀下自己項目中的代碼,看看會不會有一些隱藏的問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章