二度押しても一度だけ
目標
ネットワークでは、リトライは避けられません。 クライアントが応答を受け取れなかったとき、 リクエストが届かなかったのか、届いたのに応答だけが来なかったのかを、区別する方法がない からです。
だから、重複はサーバーが防がなければなりません。 このラボは、わざと冪等でなくした 決済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を使わないでください。そのパターンは、このコマンドを実行したシェルの
コマンドラインにも引っかかって、自分自身を殺します。
ステップ
- 重複の再現 →
01-duplicate.txt - 同じキーなら同じ答え → 採点ツールが直接確認
- キーがなければ拒否 →
03-nokey.txt - 同じキーで別の本文 →
04-conflict.txt - 同時リクエスト →
05-race.txt - 再起動しても覚えている →
06-persist.txt - リトライポリシー →
07-retry.md - まとめ →
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語は、順に「リトライ」「保存」「本文」を意味します)。