（未検証）DynamoDBで悲観ロックを実現するために検討したこと（情報収集メモ）

# 後から気づいた

https://aws.amazon.com/jp/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/

AWS公式がちゃんとしたの書いてたので、そっちを見たほうが良い。（後から気づいた）
以下は、自分なりに考えたときのメモに成り下がったが、結局公式に近い結論に至った気がするので、個人的には満足。（同じではない）

# 注意事項

- 「悲観ロックの実現」を保証するものではないです。（あくまで机上レベルのアイデア）
- 公式ドキュメントのパーツからアイデアを組み立てただけです。実際に実装して検証したわけじゃないです。
- 要は自分用のメモです。何も保証しませんので、各自で検証してね。

# 結論

**処理概要**

- ロックを取得する。
    - 「item（レコード）」が存在しない場合＝PutItem（条件付き書き込み）する。（ロック取得）
        - 条件：itemが存在しない
    - 「item（レコード）」が存在する場合＝UpdateItem（条件付き書き込み）する。（ロック取得）
        - 条件：itemが存在する、ロックの有効期限が切れている
    - どちらにも合致しない場合
        - ロックが取得できないため、処理を終了する。（悲観ロックが機能しているということ）
- 処理する
    - 一定時間ごと、もしくは一定処理まとまりごとにロックの有効期限を更新する。（ロックの更新）
- 「item（レコード）」をdelete（削除）する。
    - （個人的には削除にしたほうが、障害発生時にロック状態がどうなっているか把握しやすい気がする。）

**ロックテーブルの項目（カラム）**
- ロック名：これがどういう目的のロックなのかを簡単に示すためにあったほうが嬉しい。
- ロックを取得しているホスト名：原因調査に用いる
- ロックが切れる時間（`yyyy-MM-dd hh:mm:ss.ssss` みたいな。）：ロック取得時間のほうが大きいならロック取得できる。
- リビジョンバージョン番号 (RVN) [^rvn]：詳細は下記。

**リビジョンバージョン番号（RVN）**
- ロックを取得するたび、更新するたびに変更する。
- ロックが有効な間、更新しても良いかを判定するための条件。
- 「RVNを知っている＝ロックを取得した処理である」と判断してロックの更新を許容するイメージ。

**性能チューニングポイント**
要件によって「ロックの生存期間（ロックが切れる時間）」の設定方法を変える。

- 想定される処理の最大時間を最初にセットする。（ロック時間＝長、キャパシティ消費＝少）
    - 処理頻度が少なく、ロック期間の長さが問題になりにくい場合は、こちらを用いる。
- ロック取得後の業務処理の中でちょっとずつ更新する（ロック時間＝短、キャパシティ消費＝多）
    - 更新頻度を増やすと、処理可能な量が増えるが、キャパシティ消費が増える。
    - ロック期間による処理滞留が問題になる場合はこちらが良い。
    - 基本こっちにしておいたほうが安定する気はするので、上記の処理概要はこっちの前提で書いてる。

**細かい処理**
公式を見ろ

https://aws.amazon.com/jp/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/

# 結論に至るまでの検討メモ
## 初期アイデア→懸念点
**実装アイデア**
- 何かの処理を開始する前にやること
    - 「item（レコード）」が存在するかを確認する。
        - 存在した場合は「ロックされている」として処理終了。
    - 「item（レコード）」をput（登録）する。
- 処理する
- 「item（レコード）」をdelete（削除）する。

**実現を考えたときに気になる懸念**

1: DynamoDBって「結果整合性」なので、「item（レコード）の有無」でロックの有無を「厳密に判別する」ことってできないのでは？
2: DynamoDBの「put（登録）」は、「すでに存在するitemに対してputした場合、updateとして処理される」という特性がある。（RDBMSのinsertは、「すでに存在するitemがあると、失敗する」ことになる。）RDBMSであれば、「レコード有無」の判定が失敗しても、insertの成否で厳密な判定ができるが、DynamoDBだとそれができないのではないか？
3: 処理が異常終了したとき、「item（レコード）」が残り続けて、永続ロックになってしまうのでは？

