Online-Auktionssystem-Design: parallele Gebote und Auktionsbeendigung

1. Übersicht

Ein Auktionssystem sieht einfach aus: Benutzer bieten, das höchste Gebot gewinnt. Die Schwierigkeit liegt in zwei Zeitpunkten. Erstens können hunderte Gebote in wenigen Sekunden in eine Auktion strömen. Zweitens muss die Grenze “Auktion beendet” absolut klar sein. Ein verlorenes Gebot oder eine falsche Auswahl des Gewinners sind finanzielle Fehler.

Dieser Artikel behandelt die gesamte Gestaltung: Anforderungen, Datenmodell, API, wie man parallele Gebote mit PostgreSQL und Redis verarbeitet, den Last-Minute-Schutz (Anti-Sniping) und wie man Auktionen sicher beendet, wenn mehrere Instanzen laufen. Danach können Sie den Kern eines korrekten Auktionssystems mit Spring Boot 3 aufbauen.

2. Anforderungen

2.1. Funktionale Anforderungen

  • Der Verkäufer erstellt eine Auktion mit Startpreis, Mindestgebot und Endzeitpunkt.
  • Der Käufer bietet. Ein Gebot ist gültig, wenn die Auktion offen ist und der Preis nicht unter aktueller Preis + Mindestgebot liegt.
  • Wenn ein gültiges Gebot in den letzten 2 Minuten abgegeben wird, wird die Auktion um 2 Minuten verlängert.
  • Wenn die Zeit vorbei ist, schließt das System die Auktion, bestimmt den Gewinner und sendet Events für Zahlungsabwicklung und Benachrichtigungen.
  • Zuschauer erhalten Echtzeit-Aktualisierungen des aktuellen Preises.

2.2. Maßstab und Einschränkungen

  • Etwa 100.000 Auktionen laufen gleichzeitig. Die meisten sind “kühl”, mit nur wenigen Geboten pro Stunde.
  • Einige “heiße” Auktionen (Designerstücke, Blitzverkäufe) erhalten bis zu 2.000 Gebote/Sekunde in der letzten Minute.
  • Starke Konsistenz pro Auktion: keine verlorenen gültigen Gebote, keine Gebote nach Ablauf, nur ein Gewinner.
  • Gebote müssen idempotent sein: Client-Wiederholungen aufgrund von Timeouts erzeugen keine doppelten Gebote.
  • Die Latenz für die Platzierung von Geboten p99 liegt unter 200 ms.

Zusammenfassend benötigen wir Konsistenz pro Auktion. Zwischen verschiedenen Auktionen nicht. Dies ist der Schlüssel zur Skalierung: jede Auktion ist eine unabhängige Konkurrenzsphäre.

3. Datenmodell

Wir verwenden PostgreSQL als Quelle der Wahrheit. Die Tabelle auction speichert den aktuellen Zustand. Die Tabelle bid ist ein Nur-Anhängen-Protokoll (append-only).

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)
);

Einige Anmerkungen:

  • current_price und leader_id sind in die auction Tabelle denormalisiert. Dadurch erfordert die Überprüfung eines Gebots nur das Lesen einer Zeile, anstatt SELECT MAX(amount) auf der bid Tabelle auszuführen.
  • Der Partial Index idx_auction_open_end enthält nur offene Auktionen. Der Schließungsjob durchläuft diesen Index sehr schnell.
  • UNIQUE (auction_id, request_id) ist der Idempotenz-Mechanismus auf Datenbankebene.

4. API

POST /auctions                     Auktion erstellen
GET  /auctions/{id}                Auktionsstatus anzeigen
POST /auctions/{id}/bids           Gebot abgeben (Header Idempotency-Key: <uuid>)
WS   /ws/auctions/{id}             Echtzeit-Preisaktualisierungen erhalten

Anfrage zum Abgeben eines Gebots:

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

