Published on

Thiết kế hệ thống đấu giá cho chiếc iphone 17

Authors
  • avatar
    Name
    Dinh Nguyen Truong
    Twitter

Sẽ ra sao nếu iPhone 17 vừa ra mắt, và Apple quyết định đấu giá chiếc đầu tiên?

Trong 30 giây cuối cùng, 100,000 người cùng lúc spam nút "Đặt giá" với mức tăng từ $1,000 lên $10,000. Hệ thống sẽ xử lý như thế nào khi database phải cập nhật giá hiện tại hàng nghìn lần trong một giây? Ai sẽ là người thắng cuộc khi có 1000 người cùng đặt giá $9,999 trong cùng một millisecond?

Đây chính là tình huống mà các platform đấu giá như eBay phải đối mặt hàng ngày. Và đây cũng là bài toán mình muốn thảo luận trong bài viết này. Chúng ta sẽ đi từ những thiết kế cơ bản nhất của một hệ thống đấu giá đến khi mở rộng hệ thống và xử lý tình huống chiếc iphone hot này nhé.

Giới thiệu bài toán

Yêu cầu

  • Người mua có thể đấu giá các sản phẩm, sau khi đấu giá kết thúc người thắng cuộc sẽ là người được mua sản phẩm.
  • Xử lý nhiều người đấu giá một sản phẩm cùng lúc.
  • Khi các hot item được đấu giá, kết quả sẽ phản hồi trong khoảng thời gian chấp nhận được.

Tổng quan luồng đấu giá

Một luồng đấu giá cơ bản sẽ bao gồm một số bước:

  • Sau khi người bán đăng sản phẩm và set thời gian bắt đầu + kết thúc đấu giá, mọi người có thể vào đấu giá sản phẩm
  • Mỗi khi có một người mua đặt giá, hệ thống sẽ kiểm tra giá hiện tại, nếu cao hơn sẽ lưu lại.
  • Cứ tiếp diễn như vậy cho đến khi hết đấu giá
  • Khi này hệ thống sẽ xác định người thắng cuộc và gửi thông báo cho họ thanh toán.

Từ đây, ta có một thiết kế đơn giản như sau

Thiết kế khởi điểm

  1. API: chắc chắn rồi, để xử lý các request từ phía người dùng như tạo sản phẩm, duyệt và đặt giá
    • Bên cạnh đó, API sẽ đảm nhận thêm việc cập nhật giá hiện tại cho người dùng, và thông báo khi có người khác đặt giá cao hơn họ. Chúng ta có thể sử dụng websocket hoặc đơn giản hơn là server-sent events để triển khai tính năng này.
  2. Cơ sở dữ liệu: lưu trữ những thông tin về sản phẩm, đơn hàng.
    • Vậy chọn CSDL loại nào ? Hệ thống của chúng ta bắt buộc phải xử lý các lượt đấu giá đồng thời, vì vậy tính chất như ACID rất quan trọng => Cơ sở dữ liệu quan hệ (như PostgreSQL, MySQL,…) sẽ là phù hợp.
    • Ta có bắt đầu model một cách đơn giản với 2 bảng: một bảng lưu trữ sản phẩm và một bảng lưu trữ lịch sử đấu giá. Mặc dù yêu cầu bài toán không cần lưu trữ lịch sử, tuy nhiên để đảm bảo sự minh bạch, tránh gian lận, dữ liệu này là phải có trong bidding platform.
  3. Một cronjob xử lý kết thúc đấu giá. Do ta đã lưu trữ thời gian kết thúc đấu giá nên cronjob chỉ query theo field này là đã thỏa mãn.
    • SELECT … FROM AuctionProduct WHERE endBid < NOW() AND status = "in-auction"
    • Tuy nhiên khi hệ thống mở rộng, số lượng sản phẩm đấu giá càng ngày càng nhiều lên, đây là cách làm chưa được tối ưu.

Xử lý nhiều người đấu giá một sản phẩm cùng lúc như thế nào ?

Mỗi khi có một người dùng đặt giá, hệ thống sẽ kiểm tra từng bước

  • Thời gian đặt giá hợp lệ ?
  • Giá đặt có lớn hơn giá hiện tại ?

Nếu đồng thời nhiều người cùng đặt, hoàn toàn có thể xảy ra race condition. Vậy cách xử lý là gì, theo mình chúng ta có thể bắt đầu với phương án dùng DB transaction kết hợp với lock. Tư tưởng là đối với một sản phẩm, ở một thời điểm chỉ có một session được cập nhật currentPrice và thêm vào lịch sử.

BEGIN;

-- 1. Lock the auction product row
SELECT currentPrice, startBid, endBid
    FROM AuctionProduct
WHERE id = :auctionProductId
    FOR UPDATE;

IF (NOW() < startBid OR NOW() > endBid) THEN
    ROLLBACK;
END IF;