## 懸念点の解消
### 1: DynamoDBって「結果整合性」なので、「item（レコード）の有無」でロックの有無を「厳密に判別する」ことってできないのでは？

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html

> **強力な整合性のある読み込み**
>- 読み取りオペレーション (GetItem、Query、Scan など) には、オプションの ConsistentRead パラメータがあります。
>- ConsistentRead を true に設定すると、DynamoDB は、成功した以前のすべての書き込みオペレーションからの更新を反映した、最新データを含む応答を返します。
>- 強力な整合性のある読み込みは、テーブルとセカンダリインデックスでのみサポートされています。
>- グローバルセカンダリインデックスまたは DynamoDB ストリームからの強力な整合性のある読み込みはサポートされていません。

「強力な整合性のある読み取り」を使えば、判別できそう。
ただ、以下のようなケースが起こり得るので、何かしら対処が必要。

ロック取得を同時に行う処理Aと処理Bがある。その場合以下のような順序での処理は起こり得る。（DynamoDBへの処理の順序性は担保されていないため）
1. 処理A：「item（レコード）」が存在するかを確認する。（この時点では存在しない）
1. 処理B：「item（レコード）」が存在するかを確認する。（この時点では存在しない）
1. 処理A：「item（レコード）」をput（登録）する。（ロックが成功した気になる）
1. 処理B：「item（レコード）」をput（登録）する。（ロックをぶんどってしまう）

ということで、「処理順序性を担保しないと、厳密なロックができない」ということが推測される。（新たな課題）

**結論**
問い：DynamoDBって「結果整合性」なので、「item（レコード）の有無」でロックの有無を「厳密に判別する」ことってできないのでは？
回答：「強力な整合性のある読み取り」を用いることで、検索時点の厳密な判定は可能。ただし、「ロック取得処理の順序性」を保証しない限り「ロックの厳密性」を保証することはできない。
新たな課題：「4: 処理順序性を担保しないと、厳密なロックができない」

### 2: DynamoDBの「put（登録）」は、「すでに存在するitemに対してputした場合、updateとして処理される」という特性がある。（RDBMSのinsertは、「すでに存在するitemがあると、失敗する」ことになる。）RDBMSであれば、「レコード有無」の判定が失敗しても、insertの成否で厳密な判定ができるが、DynamoDBだとそれができないのではないか？

https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html

> Creates a new item, or replaces an old item with a new item. If an item that has the same primary key as the new item already exists in the specified table, the new item completely replaces the existing item. 
Google日本語訳
新しい項目を作成するか、古い項目を新しい項目に置き換えます。新しい項目と同じ主キーを持つ項目が指定されたテーブルにすでに存在する場合、新しい項目は既存の項目を完全に置き換えます。

ということで、PutItemの成否で厳密な判定はできない。

具体的なケースで考えると以下のようなケースを気にしていることになる。（課題1に同じ）
ロック取得を同時に行う処理Aと処理Bがある。その場合以下のような順序での処理は起こり得る。（DynamoDBへの処理の順序性は担保されていないため）
1. 処理A：「item（レコード）」が存在するかを確認する。（この時点では存在しない）
1. 処理B：「item（レコード）」が存在するかを確認する。（この時点では存在しない）
1. 処理A：「item（レコード）」をput（登録）する。（ロックが成功した気になる）
1. 処理B：「item（レコード）」をput（登録）する。（ロックをぶんどってしまう）

よって、こちらでも課題1と同様に「処理の順序性の担保」が重要な要素になってくる。

**結論**
問い：RDBMSであれば、「レコード有無」の判定が失敗しても、insertの成否で厳密な判定ができるが、DynamoDBだとそれができないのではないか？
回答：その通り。DynamoDBではPutItemでは、同じキーを持つitemが存在した場合、updateと同じ挙動をするため、「登録処理の失敗」を期待して、レコード有無の判定をすることはできない。
新たな課題：「4: 処理順序性を担保しないと、厳密なロックができない」

### 3: 処理が異常終了したとき、「item（レコード）」が残り続けて、永続ロックになってしまうのでは？

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/TTL.html

