決済システム設計:冪等性、和解、払い戻し

1. 概要

決済問題は通常のCRUD問題とは異なります。1回の誤りは実際のお金を失うことになります。クライアントの再試行により、顧客が2回引き落とされる可能性があります。支払いゲートウェイ(PSP)はトランザクション成功後もタイムアウトする可能性があります。2つの返金リクエストが並行実行されると、収集額を超える金額が払い戻される可能性があります。

本記事では、要件、データモデル、API、処理フローを含む具体的な設計を説明します。その後、3つの難しい点に対処します。冪等性、和解(調整)、払い戻しです。読み終わったら、Spring Boot 3とPostgreSQLを使用してコアロジックを構築でき、紛争時にシステムが払い戻し限度額を超えないことを証明するテストも含まれています。

2. 問題の要件

2.1. 機能要件

  • マーチャントは注文に対して支払いを作成します。システムはPSPを呼び出してカードまたはウォレットから資金を引き落とします。
  • マーチャントは全額または部分的に返金できます。返金総額は収集額を超えません。
  • 毎日、システムは内部データをPSPの決済ファイルと和解します。
  • システムは(PaymentCaptured、RefundSucceededなど)イベントを発行して、OrderやAccountingなどの他のサービスに通知します。

2.2. スケールと制約

  • 平均200トランザクション/秒。フラッシュセール時のピーク時は数分間で約5,000トランザクション/秒。
  • 同じ支払い意図に対して2回資金を引き落とすことは決して許可されません。これはハード制約です。
  • 可用性よりも一貫性を優先します:クライアントにエラーを返す方が、間違った金額を記録するよりも良いです。
  • PSPは外部システムです。遅延、タイムアウト、またはウェブフック遅延や重複が発生する可能性があります。

3. データモデル

金額は常に最小単位で保存されます(VNDの場合は1ドン、USDの場合はセント)。型はBIGINTです。doubleは使用しません。

CREATE TABLE payments (
    id              UUID PRIMARY KEY,
    merchant_id     TEXT        NOT NULL,
    order_id        TEXT        NOT NULL,
    amount          BIGINT      NOT NULL CHECK (amount > 0),
    currency        CHAR(3)     NOT NULL,
    status          TEXT        NOT NULL, -- CREATED, PROCESSING, CAPTURED, FAILED, UNKNOWN
    refunded_amount BIGINT      NOT NULL DEFAULT 0,
    psp_reference   TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (refunded_amount <= amount)
);

