Thiết kế hệ thống đấu giá trực tuyến: bid đồng thời và kết thúc phiên

1. Tổng quan

Hệ thống đấu giá trông đơn giản: người dùng đặt giá, giá cao nhất thắng. Khó khăn nằm ở hai thời điểm. Thứ nhất, hàng trăm bid có thể đổ vào cùng một phiên trong vài giây. Thứ hai, ranh giới “phiên đã kết thúc” phải tuyệt đối rõ ràng. Mất một bid hay chọn sai người thắng đều là lỗi về tiền.

Bài này đi qua toàn bộ thiết kế: yêu cầu, mô hình dữ liệu, API, cách xử lý bid đồng thời bằng PostgreSQL và Redis, cơ chế chống “bid phút chót” (anti-sniping) và cách đóng phiên an toàn khi chạy nhiều instance. Đọc xong, bạn có thể dựng được phần lõi của một hệ thống đấu giá đúng đắn với Spring Boot 3.

2. Yêu cầu bài toán

2.1. Yêu cầu chức năng

  • Người bán tạo phiên đấu giá gồm giá khởi điểm, bước giá tối thiểu và thời điểm kết thúc.
  • Người mua đặt bid. Bid hợp lệ khi phiên đang mở và giá không thấp hơn giá hiện tại + bước giá.
  • Nếu có bid hợp lệ trong 2 phút cuối, phiên được gia hạn thêm 2 phút.
  • Khi hết giờ, hệ thống đóng phiên, xác định người thắng và phát sự kiện cho các bước thanh toán và thông báo.
  • Người xem nhận giá mới theo thời gian thực.

2.2. Quy mô và ràng buộc

  • Khoảng 100.000 phiên mở đồng thời. Phần lớn phiên rất “nguội”, chỉ vài bid mỗi giờ.
  • Một số phiên “nóng” (hàng hiệu, flash sale) nhận tới 2.000 bid/giây trong phút cuối.
  • Nhất quán mạnh trên từng phiên: không mất bid hợp lệ, không chấp nhận bid sau giờ đóng, chỉ có một người thắng.
  • Bid phải idempotent: client retry do timeout không được tạo ra hai bid.
  • Độ trễ đặt bid p99 dưới 200 ms.

Tóm lại, ta cần nhất quán theo từng phiên. Giữa các phiên khác nhau thì không cần. Đây là điểm mấu chốt để scale: mỗi phiên là một đơn vị tranh chấp độc lập.

3. Mô hình dữ liệu

Ta dùng PostgreSQL làm nguồn sự thật. Bảng auction giữ trạng thái hiện tại. Bảng bid là nhật ký chỉ ghi thêm (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)
);

Vài điểm đáng chú ý:

  • current_price và leader_id được phi chuẩn hoá vào auction. Nhờ vậy, việc kiểm tra bid chỉ cần đọc một dòng thay vì SELECT MAX(amount) trên bảng bid.
  • Partial index idx_auction_open_end chỉ chứa các phiên đang mở. Job đóng phiên sẽ quét index này rất nhanh.
  • UNIQUE (auction_id, request_id) là cơ chế idempotency ở tầng database.

4. API

POST /auctions                     tạo phiên
GET  /auctions/{id}                xem trạng thái phiên
POST /auctions/{id}/bids           đặt bid (header Idempotency-Key: <uuid>)
WS   /ws/auctions/{id}             nhận giá mới theo thời gian thực

Request đặt bid:

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

Kết quả trả về có ba dạng. 201 Created khi bid được chấp nhận. 409 Conflict khi giá quá thấp hoặc phiên đã đóng. 200 OK kèm kết quả cũ khi request trùng Idempotency-Key.

5. Đặt bid đồng thời

5.1. Lỗi kinh điển: đọc rồi ghi

Cách viết tự nhiên nhất lại là cách sai:

@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("Giá quá thấp");
    }
    a.setCurrentPrice(amount);
    a.setLeaderId(bidderId);
}

Giả sử giá hiện tại là 1.000. Hai thread cùng đọc giá 1.000. Thread A đặt 1.100, thread B đặt 1.050. Cả hai đều qua bước kiểm tra. Nếu B commit sau, giá cuối cùng là 1.050 và người dẫn đầu là B. Bid cao hơn của A bị ghi đè. Đây là lỗi lost update.

5.2. Conditional update nguyên tử

Giải pháp gọn nhất là gộp phần kiểm tra và phần ghi vào một câu UPDATE. Điều kiện nằm trong 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);
}

