決済や注文APIを作っていると、「ユーザーがダブルクリックして同じ注文が2件入った」「ネットワーク再送で二重課金が起きた」というトラブルは避けて通れませんよね。これを防ぐ定番のアプローチが、StripeなどでもおなじみのIdempotency-Keyヘッダー方式です。

この記事では、Spring BootでIdempotency-Keyを処理するFilterをRedisと組み合わせて実装する方法を、並行リクエスト対策やTTL設計まで含めて解説します。

なぜREST APIに冪等性が必要なのか

二重実行は思っているよりも日常的に起きます。ユーザーが送信ボタンを連打したり、モバイル回線が一瞬切れてクライアントが自動リトライしたり、ロードバランサがタイムアウト後に再送したり、原因は様々です。

GET/PUT/DELETEは仕様上もともと冪等なので問題になりません。困るのは POST です。決済、注文、送金、メール送信のような副作用がある処理では、二重実行が即金銭的・業務的な損害につながります。

DB側のユニーク制約で済む場合もありますが、外部APIコールやメール送信などDB外に副作用が出る処理ではHTTP層で受け止めるしかありません。

Idempotency-Keyヘッダー方式の仕組み

仕組みはシンプルです。クライアントがリクエストごとにユニークなキー(UUIDなど)を Idempotency-Key ヘッダーに付け、サーバーは初回処理の結果(ステータス+ボディ)をキーと紐付けて保存する。同じキーで再送が来たら、保存済みのレスポンスをそのまま返す、というものです。

この方式はIETFの Idempotency-Key HTTP Header Field draft(draft-ietf-httpapi-idempotency-key-header-06, 2024) で標準化が進んでおり、Stripeも長年これを採用しています。

全体アーキテクチャ

Spring Bootアプリでは、Filterでリクエストとレスポンスを挟み込む形が一番扱いやすいです。レスポンスボディを保存する必要があるので、Interceptorよりも OncePerRequestFilterContentCachingResponseWrapper の組み合わせが素直です。FilterとInterceptorの使い分けは Spring BootのFilterとInterceptorの違いと使い分け を参照してください。

ストアはRedisを使います。TTLが自動で効く、分散環境で一貫したストアになる、setIfAbsent で簡単に排他制御ができる、の3点が決め手です。

プロジェクト準備

依存関係はWebとRedisの2つです。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2s

Redisのセットアップに不安がある場合は Spring BootとRedisの連携ガイド を先に読んでおくとスムーズです。

リクエストボディを読み切るラッパー

ここが地味な落とし穴です。ContentCachingRequestWrapper は内部のキャッシュに getInputStream() が消費されたバイトしか蓄積しないため、chain.doFilter 前にハッシュを計算しようとすると空配列が返ります。Filter段で先にボディハッシュが欲しい今回の用途では使えません。

そこで、ストリームを自前で読み切って再生可能にしたラッパーを用意します。

public class CachedBodyRequestWrapper extends HttpServletRequestWrapper {
    private final byte[] body;

    public CachedBodyRequestWrapper(HttpServletRequest req) throws IOException {
        super(req);
        this.body = StreamUtils.copyToByteArray(req.getInputStream());
    }

    public byte[] getBody() { return body; }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream in = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            public int read() { return in.read(); }
            public boolean isFinished() { return in.available() == 0; }
            public boolean isReady() { return true; }
            public void setReadListener(ReadListener l) {}
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
    }
}

これでFilter内ではハッシュ計算に使え、ハンドラ側でも問題なくボディを読めます。

OncePerRequestFilterの実装

POSTとPATCHのみ対象にして、それ以外はそのまま通します。キー値も入口で軽くバリデーションし、不正値は400で弾きます。

@Component
public class IdempotencyFilter extends OncePerRequestFilter {
    private static final String HEADER = "Idempotency-Key";
    private static final Duration TTL = Duration.ofHours(24);
    private static final Pattern KEY_PATTERN = Pattern.compile("^[A-Za-z0-9-]{8,128}$");

    private final IdempotencyStore store;