CREATE TABLE refunds (
    id          UUID PRIMARY KEY,
    payment_id  UUID   NOT NULL REFERENCES payments(id),
    amount      BIGINT NOT NULL CHECK (amount > 0),
    status      TEXT   NOT NULL, -- PENDING, SUCCEEDED, FAILED
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE idempotency_keys (
    merchant_id   TEXT NOT NULL,
    idem_key      TEXT NOT NULL,
    request_hash  TEXT NOT NULL,
    status        TEXT NOT NULL, -- IN_PROGRESS, COMPLETED
    response_code INT,
    response_body TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (merchant_id, idem_key)
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      TEXT  NOT NULL,
    payload    JSONB NOT NULL,
    sent_at    TIMESTAMPTZ
);

いくつかの重要なポイント:

  • CHECK (refunded_amount <= amount)はデータベースレイヤーの最後の安全弁です。コードにバグがあってもデータベースがブロックします。
  • idempotency_keysテーブルの複合主キー(merchant_id, idem_key)は重複排除メカニズムのコアです。
  • outboxテーブルは分散トランザクションを必要とせずにKafkaにイベントを発行するのに役立ちます(セクション6を参照)。

4. APIの設計

POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body:    { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201  { "id": "...", "status": "CAPTURED" }

POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body:    { "amount": 100000 }
→ 202  { "refundId": "...", "status": "PENDING" }

GET  /v1/payments/{id}

状態を変更するすべてのAPIには、クライアントが生成するIdempotency-Keyが必須です。クライアントは再試行時に同じキーを送信します。返金は202 Acceptedを返します。PSPが非同期で処理するためです。

5. 冪等性:二重引き落としの防止

5.1. 考え方

リクエストを受け取ると、何かする前にキーをデータベースに記録します。3つのシナリオがあります:

  1. キーがまだ存在しない:成功して記録し、処理を続行します。
  2. キーがCOMPLETED:保存されたレスポンスを返し、PSPを再度呼び出しません。
  3. キーがIN_PROGRESS:同じキーを持つ別のリクエストが実行中です。409 Conflictを返して、クライアントに後で再試行するよう促します。

さらに、本体のハッシュを保存します。クライアントが別の本体で同じキーを再利用する場合(例えば異なる金額)、それはクライアントエラーであり、422を返します。

5.2. 実装

必要な依存関係:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
@Service
public class IdempotencyService {

    private final JdbcTemplate jdbc;

    public IdempotencyService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public record StoredResponse(int code, String body) {}

    public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
        int inserted = jdbc.update("""
            INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
            VALUES (?, ?, ?, 'IN_PROGRESS')
            ON CONFLICT (merchant_id, idem_key) DO NOTHING
            """, merchantId, key, requestHash);
        if (inserted == 1) {
            return Optional.empty(); // 新しいリクエスト、処理を進める
        }
        Map<String, Object> row = jdbc.queryForMap("""
            SELECT request_hash, status, response_code, response_body
            FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
            """, merchantId, key);
        if (!requestHash.equals(row.get("request_hash"))) {
            throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
        }
        if ("IN_PROGRESS".equals(row.get("status"))) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "Request is being processed");
        }
        return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
    }

    public void complete(String merchantId, String key, int code, String body) {
        jdbc.update("""
            UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
            WHERE merchant_id = ? AND idem_key = ?
            """, code, body, merchantId, key);
    }
}

ON CONFLICT DO NOTHINGは「チェックしてから書き込む」を単一の原子的命令に変換します。同じキーのリクエストが同時到着すると、1つだけinserted == 1となります。もう1つのリクエストは読み直しブランチに入って409を受け取ります。「両方ともキーがまだ存在しないと考える」というレースコンディションはありません。

注意: 支払い冪等性のための単なるRedis SETNXを信実の源として使用しないでください。Redisのフェイルオーバーまたはキー削除により、信実を失い、金額が2回引き落とされます。Redisはフロントキャッシュレイヤーのみであり、データベースが仲裁者です。

5.3. PSP側の冪等性

あなたのリクエストは保護されていますが、PSPへの呼び出しも再試行される可能性があります。ほとんどのPSPは独自の冪等性キーをサポートしています。payments.idそのものをそのキーとして渡します:

PspResult result = pspClient.charge(new ChargeRequest(
        payment.id().toString(), // PSPに送信する冪等キー
        payment.amount(),
        payment.currency(),
        sourceToken));

PSPへの呼び出しがタイムアウトすると、金額がすでに引き落とされているかどうかわかりません。状態をFAILEDにマークしないでください。代わりにUNKNOWNに設定し、その後payments.idでPSPに再問い合わせするジョブを用意します。FAILEDとマークして、クライアントに新しいキーで再試行するよう指示することは、金額を2回引き落とすへの古典的な道です。

6. DatabaseとKafkaの一貫性:Transactional Outbox

金額を収集した後、paymentsを更新してPaymentCapturedイベントを発行します。データベース書き込みは成功したがKafka送信が失敗した場合(またはその逆)、Orderサービスの状態がずれます。解決策は、イベントをoutboxテーブルに同じトランザクション内で書き込むことです:

