sait0sのブログ

技術で広く浅く遊んでいる、エンジニアの真似事をしている学生です

複雑なトランザクションにおける排他制御

ここでは、トランザクションの中でも、複雑なトランザクションの実装技法について取り上げたいと思います。そのため、通常のマニュアルトランザクションや分離性の解説は省略してあります。

 

トランザクションの種類

 

対話型トランザクション

業務アプリケーションでは、何らかの対話型処理が含まれており、対話処理の前後に排他制御を要求するものがある。例えば、チケット発券システムである。

  1. 空きの座席一覧を表示
  2. 予約したい座席(座席sとする)を指定する
  3. お金を支払う
  4. チケットを 発券する

このようなシステムの場合、トランザクション制御を行わないと、2~4までの座席を確定してチケット発券までの間に座席sを他のトランザクションによる「後勝ち」が発生してしまう。

対話型トランザクションの対処法

  • 排他制御を行わず、運用でカバーする
  • 業務排他制御機能を作りこむ
  • 楽観同時実行制御を行う

排他制御を行わず、運用でカバーする

後勝ちが発生する要因としては、同時に複数のトランザクションが発生しうる状況だからである。そのため、チケット発券が一つの窓口でしか行えないようなサービスであればトランザクション制御の必要はない。ただし、これは現実的ではない。

業務排他制御機能を作りこむ

業務排他制御は、対話型トランザクションの最中にAP側が上手く排他制御を行って後勝ちを回避させる方法である。

座席ID 編集状態
s ユーザA
t なし

ユーザAが2~4のチケット発券の手続きの最中に他のユーザが座席sを予約しようとすると、AP側で検知して座席を保護することができる。実際には、この他にも編集中や予約済み、排他有効期間などの状態データを記録しなければならない。ただし、これにはデメリットもある。

  1. 障害が発生したときに原子性や一貫性が失われる可能性がある
  2. クライアントの操作やタイムアウトの取り扱い
  3. トランザクションの衝突確率が低い場合、コストが高くなる

①はチケットの発券途中で何かしらの障害が発生したときに、チケットの発券が正常に行われずに終了したときに状態フラグは編集中のままで復旧されてしまう。この場合、チケットが発券されていないにもかかわらず編集中になっているため、原子性や一貫性に違反している。そうならないためには、障害発生時に状態フラグを修正するようなリカバリ手続きを業務側で作りこまなければならない。これは、業務排他機能が複雑になるほどリカバリも複雑になる。

②はブラウザを誤って終了させたときやチケット発券までのタイムアウトになった時に状態フラグを元に戻さなければならない。座席予約システムのような即売を必要とするタイプの商品の場合、すぐに再度販売をかけることが望ましい。そのための制御を業務側で作る必要が出てくる。また、むやみに制御を行うとクライアントにとって使い勝手の悪いアプリケーションになってしまう可能性もあるため注意しなければならない。

最後、③はトランザクション量が少なく、衝突はめったに起こらないようなシステムの場合にはコストに見合わなくなってしまう。

楽観同時実行制御を行う

トランザクション量が少なく、衝突確率も低いようなシステムかつ「後勝ち」だけは避けたいというような場合に用いられる。楽観同時実行制御は、実際にデータが更新されるタイミングで、他のユーザによる更新が行われたか確認される。

座席ID 予約フラグ 最終更新
s FALSE 20240101T10:00:00.000
t TRUE 20231231T09:30:00.000
UPDATE sheet
SET reserve_flag = TRUE
WHERE id = s AND reserve_flag = FALSE AND last_update_at = 20240101T10:00:00.000;

 更新時に更新前の値と照合するようなバージョン管理をすることで、後勝ちを検出して回避することができる。ただし、後勝ちの検出精度を高めるには、更新クエリのWHERE句に一意なIDとtimestampのようなバージョンを指定する必要がある。ただし、楽観同時実行制御は、JDBCや.NETなどのフレームワークやライブラリによっては自動でサポートするものもあるため、複雑になりにくく、実装が容易である。一方でデメリットもある。

  1. トランザクション量が多い場合、衝突が頻発してスループットやユーザ体験が悪くなる

