テックキャンプ卒の弱々エンジニア日記

エンジニアとして働く中での学びをちょっとでも記録していきます。

【Rails】トランザクションとロックの関係、今更ながらまとめてみた。

こんにちは、Tochiです。 最近忙しくてあまり更新できておらず、久しぶりの更新です。 先日業務しているとき、「トランザクションをしておけば行ロックって意識しなくてもいいのかな?それともDBによるのかな??」「そもそもトランザクションとロックって別物だよな・・ 」といった感じで混乱しました。 なので今更ながら、トランザクションとロックの関係についてまとめていきたいと思います。

はじめに

題名にRailsと書いていますが、実際はDBMSの話です、、笑

ただ、トランザクションやロックの話をRailsから入るとイマイチ理解なく、 なんとなくで実装できてしまいます笑

なので基礎の基礎から理解していこうと思います。

参考

www.postgresql.jp

トランザクションとは

トランザクションとは、関連する複数の処理を1つの処理として実行・管理する仕組みで、 DBに要求される以下4つの特性を維持する仕組みです。

  • 原子性(Atomicity)
    • トランザクションは「すべて実行される」か「一つも実行されない」のどちらかの状態になる
  • 一貫性(Consistency)
    • 実行前と実行後のデータベースのデータに矛盾を生じさせない。
  • 独立性(Isolation)
  • 永続性(Durability)
    • トランザクション完了後、その結果は記録される。ハードウェアに障害が生じても、処理結果はデータベースに永続的に保存される

めっちゃ簡単に言えば、データ不整合が起きないようにする仕組みですねということです。

トランザクションの分離とは?

トランザクションには4つの分離レベルがあります(※ Postgresを前提としています) この分離レベルは、それぞれのレベルによって禁止される現象(リード現象)が異なります。

この分離レベルを理解するには、大前提にどんな禁止される現象が発生するかを確認しておくと良さそうです。

リード現象の種類

ダーティーリード

ダーティーリードとは、同時に実行されている他のトランザクションが書き込んで未だコミットしていないデータを読み込んでしまう現象です。

下の例でいうと、トランザクション2はコミットせずにロールバックしたはずのUPDATEの内容を、トランザクション1が読み込んでしまい、登録されてない「オレンジ」を読み込んでしまいます。

ダーティーリード

反復不能読み取り(ファジーリード・ノンリピータブルリード)

トランザクションが、以前読み込んだデータを再度読み込み、そのデータが(最初の読み込みの後にコミットした)別のトランザクションによって更新されたことを見出してしまう現象です。

反復不能読み取り

図を見ていると、なんだか正しい挙動のように思えます。 ただ、最初の冒頭で記載したトランザクションの定義にあった 「関連する複数の処理を1つの処理として」処理するということを考えると、その1つの処理の中で読み取った結果が違うということはバグの温床になるというのが直感的に理解できます。

たとえば、銀行口座で「残高表示→出金→残高表示」のトランザクションがあったときに 途中で誰かから入金があったときに出金したのに増えている!!!なんてこともありうるわけです

ファントムリード

トランザクションが、複数行のある集合を返す検索条件で問い合わせを再実行した時、別のトランザクションがコミットしてしまったために、同じ検索条件で問い合わせを実行しても異なる結果を得てしまう現象です。

ファントムリード

こちらは、反復不能読み取りにかなり似ています。 ただし、既存レコードの変更であるか新規レコードが追加されたかの違いがあります。

ファントムリードが起きてしまうと、在庫管理などで「もうりんごしかないので発注せねば!」と思っていたのに、「さっきん発注したのに、なぜかオレンジ増えている!」といった混乱を生みます。

直列化異常

複数のトランザクションを並行して行なった結果と、1つずつ行なった場合とで結果が異なる場合を言います。 上記の例ではすべて並行にした場合と直列にした場合で結果が異なります。

分離レベルについて

さて、ようやく分離レベルの話に戻ってきました。

今あげたリード現象をどの程度許容するかを決定するのがトランザクション分離レベルです。

Postgresの分離レベルは以下の通りになっています

13.2. トランザクションの分離

分離レベル表

Railsにおいてのデフォルトは、リードコミッティッドになります。

ロックについて

一旦トランザクションがわかったところで、続いてロックについて調べてみたいと思います。

Postgresのドキュメントを眺めていると以下のようなロックがあることがわかります。

  • テーブルロック

  • 行ロックロック

  • ページレベルロック

    • テーブルは物理的には一つのファイルであり、8Kバイトのブロック単位で分割されて管理されます。データが増えてブロック内に保存しきれなくなると、ファイルの末尾にブロックを追加してファイルサイズを増やします。このブロックをページと呼びます。

テーブルロックを使う時ってあんまりなさそうだなあと思いつつも MySQLだとギャップロックによるデッドロックの回避方法の1つと提案されていたりしそうです。

(参考: 「トランザクション張っておけば大丈夫」と思ってませんか? バグの温床になる、よくある実装パターン)

アプリ開発する上では圧倒的に行レベルロックを使うことが多いように思います。

DBの世界では厳密に細かいロックの種類が色々あるのですが、 後述するlockメソッドなどは基本的にSELECT FOR UPDATEになり最も強いロックがデフォルトになるのでその他のSELECT FOR SHAREなどといったモードはあまり考えなくても大丈夫なようです。 (どんなときにFOR SHAEを使ったりするのか知りたい。。)

Railsにおけるロック

Railsにおけるロックは、

  • lock
  • lock!
  • with_lock

で実装されます。

これらは若干の使い方の違いがあれど、やっていることは同じです。

# lockメソッド
ActiveRecord::Base.transaction do
  User.lock.find(user_id)
end


# lock!メソッド
user = User.find(user_id)
ActiveRecord::Base.transaction do
  user.lock!
end

# with_lock
user.with_lock do # トランザクション作成&ロック取得
  # 処理
end

すでに述べているように、このlockメソッドで発行されるSQLは最も強いSELECT FOR UPDATEになります。

TRANSACTION (0.2ms)  BEGIN
User Load (0.3ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 FOR UPDATE
TRANSACTION (0.1ms)  COMMIT

このロックは、トランザクションの終了時に自動で開放されます。

デッドロックについて

これらのロックを明示的に使う場合は、デッドロックを起きる可能性が高まります。 しかし、Postgresの場合デッドロックを自動検知してくれて、関係するトランザクションの1つを中止してくれます。

そのためデッドロックによってプログラムが完全停止するといったことは通常はないです。

まとめ

トランザクションとロックの関係に混乱していた私ですが、大体理解できました。

まとめとしては、

  • トランザクションをしておけばいいというわけではない
    • 特に分離レベルによっては、得た結果が必ずしも高い信頼があるものではない
  • トランザクションを使えば、安全な更新ができるわけではない
    • トランザクションはACID特性を保持するものであって、予期せぬ結果を生まないようにするにはロックを使うのが確実

といった感じでしょうか。

普段あまり意識せずなんとなくでやらないように気をつけたいです