> Amazon DynamoDB 有効期限 (TTL) では、項目ごとのタイムスタンプを定義して、項目が不要になる時期を特定できます。指定したタイムスタンプの日時のすぐ後で、DynamoDB は項目をテーブルから削除します。この削除が他のリージョンにレプリケートされた場合にのみ、スループットが消費されます。TTL は、ワークロードのニーズに合わせて最新の状態に保たれている項目のみを保持することで、保存されたデータボリュームを削減する手段として、追加料金なしで提供されます。

当たり前だが、削除するまでデータは残り続ける。
上記のようなTTLという仕組みを用いることで、「最長ロック時間」を設定できる。

また、自前で削除処理を実装した場合は、スループットが消費される（＝性能・コスト影響がある）のだが、TTLを用いた削除であればスループットは消費されない。（＝性能・コスト影響がないと思って良い）

悲観ロックを取る以上、問題になるのが「性能ボトルネックになる点」。これのチューニングポイントは明確にしておきたい。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/howitworks-ttl.html

> 有効期限が切れているものの、TTL によってまだ削除されていない項目は、読み取り、クエリ、およびスキャンに表示されます。

TTL（有効期限）が切れたとしても即座に削除されるわけではない点は要注意。

**結論**
問い：処理が異常終了したとき、「item（レコード）」が残り続けて、永続ロックになってしまうのでは？
回答：その通り。削除するまで「item（レコード）」は残り続ける。自前で削除処理を実装するよりは、DB側の機能でTTLと呼ばれる仕組みがあるのでそれを活用するのが良い。ただ、有効期限が切れてもすぐに削除されるわけではない点は要注意。
新たな課題：「5: 性能ボトルネックを考えたときにチューニングポイントになるところはどこか？」

### 4: 処理順序性を担保しないと、厳密なロックができない
#### DynamoDB Transactions による解決を検討してみる→ダメっぽい
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/transaction-apis.html

> **DynamoDB トランザクションの分離レベル**
> **SERIALIZABLE**
> 直列化可能分離レベルでは、複数の同時オペレーションの結果は、前のオペレーションが完了するまでオペレーションが開始されない場合と同じになります。
> （中略）
> たとえば、項目 A と項目 B の GetItem リクエストが、項目 A と項目 B の両方を変更する TransactWriteItems リクエストと同時に実行される場合、次の 4 つの可能性があります。
> 1. 両方の GetItem リクエストは、TransactWriteItems リクエストの前に実行されます。
> 1. 両方の GetItem リクエストは、TransactWriteItems リクエストの後に実行されます。
> 1. 項目 A の GetItem リクエストは、TransactWriteItems リクエストの前に実行されます。項目 B の場合、GetItem は TransactWriteItems の後に実行されます。
> 1. 項目 B の GetItem リクエストは、TransactWriteItems リクエストの前に実行されます。項目 A の場合、GetItem は TransactWriteItems の後に実行されます。

ということで、DynamoDB Transactionで、分離レベルSERIALIZABLEを使えば行けそう？

> **DynamoDB Transactions**
> Amazon DynamoDB Transactions を使用すれば、複数のアクションをまとめてグループ化し、1 つのオールオアナッシングの TransactWriteItems または TransactGetItems オペレーションとして送信できます。

TransactWriteItemsと、TransactGetItemsは分けられているようなので、検索→登録を同じトランザクションで実行できないかも？
少なくとも、「TransactGetItems」はGetしかできないようなので、今回の解決策にはならなさそう。

> **TransactWriteItems API**
> （中略）
> 同じトランザクション内の複数のオペレーションが同じ項目をターゲットとすることはできません。たとえば、同じトランザクション内で同じ項目に対して `ConditionCheck` を実行し、`Update` アクションも実行することはできません。
> 以下のタイプのアクションをトランザクションに追加できます。
>- `Put` — PutItem オペレーションを開始し、条件付きで、または条件をまったく指定せずに、新しい項目を作成するか、古い項目を新しい項目に置き換えます。
>- `Update` — UpdateItem オペレーションを開始し、既存の項目の属性を編集するか、まだ存在しない場合は新しい項目をテーブルに追加します。条件付きまたは条件なしで既存の項目で属性を追加、削除、更新するには、このアクションを使用します。
>- `Delete` — DeleteItem オペレーションを開始し、プライマリキーにより識別される 1 つの項目をテーブルで削除します。
>- `ConditionCheck` — 項目が存在することを確認するか、項目の特定の属性の条件を確認します。

