一句話總結:拍賣資料庫的核心設計原則就三個——出價記錄只寫不改(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,000 元,A 出 1,100、B 出 1,200,如果都是 UPDATE 同一行,後寫入的會覆蓋先寫入的——但先寫入的人根本不知道自己的出價被吃掉了
- 除錯能力:出了問題你可以回溯每一筆出價的時間戳和金額,找出 bug 在哪
正確的 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 在這個架構裡扮演三個角色:
- 原子鎖:前面提到的 Cache::lock()
- 最高價快取:每次出價成功後更新 Redis 裡的最高價,讀取時直接從 Redis 拿,不查資料庫
- 出價排名快取:用 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 秒,避免閒置連線佔資源。