サブスクリプション課金・更新システムの設計

1. 概要

サブスクリプション更新は一見簡単です。期日が来たら顧客から料金を徴収するだけです。しかし実際には多くの落とし穴があります。二重に請求してしまう、ある期間を見落とす、または初日に支払いゲートウェイをダウンさせてしまう可能性があります。

この記事では、設計全体を説明します。要件、データモデル、API、処理フロー、難しい点です。各難しい点には、Java 21、Spring Boot 3.x、PostgreSQL、Redis、Kafkaを使ったコード例が付いています。最後まで読めば、実際のシステムに適用できるしっかりした設計フレームワークが手に入ります。

2. 要件分析

2.1. 機能要件

  • 顧客は月単位または年単位のプラン(Plan)に登録します。
  • システムは期日が終わると自動的に更新して料金を徴収します。
  • 顧客は即座にキャンセルするか、期末にキャンセルすることができます。
  • 顧客は期間途中でプランを変更できます。料金は残りの時間に対して按分計算されます(proration)。
  • 支払い失敗時、システムはスケジュールに従って再試行します(dunning)。再試行回数を超えたらサブスクリプションをキャンセルします。
  • 他のシステム(メール、権限管理など)はInvoicePaidとSubscriptionCanceledイベントを受け取ります。

2.2. 規模

  • 500万件のアクティブなサブスクリプション。
  • 平均すると毎日約17万件の更新。
  • 初日(毎月1日)のピークは約100万件で、プロモーションキャンペーンによる開始日の集中により発生します。
  • 支払いゲートウェイは200 TPSに制限されています。100万件で、請求部分だけでも約1.4時間かかります。

2.3. 制約

  • 絶対に同じ期間に対して二重請求しない。
  • どの期間も見落とさない、たとえインスタンスが途中で落ちても。
  • 金額は最小単位で整数で格納(BIGINT)。doubleは使わない。
  • 日時はUTC(TIMESTAMPTZ)で格納。
  • サービスはロードバランサーの後ろで複数インスタンスで動作します。

3. データモデル

システムには3つのメインテーブルと1つのアウトボックステーブルがあります。

CREATE TABLE plan (
    id               BIGSERIAL PRIMARY KEY,
    code             VARCHAR(50) UNIQUE NOT NULL,
    price_amount     BIGINT      NOT NULL,
    currency         CHAR(3)     NOT NULL,
    billing_interval VARCHAR(10) NOT NULL          -- MONTH | YEAR
);

CREATE TABLE subscription (
    id                   BIGSERIAL PRIMARY KEY,
    customer_id          BIGINT      NOT NULL,
    plan_id              BIGINT      NOT NULL REFERENCES plan(id),
    status               VARCHAR(20) NOT NULL,     -- ACTIVE | PAST_DUE | CANCELED
    current_period_end   TIMESTAMPTZ NOT NULL,
    cancel_at_period_end BOOLEAN     NOT NULL DEFAULT FALSE,
    version              BIGINT      NOT NULL DEFAULT 0
);
CREATE INDEX idx_sub_due ON subscription (current_period_end) WHERE status = 'ACTIVE';

CREATE TABLE invoice (
    id              BIGSERIAL PRIMARY KEY,
    subscription_id BIGINT      NOT NULL REFERENCES subscription(id),
    period_start    TIMESTAMPTZ NOT NULL,
    period_end      TIMESTAMPTZ NOT NULL,
    amount          BIGINT      NOT NULL,
    status          VARCHAR(20) NOT NULL,          -- OPEN | PAID | FAILED
    attempt_count   INT         NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ,
    UNIQUE (subscription_id, period_start)
);
CREATE INDEX idx_inv_retry ON invoice (next_attempt_at) WHERE status = 'OPEN';

CREATE TABLE outbox_event (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT       NOT NULL,
    event_type   VARCHAR(50)  NOT NULL,
    payload      JSONB        NOT NULL,
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ
);

