TT Lab
はじめる
学ぶ 学習パス コース

冪等性 — 二度押しても決済は一度だけ

二度押しても一度だけ

TT Labで続きを見る

目標

ネットワークでは、リトライは避けられません。 クライアントが応答を受け取れなかったとき、 リクエストが届かなかったのか、届いたのに応答だけが来なかったのかを、区別する方法がない からです。

だから、重複はサーバーが防がなければなりません。 このラボは、わざと冪等でなくした 決済APIを、直していきます。

開始

cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1        # 키 k1 로 결제
./ledger.sh          # 지금까지 적립된 것

setsid nohupを付けてください。単に&で起動すると、シェルが変わるときに一緒に死にます。

直す場所

server.pyのhandle_payの1つです。idemテーブルはすでに作られています。 キー・リクエストのハッシュ・状態・応答・時刻です。

サーバーを再起動するとき

ps -eo pid,args | awk '$2 ~ /python3$/ && $3 == "server.py" {print $1}' | xargs -r kill

pkill -f server.pyを使わないでください。そのパターンは、このコマンドを実行したシェルの コマンドラインにも引っかかって、自分自身を殺します。

ステップ

  1. 重複の再現 → 01-duplicate.txt
  2. 同じキーなら同じ答え → 採点ツールが直接確認
  3. キーがなければ拒否 → 03-nokey.txt
  4. 同じキーで別の本文 → 04-conflict.txt
  5. 同時リクエスト → 05-race.txt
  6. 再起動しても覚えている → 06-persist.txt
  7. リトライポリシー → 07-retry.md
  8. まとめ → 08-notes.md

参考

ステップ2・5・6は、採点ツールが直接サーバーを起動してリクエストを送ります。 ログを書き写す だけでは合格できません。

リトライが二重決済を生む

サーバーを起動して同じリクエストを2回送ったあと、元帳を確認して01-duplicate.txtに残してください。

cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1
./probe.sh k1
./ledger.sh

payment_idが1、2と増えて、元帳が2件になります。クライアントは1回決済したと思っています。 タイムアウトのためにリトライしただけだからです。

ネットワークでは、リトライは避けられません。応答を受け取れなかったとき、リクエストが届かなかったのか、届いたのに応答だけが来なかったのか、クライアントは区別できません。

同じキーなら同じ答えを返す

server.pyのhandle_payを直して、同じIdempotency-Keyで2回来たら、2回目は計上せず、最初の応答をそのまま返すようにしてください。

idemテーブルがすでに作られています。キー・リクエストのハッシュ・状態・応答・時刻です。

最初のリクエストなら、計上して応答を保存します。すでにあるキーなら、保存しておいた応答をそのまま返します。新しく作りません。

確認: 2回送って、./ledger.shが1件ならOKです。そして2つの応答のpayment_idが、同じである必要があります。

キーがなければ拒否する

Idempotency-Keyなしで来たリクエストを400で拒否するようにして、03-nokey.txtに残してください。

./probe.shを引数なしで呼ぶと、キーなしで送られます。

お金が動くリクエストでキーを任意にすると、キーを送らないクライアントが必ず出てきます。 そして、そのクライアントが二重決済を作ります。最初から必須にするほうがよいです。

同じキーで別の本文が来たら

同じキーで別の金額を送ると422で拒否するようにして、04-conflict.txtに残してください。

リクエスト本文のハッシュをidem.request_hashに保存しておいて、比較します。

./probe.sh k1 '{"user":"u1","amount":1000}'
./probe.sh k1 '{"user":"u1","amount":99999}'

2回目を黙って通すと、1000ウォンの応答を99999ウォンのリクエストに返すことになります。 キーを再利用するバグが、静かに埋もれます。

同時に同じキーが来たら

同じキーで10個を同時に送っても、元帳が1件であることを確認して、05-race.txtに残してください。

pids=""
for i in $(seq 10); do ./probe.sh race >/dev/null 2>&1 & pids="$pids $!"; done
wait $pids
./ledger.sh

waitのあとにPIDを必ず書いてください。 単にwaitだけを使うと、ステップ1でバックグラウンドで起動したサーバーまで待ちます。サーバーは終わらないので、ターミナルがそのまま止まります。

「まず参照して、なければ入れる」という素朴な方式は、ここで壊れます。10個が同時に「ない」を読むからです。

正しくやるには、キーに一意制約をかけて、挿入に失敗した側を待たせるか、ロックで包む必要があります。idem.keyは、すでにプライマリキーです。

プロセスを落として起動し直しても覚えているか

決済を1回したあと、サーバーを落として起動し直し、同じキーでもう一度送って、それでも重複が生じないことを06-persist.txtに残してください。

メモリ上のディクショナリで実装したなら、ここで壊れます。再起動すると忘れるからです。

実務では、もっと一般的な理由で壊れます。サーバーが複数台あるからです。1つ目のPodが覚えたことを、2つ目のPodは知りません。そのため、保存は全員が一緒に見る場所に行う必要があります(ここではsqlite、実務ではDBやRedis)。

どのエラーでリトライすべきか

07-retry.mdに、リトライしてよい応答と、してはいけない応答を分け、それぞれ理由を書いてください。最低4つ。

考えること: 500、503、429、400、422、そして応答をまったく受け取れなかった場合。

ヒント1つ: 400をリトライすると、永遠に400です。そしてリトライするときは、間隔を延ばしながら(exponential backoff)行い、複数のクライアントが同時にリトライしないように揺らして(jitter)あげます。そうしないと、回復しようとするサーバーを、リトライがまた倒します。

3つをまとめる

08-notes.mdに3行以上。リトライがなぜ避けられないのか、冪等キーをどこに保存すべきか、同じキーで別の本文が来たらなぜ拒否すべきか。

本文に재시도・저장・본문が含まれている必要があります(韓国語の3語は、順に「リトライ」「保存」「本文」を意味します)。