    public IdempotencyFilter(IdempotencyStore store) { this.store = store; }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String key = req.getHeader(HEADER);
        String method = req.getMethod();
        if (key == null || !(method.equals("POST") || method.equals("PATCH"))) {
            chain.doFilter(req, res);
            return;
        }
        if (!KEY_PATTERN.matcher(key).matches()) {
            res.setStatus(HttpStatus.BAD_REQUEST.value());
            res.getWriter().write("{\"error\":\"invalid Idempotency-Key format\"}");
            return;
        }

        CachedBodyRequestWrapper reqWrapper = new CachedBodyRequestWrapper(req);
        ContentCachingResponseWrapper resWrapper = new ContentCachingResponseWrapper(res);
        String bodyHash = sha256(reqWrapper.getBody());

        IdempotencyStore.LockResult lock = store.tryLock(key, bodyHash, TTL);
        if (lock != null) {
            switch (lock.state()) {
                case COMPLETED -> { write(resWrapper, lock.cached()); resWrapper.copyBodyToResponse(); return; }
                case PROCESSING -> {
                    res.setStatus(HttpStatus.CONFLICT.value());
                    res.setHeader("Retry-After", "1");
                    res.getWriter().write("{\"error\":\"request in progress\"}");
                    return;
                }
                case MISMATCH -> {
                    res.setStatus(HttpStatus.CONFLICT.value());
                    res.getWriter().write("{\"error\":\"body mismatch for same Idempotency-Key\"}");
                    return;
                }
            }
        }

        chain.doFilter(reqWrapper, resWrapper);

        if (shouldCache(resWrapper.getStatus())) {
            store.complete(key, bodyHash, resWrapper.getStatus(),
                    resWrapper.getContentAsByteArray(), TTL);
        } else {
            store.release(key);
        }
        resWrapper.copyBodyToResponse();
    }

    /** 2xxとバリデーション系の4xx(400/409/422)だけキャッシュする方針。
     *  401/403/404は状態が時間で変わるため除外、5xxは再試行を許容するため除外。 */
    private boolean shouldCache(int status) {
        if (status >= 200 && status < 300) return true;
        return status == 400 || status == 409 || status == 422;
    }
}

shouldCache で 401/403/404 を意図的に除外しているのがポイントです。認可状態や対象リソースは時間で変わるため、24時間も同じレスポンスを返すと事故ります。

同時送信の応答は409 Conflictに統一しました。425 Too Earlyも候補ですが、主要HTTPクライアントの扱いが揺れているため、確実に解釈される409+Retry-Afterのほうが運用上扱いやすい、というのが理由です。

Redisでのキー管理とロック制御

ストア側です。setIfAbsent でアトミックにロックを取り、既に値があれば中身を厳密にパースして状態を判定します。

@Component
public class IdempotencyStore {
    public enum State { COMPLETED, PROCESSING, MISMATCH }
    public record LockResult(State state, CachedResponse cached) {}

    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;

    public IdempotencyStore(StringRedisTemplate redis, ObjectMapper mapper) {
        this.redis = redis;
        this.mapper = mapper;
    }

    public LockResult tryLock(String key, String bodyHash, Duration ttl) throws IOException {
        String redisKey = "idem:" + key;
        String value = mapper.writeValueAsString(Map.of("state", "processing", "hash", bodyHash));
        Boolean acquired = redis.opsForValue().setIfAbsent(redisKey, value, ttl);
        if (Boolean.TRUE.equals(acquired)) return null; // 新規取得→呼び出し側で処理続行

        JsonNode existing = mapper.readTree(redis.opsForValue().get(redisKey));
        if (!bodyHash.equals(existing.path("hash").asText())) {
            return new LockResult(State.MISMATCH, null);
        }
        if ("completed".equals(existing.path("state").asText())) {
            return new LockResult(State.COMPLETED, toCached(existing));
        }
        return new LockResult(State.PROCESSING, null);
    }
    // complete / release / toCached / sha256 などは省略
}