IF (:bidPrice <= currentPrice) THEN
    ROLLBACK;
END IF;

INSERT INTO Bids SET userId=...,price=...,time=...

UPDATE AuctionProduct
    SET currentPrice = :bidPrice
WHERE id = :auctionProductId;

COMMIT;

Khi nhiều người cùng lúc đấu giá, cách thiết kế này sẽ khá tệ:

  • Nếu họ cùng đấu giá một sản phẩm, việc lock sẽ khiến các transaction phải wait với thời gian không hề ngắn (do phải xử lý 2 lệnh DML trong transaction nữa), rủi ro gây ra treo cả ứng dụng (do Pool Exhaustion).
  • Kể cả người dùng không đấu giá một sản phẩm, việc có quá nhiều transaction với 2 lệnh DML trong một thời điểm cũng khiến tài nguyên database cạn kiệt.

Tiếp theo mình cùng xử lý những vấn đề tồn đọng trong thiết kế này nhé. Có kha khá vấn đề khó khăn cần xử lý.

Xử lý vấn đề scaling

1. Tối ưu luồng xử lý kết thúc đấu giá

Thay vì phải query liên tục liệu có cách làm nào khác tối ưu hơn không ?

Mỗi khi ta query tức sẽ kiểm tra toàn bộ đấu giá hiện tại, vậy thay vì kiểm tra toàn bộ, có cách nào để các đấu giá hết thời gian tự động gửi về hệ thống ? Giống như việc sử dụng message queue nhưng các message sẽ được gửi về hệ thống sau một khoảng thời gian ?

Thực tế là có tồn tại loại queue như vậy, bạn có thể tham khảo RabbitMQ Delayed Message Plugin là một ví dụ điển hình. Chúng ta có thể setup message sau một khoảng thời gian mới xuất hiện trong queue, khi này hệ thống mới xử lý nó.

Nếu người bán thay đổi thời gian kết thúc đấu giá thì sao? Chúng ta có thể xóa message hiện tại và tạo mới

Cách làm này không những tách rời được 2 luồng logic, mà còn giúp chúng ta dễ dàng thay đổi khi có yêu cầu mới, ví dụ như một số sàn cho phép không có thời gian kết thúc đấu giá, sau khi người cuối cùng đặt giá khoảng 1-2h thì mới chốt. Sử dụng Delay message queue sẽ là cách làm khá tối ưu trong trường hợp này.

2. Tối ưu khi nhiều người dùng cùng đặt giá

Cách làm đơn giản trước mắt là chúng ta có nhiều server xử lý request người dùng hơn, thêm cache vào hệ thống, thêm một Push Service riêng để xử lý cập nhật realtime cho UI.

Cache sẽ giúp tối ưu hệ thống ở nhiều khía cạnh:

  • Cache lại thông tin sản phẩm => việc duyệt cũng nhanh hơn
  • Cache lại giá sản phẩm hiện tại: khi này nếu người dùng đặt giá thấp hơn giá hiện tại, ta sẽ loại bỏ request đó trước khi thực hiện transaction vào database.

Tuy nhiên cách giải quyết này chưa xử lý được trường hợp lượng người dùng đặt giá tăng đột ngột (sản phẩm hot).

Ưu điểm của cách làm hiện tại là người dùng sẽ nhận được phản hồi sau khi họ đặt giá. Vậy nếu chúng ta xem xét ở một hướng khác: xử lý bất đồng bộ thì sao ?

Sau khi xử lý ở tầng API (so sánh với giá hiện tại trong cache, loại bỏ nếu thấp hơn), thay vì xử lý request của người dùng ngay lập tức, ta đẩy lệnh đó vào một queue và xử lý sau. Tiếp theo hệ thống sẽ phản hồi người dùng ngay lập tức rằng lệnh của bạn đã được tiếp nhận.

Ta sẽ thêm một dàn worker để xử lý các lệnh này một cách tuần tự. Khi này hệ thống sẽ tránh khỏi việc nghẽn do phải nhận một lượng đặt giá cao đột biến, và sẽ xử lý chúng dần dần sau đó. Với cách setup phù hợp, mình tin hệ thống hoàn toàn có thể xử lý 5-10 đặt giá / giây (400k-800k một ngày)

Vậy nếu chẳng may vào ngày đẹp trời có một sản phẩm quá hot dẫn đến 100k lượt đặt giá thì sao ?

Khi này hệ thống của chúng ta vẫn xử lý một cách chậm rãi, 10 lần / giây. Tức là sau khi kết thúc phiên, phải mất 3h chúng ta mới có kết quả người thắng cuộc. Mọi người tham dự phiên đấu giá đó đều phải chờ phản hồi từ server, họ rất muốn mua nhưng không rõ giá mình đặt đã đủ tốt chưa.

3. Tối ưu khi có sản phẩm hot