Die Antwort hat drei Formen. 201 Created, wenn das Gebot akzeptiert wird. 409 Conflict, wenn der Preis zu niedrig ist oder die Auktion geschlossen ist. 200 OK mit dem alten Ergebnis, wenn die Anfrage die gleiche Idempotency-Key hat.

5. Parallele Gebote

5.1. Klassischer Fehler: Read-then-Write

Der intuitivste Ansatz ist auch der fehlerhafte:

@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("Preis zu niedrig");
    }
    a.setCurrentPrice(amount);
    a.setLeaderId(bidderId);
}

Angenommen, der aktuelle Preis ist 1.000. Zwei Threads lesen beide 1.000. Thread A bietet 1.100, Thread B bietet 1.050. Beide passieren die Überprüfung. Wenn B später committet, wird der Endpreis 1.050 und der Leader ist B. Das höhere Gebot von A wird überschrieben. Dies ist der Lost-Update-Fehler.

5.2. Atomares Conditional Update

Die eleganteste Lösung ist es, die Überprüfung und das Schreiben in einer UPDATE-Anweisung zu kombinieren. Die Bedingung liegt in der WHERE-Klausel:

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);
}

Der Mechanismus funktioniert wie folgt:

  • PostgreSQL sperrt die Zeile auction bei UPDATE. Der zweite Thread muss warten, bis der erste committet.
  • Auf der Isolationsstufe READ COMMITTED wird PostgreSQL die WHERE-Klausel nach der Entsperrung auf der neuesten Version der Zeile neu bewerten. Das Gebot 1.050 trifft auf den neuen Preis 1.100, die Bedingung schlägt fehl, die Anweisung gibt 0 zurück.
  • Die Anti-Sniping-Logik (das CASE-Fenster) befindet sich auch in dieser Anweisung. Dadurch ist die Verlängerung immer mit dem Gebot synchronisiert, das sie verursacht hat.
  • Die Zeit wird aus clock_timestamp() der Datenbank genommen, nicht aus der Uhr jeder Anwendungsinstanz. Die Datenbank ist die einzige Zeitquelle.

Warnung: Verwenden Sie hier nicht now(). In PostgreSQL gibt now() die Zeit zum Beginn der Transaktion zurück. Eine Transaktion, die vor dem Schließungszeitpunkt geöffnet, aber langsam ausgeführt wird, könnte immer noch ein Gebot nach dem Schließen “durchlassen”.

Der Service protokolliert Gebote in der gleichen Transaktion:

@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(); // Client-Wiederholung: altes Ergebnis zurückgeben
        }
        int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
        if (updated == 0) {
            throw new BidRejectedException("Auktion geschlossen oder Preis nicht hoch genug");
        }
        return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
    }
}

Wenn zwei Wiederholungen mit der gleichen requestId parallel laufen, könnten beide die findBy... Überprüfung passieren. In diesem Fall wirft die UNIQUE-Einschränkung eine DataIntegrityViolationException für die zweite Transaktion. Diese wird vollständig zurückgerollt, einschließlich des Preisupdate. Der Controller fängt diese Exception ab und gibt das bereits vorhandene Gebot zurück.

5.3. Testen mit parallelen Geboten

Der folgende Test sendet 200 parallele Gebote über Virtual Threads an die gleiche Auktion und überprüft dann die Invarianten. Erforderliche Abhängigkeit:

<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("Antike Uhr",
                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());
    }
}

Das Gebot 3.000 (Bieter 200) ist immer gültig, da kein höheres Gebot existiert. Daher muss es unabhängig von der Ausführungsreihenfolge der Leader sein. Wenn Sie tryPlaceBid durch die naive Version aus Abschnitt 5.1 ersetzen, wird dieser Test intermittierend fehlschlagen. Das ist das Zeichen einer Race Condition.

6. “Heiße” Auktionen: Konkurrenz zu Redis verschieben