@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
    jdbc.update("UPDATE payments SET status = 'CAPTURED', psp_reference = ? WHERE id = ? AND status IN ('PROCESSING','UNKNOWN')",
            pspReference, paymentId);
    jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
            """
            {"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}

リレー(定期的なジョブまたはDebezium CDC)はsent_at IS NULLの行を読み、Kafkaに送信した後、sent_atを更新します。リレーは重複送信する可能性があるため、コンシューマは冪等である必要があります。例えば、処理済みpaymentIdを保存します。UPDATEステートメントのstatus IN (...)条件は、PSPからの重複ウェブフックが別の終了状態を上書きするのを防ぎます。

7. 返金と同時紛争

7.1. 問題

支払い100,000VND。カスタマーサービス担当者と自動ジョブが両方とも60,000VNDを返金するよう指示を送信します。コードが「refunded_amountを読み、チェック、書き込む」のように動作する場合、両方とも0 + 60,000 <= 100,000を見て、両方が承認されます。結果として、120,000VNDを払い戻します。

7.2. 解決策:条件付きアップデート

チェックと更新を**単一の条件付きUPDATE**に統合します:

@Service
public class RefundService {

    private final JdbcTemplate jdbc;

    public RefundService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public UUID requestRefund(UUID paymentId, long amount) {
        int reserved = jdbc.update("""
            UPDATE payments SET refunded_amount = refunded_amount + ?
            WHERE id = ? AND status = 'CAPTURED' AND refunded_amount + ? <= amount
            """, amount, paymentId, amount);
        if (reserved == 0) {
            throw new IllegalStateException("Refund exceeds refundable amount or payment not captured");
        }
        UUID refundId = UUID.randomUUID();
        jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDING')",
                refundId, paymentId, amount);
        jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
                """
                {"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
        return refundId;
    }
}

PostgreSQLはUPDATE時にpayments行をロックします。2番目のトランザクションは最初のトランザクションのコミットを待つ必要があります。その後、新しいデータの条件WHEREを再評価し、60,000 + 60,000 > 100,000を見てreserved == 0を受け取ります。これはデフォルトのREAD COMMITTED分離レベルでの動作であり、SERIALIZABLEは必要ありません。

先に金額を「予約」し、その後PSPを非同期で呼び出し、outboxを通じて呼び出します。PSPが返金を拒否する場合、コンシューマは予約済み部分を取り戻します:

UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds  SET status = 'FAILED' WHERE id = :refundId AND status = 'PENDING';

これら両方のステートメントは同じトランザクション内にある必要があります。status = 'PENDING'条件は、失敗したウェブフックが重複送信されても、予約済みを2回取り戻すのを防ぎます。さらに厳密にするには、2番目のステートメントが正確に1行を更新した場合のみ、最初のステートメントを実行できます。

7.3. 同時実行テスト

テスト依存関係:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired RefundService refundService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
        UUID paymentId = UUID.randomUUID();
        jdbc.update("""
            INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
            VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURED')
            """, paymentId);

        Callable<Boolean> refund60k = () -> {
            try {
                refundService.requestRefund(paymentId, 60_000);
                return true;
            } catch (IllegalStateException e) {
                return false;
            }
        };

        List<Boolean> results;
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            results = executor.invokeAll(List.of(refund60k, refund60k))
                    .stream().map(Future::resultNow).toList();
        }

        assertThat(results).containsExactlyInAnyOrder(true, false);
        Long refunded = jdbc.queryForObject(
                "SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
        assertThat(refunded).isEqualTo(60_000L);
    }
}

テストはスキーマ(セクション3)がsrc/test/resources/schema.sqlを通じてロードされることを想定しています(spring.sql.init.mode=always付き)またはFlywayを通じて。2つのvirtual threadが同時に返金を送信します。期待される結果は常に安定しています:

results          = [true, false]   (順序は任意)
refunded_amount  = 60000

UPDATEステートメントをJavaでの読み取りと比較に置き換えると、テストが時々失敗することに気づくでしょう。これがこの設計が排除するレースコンディションです。

8. 和解(調整)

あなたがどれだけ注意を払っても、内部データとPSPの間には不一致が生じる可能性があります。ウェブフックが失われる可能性があり、解決されていないUNKNOWNトランザクションがある可能性があり、またはPSPが自身でチャージバックのために返金する可能性があります。和解は最後の防衛線です。