注目すべき決定がいくつかあります:

  • UNIQUE (subscription_id, period_start)は最後の安全弁です。コードにバグがあっても、データベースは同じ期間に対して2つのインボイスを作成することを許しません。
  • 部分インデックスidx_sub_dueはACTIVEなサブスクリプションのみを含みます。更新期日が来たサブスクリプションを検索するクエリは常に小さく高速です。
  • versionカラムはオプティミスティックロックを提供し、プラン変更やキャンセルなどのAPI操作に使用されます。

4. API

Method Endpoint 説明
POST /subscriptions サブスクリプションを作成。Idempotency-Keyヘッダが必須
GET /subscriptions/{id} ステータスと現在の期間を確認
POST /subscriptions/{id}/cancel Body {"atPeriodEnd": true}
POST /subscriptions/{id}/change-plan Body {"planCode": "PRO_MONTHLY"}
POST /webhooks/payments 支払いゲートウェイが非同期で結果を報告

すべての書き込み操作は冪等であることが必須です。モバイルネットワークの遅延やリトライ、顧客がサインアップボタンを2回押すなど、1つのサブスクリプションのみが作成されるべきです。

5. 更新処理フロー

更新処理は2つの独立したステップに分かれます:

  1. インボイス作成:期日が来たサブスクリプションを見つけて、OPENステータスでinvoiceを作成し、current_period_endを新しい期間に進めます。このステップはデータベースのみで動作します。
  2. 料金徴収:OPENステータスでnext_attempt_at <= nowであるインボイスを見つけて、支払いゲートウェイを呼び出し、結果を記録します。

このように分ける理由は、支払いゲートウェイの呼び出しに数秒かかるためです。行ロックを保持するトランザクション内で直接呼び出すと、connection poolをすぐに枯らしてしまいます。

まず、以下の依存関係が必要です:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

6. 難しい点と解決策

6.1. 競合:複数インスタンスが同時にジョブを実行

各インスタンスは@Scheduledを持っています。すべてが一度に期日が来たサブスクリプションをSELECTする場合、同じサブスクリプションは複数回処理されます。解決策はFOR UPDATE SKIP LOCKEDを使うことです:

public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {

    @Query(value = """
            SELECT * FROM subscription
            WHERE status = 'ACTIVE' AND current_period_end <= :now
            ORDER BY current_period_end
            LIMIT :batchSize
            FOR UPDATE SKIP LOCKED
            """, nativeQuery = true)
    List<Subscription> lockDueBatch(Instant now, int batchSize);
}

FOR UPDATEは選択された行をロックします。SKIP LOCKEDは他のインスタンスにそれらの行をスキップさせて、次のバッチを取得するようにします。これにより、各インスタンスは固有のセットを受け取ります。スループットを増やすには、インスタンスを追加するだけです。

サービスはトランザクション内で各バッチを処理します:

@Service
@RequiredArgsConstructor
public class RenewalService {

    private final SubscriptionRepository subscriptions;
    private final InvoiceRepository invoices;

    @Transactional
    public int createDueInvoices(Instant now) {
        List<Subscription> due = subscriptions.lockDueBatch(now, 500);
        for (Subscription sub : due) {
            if (sub.isCancelAtPeriodEnd()) {
                sub.cancel();
                continue;
            }
            Instant start = sub.getCurrentPeriodEnd();
            Instant end = sub.getPlan().nextPeriodEnd(start);
            invoices.save(Invoice.open(sub, start, end, sub.getPlan().getPriceAmount(), now));
            sub.advancePeriod(end);
        }
        return due.size();
    }
}

ループ内で、サブスクリプションにcancelAtPeriodEndフラグがある場合、更新する代わりにキャンセルされます。残りのサブスクリプションはnext_attempt_at = nowでOPENインボイスが作成され、期間が新しいマイルストーンに進みます。両方の変更は1つのトランザクション内にあるため、「インボイスが作成されたが期間がまだ進んでいない」という状態は存在しません。

