バウチャーシステム設計:高負荷時の使用回数制限

1. 概要

「100,000円割引、1,000回限定」というバウチャーは一見シンプルに見えます。しかし、フラッシュセールの時間帯には数万件のリクエストが同時に到着します。その時、システムは本来の1,000回ではなく1,050回の割引を発行してしまい、余った分は実際の損失となります。この記事では、包括的なケーススタディを通じて、要件、データモデル、API、そして競合状態、一貫性、トラフィック急増という課題の解決方法を順に説明します。この記事を読み終わると、シンプルなアトミックなUPDATE文だけで十分な場合と、RedisとKafkaが必要な場合を判断できるようになります。

2. 要件

2.1. 機能

  • バウチャーコード、割引額、最小注文額、有効期間を指定して作成します。
  • 総使用回数と顧客あたりの使用回数を制限します。
  • チェックアウト時、バウチャーは予約(reserve)されます。支払い成功時に確定(confirm)され、キャンセルまたはタイムアウト時に解放(release)されます。

2.2. スケールと制約

  • 約1,000万顧客。フラッシュセール用バウチャーは1,000回限定ですが、最初の数秒間に50,000リクエスト/秒の可能性があります。
  • reserve APIのp99レイテンシーは100ms未満です。
  • 絶対にオーバーセルしない。一時的なアンダーセル(例えば、予約後15分経つまで返却されない)は許容範囲です。
  • リクエストの再試行(retry)は追加の使用回数を消費してはいけません。言い換えると、APIはorderIdに基づいてべき等である必要があります。

3. データモデル

PostgreSQLデータベースが最終的な信頼の源となります:

CREATE TABLE voucher (
    code             VARCHAR(32) PRIMARY KEY,
    discount_amount  BIGINT      NOT NULL,
    min_order_amount BIGINT      NOT NULL DEFAULT 0,
    total_limit      INT         NOT NULL,
    used_count       INT         NOT NULL DEFAULT 0,
    per_user_limit   INT         NOT NULL DEFAULT 1,
    starts_at        TIMESTAMP   NOT NULL,
    ends_at          TIMESTAMP   NOT NULL,
    CONSTRAINT ck_used CHECK (used_count <= total_limit)
);

CREATE TABLE voucher_redemption (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    voucher_code VARCHAR(32) NOT NULL REFERENCES voucher(code),
    customer_id  BIGINT      NOT NULL,
    order_id     VARCHAR(64) NOT NULL UNIQUE,
    status       VARCHAR(16) NOT NULL, -- RESERVED, CONFIRMED, RELEASED
    created_at   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_voucher_customer UNIQUE (voucher_code, customer_id)
);

注目すべき点:

  • CHECK (used_count <= total_limit)は最後の安全弁です。コードにバグがあっても、データベースはオーバーセルを許しません。
  • order_id UNIQUEはべき等性を保証します。1つの注文は最大1回の割引を使用できます。
  • UNIQUE (voucher_code, customer_id)はper_user_limit = 1の場合にのみ有効で、最も一般的なケースです。顧客あたり複数回の使用を許可する場合は、この制約を削除し、別のテーブルでカウントします。

4. API

POST   /vouchers/{code}/reservations        body: {customerId, orderId, orderAmount}
       201 {orderId, discountAmount}
       409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
       422 {error: "NOT_ELIGIBLE"}           (expired, order amount too low)
POST   /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}

orderIdはクライアント(注文サービス)によって生成されます。これにより、リトライ時、クライアントは古いorderIdを再送信し、サーバーはこれが重複したリクエストであることを認識します。

5. 単純な実装と何が悪いのか

@Transactional
public boolean redeemNaive(String code) {
    Voucher v = voucherRepository.findById(code).orElseThrow();
    if (v.getUsedCount() >= v.getTotalLimit()) {
        return false;
    }
    v.setUsedCount(v.getUsedCount() + 1);
    return true;
}