毎日のフロー:

  1. PSPの決済ファイルをロードして、psp_settlements(psp_reference, amount, type)の一時テーブルに挿入します。
  2. FULL OUTER JOINを使用して内部データと照合します。
  3. 不一致をrecon_breaksテーブルに記録して、自動化または手動処理します。
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
       p.amount,
       s.amount,
       CASE
           WHEN p.psp_reference IS NULL THEN 'MISSING_INTERNAL'
           WHEN s.psp_reference IS NULL THEN 'MISSING_AT_PSP'
           ELSE 'AMOUNT_MISMATCH'
       END
FROM (SELECT * FROM payments
      WHERE status = 'CAPTURED' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
       ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
   OR s.psp_reference IS NULL
   OR p.amount <> s.amount;

各不一致タイプの意味:

  • MISSING_INTERNAL:PSPが金額を収集したが、あなた側にCAPTUREDレコードがありません。通常はUNKNOWNトランザクションまたは失われたウェブフックです。状態を更新するか、注文がすでにキャンセルされている場合は返金することで対処します。
  • MISSING_AT_PSP:あなた側は収集済みを記録したが、PSPにはありません。これは、あなたが配送を済ませたが支払いを受け取らなかった可能性があるため、重大です。
  • AMOUNT_MISMATCH:通常は手数料、為替レート、または丸め誤差が原因です。

注意: 時間枠に注意してください。23:59のトランザクションは翌日の決済ファイルに含まれる可能性があります。T+1またはT+2の遅延で和解し、ブレークが連続する2回の実行後のみ人間に報告してください。

9. 突発的な負荷

フラッシュセール時、負荷は25倍に増加します。システムが対応するのに役立つ数つかのテクニック:

  • マーチャントごとのレート制限。Redis(INCR + EXPIREを1秒ウィンドウで使用)。これにより、1つのマーチャントの「ノイズ」からシステムを保護します。ここではRedisは制限のみに使用され、トランザクションの正確さを決定しません。
  • 同期パートと非同期パートの分離。同期パスには冪等キーの記録、支払い作成、PSP認可呼び出しのみが含まれます。返金、メール送信、会計更新はすべてoutbox→Kafkaを通じます。paymentIdでKafkaをパーティション化して、各支払いのイベント順序を保ちます。
  • PSP呼び出し時のCircuit breakerと短いタイムアウト(例えばResilience4j)。PSPが遅い場合、素早く失敗して503を返してRetry-Afterを含めます。これはスレッドプールの枯渇を待つ代わりにです。同じ Idempotency-Keyで再試行するクライアントはセクション5のおかげで安全です。
  • idempotency_keysテーブルを定期的にクリーン(24~72時間保持)し、日ごとにパーティション化して、テーブルが膨大になりINSERT操作が遅くなるのを避けます。

10. 一般的なミス

  • double/floatを金額に使用します。最小単位でlongを使用するか、為替レート時にBigDecimalを使用します。
  • タイムアウトを失敗と見なします。タイムアウトは未知を意味するため、UNKNOWNに設定してからPSPを再度照会する必要があります。
  • サーバーで冪等キーを生成します。キーはクライアントが生成し、再試行時に保持する必要があります。
  • JavaでRefund残高を確認してからUPDATEします。データベースに1つのステートメントでそれを行わせます。
  • Kafka送信を@Transactional内に行い、データベースと共にロールバックすることを期待します。ロールバックしません。outboxを使用してください。
  • 「コードは正しいので」和解をスキップします。外部システムはあなたの制御下にありません。

11. 結論

この記事は3つの原則に基づいた支払いシステムのコアを設計しました。まず、冪等性はデータベース内の一意の制約によって保証され、APIとPSP呼び出しの両方で。次に、返金時の同時実行の競合は条件付き更新とCHECK制約により解決されます。テストは同時実行時にシステムが払い戻し限度額を超えないことを証明しています。3番目に、外部システムとの一貫性は、トランザクション内のoutboxと毎日の和解に基づいています。

3つすべての共通点は、重要な不変量をデータベースレイヤーに下げることです。そこでそれらが原子的に実行されます。同時に、ネットワーク、PSP、およびあなた自身のコードでさえすべて間違える可能性があると常に仮定し、検出と修復のメカニズムを準備してください。

system-designpaymentidempotencyspring-bootpostgresql