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_pricevàleader_idđược phi chuẩn hoá vàoauction. 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ảngbid.- Partial index
idx_auction_open_endchỉ 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
auctionkhi 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ạiWHEREtrê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ề:
1là chấp nhận,0là giá thấp,-1là phiên đã đóng. - Thời gian lấy từ lệnh
TIMEcủa Redis để mọi instance dùng chung một đồng hồ. Trong thực tế, bạn có thể gọiredis.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 everysechoặcalways, chạy replica, và xử lý trường hợpkafka.sendthấ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 LOCKEDcho 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 LOCKEDbỏ qua dòng đó. Lần chạy sau sẽ kiểm tra lại, khiend_timecó thể đã được gia hạn. - Chuyển trạng thái
OPEN → CLOSEDchỉ 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ốOptimisticLockExceptionvà vòng retry. Conditional update không cần retry vìWHEREđược đánh giá lại tự động. - Dùng
SELECT ... FOR UPDATErồ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 ... WHEREnguyê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.