ユニファ開発者ブログ

ユニファ株式会社システム開発部メンバーによるブログです。

もしデータベースのトランザクションが使えなかったら

こんにちは、システム開発部のちょうです。

毎日バックエンドの開発に一番馴染みのあるものがデータベース、そしていろんな機能はトランザクションベースで開発されたのです。トランザクションはデータベース基本の機能で、トランザクションなしにはデータベースだと言えないぐらい重要です。でももしトランザクションが使えなくなったら、みんな開発できなくなるでしょうか。答えはNoです。実際、

  • トランザクションだけで対応できない
  • データベースにトランザクション機能がない
  • 分散システムでトランザクションが使えない

ようなケースは珍しくありません。

0x01

例えば、ある集計用のテーブルにデータをキー別にカウンターされます。

データ集計(キー(PK)、カウンター)

データが来ると、既存があるかどうかによって、既存データを更新したり、新しいデータを追加したりします。

record = SELECT カウンター FROM データ集計 WHERE キー = ?
if record exists
  UPDATE データ集計 SET カウンター = record.カウンター + 1 WHERE キー = ?
else
  INSERT INTO データ集計 VALUES (キー、1)

このプログラムの一番大きな問題は同時にデータが来るとデータがどうなるかを考慮していないことです。 もし新規データ2つ同時に来ると、まず同じキーにより、すこし遅れたほうが追加できなくなり、エラーで終わります。 既存のデータはエラーにならないですが、タイミングによって、カウンターは増えない可能性があります。

このプログラムをトランザクションに入れても、問題は解決されません。 一テーブル、一レコードなので、トランザクションだけだととくに何もできないです。

新規データ1つ以上に対して、キーの制限によるエラーをキャッチアップしてリトライするべきです。 (データのキーはPKではない場合、データベースのUnique Key Indexなどでも同じ効果が得られます。) 既存データ2つ来ると、SQLを改造する、あるいは楽観的ロックを使うのです。 ここフィールドの値をプラスいちのような簡単なケースでは、

UPDATE データ集計 SET カウンター = カウンター + 1 WHERE キー = ?

でカウンターの値を直接読み出しを避けながら安全に増えることができます。 もちろん、毎回このようなSQLで対応できるはずがないですが、UPDATEの条件を利用して、

UPDATE データ集計 SET カウンター = ? WHERE キー = ? AND カウンター = ?

更新する前のカウンターと更新後のカウンターを確定することで、 失敗したら、影響したレコードは0行、成功したらそのままデータベースに反映します。 失敗するケースは基本ほかのプロセスに先に更新されましたと考えられるので、最新の値を取り直してもう一回更新してみます。 もしまた失敗したら、成功するまでリトライします。

実際、複数のフィールドを更新するSQLもあるため、更新しようとするフィールドを条件として使わず、別途versionのような数値型のフィールドを用意し、毎回更新前のバージョンをチェックしながら、1をプラスします。ここのカウンターだと、SQLは

UPDATE データ集計 SET カウンター = ?, バージョン = ?(更新前の値 + 1) WHERE キー = ? AND バージョン = ?(更新前の値)

になります。

まとめてみると、カウンターのプログラムはこうなります。

loop
  record = SELECT カウンター、バージョン FROM データ集計 WHERE キー = ?
  if record exists
    record-updated-count = UPDATE データ集計 SET カウンター = ?, バージョン = ?(更新前の値 + 1) WHERE キー = ? AND バージョン = ?(更新前の値)
    if record-updated-count := 1
      break
  else
    try 
      INSERT INTO データ集計 VALUES (キー、1, 1) (最後の1はバージョン)
      break

少し複雑になりましたが、同時に来るリクエストを正しく処理するには必要です。

0x02

NoSQLにおいて、トランザクションは基本ないです。何故かと言うと、分散式の環境ではトランザクションが難しいです。その代わりに、NoSQLで別の方法で一貫性を保証します。

例えば、DynamoDBで楽観的ロックのような条件付き更新という機能があります。それにその条件はA = Bのようなイコールのテストだけではなく、属性が存在するかなどもできます。つまり、RDBMSのユニーク制限と楽観的ロック両方の機能を持っています。

DynamoDBにトランザクションがないので、もしDynamoDBで複数のテーブルを同時に更新しようとする場合、自分で「トランザクション」のような仕組みを実装するしかないです。それは「トランザクション」と完全に一致する機能を作るわけではなく、ケース・バイ・ケースで似たような機能を作るのです。

具体的には、複数のテーブルを更新する前に、更新のリクエストを保存して、非同期とシリアルで処理したりするのです。トランザクション一番難しいところは、複数トランザクションが同時に実行すると、お互い影響しやすいです。シリアルによって、影響を大きく減らせます。シリアルは遅いから、非同期も必要です。

例として、DynamoDBはよくゲームに使われます。ゲーム内の強化など基本複数テーブルを更新しなければなりません。

www.slideshare.net

このスライドで、強化は

  1. 所持金を減らす
  2. 素材カードを削除する
  3. 強化先をアップグレードする

という流れになりますが、2と3の間でなんらかのエラーが起こすと、金と素材だけがなくなってアイテムは強化されない結果になります。もちろんユーザーのクレームが来るでしょう。

なので、スライドでは、

まず強化のリクエストを保存して、各テーブルにこのリクエストのIDを処理待ちという属性に追加します。 そして、リクエストのIDをメッセージにいれてキューに投げます。

非同期の処理

  1. リクエストをステータス:開始にする
  2. 所持金を減らす(条件付き更新)
  3. 強化カードを削除する
  4. 強化先のアップグレードする(条件付き更新)
  5. リクエスト処理済み

もし途中でエラーになったら、非同期の処理は最初からやり直します。成功するまでやり続けます。 そしてこの処理は再実行耐性が必要です。つまり、何回やっても同じ結果になります。

トランザクションベースのコードに比べると、大分難しくなりましたが、1つのデータベースの処理能力に制限されるより、パフォーマンス面は優れるはずです。ちなみに、こういう処理のパターンは結果一貫性と言われています。

0x03

複雑な機能で中心にいるシステム以外にいろいろ外部サービスが使われるはずです。1つのリクエストに複数のサービスへアクセスすると、特に更新系のAPIが多い場合、一貫性に注意しなければなりません。

例えば、こんな処理があります。

  1. データベースにレコードを保存する
  2. 外部サービスを通知する

一見シンプルな処理ですが、外部サービスへの通知が失敗するケースを含めてトランザクションを入れたら完璧のようなんです。実際

  1. トランザクションに入る
  2. データベースにレコードを保存する
  3. 外部サービスを通知する 3.1 外部サービスの処理が走る、処理成功 3.2 突然ネットワーク問題が起こして、外部サービスからのレスポンスがはいれない
  4. 外部サービスへのリクエストはタイムアウトし、データベースロールバック
  5. 外部サービスは成功したのに、ローカルのシステムは失敗した状態になる

通信状況によってこういうケースが発生する可能性もあります。

ここの問題は外部サービスへの通信をトランザクションに入れるかどうかの問題ではなく、分散システムにおいて1つのリクエストで原子性を保証することが難しいです。必要なのは、いろんなケースを考慮して、よりフォールトトレラントなシステムを作るのです。

上の処理について、外部サービスへの通知を非同期にし、外部サービスの再実行耐性が保証できれば、失敗したらリトライします。

まとめ

いかがでしょうか。データベースのトランザクションは重要な機能ですが、トランザクションなしでどう実装するか、それと分散システムでどう考えるすべきかのも学びましょう。システムが複雑になると共に、いつか必要になるかもしれません。