ここが前回版からの最大の修正点です。同一キー+同一ボディの並行リクエストでも、先発がまだ processing のうちは後発を必ず PROCESSING として返し、chain.doFilter には進めません。これで「同時に2回送られて2件作られた」という本来防ぎたい状況を確実に止められます。

statehash は文字列の部分一致ではなくJSONとして厳密にパースし、フィールド単位で比較しています。

Filterの登録順序

FilterRegistrationBean で対象URLと順序を指定します。

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<IdempotencyFilter> register(IdempotencyFilter filter) {
        FilterRegistrationBean<IdempotencyFilter> bean = new FilterRegistrationBean<>(filter);
        bean.addUrlPatterns("/api/payments/*", "/api/orders/*");
        // Spring SecurityのFilterChainProxy(DEFAULT_FILTER_ORDER=-100)より後に動かす
        bean.setOrder(0);
        return bean;
    }
}

Spring Securityより前に置くと、未認証ユーザーがIdempotency-Keyだけで処理キャッシュを汚染できてしまいます。必ず認証・認可の後に動かしてください。

TTLとエラー応答の扱い

ステータスごとの扱いは次の通りです。

  • 2xxは基本キャッシュ。これが本来の目的。
  • 400/409/422のようなバリデーション系のみキャッシュ。再送しても結果が変わらないため。
  • 401/403/404はキャッシュしない。認可状態やリソース存在が時間で変わるため。
  • 5xxもキャッシュしない。クライアントの再試行を許容する。

TTLはStripe同様に 24時間 が出発点として妥当です。長すぎるとRedisを圧迫し、短すぎるとクライアントのリトライ窓を覆えません。

なお同一キーで異なるボディが来た場合のステータスについて、本記事ではStripe方式に合わせて409を返しています。IETF draftでは422 Unprocessable Contentの提案もあるため、API仕様としてどちらを採用するかは事前に決めておくとよいでしょう。

動作確認

同じキーで2回POSTして、レスポンスとDBの状態をチェックします。

KEY=$(uuidgen)
for i in 1 2; do
  curl -X POST http://localhost:8080/api/payments \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $KEY" \
    -d '{"amount":1000,"currency":"JPY"}'
done

2回目のレスポンスが1回目と完全一致し、DBに1件しか入っていなければ成功です。並行送信は k6 で同じキーを20並列ほど投げるとロック制御の動きが確認できます。テスト自動化はTestcontainersでRedisを立てるのが手堅いです。CRUD APIの土台は Spring BootでREST APIのCRUDを作るチュートリアル も合わせて参照してください。

本番運用での注意点

まず キーのスコープ です。ユーザー×エンドポイントでスコープを切り、Redisキーに idem:{userId}:{endpoint}:{key} のように識別子を含めます。グローバルにすると、別ユーザーのレスポンスが偶発的に返るリスクがあります。

次に Redis障害時の挙動 です。フェイルクローズ(Redis停止でAPIも止める)なら二重実行は防げますが可用性が落ちます。決済のような重要処理ではフェイルクローズ、それ以外はフェイルオープン、と業務影響度で分けるのが現実的です。

最後に ログ周りの注意 です。Idempotency-Keyはクライアント生成のため、PIIや推測可能な情報を含む可能性があります。ログにそのまま出さず、ハッシュ化や先頭数文字への切り詰めを推奨します。

DB層の競合制御と組み合わせるとさらに堅牢になります。気になる方は Spring Boot JPAの楽観的ロック(@Version)実装ガイド も参照してみてください。

まとめ

Idempotency-Keyは、決済や注文といったクリティカルなPOSTを守るための、実装コストの低い盾です。Spring BootならOncePerRequestFilterとRedisの組み合わせで、思ったよりシンプルに書けます。

ポイントは、自前ラッパーでボディを読み切ってからハッシュ化すること、setIfAbsent でロックを確保し processing 状態の後発リクエストは409+Retry-Afterで弾くこと、完了レスポンスは2xxとバリデーション系の4xxのみキャッシュすること、TTLは24時間。あとは自分のドメインに合わせて、スコープ設計と障害時の挙動を決めていきましょう。