`ConditionCheck` → `Put` で行けるのでは？と思ったけど、 `同じトランザクション内の複数のオペレーションが同じ項目をターゲットとすることはできません。` ってあるから多分ダメだな・・・

DynamoDB Transactionsを用いるのは厳しそうなので、別の解決策を探す。

#### 条件式（条件付き書き込み）による解決を検討してみる　→　これが良さそう
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html

> PutItem オペレーションはプライマリキーが同じ項目を上書きします (存在する場合)。これを回避するには、条件式を使用します。これにより、問題の項目が同じプライマリキーを持っていない場合にのみ書き込みが続行されます。
> 次の例では、attribute_not_exists() を使用して、書き込み操作を試みる前に、プライマリキーがテーブルに存在するかどうかを確認しています。
> ```shell
> aws dynamodb put-item \
>     --table-name ProductCatalog \
>     --item file://item.json \
>     --condition-expression "attribute_not_exists(Id)"
> ```

ぴったりのやつあるじゃん。これこれー。
そうすると、検索→登録って2つの処理を考えてたけど、1発でいけるな。

ただ、内部的に検索→登録をやっていて、実は懸念が残ったままみたいなことは無いのだろうか？

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/WorkingWithItems.html#WorkingWithItems.ConditionalUpdate

> 条件付き `PutItem`、`DeleteItem`、または `UpdateItem` をリクエストするには、条件式を指定します。条件式は、属性名、条件付き演算子および組み込み関数を含む文字列です。式全体の評価が true になる必要があります。それ以外の場合は、このオペレーションは失敗します。

`式全体の評価が true になる必要があります。それ以外の場合は、このオペレーションは失敗します。`ってあるので、安心して良さそう？
いや、「式全体」が更新処理まで含むかわからんな。試したりサポートに聞いたりして確証がほしいなぁ。
　→課題6： 条件付き書き込みが同時に実施されたときに後勝ちになったりしないか？

また条件付き書き込みにおける読み取りは強力な整合性で行われるのか？

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/WorkingWithItems.html#WorkingWithItems.ConditionalUpdate

> 条件付き書き込みでは、その条件についてレコードの最新更新バージョンと照合します。レコードが以前に存在しなかった場合や、そのレコードに対して最後に成功したオペレーションが削除であった場合、条件付き書き込みでは以前のレコードは検出されないことに注意してください。

`最新更新バージョンと照合します` とあるので、大丈夫そう。

### 5: 性能ボトルネックを考えたときにチューニングポイントになるところはどこか？
#### ボトルネックポイントの特定
現時点でどのような処理イメージになるかをまず整理する。

- 何かの処理を開始する前にやること
    - 「item（レコード）」が存在しない場合のみPutItemする。（条件付き書き込み）
    - 「item（レコード）」には「TTL（有効期限）」を含めておく。
    - PutItemが失敗した場合は、処理を終了させる。
- 処理する
- 「item（レコード）」をdelete（削除）する。

その上でボトルネックになりそうなポイント

- 条件付き書き込み
- TTLの長さ

**条件付き書き込み**
最新更新バージョンを取得する挙動のため、何かしらのボトルネックにはなる可能性はあるが、チューニングしようが無いみたいなので、制約として受け入れるしか無さそう。受け入れる。

**TTLの長さ**
いくつか解決策がありそう。

#### TTLの長さをチューニングする
考えられるアイデア

- 想定される処理の最大時間を最初にTTLにセットする：ロック時間＝最大、キャパシティ消費＝最小
- ロック取得後の業務処理の中でちょっとずつTTLを更新する：ロック時間＝小さい、キャパシティ消費＝大きい