スケジューラはこのサービスを何度も呼び出します:

@Scheduled(fixedDelay = 10_000)
public void renew() {
    while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}

プラン変更やキャンセルなどのAPI操作も、更新ジョブと競合する可能性があります。JPAのversionカラムと@Versionアノテーションがこれを処理します。後でcommitする方がOptimisticLockExceptionを受け取ります。クライアントは状態を再度読み取って再試行するだけです。

6.2. 二重請求しない:冪等性キー

これが最も難しい点です。支払いゲートウェイを呼び出してタイムアウトします。顧客がすでに請求されたかどうかわかりません。無分別に再試行すると、二重請求されてしまう可能性があります。

解決策は冪等性キーを送ることです。支払いゲートウェイが同じキーを受け取ると、古い結果を返し、追加で請求しません:

public void chargeInvoice(long invoiceId) {
    Invoice inv = invoices.findById(invoiceId).orElseThrow();
    if (inv.getStatus() != InvoiceStatus.OPEN) {
        return;
    }
    String key = "inv-%d-try-%d".formatted(inv.getId(), inv.getAttemptCount());
    PaymentResult result = paymentGateway.charge(inv.getCustomerId(), inv.getAmount(), key);
    billingTx.recordResult(invoiceId, result);
}

キーはinvoiceIdとattemptCountで構成されています。単にinvoiceIdだけではありません。多くの支払いゲートウェイはキーに従って失敗の結果も保存します。単一の一意のキーを使用する場合、3日後の再試行時(顧客がお金を入金した後)でも、古い「カード拒否」エラーが返されてしまいます。

attemptCountを増やすルール:

  • 成功:インボイスはPAIDに遷移します。
  • 明確な拒否(残高不足、カード期限切れ):attemptCountを増やして後で再試行をスケジュール。
  • 結果不明(タイムアウト、5xxエラー):attemptCountはそのままにしてください。後の再試行は同じ古いキーを使用するため、支払いゲートウェイは前回の実際の結果を返します。

注意: 支払いゲートウェイからのWebhookは、前に到着したり、後に到着したり、何度も到着したりする可能性があります。Webhookハンドラはインボイスの状態を確認してから更新する必要があります。すでにPAIDであるインボイスは無視します。

6.3. 支払い失敗時の再試行スケジュール(ダニング)

再試行スケジュールは純粋な関数なので、非常にテストしやすいです:

public final class DunningPolicy {

    private static final List<Duration> SCHEDULE =
            List.of(Duration.ofDays(1), Duration.ofDays(3), Duration.ofDays(5));

    public static Optional<Instant> nextAttempt(int failedAttempts, Instant now) {
        if (failedAttempts > SCHEDULE.size()) {
            return Optional.empty();
        }
        long jitterMinutes = ThreadLocalRandom.current().nextLong(0, 120);
        return Optional.of(now.plus(SCHEDULE.get(failedAttempts - 1)).plusSeconds(jitterMinutes * 60));
    }
}

最初の失敗の後、システムは1日後に再試行します。2回目は3日後、3回目は5日後です。4回目の失敗の後、この関数はOptional.empty()を返します。その時点で、インボイスはFAILEDに遷移し、サブスクリプションはCANCELEDに遷移します。ダニング期間中、サブスクリプションはPAST_DUE状態にあり、顧客はまだサービスを使用できます(猶予期間)。

ランダムな0~120分のジッターにより、再試行のタイミングが同じポイントに集中しません。

6.4. データベースとKafka間の一貫性:アウトボックスパターン

インボイスにPAIDのマークを付けた後、InvoicePaidイベントを送信する必要があります。DBをcommitした後にKafkaを送信する場合、インスタンスが途中で落ちるとイベントが失われる可能性があります。Kafkaを先に送信してからDBをrollbackする場合、他のシステムは「幻」のイベントを受け取ります。

