🐬

Postgresにおけるトランザクション分離レベルとリード現象

2022/12/21に公開

こんにちは。株式会社TOKIUM・経費精算チームでWebエンジニアをしている城戸です。
こちらの記事でデータベーススペシャリスト試験を受験してきた事について書きました。
今回は、その学習の中で学んだトピックのうち、トランザクション分離レベルについてまとめます。

トランザクション分離レベル、名前だけは聞いたことがありましたが、ずっと理解しようとはせずに流しておりました…笑
が…DBスペシャリストの演習をしていたら、このトピックはバンバン頻出します
恐らく、それだけ重要な概念なのだろうと考え、本記事にまとめました。
この記事が、誰かのお役に立てれば幸いです。

前提知識

まずは、本題に入る前に、データベースに一般的に要求されるACID属性というものについてさらっと紹介します。

  • 原子性 (Atomicity)
    • トランザクション内の操作が全て実行されるか、または全て実行されないかのどちらかになります。中途半端な状態は有り得ないです。つまり、トランザクションはそれ以上細かい単位に分割することができない作業単位であるということです。
  • 一貫性 (Consistency)
    • トランザクションの実行前と後でデータに矛盾がなく整合性が保たれる性質です。
  • 分離性 (Isolation)
    • トランザクション中に行われる操作は他のトランザクションに影響を与えない性質です。つまり、それぞれのトランザクションは分離された状態で操作を行わなければなりません。
  • 永続性 (Durability)
    • トランザクションが完了すると、その処理結果は永続的となります。たとえシステム障害が発生してもデータが失われることがない性質です。

…テキストでまとめると、「当然じゃん!」という感想になってしまうかも知れません…汗
しかし、アプリケーションを運用する中でこれらを実際に実現する事は、とっても難しいのです。
特に分離性の実現は難しく、今回のブログの肝でもあります。複数のトランザクションが同時に実行されると、各々のトランザクションの結果が相互に影響してしまうのです。

トランザクション分離レベルとリード現象 一覧表

では、はじめに今回の記事の結論です。
この時点だとまだまだイメージつかないかも知れませんが、のちほど各リード現象についての説明を通じて明確化してゆきます。

分離レベル/事象 ダーティーリード ファジーリード ファントムリード 直列化異常
READ UNCOMMITED
READ COMMITED Postgresのデフォルト
REPEATABLE READ MySQLでのデフォルト
SERIALIZABLE
他のトランザクションでコミットされていない更新を参照してしまう 他のトランザクションでコミットされた更新を参照してしまう 他のトランザクシンでコミットされた新規作成・削除を参照してしまう 複数のトランザクションを並列実行した結果が直列実行した場合と等価にならない

各リード現象の発生手順

初期状態で、下記の状態であるDBを例にとります。
「商品」テーブルに、みかん/りんごの、2レコードが格納されています。
初期状態

(1) ダーティーリード

他のトランザクションでコミットされていない更新を参照してしまうのが、ダーティーリードです。
下記の④で、コミットされていない「りんご」レコードを読み込んでしまう事です。
Postgresのデフォルトでは発生しません

ダーティリード

(2) ファジーリード

他のトランザクションでコミットされた更新を参照してしまうのが、ファジーリードです。
①と④で、SELECT句の結果が違う事になってしまいます(バナナの数量)。
Postgresのデフォルトでは発生します
(※)ちなみに、最後に触れますがMySQLのデフォルトだと話が違ってきます
ファジーリード

(3) ファントムリード

他のトランザクションでコミットされた新規作成・削除を参照してしまうのが、REPEATABLE READです。
②のSELECTには「りんご」は存在しないのに、④では存在してしまうのです。
Postgresのデフォルトでは発生します
ファントムリード

(4) 直列化異常

複数のトランザクションを並列実行した結果が直列実行した場合と等価にならないのが、直列化異常です。
上記に挙げたケースではいずれも、トランザクションT1とT2が同時に走っているためデータ不整合が発生しておりました。
もしもT1, T2を直列で実行していた場合は、データ不整合は起こっていない事になります。

Postgresにおける各分離レベルの内部処理

(※)TOKIUMではPostgresを取り扱っているためPostgresについてまとめています。MySQLだと事情が少し違うため、ご了承ください

ここでは、上記に紹介したリード現象がどうして起こってしまうのか…Posgresの内部の処理をまとめます。

  • READ UNCOMMITTED
    • スナップショットという概念はなく、コミット前されていないデータをわざわざ見にいきます
  • READ COMMITTED
    • トランザクションの中でSQLが発行されるたびに毎回、最新のDBからスナップショット(DBの魚拓みたいなもの)を作成します。
    • ↑「SQLから結果を取得する度に、所有したテーブル(or レコード)を開放し、他トランザクションによる追加/更新/削除を許します」と言い換える事もできます。
  • REPEATABLE READ
    • トランザクション開始時点で、スナップショットをバージョン付きで確定させますSQLが発行されたら、そのスナップショットを見ます。中でクエリが走った際には、このスナップショットを参照する事でファジーリード及びファントムリードを防ぎます。
    • トランザクションの実行中に別のトランザクションによってレコードが更新されれば、「ERROR: could not serialize access due to concurrent update」のメッセージとともにロールバックします。
    • ↑アプリケーションがこのエラーメッセージを受け取った場合、現在のトランザクションを中止して、トランザクション全体を始めからリトライする必要があります。
  • SERIALIZABLE
    • 強制的にトランザクションを順序付けて処理します(直列化)。それ故に、REPEATABLE READ以下における「トランザクションの実行中に別のトランザクションによってレコードが更新されれば~~」といったケースを考慮せずに済みます。

