Lazy Diary @ Hatena Blog

PowerShell / Java / miscellaneous things about software development, Tips & Gochas. CC BY-SA 4.0/Apache License 2.0

Webアプリケーションにおける誤った悲観ロックの実装方法(Q&A形式)

※(2024/02/11 追記)本記事内で「悲観排他」「悲観ロック」と表記がゆれているが、いずれも悲観的ロック(optimistic locking)を使った排他制御(mutual exclusion)のことを意味している。

Q1: ググると「悲観排他の実装にはSELECT FOR UPDATEを使え」と書いてある記事*1と、それじゃうまくいかないと書いてある記事があるけど、どっちが正しいの?

A1: よくあるJavaによる3層型Webアプリケーションで悲観ロックにDBMSの行ロック(OracleならSELECT FOR UPDATE)を使うことはまず不可能と思われる。

なお、ユーザの画面操作中もDBのトランザクションを継続できる2層型C/Sアプリケーションなら行ロックを使って悲観ロックを実装することもできる。この場合、SELECT FOR UPDATEにはNOWAITオプションを付けないと排他解放待ちでプログラムが長時間停止してしまう可能性があるので注意。

Q2: Javaの3層型Webアプリケーションで悲観ロックにDBMSの行ロックを使えないのはなぜ?

A2: 通常、HTTPリクエストの単位をまたいでDBのトランザクションを保持しないため。 たとえば、Spring Frameworkなど宣言的トランザクション制御を採用したフレームワークでは特定のメソッドがトランザクションルートになるため、開始したトランザクションを決着させないままHTTPレスポンスを返すことができない。そのため、更新画面を表示するボタンが押されてHTTPリクエストを受け付けたときに更新対象行の行ロックを獲得しても、更新画面のHTMLをレスポンスで返すときにはその行ロックは解放されてしまう。

Q3: じゃあ、宣言的トランザクション制御を使わなければ、行ロックを使って悲観ロックを実装できるの?

A3: トランザクションの開始と終了を手続き的に行えばトランザクションを決着せずHTTPレスポンスを返すこと自体は不可能ではないと思う。ただし、なんらかの形でHTTPクライアントとDBのコネクションを紐づけておく方法が必要になるので、以下のような落とし穴ができるはず。

  • 同一のクライアント端末上でWebブラウザのウィンドウを複数開いて操作することができない(どのHTTPリクエストとどのDBコネクションを紐付けたらいいかわからなくなるため)
  • 同一クライアント端末から同時並行してHTTPリクエストを送ることで、DBのコネクションを枯渇させられてしまう
  • Webブラウザの操作終了をWebサーバ側で検知できないので、ブラウザのxボタンを押下するユーザーが多いとDBのコネクションが容易に枯渇する

Q4: 逆に、2層型C/Sシステムで悲観ロックに行ロックを使っても問題にならないのはなぜ?

A4: 以下の理由から、クライアントアプリケーションからDBMSへのコネクション数の上限を容易に制御可能だからだと思われる。

  • クライアントアプリケーションをインストールする端末数の上限が決まっている(ことが多い)ため
  • クライアント端末上で同時起動抑止の仕組みを実装している(ことが多い)ため
  • クライアントアプリケーションの終了時にDBのコネクションの解放処理を行えるため

Q5: じゃあ、なんでこんなに「悲観ロックにSELECT FOR UPDATEを使え」という記事がいっぱいあるの?

A5: だいぶ想像が入るが、おそらく昔C/S型アプリケーションを開発していたころに悲観排他処理について知り、その後悲観排他を使った実装をWebアプリケーションで行ったことがなかったので知識がアップデートされていない……とかではないだろうか。

Q6: じゃぁ、もともとは2層型C/Sアプリケーションで、そこからマイグレーションした3層型Webアプリケーションなら、悲観ロックに行ロックを使っても問題ないの?

A6: 3層型Webアプリケーションになったときに、おそらく画面表示の方法の見直しが行われているはず。なので、行ロックをそのまま使うことはできないと思われる*2

Q7: 悲観排他を使った方がいい条件に「やり直しが難しい」ってあるけど*3、これは何?

A7: たとえば、更新画面の入力と連動して業務的・物理的なワークフローを進めてしまう場合が考えられる。更新画面を開いている間に工場の生産ラインを始動して上手く動きだしたことを確認したら更新ボタンを押すとか、更新ボタンを押すと同時に書類にハンコを押すとか。

(2024/02/11 追記)他には、DBのトランザクションに合わせて「なかったこと」にできない処理が挙げられる。これは「Webアプリケーションにおける誤った楽観ロックの実装方法(Q&A形式)」の方に追記した。