アウトボックスパターンは、同じトランザクション内にoutbox_eventテーブルにイベントを書き込むことで解決します:

@Transactional
public void recordResult(long invoiceId, PaymentResult result) {
    Invoice inv = invoices.findById(invoiceId).orElseThrow();
    switch (result.status()) {
        case SUCCESS -> {
            inv.markPaid();
            outbox.save(OutboxEvent.of(inv.getId(), "InvoicePaid", inv.toJson()));
        }
        case DECLINED -> inv.recordFailure(Instant.now());
        case UNKNOWN -> inv.retryLater(Instant.now().plus(Duration.ofMinutes(15)));
    }
}

この関数は支払いゲートウェイ呼び出しから分離された独立したトランザクションで実行されます。リレーは定期的に実行され、アウトボックスを読んでKafkaに送信します:

@Scheduled(fixedDelay = 1_000)
@Transactional
public void publishOutbox() {
    for (OutboxEvent e : outbox.lockUnpublished(200)) {
        kafka.send("billing-events", String.valueOf(e.getAggregateId()), e.getPayload()).join();
        e.markPublished(Instant.now());
    }
}

lockUnpublishedも6.1のFOR UPDATE SKIP LOCKEDを使います。join()呼び出しはKafkaがメッセージを受け取った後にcommitしてからpublished_atにマークします。メッセージのキーはaggregateIdなので、同じインボイスのイベントは常に同じパーティションに入り、順序を保ちます。

このアプローチはat-least-onceを保証します。リレーがcommitの前に送信した後に落ちた場合、イベントは再度送信されます。そのため、consumerは冪等でなければなりません。例えば、処理済みのeventIdを保存します。

6.5. バースト時の処理:初日(毎月1日)

初日には100万件のインボイスを徴収する必要があります。支払いゲートウェイは200 TPSのみ対応できます。3層の防御があります:

  1. 請求タイミングの分散:current_period_endは顧客の登録時刻に基づいて設定されます。時間と分を含めます。これだけでも、負荷を24時間全体に分散させることができます。
  2. 自然なキュー:インボイスはDBにnext_attempt_atで存在します。ワーカーは能力に応じて処理します。何も失われませんが、遅くなります。
  3. Redisを使ったグローバルレート制限:すべてのインスタンスが共有カウンタを使用します。
@Component
@RequiredArgsConstructor
public class GatewayRateLimiter {

    private final StringRedisTemplate redis;
    private final int limitPerSecond = 200;

    public boolean tryAcquire() {
        String key = "rl:gateway:" + Instant.now().getEpochSecond();
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofSeconds(2));
        }
        return count != null && count <= limitPerSecond;
    }
}

各秒ごとに独自のキーがあります。INCRはアトミック操作なので、10インスタンスあっても1秒間の呼び出し総数は200を超えません。ワーカーがfalseを受け取るとインボイスを置いて、後のラウンドで処理します。これは固定ウィンドウアルゴリズムです。シンプルで十分ですが、2つのウィンドウの境界でわずかにオーバーシュートすることがあります。

注意: Redisにアクセスできない場合は、レート制限を無視するのではなく、fail-closed(料金徴収を一時停止)します。数分の遅延は、支払いゲートウェイプロバイダーがマーチャントアカウントをロックするより良いです。

7. 期間途中でのプラン変更の料金とテスト

顧客が期間途中でプランをアップグレードする場合、システムは古いプランの未使用部分を返金し、新しいプランの残りの部分を徴収します:

public final class Proration {

    public static long amountDue(long oldPrice, long newPrice,
                                 Instant periodStart, Instant periodEnd, Instant changeAt) {
        long total = Duration.between(periodStart, periodEnd).toSeconds();
        long remaining = Duration.between(changeAt, periodEnd).toSeconds();
        long credit = Math.floorDiv(oldPrice * remaining, total);
        long charge = Math.floorDiv(newPrice * remaining, total);
        return charge - credit;
    }
}