業務排他トランザクションと比べて、レコードを更新するまでバージョンが正しいかわからず、トランザクション量が多いと全てブロックされてしまう。また、レコードの編集中でも該当レコードを取得できてしまう問題もある。

 

バッチ型トランザクション

まず初めに、今までのトランザクション指向処理とバッチ処理の違いについてまとめる。

  トランザクション指向処理 バッチ処理
実施タイミング ユーザ要求から即時 日次、月次など
時間帯 日中のオンラインの時間帯 夜間などのオフラインの時間帯
一度に取り扱うデータ 少量 大量
同時に並行実行される処理の数 多数 小数
一貫性の保証 トランザクション制御によりACID性を保証 特に行わず、リランなどにより対処

 

オフラインバッチ

オフラインバッチでは、バッチ処理がデータベースを占有するため物理的な排他制御が不要になる。そして、バッチ開始時点からトランザクションをかければエラー発生時でもロールバックするだけでよい。ただし、注意点がある。

  1. 膨大なデータを扱うバッチ処理ではアボートは使えない
  2. オンライン時間までにバッチ処理が終了するように見積もらなければならない

①は処理の開始時点でバックアップをとっておき、エラー発生時にはリストアすることで対応する。また、チェックポイントやミニバッチ、頻繁なコミットなどの方法もある。

②はオンライン時間帯に間に合うように十分なリラン時間を確保しておく必要がある。バッチ処理では対象データを1件ずつ順番に処理していくため、データ量によって要する時間が大きく異なる。処理時間を十分に確保できない場合は、バッチ処理を何かしらの条件で分類して並列実行させる。

オンラインバッチ

そもそもオフライン時間帯がないシステムも多く、オフラインバッチと同じ考え方で実装できないこともある。そこで、業務の重要性や求められる即時性などからいくつかのサブシステムやDWHを作り、日時や週次単位でデータを複製する。分析業務や印刷業務であれば複製で問題ないが、オリジナルのデータを用いて行わなければならないときもある。その場合、バッチ処理が長時間データをブロックしないように設計しなければならない。

  1. 一括処理への修正
  2. ミニバッチ
  3. サーガ

一括処理への修正

具体的には、一括した更新を行うことでラウンドトリップ回数を少なくすることができる。

/* レコード指向処理 */
SELECT price FROM item WHERE id = '1';
UPDATE item SET price = 2000 WHERE id = '1';
        :

/* 一括処理 */
UPDATE item SET price = price + 1;

ただし、業務ロジックが比較的単純であり、他のトランザクションによるデータの矛盾とブロッキングが起こらない場合に限る。特に、一括更新する場合は、SQLクエリやデータベースによってはロック粒度が大きくなる可能性がある。

ミニバッチ

データ件数が多い場合には、バッチ処理を細分化して逐次実行していく必要がある。仮にデータ更新中にシステムがクラッシュしても、進捗管理テーブルを基にリスタートできる。

/* ミニバッチで10件ずつ更新 */
SELECT price FROM item WHERE id BETWEEN '1' AND '10';
        (更新処理)
SELECT price FROM item WHERE id BETWEEN '11' AND '20';
        (更新処理)
           :

しかし、以下のような注意点もある。

  • バッチ処理の対象データの隔離性が保証されないため、バッチ処理時に他のトランザクションによってデータが更新されている可能性がある
  • パフォーマンスやスケジュールの観点からミニバッチサイズのチューニングや設計を考慮しなければならない
  • システム障害時にバッチ処理が必ず完了する保証はなく、既にコミットしたものはロールバックできない

サーガ

ミニバッチによって一方的に進められた処理は、何かしらのエラーが発生したときのロールバックを想定していない。ロールバックの際には、補償トランザクションによって解決する。ただし、補償トランザクションは自力で実装しなければならず、ミニバッチ処理の設計に大きく依存する。加えて、補償トランザクション中に起きたトラブルや進捗管理に関する設計も考慮しなければならない。

 

キュー型トランザクション

