はじめに:ワンショット処理を守る「重複リクエスト」問題

この記事は、GoでWeb APIやWebhookエンドポイントを実装する中級者以上のエンジニアを対象にしています。
特に「同一クライアントからのリクエストが連打され、処理が重複してしまう」「レスポンスを返す前に次のリクエストが来て、DBや外部APIが無駄に呼ばれる」といった悩みを抱えている方に最適です。

本記事を読むと、以下のことがわかります。

  • リクエスト単位で「一度だけ実行」を保証する設計パターン
  • sync.Oncecontext を組み合わせた安全な排他制御
  • テストで再現・検証するための具体的手順

これらを踏まえ、本番で起きやすい「重複処理」を防ぐ堅牢なHTTPハンドラを実装できるようになります。

前提知識

  • Goの基本文法と標準パッケージの扱い方
  • HTTPサーバー(net/http)のハンドラ実装経験
  • ゴルーチンとsyncパッケージの基礎知識

なぜ「レスポンス返却前の重複」が問題になるのか

決済や在庫確保、メール送信など「ワンショットで済ませたい」処理を持つエンドポイントでは、クライアント側の再試行や二重クリックにより、同一ペイロードで複数回リクエストが飛ぶことがあります。
GoのHTTPサーバーはリクエストごとにゴルーチンを起動するため、排他制御を怠ると

  • 同じレコードに対して2重にUPDATEが走る
  • 外部決済APIに2回課金リクエストが飛ぶ
  • 重複メールが届く

といった事故につながります。
そこで「同じクライアントからの同じリクエストを、HTTPレスポンスを返すまでの間だけ無視・スキップする」という仕組みが必要になります。

sync.Once + context で「1リクエスト1回実行」を実現する

ステップ1:リクエスト単位の「一度だけ」キーを設計する

まず、リクエストを一意に特定するキーが必要です。
例えば、ヘッダーにX-Request-IDを必須とし、同一IDを持つリクエストは同一処理として扱います。

Go
type key string const requestIDKey key = "requestID" func requestID(r *http.Request) string { return r.Header.Get("X-Request-ID") }

次に、サーバー起動時にグローバルなsync.Mapを用意し、リクエストIDごとに*sync.Onceを保持します。

Go
var onceStore sync.Map // key: requestID(string), value: *sync.Once

ステップ2:ハンドラ内でOnceを取得・実行する

ハンドラでは、リクエストIDに対応する*sync.Onceを取得(または新規作成)し、Doメソッド内で実処理を実行します。
同時に、処理完了後にcontext.WithTimeoutで一定時間(例:30秒)経過したらsync.Mapからエントリを削除してメモリを開放します。

Go
func handleCharge() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rid := requestID(r) if rid == "" { http.Error(w, "X-Request-ID required", http.StatusBadRequest) return } entry, _ := onceStore.LoadOrStore(rid, &sync.Once{}) once := entry.(*sync.Once) var ( result *ChargeResponse errOnce error ) once.Do(func() { // ここに実際の決済処理・DB更新など result, errOnce = processCharge(r.Context(), rid) // 処理完了後、30秒後にOnceを削除 time.AfterFunc(30*time.Second, func() { onceStore.Delete(rid) }) }) if errOnce != nil { http.Error(w, errOnce.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(result) } }

ハマった点:Onceの再利用とメモリリーク

最初の実装では、sync.OnceDeleteしてすぐに同じリクエストIDで再びLoadOrStoreすると、一度完了したOnceは二度と実行されないという仕様にハマりました。
結果として、2回目のリクエストが来ても何も実行されず、レスポンスが返せないという事象が発生しました。

解決策:30秒の猶予を設けてから削除

sync.Onceを即削除せず、time.AfterFuncで30秒後に削除することで、同一クライアントが短時間に再送してきた場合に備えつつ、長期的にはメモリを開放するという運用を採用しました。
また、テストコードではhttptestsync.WaitGroupを組み合わせ、並行して10リクエスト飛ばしてもprocessChargeが1回しか呼ばれないことを検証しています。

Go
func TestHandleCharge_Once(t *testing.T) { srv := httptest.NewServer(handleCharge()) defer srv.Close() const rid = "test-123" var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() req, _ := http.NewRequest("POST", srv.URL, nil) req.Header.Set("X-Request-ID", rid) resp, _ := http.DefaultClient.Do(req) io.Copy(io.Discard, resp.Body) resp.Body.Close() }() } wg.Wait() if callCount := atomic.LoadInt32(&processCallCount); callCount != 1 { t.Fatalf("expected 1 call, got %d", callCount) } }

まとめ

本記事では、GoのHTTPサーバーで「レスポンスを返す前に同一クライアントから重複リクエストが来た場合」に備え、以下を実装しました。

  • sync.Mapでリクエスト単位の*sync.Onceを管理
  • Once.Doで処理を1回に制限
  • 処理完了後30秒でエントリを削除しメモリリークを防ぐ

これにより、決済・在庫確保など「絶対に重複させたくない」処理を、簡潔なコードで安全に実行できます。
今後は、リクエストIDの代わりにJWTのjtiクレームを使ったバージョンや、Redisなどの外部ストレージでOnce状態を共有する分散版についても記事にする予定です。

参考資料