オンライン入札システムの設計: 並行Bid と終了処理

1. 概要

オークションシステムは一見シンプルに見えます。ユーザーが入札し、最高額が落札します。困難は2つのタイミングで生じます。まず、数百のBidが数秒で1つのセッションに殺到する可能性があります。次に、「セッション終了」という境界線は絶対に明確である必要があります。1つのBidを失うか落札者を誤認識することは、お金に関わる重大なエラーです。

この記事は、要件、データモデル、API、PostgreSQLとRedisを使った並行Bid処理、「最後の瞬間Bid」(anti-sniping)防止メカニズム、複数インスタンス実行時の安全なセッション終了処理を含む、設計全体をカバーしています。読み終わる頃には、Spring Boot 3で正しいオークションシステムのコアを構築できるようになります。

2. 要件

2.1. 機能要件

  • 売り手がオークションセッションを開始します。開始価格、最小単位増加額、終了時刻を指定します。
  • 買い手が入札します。セッションが開いており、価格が現在の価格 + 最小単位増加額以上の場合、入札は有効です。
  • 最後の2分以内に有効なBidがあれば、セッションはさらに2分延長されます。
  • 時間が終了したら、システムはセッションを閉じ、落札者を決定し、支払いと通知ステップ用のイベントを発行します。
  • 視聴者はリアルタイムで新しい価格を受け取ります。

2.2. スケールと制約

  • 約100,000のセッションが同時に開いています。ほとんどのセッションは非常に「冷たい」ため、1時間あたりわずか数個のBidしかありません。
  • 一部の「ホットな」セッション(ブランド商品、フラッシュセール)は、最後の分に最大2,000 Bid/秒を受け取ります。
  • 各セッションでの強い一貫性が必要です。有効なBidを失わない、終了時刻後のBidを受け付けない、落札者は1人だけ。
  • Bidはべき等である必要があります。タイムアウトによるクライアントのリトライが2つのBidを作成してはいけません。
  • Bid配置のp99遅延は200 ms未満。

つまり、各セッションごとの一貫性が必要です。異なるセッション間では必要ありません。これはスケーリングの鍵です。各セッションは独立した競争ユニットです。

3. データモデル

PostgreSQLを信頼できるソースとして使用します。テーブルauctionが現在の状態を保持します。テーブルbidはアペンドオンリーログです。

CREATE TABLE auction (
    id             BIGSERIAL PRIMARY KEY,
    seller_id      BIGINT        NOT NULL,
    title          VARCHAR(200)  NOT NULL,
    current_price  NUMERIC(14,2) NOT NULL,
    min_increment  NUMERIC(14,2) NOT NULL,
    leader_id      BIGINT,
    status         VARCHAR(10)   NOT NULL DEFAULT 'OPEN',  -- OPEN | CLOSED
    end_time       TIMESTAMPTZ   NOT NULL
);
CREATE INDEX idx_auction_open_end ON auction (end_time) WHERE status = 'OPEN';

CREATE TABLE bid (
    id          BIGSERIAL PRIMARY KEY,
    auction_id  BIGINT        NOT NULL REFERENCES auction(id),
    bidder_id   BIGINT        NOT NULL,
    amount      NUMERIC(14,2) NOT NULL,
    request_id  UUID          NOT NULL,
    created_at  TIMESTAMPTZ   NOT NULL DEFAULT clock_timestamp(),
    UNIQUE (auction_id, request_id)
);

いくつかの重要なポイントです。

  • current_priceとleader_idは非正規化されています。これにより、Bid検証では1行の読取だけで済み、bidテーブルでSELECT MAX(amount)を実行する必要がありません。
  • 部分インデックスidx_auction_open_endは開いているセッションのみを含みます。セッション終了ジョブはこのインデックスを非常に高速にスキャンします。
  • UNIQUE (auction_id, request_id)はデータベース層でのべき等メカニズムです。

4. API

POST /auctions                     セッション開始
GET  /auctions/{id}                セッション状態確認
POST /auctions/{id}/bids           入札 (header Idempotency-Key: <uuid>)
WS   /ws/auctions/{id}             リアルタイム価格更新

Bidリクエスト:

{ "bidderId": 42, "amount": 1250000 }

3つのレスポンスタイプがあります。Bidが受け入れられた場合は201 Created。価格が低すぎるか、セッションが終了している場合は409 Conflict。Idempotency-Keyが重複している場合は、古い結果と共に200 OK。

5. 並行Bid処理

5.1. 古典的なエラー: 読み取ってから書き込み

最も自然な書き方は最も間違っています。