とある疑問

以上で、ざっとトランザクション分離レベルとリード現象について見てきました。
しかし、僕はここで1つの疑問が生まれました。

それは…
「Postgresのデフォルトではファジーリードやファントムリードが起きてしまう…
ならば、最初からDBのデフォルトの分離レベルをREPEATABLE READ / SERIALIZABLEにしたら良いのでは?」
というものです。

しかし、調査する中で、その案は「運用上の問題orパフォーマンスの問題があるため現実的では無い」と考えました。

分離レベルをREPEATABLE READにした場合:

  • もしもトランザクションT1の実行中にT2で更新が走ってしまった場合、T1はロールバックされます。その際にリトライする機構を作成する必要があります。

分離レベルをSERIALIZABLEにした場合:

  • 各トランザクションの順番まで制御される事で、並列実効性が著しく落ちます( = 常にトランザクションは1つずつしか実行されないことになるので)。こうなった場合、より一層パフォーマンスが極端に悪化する事は容易に難くないのでは、と思います。

データ整合性と速度の2つはトレードオフであり、どちらを取るか?のバランスが重要です。
サービス全体でなくとも、トランザクション単位で分離レベルを変更する事は可能であり、「データ不整合が絶対に許されない」ようなクエリでは分離レベルを上げる、といった運用が適切なのでは、と帰着しました (ありきたりな帰結でスミマセン…汗)。

バックエンドエンジニアとして実務をやる上でトランザクション分離レベルを理解する必要はあるのか?

正直、現状のTOKIUMの業務をする上では意識できなくてもある程度は回るのかな…と思います。
現状のTOKIUMではPostgresのデフォルトであるREAD COMMITTEDで運用しており、分離レベルをREPEATABLE READ以上にしている箇所はログ収集ぐらいです。

Railsでは、下記のように実装する事で関数単位で分離レベルを切り替えることができます。

def perform(hoge, fuga)
  ActiveRecord::Base.transaction(isolation: :serializable) do
    # このブロック内では分離レベル:SERIALIZABLEで処理が走る
  end
end

恐らく、現状のTOKIUMのサービス上では、1つの資源に大してトランザクションが競合する業務がそんなに無いため、ユーザーが普通にサービスを使う限りではこれが原因でデータ不整合が発生する事はさほど発生しないためです(TOKIUM経費精算では大体の業務で、「申請者」「承認者」の2人しか登場人物がおらず、2人が一緒のタイミングで同一の資源を操作するケースは滅多にないです)。

が…もし今後、1つの資源に対して短期間で大量のトランザクションが発生するような業務が発生するとしたら、データ不整合が発生しそうな箇所の勘所をつけ、分離レベルの事を考えつつ設計しないと破綻すると思います。
(例えば、ECサイトにおける在庫管理。0.001秒前に在庫が0になったのにソレが反映されず購入が成功し、在庫が -1になってしまったら相当マズいですよね)

また、単純にデータ不整合が発生して、その原因究明が必要になった際。
その時、トランザクション分離レベルの事をそもそも知っていないと、調査が難航するケースは発生しうるな…と思いました。

そういった意味で、各分離レベルとリード現象について丸暗記している必要はないが、キーワードは知っておく必要があるという感想です。
(※) 逆に、当記事を読んで頂いている皆さまの中で、普段から分離レベルの事を意識して設計/実装しているという方がいれば、是非ともお話を伺いたいです。

参考記事および書籍

いろんな記事を拝見しましたが、結局は公式ドキュメントが一番正確な情報を提供してくれます。
ちなみに、僕が新しい技術を学ぶ際のやり方は、「Qiita等のブログでざっくり概要を掴む→実際に手を動かしてみる→公式ドキュメントを読む」というプロセスです。
エンジニアになってから、「とにかくドキュメントを読め!」と何度も言われてきましたが、いきなり公式ドキュメントは個人的にはしんどいため、まずはQiita等のブログで概要をざっくり掴む、というのが僕が新しい技術を学ぶ際のやり方です (賛否両論ありそうですが…)。

https://www.postgresql.jp/document/13/html/mvcc.html
https://www.shoeisha.co.jp/book/detail/9784798174907
https://qiita.com/song_ss/items/38e514b05e9dabae3bdb#read-committed

今回はPostgresに焦点を絞ったため触れませんでしたが、MySQLのトランザクション分離レベルについても調べて頂くと理解がより深まるかと思います。
キーワードは下記のあたりになると思います(僕も殆ど理解できていないので勉強します…)

  • MySQLのデフォルトは、REPEATABLE READ
  • ロストアップデート
  • ネクストキーロックとデッドロック
  • Postgresと比較した、スナップショットの取り扱いの違い

さいごに

私は普段Railsで開発をしており、今回の箇所は正直なところ普段あまり意識できていない所でした。
しかし、調べてみると個人的に非常に学びが多く、いちど本腰を入れて学んでみてよかったな、という感想です。
こうして、ActiveRecordを普段扱っているだけでは意識できない事について学ぶ事でSQL/DB力が底上げされ、自身の成長に繋がると考えます。

TOKIUMでは、設計やDBに関して高い技術と意欲をもったメンバーが多く、成長できる環境は充分にあります。
この記事で、TOKIUMに興味を持って頂いた方がいれば、カジュアル面談までお越し頂ければ幸いです。
また、記事にいいね!を頂けるととても喜びます。
読んで頂き、ありがとうございました。

株式会社TOKIUM テックブログ

Discussion