satob.hatenablog.com

Q8: 悲観排他をするには、ロック対象のテーブルに「ロック中のユーザID」とかのカラム*4を追加したらいいの?

A8: 「テーブルに『ロック中のユーザー』というカラムを追加する」という方法は、テーブルの追加も不要なのでやってしまいがちなのだが、これは実装方法によってはTOCTOUの問題で複数ユーザーが更新画面に入ってしまう可能性がある。

「更新画面に入るときにロック中のユーザーを記録して、更新処理を行うときに自分がロック中かチェックする。チェックに失敗したらエラーとして更新画面から出る」という方法をとっていれば、更新が後勝ちになるだけでDBのデータが論理破壊*5されることはないと思われる。悲観ロックを採用した理由が「時間が長くかかる業務」というだけで、競合の発生確率を減らしたいというくらいならこのくらいの方法でもまぁいいかもしれない。一方で「やり直しが難しい」という理由で悲観排他を採用していた場合には、そもそも防ぎたい事象を防げていないことになる。

Q9: 悲観排他でDBにロック状態を保存して、かつTOCTOUが起こらないようにするにはどうしたらいいの?

A9: ロック状態を記録する専用のテーブル(以降、ロックテーブルと呼ぶ)を作る必要がある。基本的に1テーブルにつきロックテーブルを1つ作り、ロックテーブルにはロック対象のテーブルの主キーと同じカラムを設け、それらのカラムに一意性制約をつける*6*7。更新画面を表示するリクエストを受け付けたらロックテーブルにINSERTを行い、正常にINSERTができた場合のみ更新画面を表示する。この方法では、DBMSの一意性制約を利用してロック状態のチェックと記録を1つのSQLでアトミックに行う*8ことで、TOCTOUの発生を防いでいる。なおこの場合に一意性制約違反でINSERTがエラーになった場合は、システムエラーでなく業務エラーとする必要があることに注意。

Q10: 悲観排他用に「ロック中のユーザID」のカラムを追加する方針で開発を進めちゃったんだけど、TOCTOUを防ぐ方法はないの?

A10: たとえば、更新画面に入るときのロック状態のチェックと記録をアトミックに行えるように、アプリケーションの処理を直列化する*9。ただし、ロックテーブルを使う方法と比べて、synchronizedが原因で性能が低下する可能性が高いのと、アプリケーション*10をまたがって排他ロックを行う場合には使えないことに注意。

Q11: 悲観ロックってロック状態の持たせかたが楽観ロックと違うだけで、ロックテーブルへのINSERTは更新ボタンを押したときでいいんでしょ?(2024/02/11 追記)

A11: 更新ボタンを押したときではだめで、更新ボタンのある画面を表示する時点で取得しておく必要がある。DBMSにもよるかもしれないが、トランザクションAとトランザクションBとがロックテーブルに同時にINSERTをした場合、どちらかのトランザクションで一意性制約エラーが発生するのはコミット時になる。なので、トランザクションの最中に紙への印刷など「やり直しが難しい」「なかったことにできない」処理を行っていると、たとえば印刷は正常に完了してしまったのにトランザクションはエラーになるという状況が発生する。

*1:例: https://qiita.com/NagaokaKenichi/items/73040df85b7bd4e9ecfc , https://medium-company.com/%E6%82%B2%E8%A6%B3%E3%83%AD%E3%83%83%E3%82%AF/

*2:他にも3層型Webアプリケーションで使えない処理は見直されているはず。たとえば、「処理中にユーザに確認ダイアログを表示する」など。

*3:https://qiita.com/NagaokaKenichi/items/73040df85b7bd4e9ecfc

*4:https://rainbow-engine.com/optimistic_lock_pessimistic_lock/

*5:アプリケーションのログにはレコードをAさん→Bさん→Cさんという順で更新した記録が残っているが、DBを見るとAさん→Cさんの順に更新したように見えBさんが更新したはずの内容は消えている等、DBMS的には何らおかしいところはないが業務要件から見ると矛盾が発生している状態。

*6:単に主キーを同じにするのでもよい。

*7:ここではテーブルの主キーがナチュラルキーである前提で説明している。サロゲートキーを使っている場合で、特にレコードの更新に伴い主キーが変わる可能性がある場合にこれで上手く行くかはやったことがないのでわからない。

*8:cf. テスト・アンド・セット

*9:Javaであればsynchronizedブロック内でまとめて処理を行う

*10:JavaであればWARファイル