**想定される処理の最大時間を最初にTTLにセットする**
異常終了時であっても、業務処理が最短でおわっても、毎回「最大時間」ロックされるため、ボトルネックになりやすい。
一方で、キャパシティ消費は最初の登録以外には無いため、コスト面では安く済む。

処理頻度が非常に少ないケースでは有用かも。

**ロック取得後の業務処理の中でちょっとずつTTLを更新する**
1件ずつ処理するような業務処理であれば、1件ずつもしくは10件ずつなどでTTLを更新するイメージ。件数ごとじゃない場合は、処理のまとまりごとにTTLを更新する感じかなぁ。
必要なだけロック時間を取得する形になるので、ロック時間を必要最小限に収めやすい。
一方で、更新都度キャパシティを消費するため、更新頻度が高いほどコスト影響も大きい。

処理頻度が高く、ある程度コストもかけられる場合にはこれが有用かなぁ。
コスト制約がある場合は、TTLの更新頻度を減らす感じかなぁ。

### 6： 条件付き書き込みが同時に実施されたときに後勝ちになったりしないか？
ここは実装して確認したり、サポートに確認したりするしかなさそうなので、一旦保留。

## 最終形態

**処理概要**

- 何かの処理を開始する前にやること
    - 「item（レコード）」が存在しない場合のみPutItemする。（条件付き書き込み）
    - 「item（レコード）」には「TTL（有効期限）」を含めておく。
    - PutItemが失敗した場合は、処理を終了させる。
- 処理する
- 「item（レコード）」をdelete（削除）する。

**性能チューニングポイント**
要件によってTTL（有効期限）の設定方法を変える。

- 想定される処理の最大時間を最初にTTLにセットする。（ロック時間＝長、キャパシティ消費＝少）
- ロック取得後の業務処理の中でちょっとずつTTLを更新する（ロック時間＝短、キャパシティ消費＝多）

### 公式との差分
後から気づいたけど、公式が出してたので、そことの差分を確認する。

https://aws.amazon.com/jp/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/

#### ロックテーブルのカラム
自分は具体的に考えてなかったけど、公式では以下のような設計をしている。
- ロック名
- ロックを取得しているホスト名
- ロック取得した時間（`yyyy-MM-dd hh:mm:ss.ssss` みたいな。）
- ロックを取得していられる期間（ミリ秒。2000とか。）
- ホストに固有のUUID＝リビジョンバージョン番号 (RVN) [^rvn]
[^rvn]: リビジョンバージョン番号と言ったり、レコードバージョン番号と言ったり、若干表記がゆれてるのが気になるけど、まぁいいや。

仕組みを理解した上で、自分ならどうするか。
- （採用）ロック名：これがどういう目的のロックなのかを簡単に示すために合ったほうが嬉しい。
- （採用）ロックを取得しているホスト名：原因調査に用いる
- （削除する）ロック取得した時間（`yyyy-MM-dd hh:mm:ss.ssss` みたいな。）：公式ではここにロック期間を足してロック取得条件にしてたが、それならいっそ「ロックが切れる時間」が良くない？
- （追加する）ロックが切れる時間（`yyyy-MM-dd hh:mm:ss.ssss` みたいな。）：ロック取得時間のほうが大きいならロック取得できる。
- （削除する）ロックを取得していられる期間（ミリ秒。2000とか。）：シンプルに不要じゃない？
- （採用）ホストに固有のUUID＝リビジョンバージョン番号 (RVN) [^rvn]：ちょっと考えたけど使ったほうが安定しそう。

**リビジョンバージョン番号（RVN）**
- ロックを取得するたび、更新するたびに変更する。
- ロックが有効な間、更新しても良いかを判定するための条件。
- 「RVNを知っている＝ロックを取得した処理である」と判断してロックの更新を許容するイメージ。

#### TTL
公式はTTL使ってない。
そもそもTTLが厳密なタイミングを保証してくれないので、やむなしな気がした。
レコードの有無というよりは、「UUID」と「ロック期限が切れているかどうか」で判別している感じ。
そっちのが厳密な気がするので、公式にならうことにする。
→TTL使わない。

# さいごに
細かい制約などが分かって面白かったです。

以上。