これは古典的な「チェック・ザン・アクト」バグです。2つのスレッドがusedCount = 999を同時に読み、どちらも余裕があると判断し、両方が1000を書き込みます。結果として2人の顧客は割引を受けますが、カウンターは1回だけ増えます。デフォルトのREAD COMMITTED分離レベルでも、トランザクションはこの場合を防ぎません。

6. ソリューション1:データベースでの条件付きUPDATE

最も簡単な修正は、チェックと書き込みを1つのアトミック操作に統合することです:

@Service
public class VoucherService {

    private final JdbcTemplate jdbc;

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

    @Transactional
    public boolean redeem(String code, long customerId, String orderId) {
        int updated = jdbc.update("""
                UPDATE voucher SET used_count = used_count + 1
                WHERE code = ?
                  AND used_count < total_limit
                  AND CURRENT_TIMESTAMP BETWEEN starts_at AND ends_at
                """, code);
        if (updated == 0) {
            return false; // 在庫切れまたは有効期限切れ
        }
        jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'CONFIRMED')
                """, code, customerId, orderId);
        return true;
    }
}

上記のコードは次のように機能します:

  • UPDATE文はバウチャー行をロックします。データベースはロックを取得した後、used_count < total_limitを再評価するため、2つのリクエストが最後の枠を超過することはできません。
  • updated == 0は条件が満たされていないことを意味します。在庫切れまたはバウチャーの有効期限が切れています。
  • 顧客がバウチャーをすでに使用しているか、orderIdが再送信された場合、INSERT文はDuplicateKeyExceptionをスローします。トランザクションはロールバックされ、used_countは元の値に戻ります。コントローラー層で、この例外を409 ALREADY_USEDにマップします。

6.1. 並行負荷下での動作テスト

@JdbcTest
@Import(VoucherService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class VoucherServiceTest {

    @Autowired JdbcTemplate jdbc;
    @Autowired VoucherService voucherService;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM voucher_redemption");
        jdbc.update("DELETE FROM voucher");
        jdbc.update("""
                INSERT INTO voucher(code, discount_amount, total_limit, starts_at, ends_at)
                VALUES ('FLASH100', 100000, 10, DATEADD('DAY', -1, CURRENT_TIMESTAMP),
                        DATEADD('DAY', 1, CURRENT_TIMESTAMP))
                """);
    }

    @Test
    void shouldNeverExceedTotalLimit() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 200; i++) {
                long customerId = i;
                executor.submit(() -> {
                    if (voucherService.redeem("FLASH100", customerId, "order-" + customerId)) {
                        success.incrementAndGet();
                    }
                });
            }
        }

        Integer used = jdbc.queryForObject(
                "SELECT used_count FROM voucher WHERE code = 'FLASH100'", Integer.class);
        assertThat(success.get()).isEqualTo(10);
        assertThat(used).isEqualTo(10);
    }
}

@Transactional(propagation = NOT_SUPPORTED)は@JdbcTestのデフォルトトランザクションを無効にします。そうしないと、setUpで作成されたデータはまだコミットされておらず、他のスレッドから見えません。200個の仮想スレッドが10個の枠を争い、結果は常に正確です:

success = 10, used_count = 10

6.2. ソリューション1の限界

このアプローチは正しく、ほとんどの通常のバウチャー(数百リクエスト/秒)では十分です。問題は、トラフィックが単一の行(ホットロー)に集中する場合にのみ発生します。毎秒50,000リクエストが行FLASH100のロック待ちでキューに入ります。各トランザクションはロックを数ミリ秒保持するため、実際のスループットは数千リクエスト/秒に制限されます。残りのリクエストはタイムアウト、コネクションプールが枯渇し、データベース全体が遅くなり、バウチャーと無関係なテーブルにも影響します。

7. ソリューション2:Lua ScriptでRedisをゲートキーパーとして使用

アイデア:残り使用回数のカウンターをRedisに配置します。Redisはシングルスレッドで実行され、各Lua scriptはアトミックに実行され、マイクロ秒単位で完了します。予約に成功したリクエストだけがデータベースに進みます。1,000回限定のバウチャーの場合、データベースは50,000回ではなく最大1,000回の書き込みしか受け取りません。

依存関係:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

予約スクリプトをresources/scripts/reserve.luaに保存:

-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users      (hash customerId -> 使用回数)
-- KEYS[3] = voucher:{code}:orders     (hash orderId -> customerId)
-- ARGV[1] = customerId, ARGV[2] = perUserLimit, ARGV[3] = orderId
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then return 0 end   -- リトライ、すでに予約済み
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end            -- バウチャーまだロードされていない
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end                      -- 顧客が使用限度に達している
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end      -- 在庫切れ
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1

キーは{code}ハッシュタグを使用します。そのため、Redis Clusterでも3つのキーはすべて同じスロットに配置され、スクリプトが実行されます。

Java側:

@Component
public class VoucherGate {

    public enum Result { RESERVED, DUPLICATE, OUT_OF_STOCK, ALREADY_USED, NOT_LOADED }

    private static final RedisScript<Long> RESERVE =
            RedisScript.of(new ClassPathResource("scripts/reserve.lua"), Long.class);

    private final StringRedisTemplate redis;

    public VoucherGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Result reserve(String code, long customerId, int perUserLimit, String orderId) {
        String p = "voucher:{" + code + "}:";
        Long r = redis.execute(RESERVE,
                List.of(p + "remaining", p + "users", p + "orders"),
                String.valueOf(customerId), String.valueOf(perUserLimit), orderId);
        return switch (r.intValue()) {
            case 1 -> Result.RESERVED;
            case 0 -> Result.DUPLICATE;
            case -1 -> Result.OUT_OF_STOCK;
            case -2 -> Result.ALREADY_USED;
            default -> Result.NOT_LOADED;
        };
    }
}

DUPLICATEは成功と見なされます。クライアントがリトライすると同じ結果が返されます。NOT_LOADEDはバウチャーがまだ「ウォームアップ」されていないことを意味します。SET ... NXを使用してDBから遅延ロードするか、セール開始前にジョブでプリロードするのが良いです:

redis-cli SET "voucher:{FLASH100}:remaining" 1000

さらなる最適化:remainingが0になると、APIゲートウェイまたはローカルキャッシュ(TTL数秒)がOUT_OF_STOCKをRedisに問い合わせることなく直接返せます。数万件の「遅れたリクエスト」は外側の層で停止します。

8. RedisとデータベースのConsistency

Redisは今や決定権を持っていますが、データベースだけが金銭の真実です。同期の方法は2つあります。

方法A:Redisの直後にDBに同期的に書き込みます。 セクション6のUPDATE文を実行したままです。DBでエラーが発生した場合、releaseスクリプトを呼び出してRedisに枠を返します。この方法はシンプルで、より厳密なConsistencyを提供し、枠数が少ない場合に適しています。

方法B:DBをホットパスから完全に分離したいときはKafkaでイベントをプッシュします:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record VoucherReserved(String code, long customerId, String orderId) {}

@Component
public class RedemptionConsumer {

    private final JdbcTemplate jdbc;

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

    @KafkaListener(topics = "voucher-reserved", groupId = "voucher-writer")
    @Transactional
    public void on(VoucherReserved e) {
        int inserted = jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'RESERVED')
                ON CONFLICT (order_id) DO NOTHING
                """, e.code(), e.customerId(), e.orderId());
        if (inserted == 1) {
            jdbc.update("UPDATE voucher SET used_count = used_count + 1 WHERE code = ?", e.code());
        }
    }
}