Cơ chế hoạt động như sau:

  • PostgreSQL khoá dòng auction khi UPDATE. Thread thứ hai phải chờ thread thứ nhất commit.
  • Ở mức READ COMMITTED, sau khi được nhả khoá, PostgreSQL đánh giá lại WHERE trên phiên bản dòng mới nhất. Bid 1.050 gặp giá mới 1.100, điều kiện sai, nên câu lệnh trả về 0.
  • Logic anti-sniping (khối CASE) nằm luôn trong câu lệnh này. Nhờ vậy, việc gia hạn không bao giờ lệch với bid gây ra nó.
  • Thời gian lấy từ clock_timestamp() của database chứ không lấy từ đồng hồ của từng instance ứng dụng. Database là một nguồn thời gian duy nhất.

Lưu ý: Đừng dùng now() ở đây. Trong PostgreSQL, now() trả về thời điểm bắt đầu transaction. Một transaction mở từ trước giờ đóng nhưng chạy chậm vẫn có thể “lọt” bid sau giờ đóng.

Service ghi nhật ký bid trong cùng transaction:

@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(); // retry của client: trả lại kết quả cũ
        }
        int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
        if (updated == 0) {
            throw new BidRejectedException("Phiên đã đóng hoặc giá không đủ cao");
        }
        return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
    }
}

Nếu hai retry trùng requestId chạy song song, cả hai đều có thể vượt qua bước findBy.... Khi đó, ràng buộc UNIQUE sẽ ném DataIntegrityViolationException cho transaction thứ hai. Transaction này bị rollback toàn bộ, kể cả phần UPDATE giá. Controller bắt exception này và trả về bid đã tồn tại.

5.3. Kiểm thử với bid đồng thời

Test dưới đây bắn 200 bid song song bằng virtual thread vào cùng một phiên, sau đó kiểm tra bất biến. Dependency cần thêm:

<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("Đồng hồ cổ",
                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());
    }
}

Bid 3.000 (bidder 200) luôn hợp lệ vì không có bid nào cao hơn. Vì vậy, dù thứ tự thực thi thế nào, nó phải là người dẫn đầu. Nếu thay tryPlaceBid bằng phiên bản naive ở mục 5.1, test này sẽ fail ngắt quãng. Đó chính là dấu hiệu của race condition.

6. Phiên “nóng”: đưa tranh chấp lên Redis

Conditional update tuần tự hoá mọi bid trên một dòng. Với một phiên, PostgreSQL thường xử lý được vài trăm đến khoảng một nghìn UPDATE mỗi giây. Phiên nhận 2.000 bid/giây sẽ tạo hàng đợi khoá dài và đẩy p99 vượt ngưỡng.

Với các phiên được đánh dấu “nóng”, ta chuyển phần quyết định sang Redis. Redis chạy Lua script đơn luồng và nguyên tử. Dependency cần thêm:

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

Giải thích ngắn:

  • Script đọc giá, bước giá, giờ kết thúc và trạng thái, kiểm tra rồi ghi, tất cả trong một bước nguyên tử. Giá trị trả về: 1 là chấp nhận, 0 là giá thấp, -1 là phiên đã đóng.
  • Thời gian lấy từ lệnh TIME của Redis để mọi instance dùng chung một đồng hồ. Trong thực tế, bạn có thể gọi redis.call('TIME') ngay trong script. Để làm vậy, cần bật chế độ script effects replication, vốn là mặc định từ Redis 5.
  • Bid được chấp nhận sẽ được gửi lên Kafka với key là auctionId. Mọi bid của một phiên rơi vào cùng partition và giữ đúng thứ tự. Consumer ghi chúng xuống PostgreSQL theo lô.

Lưu ý: Lúc này Redis trở thành nguồn sự thật trong suốt phiên. Bạn cần bật AOF với appendfsync everysec hoặc always, chạy replica, và xử lý trường hợp kafka.send thất bại sau khi Redis đã ghi (retry, hoặc đối soát định kỳ giữa Redis và PostgreSQL). Đừng áp dụng mô hình này cho mọi phiên. Phần lớn phiên nguội chạy tốt với conditional update và đơn giản hơn nhiều.

7. Kết thúc phiên

7.1. Đóng phiên bằng một câu lệnh nguyên tử

Ta không cần scheduler chính xác tới mili-giây cho từng phiên. Ranh giới thật sự đã nằm trong điều kiện end_time > clock_timestamp() của câu UPDATE đặt bid. Sau giờ đóng, không bid nào lọt qua được, kể cả khi trạng thái vẫn là OPEN. Job đóng phiên chỉ cần “chốt sổ” trong vòng vài giây:

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