@Transactional
public void placeBidNaive(long auctionId, long bidderId, BigDecimal amount) {
    Auction a = auctionRepository.findById(auctionId).orElseThrow();
    if (amount.compareTo(a.getCurrentPrice().add(a.getMinIncrement())) < 0) {
        throw new BidRejectedException("価格が低すぎます");
    }
    a.setCurrentPrice(amount);
    a.setLeaderId(bidderId);
}

現在の価格が1,000だとします。2つのスレッドが1,000を読みます。スレッドAは1,100を、スレッドBは1,050を入札します。どちらも検証を通ります。Bが後でコミットする場合、最終価格は1,050になり、リーダーはBです。Aの高い入札は上書きされます。これは失われた更新エラーです。

5.2. 条件付きアップデートのアトミック実行

最もシンプルな解決策は、チェックと書き込みを1つのUPDATEステートメントに統合することです。条件はWHEREに入ります。

public interface AuctionRepository extends JpaRepository<Auction, Long> {

    @Modifying
    @Query(value = """
        UPDATE auction
           SET current_price = :amount,
               leader_id     = :bidderId,
               end_time      = CASE
                   WHEN end_time - clock_timestamp() < INTERVAL '2 minutes'
                   THEN clock_timestamp() + INTERVAL '2 minutes'
                   ELSE end_time END
         WHERE id = :id
           AND status = 'OPEN'
           AND end_time > clock_timestamp()
           AND :amount >= current_price + min_increment
        """, nativeQuery = true)
    int tryPlaceBid(@Param("id") long id,
                    @Param("bidderId") long bidderId,
                    @Param("amount") BigDecimal amount);
}

このメカニズムはこのように機能します。

  • PostgreSQLはUPDATEのときにauction行をロックします。2番目のスレッドは最初のスレッドのコミットを待つ必要があります。
  • READ COMMITTEDレベルでは、ロック解放後、PostgreSQLは最新の行バージョンで**WHEREを再評価します**。
  • 1,050のBidは新しい価格1,100に遭遇し、条件は偽になり、ステートメントは0を返します。
  • Anti-sniping ロジック(CASEブロック)はこのステートメント内にあります。これにより、延長はそれを引き起こしたBidとずれることはありません。
  • 時刻はデータベースのclock_timestamp()から取得され、各アプリケーションインスタンスのクロックからではありません。データベースは単一の時間ソースです。

注意: ここでnow()を使用しないでください。PostgreSQLでは、now()はトランザクション開始時点を返します。終了時刻の前に開始されたが遅く実行されるトランザクションは、終了後のBidを「すり抜ける」ことができます。

サービスは同じトランザクション内にBidログを記録します。

@Service
@RequiredArgsConstructor
public class BidService {

    private final AuctionRepository auctionRepository;
    private final BidRepository bidRepository;

    @Transactional
    public Bid placeBid(long auctionId, long bidderId, BigDecimal amount, UUID requestId) {
        var existing = bidRepository.findByAuctionIdAndRequestId(auctionId, requestId);
        if (existing.isPresent()) {
            return existing.get(); // クライアントのリトライ: 古い結果を返す
        }
        int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
        if (updated == 0) {
            throw new BidRejectedException("セッションが終了したか、価格が十分に高くありません");
        }
        return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
    }
}

同じrequestIdの2つのリトライが並行して実行される場合、両方がfindBy...ステップを通過する可能性があります。その場合、UNIQUE制約は2番目のトランザクションに対してDataIntegrityViolationExceptionをスローします。このトランザクションは、価格UPDATEを含むすべてが完全にロールバックされます。Controllerはこの例外をキャッチし、既存のBidを返します。

5.3. 並行Bidのテスト

以下のテストは、virtual threadを使用して200個の並行Bidを同じセッションに送信し、不変量を確認します。以下の依存関係が必要です。

<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 BidServiceConcurrencyTest {

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

    @Autowired BidService bidService;
    @Autowired AuctionRepository auctionRepository;
    @Autowired BidRepository bidRepository;

    @Test
    void concurrentBids_keepHighestAcceptedBidAsLeader() throws Exception {
        long id = auctionRepository.save(Auction.open("アンティーク時計",
                new BigDecimal("1000"), new BigDecimal("10"),
                Instant.now().plus(Duration.ofHours(1)))).getId();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 200; i++) {
                long bidder = i;
                var amount = new BigDecimal(1000 + i * 10);
                executor.submit(() -> {
                    try {
                        bidService.placeBid(id, bidder, amount, UUID.randomUUID());
                    } catch (BidRejectedException ignored) { }
                });
            }
        }

        Auction result = auctionRepository.findById(id).orElseThrow();
        BigDecimal maxAccepted = bidRepository.findMaxAmount(id);

        assertThat(result.getCurrentPrice()).isEqualByComparingTo("3000");
        assertThat(result.getLeaderId()).isEqualTo(200L);
        assertThat(maxAccepted).isEqualByComparingTo(result.getCurrentPrice());
    }
}