サービス側では、LuaがRESERVEDを返した後、kafkaTemplate.send("voucher-reserved", code, event)を呼び出します。キーはcodeです。コンシューマーは以下の特性を持ちます:

  • Kafkaはat-least-onceを保証するため、同じイベントが2回到着する可能性があります。ON CONFLICT (order_id) DO NOTHINGは2回目の挿入をno-opにし、used_countは新しい行が実際に挿入されたときだけインクリメントされます。
  • CHECK (used_count <= total_limit)制約は依然として存在します。Redisに不正な値がロードされた場合、DBはそれを拒否し、イベントはdead-letter topicに落ちて、オペレーターが処理します。

注意: Redisが正常に予約してもKafka送信が失敗した場合、その枠は「ハング」状態のままRedisに残ります。これはアンダーセルで、金銭的には安全ですが、クリーンアップが必要です。したがって、対照テスト(セクション8.1)が必要です。

8.1. 予約の有効期限切れと対照テスト

顧客がバウチャーを予約してからカートを放棄した場合、その枠を返す必要があります。定期的なジョブがこれを処理します:

@Scheduled(fixedDelay = 60_000)
public void releaseExpired() {
    List<Map<String, Object>> expired = jdbc.queryForList("""
            UPDATE voucher_redemption SET status = 'RELEASED'
            WHERE status = 'RESERVED' AND created_at < CURRENT_TIMESTAMP - INTERVAL '15 minutes'
            RETURNING voucher_code, customer_id, order_id
            """);
    expired.forEach(row -> voucherGate.release(
            (String) row.get("voucher_code"),
            ((Number) row.get("customer_id")).longValue(),
            (String) row.get("order_id")));
}