この関数は乗算してから除算するので、精度の喪失を避けます。すべての計算はlongを使用します。負の結果は、顧客がプランをダウングレードしたことを意味し、その金額は次の期間のクレジットとして記録されます。

JUnit 5とAssertJでテストします:

class BillingRulesTest {

    private final Instant start = Instant.parse("2026-10-01T00:00:00Z");
    private final Instant end = Instant.parse("2026-10-31T00:00:00Z");

    @Test
    void upgradeAtHalfPeriodChargesHalfOfDifference() {
        Instant mid = Instant.parse("2026-10-16T00:00:00Z");

        long due = Proration.amountDue(100_000, 300_000, start, end, mid);

        assertThat(due).isEqualTo(100_000);
    }

    @Test
    void stopsRetryingAfterThreeScheduledAttempts() {
        Instant now = Instant.parse("2026-10-01T00:00:00Z");

        assertThat(DunningPolicy.nextAttempt(1, now)).get()
                .satisfies(t -> assertThat(t).isBetween(now.plus(Duration.ofDays(1)),
                        now.plus(Duration.ofDays(1)).plus(Duration.ofHours(2))));
        assertThat(DunningPolicy.nextAttempt(4, now)).isEmpty();
    }
}

最初のテストは、30日の期間の正確に中央でプランを100,000から300,000にアップグレードします。クレジットは50,000、徴収は150,000なので、顧客は追加で100,000を支払います。2番目のテストは2つをチェックします。最初の再試行は1日プラスジッター内に落ち、4回目の失敗の後は再試行スケジュールがありません。

BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED

6.1の競合部分では、Testcontainers PostgreSQLを使用して統合テストを書いています。2つのスレッドが同時にcreateDueInvoicesを呼び出し、作成されたインボイス数が期日が来たサブスクリプション数と正確に同じであることをアサートします。

8. よくあるエラー

  • @Transactional内で支払いゲートウェイを呼び出す:トランザクションがロックを保持し、ネットワーク待機中のconnectionが保持されます。読み取り、ゲートウェイ呼び出し外部、結果記録のステップに分離します。
  • @Scheduledに複数インスタンス時のロック機構がない:重複処理につながります。
  • サーバーのタイムゾーンに従うLocalDateTimeで日付を計算:時差エラーと31日の問題が発生します。Instantを使用してください。月ごとの期間の場合は、顧客のタイムゾーンに従ってZonedDateTime.plusMonths(1)を使用してください。このメソッドは1月31日を自動的に2月28日または29日に調整します。
  • すべての再試行で同じ冪等性キーを使用:古いエラーはキャッシュされ、常に返されます。
  • 単純なモデルで少数の顧客のみの場合、システム全体を自分で構築すべきではありません。支払いゲートウェイのビルトイン課金サービスは通常、メンテナンスコストより安いです。複雑な価格設定ロジック、複数のゲートウェイ、または厳格なデータコントロールが必要な場合のみ自分で構築します。

9. 結論

この記事では、数百万の顧客規模に対応できるサブスクリプション課金・更新システムを設計しました。主な点:

  • インボイス作成と料金徴収を2つのステップに分離。
  • FOR UPDATE SKIP LOCKEDを使用して複数インスタンス間で作業を分配し、UNIQUE制約を最後の安全弁として。
  • 冪等性キーを使用して同じ期間に対して二重請求しない。
  • データベースとKafka間の一貫性を保つためにアウトボックスパターンを使用。
  • 請求タイミングの分散、DB内キュー、Redisレート制限を組み合わせてバースト負荷に対応。

このフレームワークから、データベースに支障を与えることなく、税金、割引コード、使用量ベースの価格設定など、さらに拡張することができます。

system-designspring-bootbillingkafkaredis