3,000のBid(bidder 200)は常に有効です。高いBidが存在しないためです。したがって、実行順序がどうであれ、それが現在のリーダーである必要があります。tryPlaceBidをセクション5.1のナイーブなバージョンに置き換えると、このテストは断続的に失敗します。これはレースコンディションの兆候です。

6. 「ホットな」セッション: Redisへの競争を移す

条件付きアップデートは1行上のすべてのBidをシリアル化します。1つのセッションでは、PostgreSQLは通常、毎秒数百から約1,000のUPDATEを処理できます。毎秒2,000のBidを受け取るセッションは、長いロック待機キューを作成し、p99をしきい値を超えます。

「ホットな」としてマークされたセッションでは、決定ロジックをRedisに移します。Redisはシングルスレッドで原子的な Lua scriptを実行します。以下の依存関係が必要です。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Service
@RequiredArgsConstructor
public class HotBidService {

    private static final RedisScript<Long> PLACE_BID = RedisScript.of("""
        local a = redis.call('HMGET', KEYS[1], 'price', 'inc', 'end', 'status')
        local now = tonumber(ARGV[3])
        if a[4] ~= 'OPEN' or now >= tonumber(a[3]) then return -1 end
        if tonumber(ARGV[1]) < tonumber(a[1]) + tonumber(a[2]) then return 0 end
        local newEnd = tonumber(a[3])
        if newEnd - now < 120000 then newEnd = now + 120000 end
        redis.call('HSET', KEYS[1], 'price', ARGV[1], 'leader', ARGV[2], 'end', newEnd)
        return 1
        """, Long.class);

    private final StringRedisTemplate redis;
    private final KafkaTemplate<String, BidAccepted> kafka;

    public boolean placeBid(long auctionId, long bidderId, long amount, UUID requestId) {
        long now = Long.parseLong(redis.execute(
                (RedisCallback<Long>) c -> c.serverCommands().time()).toString());
        Long r = redis.execute(PLACE_BID, List.of("auction:" + auctionId),
                String.valueOf(amount), String.valueOf(bidderId), String.valueOf(now));
        if (r == null || r != 1L) return false;
        kafka.send("bid-accepted", String.valueOf(auctionId),
                new BidAccepted(auctionId, bidderId, amount, requestId, now));
        return true;
    }
}

簡単な説明です。

  • スクリプトは価格、単位増加額、終了時刻、ステータスを読み取り、チェックして、すべてが1つの原子操作で書き込みます。戻り値: 1は受け入れ、0は価格が低い、-1はセッションが終了。
  • 時刻はRedisのTIMEコマンドから取得されるため、すべてのインスタンスは同じクロックを使用します。実際には、スクリプト内で直接redis.call('TIME')を呼び出すことができます。
  • これを行うには、スクリプト効果レプリケーションモードを有効にする必要があります。これはRedis 5からのデフォルトです。
  • 受け入れられたBidは**key がauctionId**のKafkaに送信されます。1つのセッションのすべてのBidは同じパーティションに入り、適切な順序を保ちます。Consumerは、バッチでそれらをPostgresQLに記録します。

注意: この場合、Redisはセッション全体の信頼できるソースになります。appendfsync everysecまたはalwaysでAOFを有効にし、レプリカを実行し、RedisがすでにBidを記録した後にkafka.sendが失敗する場合(retry、またはRedisとPostgreSQL間の定期的な調整)を処理する必要があります。このモデルをすべてのセッションに適用しないでください。ほとんどのコールドセッションは条件付きアップデートで問題なく実行でき、はるかに単純です。

7. セッション終了処理

7.1. 1つのアトミックステートメントでセッションを閉じる

各セッションのミリ秒精度のスケジューラーは不要です。真の境界線はすでに、Bid配置のUPDATEステートメントの条件end_time > clock_timestamp()にあります。終了時刻後、ステータスがOPENのままであっても、Bidはスリップできません。セッション終了ジョブは数秒以内に「帳簿を閉じる」だけで済みます。

@Component
@RequiredArgsConstructor
public class AuctionCloser {

    private final JdbcTemplate jdbc;

    @Scheduled(fixedDelay = 1000)
    @Transactional
    public void closeExpired() {
        var closed = jdbc.query("""
            UPDATE auction SET status = 'CLOSED'
             WHERE id IN (SELECT id FROM auction
                           WHERE status = 'OPEN' AND end_time <= clock_timestamp()
                           ORDER BY end_time LIMIT 500
                           FOR UPDATE SKIP LOCKED)
            RETURNING id, leader_id, current_price
            """, (rs, i) -> new AuctionClosed(rs.getLong("id"),
                    (Long) rs.getObject("leader_id"), rs.getBigDecimal("current_price")));

        for (var e : closed) {
            jdbc.update("INSERT INTO outbox(topic, aggregate_id, payload) VALUES (?, ?, ?::jsonb)",
                    "auction-closed", e.auctionId(), e.toJson());
        }
    }
}

