フューチャー技術ブログ

DynamoDB利用時の書き込みスキュー(Write Skew)の回避

はじめに

一般的に複数のトランザクションが並行して同じオブジェクトに対してアクセスを行う場合には、トランザクションの分離レベル(SERIALIZABLE/REPEATABLE READ/READ COMMITTED/READ UNCOMMITTED)によって様々な問題が発生します。

DynamoDBは2018年にトランザクションがサポートされましたが、本記事ではファントムリードによる書き込みスキューの問題とその対応について取り上げたいと思います。

書き込みスキューとは

まずはじめに、「書き込みスキュー」とは具体的にどのような問題なのか、まず例を見てみるのが一番わかりやすいでしょう。

書き込みスキューの例

ここではイベントの申し込みシステムを考えてみましょう。
要件としてイベントの申し込み人数の上限は3人であると仮定します。
これを実現するためには、ユーザが申し込みの要求を行なった際に現在の申し込み人数を取得し、3人未満であれば登録を行う、3人以上であればエラーを返却する、という形になります。

問題が起きるのは下記の図のようにユーザAとユーザBが同時に申し込みを行なった場合となります。
1-1 及び 2-1 の処理にて現在の申し込み人数を取得する場合、両方の結果は2人となり、ユーザAもユーザBも登録が正常に完了してしまいます。
しかしながら結果として申し込み人数は4人となってしまうため、これは要件を満たしていません。

DynamoDB_Write_Skew_Example_1.drawio

このように、あるトランザクションにおける書き込みの結果が別のトランザクションの読み込み結果を変化させる(今回の場合はユーザAの書き込みによって、ユーザBの検索結果が過去のものになってしまっている)効果はファントムリードと呼ばれ、このように読み込んだ結果を元に書き込みを行なう場合に生じる問題を書き込みスキューと呼びます。

書き込みスキューが発生する他の例としては以下のようなケースが考えられます。

  • ユニークなユーザ名の要求
    ユーザが自由にユーザ名を決定できるシステムにおいて、既に利用されているユーザ名のチェックを行う場合に同様の問題が発生し得ます。

  • 二重支払いの防止
    ユーザがポイントを利用してアイテムを購入するようなシステムにおいて、ユーザのポイントの収支をリストアップしてマイナスにならないことを確認する場合に同様の問題が発生し得ます。

書き込みスキューの発生条件

書き込みスキューは一般的に以下の条件で発生します。

  1. データベースからデータを読み込み、特定の要求が満たされているかを確認する。
  2. 1の結果に応じて処理を中断するか、継続するか判断を行う。
  3. 処理を継続する場合にデータベースに書き込みを行い、書き込みによって1の結果が変化し得る。

DynamoDBにおける対応策

まず前提としてお伝えしておきたいのが、DynamoDBのトランザクション機能はこの書き込みスキューを回避するために利用できるものではありません。
(後述するように副次的に利用するケースはあります)。

一般的に書き込みスキューはトランザクション分離レベルがSERIALIZABLE(直列化可能)なら回避可能であり、DynamoDBのトランザクションについて調べると、トランザクション分離レベルはSERIALIZABLEとなっています。
しかしながら、間違ってもドキュメントだけを読んで、これで大丈夫だと思わないようにしてください。

DynamoDBにおけるトランザクションはTransactGetItemsTransactWriteItemsといったDynamoDBに対するオペレーションの単位で制御される(ACIDを保証する)ものであり、RDBのようにトランザクションの開始(BEGIN)と終了(COMMIT/ROLLBACK)をアプリケーションのレイヤで制御できるものではありません。したがってテーブルロックや行ロックを取得するということもできません。

DynamoDBが提供するトランザクションに依存しない方法で対応を考えていく必要があります。

対応案1: 集計・集約処理の直列化

対応案のひとつとして、集計・集約処理(今回の例の場合、申し込み人数が3人を超過しているかどうかのチェック)を直列化する方法が挙げられます。
例えばDynamoDB Streamsを利用することで、パーティション単位に、データの変更の発生順に処理を直列化して、非同期実行できます。

DynamoDB_Write_Skew_Example_2.drawio.png
  1. ユーザからの申し込み要求に対して、まず一時登録用のテーブルにデータの書き込みを行います1
    ユーザから見るとこれは仮登録状態となります。
  2. DynamoDB Streamsをトリガーに実行されたLambdaの処理として Statusが accepted(受付完了)であるレコードの件数を取得します。
  3. 取得した件数が3件未満の場合は対象のレコードの Status を accepted(受付完了)として本テーブルに登録し、3件以上の場合は Statusを rejected(受付不可)として本登録用のテーブルに登録を行います2

このように、DynamoDB Streamなどを利用して、集計・集約処理を別のワークロードで非同期に直列化して実行するような処理方式は、DynamoDBの使い方にマッチしています。
もちろん要件によっては(特に購入処理などお金に絡む場合は)ユーザからの要求に応じて同期的に集計・集約処理を行う必要があり、このような処理方式を適用できない可能性があります。

そのような場合は次に紹介するような対応が考えられます。

対応案2: Conditional Update による擬似的な直列化

