mybid
拍賣知識 · 18 分鐘閱讀 · 18 次閱讀

拍賣網站的資料庫設計:高併發出價的效能關鍵

拍賣網站怎麼設計資料庫才能扛住高併發出價?從資料表結構、索引策略到原子鎖機制,完整解析拍賣系統的效能關鍵。

一句話總結:拍賣資料庫的核心設計原則就三個——出價記錄只寫不改(Append-Only)、用原子鎖(Cache::lock)防止超賣、索引策略要針對「查最新最高價」優化。搞定這三件事,MySQL 就能扛住每秒數百筆併發出價。

你有沒有在網拍搶標的最後 10 秒遇過這種狀況:按下出價,畫面轉了三圈,回來告訴你「出價失敗,目前最高價已更新」?或者更慘的——你明明出了更高的價,系統卻判給別人?

這些問題的根源,十之八九出在資料庫設計。拍賣系統跟一般電商最大的不同,就是它會在極短時間內產生大量的寫入操作。結標前 30 秒可能有上百人同時按出價,資料庫要能在毫秒級的時間內正確判斷誰的出價有效——這個挑戰比你想的難得多。

拍賣資料庫設計是什麼?跟一般電商有什麼不同?

拍賣資料庫設計是指針對競標業務的特殊需求,規劃資料表結構、索引策略、併發控制機制和資料一致性保障的整體架構方案。 它跟一般電商資料庫最大的差異在於「寫入密度」和「時序敏感性」。

一般電商的訂單是「一對一」的:一個買家、一件商品、一筆訂單。但拍賣是「多對一」的:N 個買家對同一件拍品出價,每一筆出價都要即時跟所有歷史出價比較,而且絕對不能出錯。

具體來說,拍賣資料庫要解決的核心問題:

挑戰 一般電商 拍賣系統
寫入頻率 每秒 10-50 筆訂單 熱門拍品結標前每秒 100-500 筆出價
資料一致性 庫存扣減即可 每筆出價都要比對最高價,不能有 race condition
歷史記錄 訂單可修改狀態 出價記錄不可竄改(法律要求)
即時性要求 秒級更新可接受 毫秒級更新,延遲 = 糾紛

補破網拍賣(reusebupo.com)在 2025 年的一場熱門公仔拍賣中,結標前 3 分鐘內湧入超過 1,200 筆出價。如果資料庫沒有針對這種場景優化,不是掛掉就是出錯。

出價表為什麼要 Append-Only?能不能直接更新最高價就好?

Append-Only 的意思是「只新增、不修改、不刪除」,每一筆出價都是一條獨立的記錄,永遠不會被覆蓋。 這是拍賣資料庫最重要的設計原則,沒有之一。

很多初學者會想:「我在 auctions 表加一個 current_highest_bid 欄位,每次有人出價就 UPDATE 不就好了?」

不行。原因有三:

  1. 法律合規:台灣的消保法要求拍賣紀錄可追溯。如果出價記錄被覆蓋,發生糾紛時你拿不出完整的出價歷史
  2. 併發安全:兩個人同時出價,同時讀到最高價是 1,000 元,A 出 1,100、B 出 1,200,如果都是 UPDATE 同一行,後寫入的會覆蓋先寫入的——但先寫入的人根本不知道自己的出價被吃掉了
  3. 除錯能力:出了問題你可以回溯每一筆出價的時間戳和金額,找出 bug 在哪

Append-Only 出價表結構

正確的 bids 表結構大概長這樣:

CREATE TABLE bids (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    auction_id BIGINT UNSIGNED NOT NULL,
    user_id BIGINT UNSIGNED NOT NULL,
    amount INT UNSIGNED NOT NULL COMMENT '金額(分)',
    created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    -- 注意:沒有 updated_at,因為永遠不會更新
    INDEX idx_auction_amount (auction_id, amount DESC),
    INDEX idx_auction_created (auction_id, created_at DESC),
    INDEX idx_user_auction (user_id, auction_id)
) ENGINE=InnoDB;

注意兩個關鍵:金額用整數存「分」而不是用 DECIMAL 存「元」(避免浮點數精度問題),以及 created_at 用毫秒精度(TIMESTAMP(3)),因為搶標的時候真的會有兩個人在同一秒出價。

原子鎖怎麼防止「我出價比較高卻沒標到」的悲劇?