このステートメントは複数のインスタンスで実行するのは安全です、なぜなら。

  • FOR UPDATE SKIP LOCKEDを使用すると、インスタンスはセッションを分散できます。2つのインスタンスは同じセッションを閉じることはありません。
  • 1つのBidが行ロックを保持している場合、SKIP LOCKEDはその行をスキップします。
  • 次の実行は再度チェックします。その時点でend_timeはすでに延長されているかもしれません。
  • OPEN → CLOSEDステータスの遷移は1回だけ発生します。ジョブを再実行しても副作用がありません。つまり、ジョブはべき等です。

7.2. Outboxを使用したイベント発行

auction-closedイベントが支払い処理をトリガーし、落札者にメールを送信します。トランザクション内で直接kafka.sendを呼び出すと、2つの悪い状況に遭遇する可能性があります。1つは、イベントを送信したがトランザクションがロールバックした。もう1つはコミットしたが、送信に失敗した。

outboxテーブルはセッション終了と同じトランザクションで記録されます。その後、リレー(pollingまたはDebezium CDC)が行をKafkaにプッシュします。Consumer側はauctionIdごとにべき等である必要があります。リレーはat-least-onceを保証するからです。

8. バースト負荷とリアルタイムファンアウト

ホットセッションの最後の数分には、多くのBidだけでなく、数万の視聴者がいます。いくつかの補完的なテクニックです。

  • ユーザーごとのレート制限。例えば、Redisのトークンバケットで最大5 Bid/秒。この技術は、Botスパムが競争層に到達する前にそれをブロックします。
  • 早期拒否: 現在の価格をメモリにキャッシュ(数百ミリ秒)し、DBやRedisを呼び出す前に明らかに低いBidを除外します。キャッシュは拒否にのみ使用され、受け入れには使用されません。
  • pub/subによるファンアウト: Bidが受け入れられたら、Redisチャネルauction:{id}に発行します。各WebSocketノードは購読し、そのクライアントにプッシュします。Bidが多すぎる場合は、100~200ミリ秒ごとに更新をバッチ処理します。視聴者は最新の価格のみを表示する必要があり、各Bidを表示する必要はありません。
  • 読み取りと書き込みの分離: GET /auctions/{id}はレプリカまたはキャッシュから読み取ることができます。新しいBidのパスだけがプライマリに必要です。

9. 一般的なエラーと使用すべきでない場合

  • アプリケーション時計を使用するend_timeと比較します。インスタンス間で数秒ずれることは通常のことです。常にデータベースまたはRedisに時刻の判定を任せてください。
  • ホットセッションに@Version(楽観的ロック)を使用します。 それはまだ正しいですが、毎秒2,000のBidは無数のOptimisticLockExceptionと再試行ループを生成します。条件付きアップデートは再試行を必要としません。WHEREは自動的に再評価されるからです。
  • JavaでSELECT ... FOR UPDATEを使用してからチェックします。 この方法は正しいですが、アプリケーションを通じたラウンドトリップを待つ必要があるため、ロックをより長く保持します。チェックロジックがSQLで書くには複雑すぎる場合のみ使用してください。
  • Bid関数全体の周りに分散ロック(Redisson)。 この方法は単一の障害点を追加し、データベースがすでに行ロックを持っている場合、ロックの有効期限切れの中途のリスクがあります。
  • べき等性を無視します。 ネットワーク障害時のモバイルクライアントのリトライは日常茶飯事です。request_idがないと、同じ人が自分自身に「入札」できます。

10. まとめ

オークションシステムの鍵は、各セッションを独立した一貫性の単位として扱うことです。この記事は、以下のように各課題に対処しています。

  • 並行Bid: チェックと書き込みをアトミックUPDATE ... WHEREステートメントに統合し、同じステートメントにanti-sniping ロジックを含めます。
  • 終了時刻: データベース(clock_timestamp())から時刻を取得し、アプリケーションからではありません。
  • ホットセッション: 競争をRedis Luaに移し、Kafkaで永続化します。代わりに、運用がより複雑になります。
  • セッション終了: FOR UPDATE SKIP LOCKEDを使用し、べき等なステータス遷移を行い、Outboxを通じてイベントを発行します。

PostgreSQLの純粋なバージョンから始めるべきです。ほとんどの場合に十分で、セクション5.3のような並行テストで検証しやすいです。実際のメトリクスがauction行がボトルネックになっていることを示している場合にのみ、それらのセッションをRedisに移動してください。

system-designconcurrencyspring-bootpostgresqlredis