Câu lệnh này an toàn khi chạy nhiều instance vì:

  • FOR UPDATE SKIP LOCKED cho phép các instance chia nhau các phiên. Hai instance không bao giờ đóng cùng một phiên.
  • Nếu một bid đang giữ khoá dòng, SKIP LOCKED bỏ qua dòng đó. Lần chạy sau sẽ kiểm tra lại, khi end_time có thể đã được gia hạn.
  • Chuyển trạng thái OPEN → CLOSED chỉ xảy ra một lần. Chạy lại job không gây tác dụng phụ, tức là job idempotent.

7.2. Phát sự kiện bằng Outbox

Sự kiện auction-closed kích hoạt thanh toán và gửi email cho người thắng. Nếu gọi kafka.send ngay trong transaction, bạn có thể gặp hai tình huống xấu. Một là đã gửi event nhưng transaction rollback. Hai là đã commit nhưng gửi thất bại. Bảng outbox được ghi cùng transaction với việc đóng phiên. Sau đó, một relay (polling hoặc Debezium CDC) đẩy các dòng outbox lên Kafka. Phía consumer phải idempotent theo auctionId, vì relay đảm bảo at-least-once.

8. Tải đột biến và fan-out thời gian thực

Phút cuối của phiên nóng không chỉ có nhiều bid mà còn có hàng chục nghìn người xem. Một vài kỹ thuật bổ trợ:

  • Rate limit theo người dùng, ví dụ tối đa 5 bid/giây với token bucket trên Redis. Kỹ thuật này chặn bot spam trước khi chúng chạm vào tầng tranh chấp.
  • Từ chối sớm: cache giá hiện tại trong bộ nhớ (vài trăm mili-giây) và loại ngay các bid rõ ràng quá thấp trước khi gọi DB hoặc Redis. Cache chỉ dùng để từ chối, không bao giờ dùng để chấp nhận.
  • Fan-out qua pub/sub: khi bid được chấp nhận, publish lên Redis channel auction:{id}. Mỗi node WebSocket subscribe và đẩy tới client của nó. Nếu bid quá dày, hãy gộp cập nhật mỗi 100–200 ms. Người xem chỉ cần thấy giá mới nhất, không cần thấy từng bid.
  • Tách đọc và ghi: GET /auctions/{id} có thể đọc từ replica hoặc cache. Chỉ đường bid mới cần tới primary.

9. Lỗi hay gặp và khi nào không nên dùng

  • Dùng đồng hồ ứng dụng để so với end_time. Các instance lệch nhau vài giây là chuyện bình thường. Hãy luôn để database hoặc Redis quyết định thời gian.
  • Dùng @Version (optimistic locking) cho phiên nóng. Nó vẫn đúng, nhưng 2.000 bid/giây sẽ sinh ra vô số OptimisticLockException và vòng retry. Conditional update không cần retry vì WHERE được đánh giá lại tự động.
  • Dùng SELECT ... FOR UPDATE rồi mới kiểm tra trong Java. Cách này đúng nhưng giữ khoá lâu hơn, vì phải chờ một vòng round-trip qua ứng dụng. Chỉ dùng khi logic kiểm tra quá phức tạp để viết bằng SQL.
  • Distributed lock (Redisson) quanh toàn bộ hàm bid. Cách này thêm một điểm lỗi và rủi ro khoá hết hạn giữa chừng, trong khi database đã có sẵn khoá dòng.
  • Bỏ qua idempotency. Mobile client retry khi mạng chập chờn là chuyện thường ngày. Thiếu request_id, cùng một người có thể “tự trả giá” với chính mình.

10. Kết luận

Chìa khoá của hệ thống đấu giá là coi mỗi phiên là một đơn vị nhất quán độc lập. Bài này đã xử lý từng điểm khó như sau:

  • Bid đồng thời: gộp kiểm tra và ghi vào một câu UPDATE ... WHERE nguyên tử, kèm logic anti-sniping trong cùng câu lệnh.
  • Thời điểm kết thúc: lấy thời gian từ database (clock_timestamp()), không lấy từ ứng dụng.
  • Phiên nóng: chuyển tranh chấp sang Redis Lua và ghi bền qua Kafka, với cái giá là vận hành phức tạp hơn.
  • Đóng phiên: dùng FOR UPDATE SKIP LOCKED, chuyển trạng thái idempotent và phát sự kiện qua Outbox.

Bạn nên bắt đầu với phiên bản PostgreSQL thuần. Nó đủ cho đa số trường hợp và dễ kiểm chứng bằng test đồng thời như ở mục 5.3. Chỉ khi số liệu đo thực tế cho thấy một dòng auction thành nút thắt, hãy chuyển những phiên đó sang Redis.

system-designconcurrencyspring-bootpostgresqlredis