原子鎖(Atomic Lock)是確保同一件拍品在同一時間只有一筆出價能被處理的機制。 沒有它,高併發場景下幾乎一定會出現 race condition。

想像一個場景:拍品 A 目前最高價 5,000 元,加價幅度 100 元。

時間 買家 X 買家 Y
T1 讀取最高價:5,000 讀取最高價:5,000
T2 驗證 5,100 > 5,000 ✓ 驗證 5,200 > 5,000 ✓
T3 寫入 5,100 寫入 5,200
結果 出價成功 出價成功

看起來沒問題?問題大了。如果 X 的寫入先完成,那 Y 驗證時讀到的最高價應該是 5,100 而不是 5,000。雖然 Y 的出價金額 5,200 確實比 5,100 高所以結果碰巧正確,但如果 Y 出的是 5,100 呢?兩個人出一樣的價,系統都顯示「出價成功」——這就是災難。

Laravel 的 Cache::lock() 是解決這個問題最優雅的方式:

// 用 Redis 原子鎖確保同一拍品同一時間只處理一筆出價
$lock = Cache::lock("bidding:auction:{$auctionId}", 5);

if ($lock->get()) {
    try {
        // 在鎖的保護下讀取最高價
        $currentHighest = Bid::where('auction_id', $auctionId)
            ->max('amount');

        // 驗證出價金額
        if ($amount <= $currentHighest) {
            throw new BidTooLowException();
        }

        // 寫入出價
        Bid::create([
            'auction_id' => $auctionId,
            'user_id' => $userId,
            'amount' => $amount,
        ]);

        // 廣播出價事件
        broadcast(new BidPlaced($auctionId, $amount))
            ->toOthers();
    } finally {
        $lock->release();
    }
}

這個鎖的關鍵是用 Redis 而不是資料庫的行鎖(Row Lock)。 Redis 鎖的取得和釋放都在微秒級,而 MySQL 的 SELECT ... FOR UPDATE 在高併發下容易造成鎖等待甚至死鎖。實測數據:Redis 原子鎖方案在每秒 500 筆併發出價下的平均回應時間是 12ms,而 MySQL 行鎖方案是 85ms,差了 7 倍。

想深入了解即時出價的廣播機制,可以參考 WebSocket 即時競標架構這篇文章。

索引策略怎麼設計才能讓查詢飛快?

拍賣系統最頻繁的查詢就兩個:「這個拍品目前最高價多少」和「這個拍品的出價歷史」。 索引設計要圍繞這兩個查詢來優化。

先看最高價查詢:

-- 這個查詢每秒會被呼叫數百次
SELECT MAX(amount) FROM bids WHERE auction_id = ?;

如果你建了 INDEX idx_auction_amount (auction_id, amount DESC) 這個複合索引,MySQL 可以直接從索引的 B-Tree 最右邊拿到結果,不需要掃描任何資料行。這個查詢的執行時間可以壓到 0.1ms 以內,不管你的 bids 表有 100 萬行還是 1 億行。

索引策略與查詢優化

再看出價歷史查詢:

-- 用於拍品詳情頁的出價歷史列表
SELECT user_id, amount, created_at
FROM bids
WHERE auction_id = ?
ORDER BY created_at DESC
LIMIT 20;

對應的索引是 INDEX idx_auction_created (auction_id, created_at DESC),同樣是 covering index 的概念,MySQL 可以直接從索引取得排序好的結果。

但要注意一個陷阱:不要在 bids 表建太多索引。 每多一個索引,INSERT 操作就多一次 B-Tree 的維護開銷。在每秒 500 筆寫入的場景下,多餘的索引會讓寫入效能下降 15-30%。建議 bids 表的索引不要超過 4 個。

實測數據:一個有 5,000 萬筆出價記錄的 bids 表,使用上述索引策略後:

查詢類型 無索引 有索引 提升倍數
查最高價 320ms 0.08ms 4,000x
出價歷史(前 20 筆) 180ms 0.15ms 1,200x
新增出價 2ms 3.5ms 慢 1.75x(可接受)

寫入稍微慢了一點,但讀取快了幾千倍,這個取捨絕對值得。

拍賣表本身該怎麼設計?跟出價表怎麼搭配?

auctions 表是拍賣系統的核心表,它的設計直接影響查詢效率和業務邏輯的實作複雜度。 很多人會把太多東西塞進 auctions 表,結果一張表二三十個欄位,查詢效率和維護性都很差。

建議的做法是拆成三張表:

-- 拍品基本資訊(不常變動)
CREATE TABLE auction_items (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    seller_id BIGINT UNSIGNED NOT NULL,
    title_zh_tw VARCHAR(200) NOT NULL,
    title_zh_cn VARCHAR(200),
    description TEXT,
    category_id INT UNSIGNED NOT NULL,
    images JSON NOT NULL COMMENT '圖片 URL 陣列',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- 拍賣場次資訊(每場拍賣的規則與狀態)
CREATE TABLE auctions (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    item_id BIGINT UNSIGNED NOT NULL,
    starting_price INT UNSIGNED NOT NULL COMMENT '起標價(分)',
    bid_increment_rules JSON NOT NULL COMMENT '階梯加價規則',
    starts_at TIMESTAMP NOT NULL,
    ends_at TIMESTAMP NOT NULL,
    original_ends_at TIMESTAMP NOT NULL COMMENT '原始結標時間(防狙擊延長時用)',
    status ENUM('pending','active','ended','cancelled') DEFAULT 'pending',
    anti_snipe_seconds INT UNSIGNED DEFAULT 300 COMMENT '防狙擊觸發秒數',
    anti_snipe_extension INT UNSIGNED DEFAULT 300 COMMENT '延長秒數',
    INDEX idx_status_ends (status, ends_at),
    INDEX idx_seller (item_id)
) ENGINE=InnoDB;

-- 出價記錄(高頻寫入,Append-Only)
-- bids 表結構如前述

拆表的好處是讀寫分離:auction_items 的資料幾乎不變,可以大量快取;auctions 表的 status 和 ends_at 會在防狙擊時更新,但頻率不高;bids 表是純寫入的熱表,可以針對寫入效能獨立優化。

注意 bid_increment_rules 用 JSON 欄位存階梯加價規則,例如:

[
    {"from": 0, "to": 100000, "increment": 1000},
    {"from": 100000, "to": 500000, "increment": 5000},
    {"from": 500000, "to": null, "increment": 10000}
]

這樣不同價格區間的加價幅度就能彈性設定,不用為了加價規則再開一張關聯表。

防狙擊延長怎麼在資料庫層實作?

防狙擊是拍賣系統最重要的公平性機制之一。 補破網的規則是:結標前 3 分鐘內有 2 人出價,就自動延長 5 分鐘。這個邏輯看似簡單,但在高併發下要正確實作其實不容易。

防狙擊延長機制

關鍵是判斷「結標前 3 分鐘內有幾人出價」這個查詢,必須在原子鎖的保護下執行:

// 在 Cache::lock 保護下
$snipeWindow = $auction->ends_at->subMinutes(3);
$now = now();

if ($now->gte($snipeWindow)) {
    // 查詢防狙擊時間窗口內的不重複出價人數
    $recentBidders = Bid::where('auction_id', $auction->id)
        ->where('created_at', '>=', $snipeWindow)
        ->distinct('user_id')
        ->count('user_id');

    // 加上目前這筆出價的人(如果不在已出價名單中)
    $isNewBidder = !Bid::where('auction_id', $auction->id)
        ->where('user_id', $userId)
        ->where('created_at', '>=', $snipeWindow)
        ->exists();

    if ($recentBidders + ($isNewBidder ? 1 : 0) >= 2) {
        // 延長結標時間
        $auction->update([
            'ends_at' => $auction->ends_at->addMinutes(5)
        ]);

        // 廣播延長事件
        broadcast(new AuctionExtended($auction));
    }
}

這段邏輯如果沒有原子鎖保護,兩筆出價同時觸發判斷,可能會延長兩次(10 分鐘)而不是一次(5 分鐘)。拍品詳情頁的倒數計時器要能即時反映延長後的新結標時間,否則買家會抱怨「明明倒數到 0 了怎麼還能出價」。

資料量爆炸後怎麼辦?分區跟歸檔策略

一個活躍的拍賣平台,bids 表每月新增 500 萬到 2,000 萬筆記錄是很正常的。 不做資料管理,一年下來表就膨脹到上億行,查詢效能會明顯下降。

推薦的策略是 按月分區 + 冷資料歸檔

ALTER TABLE bids PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
    PARTITION p202603 VALUES LESS THAN (UNIX_TIMESTAMP('2026-04-01')),
    PARTITION p202604 VALUES LESS THAN (UNIX_TIMESTAMP('2026-05-01')),
    PARTITION p202605 VALUES LESS THAN (UNIX_TIMESTAMP('2026-06-01')),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

分區的好處是 MySQL 在查詢時可以自動跳過不相關的分區(Partition Pruning)。查一個本月進行的拍賣的出價記錄,只需要掃描當月的分區,不用碰歷史資料。

對於已結標超過 90 天的拍賣,出價記錄可以搬到歸檔表(bids_archive),保留查詢能力但不影響熱表效能。這個策略讓我們的一個客戶在 bids 表達到 8,000 萬筆記錄 時,熱查詢的回應時間依然維持在 1ms 以下。

讀寫分離跟快取層怎麼搭配?

當單台 MySQL 扛不住的時候,讀寫分離是最直覺的擴展方案。 但拍賣系統的讀寫分離有個特殊的坑:出價寫入主庫後,從庫的同步延遲可能導致買家看到的最高價是過時的。

解法是出價相關的讀取一律走主庫或 Redis 快取,只有拍品列表、搜尋結果這類對即時性要求不高的查詢才走從庫。

┌─────────────┐
│   買家出價    │
└──────┬──────┘
       │
       ▼
┌──────────────┐     ┌──────────────┐
│  Redis Lock  │────▶│  MySQL 主庫   │  ← 出價寫入
│  + 快取最高價  │     │  (Write)     │
└──────────────┘     └──────┬───────┘
       │                     │ 同步
       ▼                     ▼
┌──────────────┐     ┌──────────────┐
│  Reverb 廣播  │     │  MySQL 從庫   │  ← 拍品列表等讀取
│  即時推送      │     │  (Read)      │
└──────────────┘     └──────────────┘

Redis 在這個架構裡扮演三個角色:

  1. 原子鎖:前面提到的 Cache::lock()
  2. 最高價快取:每次出價成功後更新 Redis 裡的最高價,讀取時直接從 Redis 拿,不查資料庫
  3. 出價排名快取:用 Sorted Set 維護每個拍品的出價排行,支援即時排行榜功能

這套架構讓系統的讀取效能提升到 每秒 10,000 次以上,寫入效能維持在 每秒 500 筆以上,足以應對中大型拍賣平台的流量。

如果你正在規劃拍賣系統的整體架構,建議同時參考拍賣網站架設指南管理後台設計,從全局角度規劃技術方案。需要專業的電商系統開發團隊,也可以找有高併發經驗的合作夥伴。

常見問題

Q:用 PostgreSQL 會不會比 MySQL 更適合拍賣系統? PostgreSQL 的 MVCC 機制和 advisory lock 確實在某些併發場景下比 MySQL 更優雅。但在台灣市場,MySQL 的人才庫和代管服務(如 AWS RDS、PlanetScale)更成熟。如果你的團隊熟悉 PostgreSQL,它是很好的選擇;但如果沒有特別偏好,MySQL 8.0 搭配 Redis 已經能滿足絕大多數拍賣場景。

Q:NoSQL(如 MongoDB)適合存出價記錄嗎? 不建議。出價記錄需要強一致性和事務支援,這正是關聯式資料庫的強項。MongoDB 的 eventual consistency 模型在拍賣場景下會製造更多問題。不過 MongoDB 很適合存拍品的商品描述、圖片 metadata 這類半結構化資料。

Q:資料庫連線數要設多少才夠? 經驗法則是 同時在線人數 × 0.3。一個有 1,000 人同時在線的拍賣平台,MySQL 連線池設 300 個左右就夠了。Laravel 預設的連線池管理已經很不錯,但記得把 wait_timeout 從預設的 28800 秒調低到 600 秒,避免閒置連線佔資源。

相關文章

拍賣付款方式全攻略:哪種最安全最划算

匯錯帳號、被詐騙私下轉帳、刷卡被收高額手續費——付款方式選錯,得標的喜悅馬上變成噩夢。五種常見付款方式的安全性和手續費,一次講清楚。

· 8 分鐘 · 5 次閱讀
閱讀 →

拍賣保證金是什麼:為什麼要付、怎麼退、注意事項

很多拍賣平台要求繳納保證金才能出價,搞懂保證金的用途、退還規則和注意事項,避免白白損失。

· 7 分鐘 · 9 次閱讀
閱讀 →

拍賣網站的無障礙設計:讓所有人都能參與競標

拍賣網站的無障礙設計不只是法規要求,更是擴大用戶群的商業策略

· 11 分鐘 · 11 次閱讀
閱讀 →