DynamoDBはConditional Update(条件付きの書き込み)を利用することで、楽観的排他制御を実現し、書き込み処理を擬似的に直列化できます。
ただし今回のように特定の条件を満たす行が存在しないことが条件となっていて、書き込みによってその条件を満たす行が追加されるケースにおいては、そもそも楽観ロック対象となるレコードが存在しないため、単純に Conditional Update を適用できません。

以下、具体的な方法について説明します。

1. テーブル構造を変更してロック対象を実体化させる

ロック対象を実体化させるために下記のようにテーブルの構造を変更し、Event IDをハッシュキーとしてユーザを配列で保持し、楽観ロック用にバージョンを保持します。
また、ここでの話の本質ではありませんが、合わせて現在の申込者数(Count)も保持しておきます。

DynamoDB_Write_Skew_Example_3.drawio.png

これによりConditional Updateを利用して書き込みスキューを回避できます。
具体的な処理の流れは下記のとおりとなります。

DynamoDB_Write_Skew_Example_4.drawio.png
  1. ユーザからの申し込み要求が行われた場合に、テーブルから対象イベント(EVENT01)のレコードを取得します。
    このときDynamoDBのConsistent Readを利用して、 一貫性のあるデータの読み取りを行う必要があります。これを行わないと最新の書き込みデータを読み込むことが保証できず、楽観的排他制御を正しく実現できません。
  2. レコードが存在しない、または申込者数(Count)が3件未満の場合は処理を継続し、3件以上の場合はエラーを返却します。
  3. 処理を継続する場合は、各カラムの値を設定して Conditional Update を行います。
    ここでの条件は 「キー(Event ID: EVENT01)が存在しない」 または 「キー(Event ID: EVENT01)が存在し、Versionカラムの値が 1 である」 ことになります。
    キーが存在しない場合の条件が必要なのは、1人目の申し込み時はイベントのレコード自体が存在しないことへの対応です。

ユーザBが書き込みを行うタイミングでは、更新対象のレコードのバージョンは 2 となっているため、条件に合致せず更新処理が失敗する形になります。

このようにテーブルの構造を変更することが可能な場合は、書き込みスキューに対する有効な対応策となります。
一方で、要件上、元のテーブルの構造を変更できないケースも往々にしてあるでしょう。例えば今回のケースでいうと、ユーザをキーとしたGSIを設定することで、ユーザ単位で申し込みをしているイベントの一覧を取得したいケースなどが考えられます。

そのような場合は次に紹介するような対応が考えられます。

2. テーブルを追加してロック対象を実体化させる

テーブル構造を変更できない場合は、テーブルを追加することでロック対象を実体化させます。

DynamoDB_Write_Skew_Example_5.drawio.png

処理の流れは先述の「テーブル構造を変更してロック対象を実体化させる」場合と基本的に同様ですが、書き込み時は TransactWriteItems を利用して、2テーブルをAtomicに更新する必要があります。
冒頭でトランザクションを「副次的に利用するケースがある」と述べたのはこの件になります。

3. レコードを事前に追加してロック対象を実体化させる

別の方法として事前にロック対象となるレコードを全て作成しておくという方法も考えられます。
例えば新規イベントの作成時など、ユーザの申し込みに先行する形で上限数となる3レコードを作成しておき、ユーザの要求に対してはConditional Updateで楽観的排他制御を実現する方法になります。

DynamoDB_Write_Skew_Example_6.drawio.png

今回のケースではレンジキーにシーケンスなどを利用せざるを得ないため、アプリケーションからの取り扱いが少し煩雑になりそうですが、例えば時間単位の会議室の予約システムなどレコードの総量とキーが事前に確定しているようなケースではマッチする可能性があります。

どの対応策が良いか

ここまでみてきた通り、要件次第で取り得る対応が変わるため、一概にこれが優れているということはできません。

しかしながら参照要件が満たせるのであれば、対応案2の「Conditional Updateによる擬似的な直列化」の中でも「1. テーブル構造を変更してロック対象を実体化させる」方式が最も開発コストを抑えられるでしょう。
一方で、システム全体としてDynamoDB Streamsを積極的に活用しており、要件として集計・集約処理を非同期処理に逃がせるのであれば、対応案1の「集計・集約処理の直列化」が自然とマッチするかもしれません。

上記の対応で要件が満たせない場合には「テーブル追加によるロック対象の実体化」や「レコードの事前追加によるロック対象の実体化」を検討していくと良いのではないかと思います。

もし書き込みスキューの問題が頻繁に発生する場合は、根本的にDynamoDBの利用自体が適していない可能性があり、RDBを検討した方が良いかもしれません。

システムやチームの状況に応じて最適な処理方式を検討していただければと思います。


  1. 1.この段階で申し込み件数のチェックを実施することは、データを保証するという観点では有効ではありませんが、早期にエラーを返却できるためユーザビリティの観点では有効です。
  2. 2.一時登録用のテーブルと本登録用のテーブルは同一テーブルでも構いません。ただしその場合は、本登録用のテーブルのUPDATEを行った際にもDynamoDB StreamsをトリガーとしてLambdaが発火するため、コンピューティングリソースが不要に消費されます。一方で、ユーザの現在の登録状態を取得するのに1テーブルの参照で済むため、実装上のメリットが大きくなるケースもあります。