UPDATE ... RETURNING文(PostgreSQL)は状態を変更して同時に影響を受けた行を返します。したがって、複数のインスタンスが同時にジョブを実行しても、二重解放は発生しません。既にRELEASEDに変更された行は条件に合致しなくなるからです。また、対応するDBのused_countを減らす必要があります。release関数は逆のLuaスクリプトを呼び出します。HDEL orders orderIdが1を返すときだけINCR remainingとHINCRBY users customerId -1を実行します。これにより、releaseを2回呼び出しても安全です。

セール後に各セッションの後で実行される対照テストジョブも追加します。DBのtotal_limit - used_countとRedisのremainingを比較し、差がある場合は警告を発します。

9. よくある間違いと使うべきではない場合

  • JavaからJavaの個別コマンドGET、その後DECR。 これは別の形のcheck-then-act bug、ただしRedis上です。チェックと減らす操作は同じLuaスクリプト内でなければなりません。
  • 単にDECRを実行し、負の場合はINCRで戻す。 このアプローチは機能しますが、値が一瞬負になっている間、別のリクエストが誤った値を読む可能性があります。また、release メカニズムも複雑になります。
  • ホットなバウチャーに楽観的ロック(@Version)を使う。 50,000リクエストが1つの行を争うとき、ほぼすべてが失敗してリトライします。負荷は悲観的ロックより悪化します。
  • Redisが永続性/レプリケーションなしで実行される。 Redisが再起動してデータを失い、warmupジョブがtotal_limitから再ロードする場合、オーバーセルが発生します。常にtotal_limit - used_countをDBから再ロードし、RESERVED状態の枠を差し引きます。
  • すべてのバウチャーにRedis + Kafkaを適用する。 通常のバウチャーで低い負荷であればセクション6で十分です。2つのシステムを追加するということは、2つの新しい矛盾ポイントが増えることです。フラッシュセールとしてマークされたバウチャーだけを「ハイロードモード」で有効にします。

注意: 上記のすべてのレイヤーは最適化に過ぎません。データベースの制約(CHECK、UNIQUE)だけが、どの枠も超過発行されないという最終的な保証です。

10. 結論

バウチャーの使用回数制限の問題は、本質的には競争下での原子的カウントの問題です。中程度の負荷では、UPDATE ... WHERE used_count < total_limitとユニーク制約で十分で、正しく、高速です。フラッシュセールとホットロー時には、Redis Luaスクリプトをアトミックゲートキーパーとして使用します。KafkaはDB書き込みをホットパスから削除するのに役立ち、有効期限付き予約メカニズムと対照テストジョブは矛盾を処理します。一貫した原則は、一時的なアンダーセルを許容し、オーバーセルは決してしないこと、そしてデータベースに最終的な保護レイヤーを保たせることです。

system-designspring-bootrediskafkaconcurrency