問題描述
最近在排查一個問題,爲了方便說明,我們假設現在有如下一個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_TEARDOWN
爲True
,那麼首先觸發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
一次。根據這個原則,我們可以再仔細閱讀下自己項目中的代碼,看看會不會有一些隱藏的問題。