Conditional Update serialisiert alle Gebote auf einer Zeile. Mit einer Auktion kann PostgreSQL normalerweise mehrere hundert bis etwa tausend UPDATEs pro Sekunde verarbeiten. Eine Auktion mit 2.000 Geboten/Sekunde erzeugt eine lange Lock-Warteschlange und treibt p99 über den Schwellenwert.

Für Auktionen, die als “heiß” markiert sind, verschieben wir die Entscheidungslogik zu Redis. Redis führt Lua-Skripte single-threaded und atomar aus. Erforderliche Abhängigkeit:

<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;
    }
}

Kurze Erklärung:

  • Das Skript liest Preis, Mindestgebot, Endzeitpunkt und Status, überprüft und schreibt, alles in einem atomaren Schritt. Rückgabewerte: 1 akzeptiert, 0 Preis zu niedrig, -1 Auktion geschlossen.
  • Die Zeit wird vom Redis TIME-Befehl genommen, damit alle Instanzen eine gemeinsame Uhr verwenden. In der Praxis können Sie redis.call('TIME') direkt im Skript aufrufen. Dazu müssen Sie Script-Effects-Replikation aktivieren, was seit Redis 5 Standard ist.
  • Akzeptierte Gebote werden an Kafka mit key als auctionId gesendet. Alle Gebote einer Auktion fallen in die gleiche Partition und behalten die richtige Reihenfolge. Ein Consumer schreibt sie in Batches zu PostgreSQL.

Warnung: Jetzt wird Redis zur Quelle der Wahrheit während der Auktion. Sie müssen AOF mit appendfsync everysec oder always aktivieren, Replicas ausführen und die Fälle behandeln, wenn kafka.send nach dem Redis-Schreiben fehlschlägt (Retry oder regelmäßiger Abgleich zwischen Redis und PostgreSQL). Wenden Sie dieses Modell nicht auf jede Auktion an. Die meisten “kalten” Auktionen funktionieren gut mit Conditional Updates und sind viel einfacher.

7. Auktion beenden

7.1. Auktion mit einer atomaren Anweisung schließen

Wir brauchen keinen präzisen Scheduler bis auf die Millisekunde für jede Auktion. Die echte Grenze liegt bereits in der Bedingung end_time > clock_timestamp() der Gebot-UPDATE-Anweisung. Nach dem Endzeitpunkt kein Gebot, auch wenn der Status noch OPEN ist. Der Schließungsjob muss nur “das Buch schließen” innerhalb weniger Sekunden:

@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());
        }
    }
}

Diese Anweisung ist sicher beim Ausführen auf mehreren Instanzen, weil:

  • FOR UPDATE SKIP LOCKED ermöglicht es den Instanzen, Auktionen untereinander aufzuteilen. Zwei Instanzen schließen niemals die gleiche Auktion.
  • Wenn ein Gebot eine Zeilensperre hält, überspringt SKIP LOCKED diese Zeile. Der nächste Lauf überprüft sie erneut, wenn die end_time möglicherweise verlängert wurde.
  • Der Statusübergang OPEN → CLOSED geschieht nur einmal. Das Ausführen des Jobs erneut hat keine Nebenwirkungen, d.h. der Job ist idempotent.

7.2. Events mit Outbox veröffentlichen

Das Event auction-closed triggert Zahlungsabwicklung und sendet eine E-Mail an den Gewinner. Wenn Sie kafka.send direkt in einer Transaktion aufrufen, können Sie in zwei schlechte Situationen geraten. Eins: Das Event wurde gesendet, aber die Transaktion wurde zurückgerollt. Zwei: Sie haben committed, aber das Versenden ist fehlgeschlagen. Die Tabelle outbox wird in der gleichen Transaktion wie der Auktionschluss geschrieben. Danach pusht ein Relay (Polling oder Debezium CDC) die Outbox-Zeilen zu Kafka. Der Consumer muss idempotent pro auctionId sein, da das Relay at-least-once garantiert.

8. Spitzenlast und Echtzeit-Fan-out

