App Engine 中的交易隔離

Max Ross

根據 Wikipedia 的說明,資料庫管理系統的隔離等級「定義一個作業所做的變更,何時/如何對其他並行作業顯示」。本文旨在說明 App Engine 所用 Cloud Datastore 中的查詢交易隔離。閱讀本文後,您應該會更瞭解交易內外的並行讀取和寫入作業行為。

交易內部:可序列化

從最強到最弱,四個隔離等級分別是可序列化、可重複讀取、已修訂的讀取作業,以及未修訂的讀取作業。Datastore 交易符合可序列化隔離等級。每筆交易都會與所有其他 Datastore 交易和作業完全隔離。系統會依序執行特定實體群組的交易。

詳情請參閱交易說明文件的「隔離和一致性」一節,以及維基百科的快照隔離文章。

外部交易:已修訂的讀取作業

交易外部的 Datastore 作業最接近「已提交讀取」隔離等級。透過查詢或取得作業從資料儲存區擷取的實體,只會看到已提交的資料。擷取的實體絕不會有部分已提交的資料 (部分來自提交前,部分來自提交後)。不過,查詢和交易之間的互動比較細微,如要瞭解這點,我們需要更深入地探討提交程序。

提交程序

如果系統成功傳回修訂內容,保證會套用交易,但這不代表讀取者會立即看到寫入結果。套用交易包含兩個里程碑:

  • 里程碑 A:實體變更套用的時間點
  • 里程碑 B:系統已套用該實體的指數變更

顯示從提交交易到可見實體變更,再到可見實體和索引變更的進度箭頭。

在 Cloud Datastore 中,交易通常會在提交作業傳回後的幾百毫秒內完全套用。不過,即使未完全套用,後續的讀取、寫入和祖系查詢作業一律會反映提交結果,因為這些作業會在執行前套用任何未完成的修改。不過,涵蓋多個實體群組的查詢無法在執行前判斷是否有任何待處理的修改,因此可能會傳回過時或部分套用的結果。

在里程碑 A 之後,如果要求按鍵查詢更新的實體,一定會看到該實體的最新版本。不過,如果並行要求執行的查詢,其述詞 (SQL/GQL 粉絲的 WHERE 子句) 不符合更新前的實體,但符合更新後的實體,則只有在查詢於套用作業達到里程碑 B 後執行,實體才會成為結果集的一部分。

換句話說,在短暫的期間內,結果集可能不會包含屬性 (根據按鍵查詢結果) 滿足查詢述詞的實體。此外,如果實體的屬性 (同樣根據索引鍵的查閱結果) 無法滿足查詢述詞,結果集也可能包含該實體。查詢作業無法在決定要傳回哪些實體時,將里程碑 A 和里程碑 B 之間的交易納入考量。這項作業會針對過時資料執行,但對傳回的金鑰執行 get() 作業時,一律會取得該實體的最新版本。也就是說,您可能會錯過與查詢相符的結果,或在取得相應實體後,得到不相符的結果。

在某些情況下,系統保證會在執行查詢前完全套用所有待處理的修改,例如 Cloud Datastore 中的任何祖先查詢。在這種情況下,查詢結果一律會是最新且一致的。

範例

我們已概略說明並行更新和查詢的互動方式,但如果您和我一樣,通常會透過具體範例較容易瞭解這些概念,我們來看看幾個例子。我們會先從簡單的範例開始,然後再介紹更有趣的範例。

假設我們有一個應用程式會儲存 Person 實體。Person 具有下列屬性:

  • 名稱
  • 高度

此應用程式支援下列作業:

  • updatePerson()
  • getTallPeople(),傳回所有身高超過 72 英寸的人。

我們在資料儲存庫中有 2 個 Person 實體:

  • Adam,68 吋高。
  • Bob,73 吋高。

範例 1 - 讓 Adam 更高

假設應用程式在幾乎同一時間收到兩項要求。第一個要求將 Adam 的身高從 68 吋更新為 74 吋。 成長速度加快!第二個要求會呼叫 getTallPeople()。getTallPeople() 會傳回什麼?

答案取決於由要求 1 觸發的兩個提交里程碑,以及由要求 2 執行的 getTallPeople() 查詢之間的關係。假設其關聯性如下:

  • 要求 1,put()
  • 要求 2,getTallPeople()
  • 要求 1,put()-->commit()
  • 要求 1、put()-->commit()-->里程碑 A
  • 要求 1,put()-->commit()-->里程碑 B

在此情境中,getTallPeople() 只會傳回「Bob」。為什麼?因為增加 Adam 身高的更新尚未提交,所以我們在要求 2 中發出的查詢還看不到這項變更。

現在,假設其關聯性如下:

  • 要求 1,put()
  • 要求 1,put()-->commit()
  • 要求 1、put()-->commit()-->里程碑 A
  • 要求 2,getTallPeople()
  • 要求 1,put()-->commit()-->里程碑 B

在這個情境中,查詢會在「要求 1」達到里程碑 B 之前執行,因此系統尚未套用 Person 索引的更新。因此, getTallPeople() 只會傳回 Bob。以下是結果集的範例,其中排除屬性符合查詢述詞的實體。

範例 2 - 縮短 Bob 的長度 (抱歉,Bob)

在本範例中,我們會讓要求 1 執行其他動作。系統不會將 Adam 的身高從 68 吋增加到 74 吋,而是將 Bob 的身高從 73 吋減少到 65 吋。再次強調, getTallPeople()

return?
  • 要求 1,put()
  • 要求 2,getTallPeople()
  • 要求 1,put()-->commit()
  • 要求 1、put()-->commit()-->里程碑 A
  • 要求 1,put()-->commit()-->里程碑 B

在此情境中,getTallPeople() 只會傳回 Bob。為什麼?因為系統尚未提交 Bob 身高變矮的更新,所以我們在要求 2 中發出的查詢還看不到這項變更。

現在,假設其關聯性如下:

  • 要求 1,put()
  • 要求 1,put()-->commit()
  • 要求 1、put()-->commit()-->里程碑 A
  • 要求 1,put()-->commit()-->里程碑 B
  • 要求 2,getTallPeople()

在這種情況下,getTallPeople() 不會傳回任何項目。為什麼?因為在我們於要求 2 中發出查詢時,系統已提交將 Bob 高度調低的更新。

現在,假設其關聯性如下:

  • 要求 1,put()
  • 要求 1,put()-->commit()
  • 要求 1、put()-->commit()-->里程碑 A
  • 要求 2,getTallPeople()
  • 要求 1,put()-->commit()-->里程碑 B

在這個情境中,查詢會在里程碑 B 之前執行,因此系統尚未套用 Person 索引的更新。因此, getTallPeople() 仍會傳回 Bob,但傳回的 Person 實體高度屬性是更新後的值:65。以下是結果集的範例,其中包含屬性無法滿足查詢述詞的實體。

結論

如上述範例所示,Cloud Datastore 的交易隔離等級相當接近「已修訂的讀取作業」。當然,兩者之間存在顯著差異,但現在您已瞭解這些差異和背後原因,應該能更妥善地在應用程式中做出與資料儲存區相關的設計決策。