キュー型トランザクションモデルでは、クライアントとサーバ間に永続性を持つリクエストキューやレスポンスキューを挟み込んで一連の処理を行う。そのため、キューを永続化させるために永続ストレージ上に構築し、アプリケーションプロセスに障害が起きても処理データを生き残らせる。以下の手順で行われる。

  1. クライアントのリクエストキューの処理内容を一旦書き込み、コミットさせる
  2. サーバ側で定期的にリクエストキューを取り出して処理し、レスポンスキューへ書き込む
  3. クライアントは定期的にレスポンスキューを確認し、該当のキューがあれば結果を表示させるなどの処理を行う

これにより、各種システム障害に対する耐性やトランザクション内の一部処理の切り離しによって処理効率の向上が期待できる。また、キューからのメッセージの取り出しを工夫することで、優先制御や流量制御などのスケジューリングも可能になる。一方で、以下のようなデメリットもある。

  • キュー型トランザクションモデルを取れる業務は少ない
  • 平常時の処理速度や処理結果の受け取り遅延に配慮する必要がある
  • 明示的なキュー操作が必要になるため、設計が複雑になりやすい

キュー型トランザクションはリアルタイムで結果を受け取ることができず、対話的な性格の強い業務アプリケーションでは頻繁に直接型トランザクションによるサーバ呼び出しが発生することになる。そして、単純な読み書き処理ではほとんどメリットがない。

キュー型トランザクションでの設計課題

キュー型トランザクションモデルの課題としては以下のものがある。

  1. 効率的に動作するリスナーアプリケーションの設計
  2. バックエンドサービス障害時のリトライ制御
  3. 処理順序逆転に対するアプリケーション設計の工夫
  4. ポイズンメッセージ対策
  5. サーバ側の非同期処理の確認・受け取り方法
  6. メッセージキューの障害時のリカバリやセキュリティ

① リスナーアプリケーション*1は、マルチプロセスまたはマルチスレッドで動作させなければならないことが多く、キューのポーリング間隔や負荷、リスナーアプリケーションそのものの多重制御や監視も考慮しなければならない

② 外部のバックエンドサービスが停止していた場合、リスナーアプリケーションの処理は必ず失敗するため、キューを書き戻して処理を再試行しなければならない。しかし、失敗直後にリトライしても再び失敗する可能性が高いため、ある程度時間間隔を置きながらリトライを行う必要がある。

③ 一般的にキューはFIFOで動作する。しかし、リスナーアプリケーションはマルチスレッドやマルチプロセスで動作するためキュー型トランザクションは必ずしも完全なFIFOにならないことが多い。そのため、キューの格納順序や実行順序が変わっても不具合が起こらないようにしなければならない。

④ 仮にクライアントが投入したキューそのものに誤りがあったとする。リスナーアプリケーションでエラーが発生して何度もリトライされるが、決して成功しない。このようなポイズンメッセージを検知することは難しいため、ある一定回数リトライしても成功しないときはそれ以上再試行しないなどの工夫が必要である。

⑤ クライアントがレスポンスキューを適宜確認すれば簡単ではあるが、リスナーアプリケーションの処理が終了したことをクライアントに通知しなければならないときが問題になる。

⑥ 障害発生時のリカバリやセキュリティが手薄になることが多いが、無視してはならないポイントでもある。リカバリに関しては、クラッシュしたことて処理するべきデータが不明となってしまったときはデータベースに記録された内容から復元することで対応できる。セキュリティに関しても、インフラ設計の工夫で改善できることも多い。

 

トランザクションの冪等性と再試行

複雑な短時間トランザクションを上手く設計するうえで重要なことが「冪等性」と「再試行」である。例えば、注文受付完了のメール配信処理を考えてみる。メール配信時に何かしらのエラーが発生したときは念のため再試行されるが、再試行によって重複してメールが届いてしまう可能性がある。それは冪等性ではない。ある処理をn回繰り返しても実質的に同じ1回行ったことになければならない。そのため、まず先方で処理が正しく行われたか確認してから再試行する必要がある。

*1:リクエストキューのデータを取り出して処理を行うアプリケーション