Die letzte Minute einer heißen Auktion hat nicht nur viele Gebote, sondern auch zehntausende Zuschauer. Ein paar unterstützende Techniken:

  • Rate-Limiting pro Benutzer, z.B. maximal 5 Gebote/Sekunde mit Token Bucket in Redis. Diese Technik blockiert Bot-Spam, bevor er die Konkurrenzsphäre erreicht.
  • Frühe Ablehnung: Den aktuellen Preis im Speicher (ein paar hundert Millisekunden) cachen und offensichtlich zu niedrige Gebote ablehnen, bevor Sie DB oder Redis aufrufen. Der Cache wird nur zum Ablehnen verwendet, nie zum Akzeptieren.
  • Fan-out über Pub/Sub: Wenn ein Gebot akzeptiert wird, auf dem Redis-Channel auction:{id} veröffentlichen. Jeder WebSocket-Node subscribet und pusht zu seinen Clients. Wenn Gebote zu häufig sind, geben Sie Updates alle 100–200 ms zusammen. Zuschauer müssen nur den neuesten Preis sehen, nicht jedes einzelne Gebot.
  • Lese- und Schreibvorgänge trennen: GET /auctions/{id} kann aus Replica oder Cache lesen. Nur Gebote müssen zum Primary.

9. Häufige Fehler und wann man es nicht verwenden sollte

  • Die Anwendungsuhr zum Vergleich mit end_time verwenden. Instanzen sind normalerweise um ein paar Sekunden versetzt. Lassen Sie immer die Datenbank oder Redis über die Zeit entscheiden.
  • @Version (optimistic locking) für heiße Auktionen verwenden. Es ist immer noch korrekt, aber 2.000 Gebote/Sekunde erzeugen unzählige OptimisticLockException und Wiederholungen. Conditional Update braucht keine Wiederholung, da WHERE automatisch neu bewertet wird.
  • SELECT ... FOR UPDATE und dann in Java überprüfen. Dieses ist korrekt, aber hält die Sperre länger, da auf einen round-trip durch die Anwendung gewartet werden muss. Verwenden Sie es nur, wenn die Überprüfungslogik zu kompliziert für SQL ist.
  • Distributed Lock (Redisson) um die gesamte Gebotsfunktion. Dies fügt einen Fehlerpunkt hinzu und das Risiko, dass die Sperre halbwegs ausfällt, während die Datenbank bereits Zeilensperren hat.
  • Idempotenz ignorieren. Mobile Clients wiederholen bei Netzwerkschwankungen. Ohne request_id könnte die gleiche Person “gegen sich selbst bieten”.

10. Fazit

Der Schlüssel zu einem korrekten Auktionssystem ist es, jede Auktion als eine unabhängige Konsistenzeinheit zu betrachten. Dieser Artikel hat jeden schwierigen Punkt behandelt:

  • Parallele Gebote: Überprüfung und Schreiben in einer atomaren UPDATE ... WHERE-Anweisung zusammenfassen, mit Anti-Sniping-Logik in der gleichen Anweisung.
  • Endzeitpunkt: Zeit aus der Datenbank (clock_timestamp()), nicht aus der Anwendung.
  • Heiße Auktionen: Konkurrenz zu Redis Lua verschieben und Dauerhaftigkeit über Kafka, auf Kosten komplexerer Verwaltung.
  • Auktion beenden: FOR UPDATE SKIP LOCKED verwenden, Statusübergang idempotent machen und Events über Outbox veröffentlichen.

Sie sollten mit der reinen PostgreSQL-Version beginnen. Sie reicht für die meisten Fälle aus und lässt sich mit gleichzeitigen Tests wie in Abschnitt 5.3 verifizieren. Nur wenn echte Messungen zeigen, dass eine auction Zeile zum Engpass wird, sollten Sie diese zu Redis verschieben.

system-designconcurrencyspring-bootpostgresqlredis