Khi xử lý theo hướng bất đồng bộ, ta đã có một tập rất lớn các lệnh được lưu vào queue, vậy có cách này sử dụng batching để tối ưu luồng ghi vào DB ? Thay vì một transaction chỉ ghi 1 lượt đặt giá, ta sẽ ghi 1000 lượt ?

BEGIN;

-- Start: Product 1
SELECT currentPrice, startBid, endBid
    FROM AuctionProduct
WHERE id = :auctionProductId
    FOR UPDATE;
IF (NOW() < startBid OR NOW() > endBid) THEN
    ROLLBACK;
END IF;
IF (:bidPrice <= currentPrice) THEN
    ROLLBACK;
END IF;
INSERT INTO Bids SET userId=...,price=...,time=...
UPDATE AuctionProduct
    SET currentPrice = :bidPrice
WHERE id = :auctionProductId;
-- End: Product 1

-- Start: Product 2
...
-- End: Product 2

-- Start: Product 3
...
-- End: Product 3

COMMIT;

Điểm yếu của cách làm này là nếu một product nào đó fail, cả transaction sẽ fail.

Cần lưu ý rằng khi có một sản phẩm hot, phần lớn cách event trong queue sẽ chứa lệnh đặt giá của nó => ta sẽ tập trung tối ưu các event của một sản phẩm.

Vậy nếu như ta kiểm tra thời gian các event, tính toán giá cao nhất tại API rồi đồng thời ghi chúng vào database thì sao ?

Các làm này tối ưu phương án trước, vậy nhưng các event vẫn đang được route lộn xộn đến các worker => nhiều worker xử lý batching cho một sản phẩm => dẫn đến transaction bị wait do lock.

Để batching hiệu quả hơn + hạn chế lock, ta có thể setup để một sản phẩm luôn luôn được route đến duy nhất một worker (có thể đạt được thông qua setup subscription/partition cho queue). Khi này flow xử lý các lệnh sẽ như sau:

Vậy là transaction thực sự đã có khả năng xử lý nhiều event (của một sản phẩm) và ít bị wait do lock nhất có thể, hiệu năng đã được tối ưu hơn rất nhiều. Giả sử một sản phẩm có 100k lượt đấu giá thực sự, thì mỗi giây chỉ cần xử lý 1 transaction batching 1000 events đã rút ngắn thời gian tính toán xuống còn 2 phút.

Liệu có cách nào thực sự loại bỏ lock ?

Nếu như trong một thời điểm, với một sản phẩm nhất định, chỉ có một thread đảm nhận việc cập nhật giá của nó thì sẽ tránh được race condition xảy ra.

Để đạt được việc này thì đầu tiên ta cần chắc chắn rằng một sản phẩm luôn luôn được route đến duy nhất một worker như trên. Và cần chắc chắn rằng worker sẽ không xử lý đồng thời 2 luồng cho một sản phẩm.

Cách làm này lại tối ưu việc đặt giá hơn một chút, tuy nhiên cần sự cẩn thận và test kĩ càng. Hay hơn nữa là ta có thể sử dụng kĩ thuật Optimistic Locking (kết hợp retry) ở đây, do khả năng xảy ra conflict đã xuống rất thấp (anh em có thể tự nghiên cứu thêm theo hướng này nhé)

Tổng kết lại, chúng ta có một thiết kế cuối cùng như sau

Thiết kế khi mở rộng

Và flow cho tính năng đặt giá

Mặc dù còn nhiều ý để mở rộng và tối ưu cho hệ thống này, tuy nhiên bài viết đã dài nên mình sẽ liệt kê chúng ngắn gọn tại đây:

  • Xử lý lock tại tầng caching để lưu trữ lượt đặt giá cao nhất tại redis luôn, như vậy thì hệ thống có thể dễ dàng phản hồi chính xác lượt đặt giá nào không hợp lệ, cập nhật giá sản phẩm đó realtime, đặc biệt khi có sản phẩm hot như trên thì phản hồi ngay lập tức sẽ tạo ra một trải nghiệm rất tốt. Tuy nhiên thì ta lại phải xem xét xử lý HA cho redis, và phải chắc chắn rằng giá cao nhất tại redis và database không bị lệch nhau.
  • Ngoài ra thì kĩ thuật partition để tối ưu hiệu năng ghi cho database cũng là một hướng để tối ưu hiệu năng.

Summary

Okay, vậy là bạn đã đi cùng mình đến hết bài viết về hệ thống này. Mình tin rằng một hệ thống đấu giá thực sự thì yêu cầu sẽ phức tạp hơn, model sẽ phức tạp hơn và có thể cách làm trong bài viết của mình sẽ không còn phù hợp. Nhưng mình tin những phân tích này sẽ đem lại cách nhìn cho về việc xây dựng một hệ thống ra sao từ khởi điểm đến khi mở rộng.

Hẹn gặp lại trong những bài viết tiếp theo nhé.