使用 Supabase Realtime 構建實時競價引擎
你的第一個競價引擎每秒輪詢數據庫。出價亂序到達。有些完全消失。第二個版本添加了單獨的 WebSocket 服務器——更好,但現在你要管理兩個基礎設施。第三次嘗試使用了 Supabase Realtime,競態條件停止了。沒有輪詢循環。沒有獨立的套接字服務器。PostgreSQL 觸發器在出價提交時廣播它們,每個連接的客戶端在 50 毫秒內看到相同的狀態。我在兩年內推出了三個競價系統。Supabase 版本是唯一一個我不必在啟動後重建的。以下是使其工作的架構——以及幾乎破壞它的那個函數。
Supabase Realtime 構建在 PostgreSQL 的預寫日誌 (WAL) 之上,使用基於 Elixir 的服務器通過 WebSocket 將數據庫更改推送到連接的客戶端。對於競價系統,這意味著每次出價都會立即傳播到觀看該競價的每個出價者。無需輪詢。無需單獨的 pub/sub 基礎設施。你的數據庫就是你的事件系統。
讓我們從頭開始構建一個。
目錄
- 架構概述
- 數據庫模式和設置
- PostgreSQL 觸發器用於競價邏輯
- JavaScript 客戶端訂閱
- 處理競態條件和出價驗證
- 活躍出價者的在線狀態跟蹤
- 性能調優和生產考慮因素
- Supabase Realtime 對比替代方案
- 部署和擴展競價系統
- 常見問題
架構概述
在編寫任何代碼之前,讓我們理解我們正在構建什麼以及各個部分如何配合。
Supabase Realtime 為你提供三個原語,完美映射到競價要求:
- Postgres Changes: 訂閱你的出價和競價表的 INSERT、UPDATE 和 DELETE 事件。當有人出價時,每個訂閱者在數毫秒內獲得新行數據。
- Broadcast: 向頻道參與者發送臨時消息。非常適合不需要持久化的「你被超出」通知。
- Presence: 跟蹤誰當前在觀看競價。這讓你可以在 UI 中顯示「14 個出價者在觀看」並檢測幽靈會話。
數據流如下所示:
- 出價者通過前端提交出價
- RPC 調用或直接插入命中你的
bids表 - PostgreSQL 觸發器驗證出價金額並更新
auctions.current_high_bid - Supabase Realtime 獲取 WAL 更改並將其推送到該競價頻道的所有訂閱者
- 第二個觸發器觸發 Broadcast 事件以通知前高出價者他們被超出了
- 每個連接的客戶端實時更新其 UI
從出價投置到跨所有客戶端 UI 更新的延遲通常低於 100 毫秒。我在 Supabase Pro 層的生產環境中測量了 p99 約為 80-90 毫秒。
為什麼不使用輪詢?
我知道你們中的一些人想著「我不能每 500 毫秒輪詢一次嗎?」你可以。但在單個競價上有 200 個並發出價者時,每秒有 400 個請求命中你的數據庫來處理一個競價。將其乘以 50 個活躍競價,你達到每秒 20,000 次查詢——其中大多數返回沒有新結果。WebSocket 翻轉了這個模型:什麼都沒變時零查詢,有變時立即更新。
數據庫模式和設置
這是我使用的模式。它刻意簡單——你可以擴展它,但核心結構處理大多數競價類型。
-- 競價表
CREATE TABLE auctions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_name TEXT NOT NULL,
description TEXT,
starting_price DECIMAL(12,2) NOT NULL DEFAULT 0,
current_high_bid DECIMAL(12,2) DEFAULT 0,
highest_bidder_id UUID REFERENCES auth.users(id),
min_increment DECIMAL(12,2) DEFAULT 1.00,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('scheduled', 'active', 'ended', 'sold', 'cancelled')),
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ends_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '30 minutes',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 出價表
CREATE TABLE bids (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
auction_id UUID NOT NULL REFERENCES auctions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id),
amount DECIMAL(12,2) NOT NULL,
placed_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT positive_amount CHECK (amount > 0)
);
-- 快速查詢每個競價的出價索引
CREATE INDEX idx_bids_auction_amount ON bids(auction_id, amount DESC);
CREATE INDEX idx_bids_auction_time ON bids(auction_id, placed_at DESC);
-- 關鍵:啟用 Realtime 的副本身份
ALTER TABLE auctions REPLICA IDENTITY FULL;
ALTER TABLE bids REPLICA IDENTITY FULL;
REPLICA IDENTITY FULL 設置很關鍵。沒有它,Supabase Realtime 只能在 UPDATE 和 DELETE 事件中獲得主鍵——而不是完整的行數據。對於競價系統,你需要完整的有效負載,以便客戶端可以更新出價金額而無需進行單獨的查詢。
啟用 Realtime 複製
在 Supabase 儀表板中,轉到 Database → Replication 並為 auctions 和 bids 表切換複製。或者,你可以使用 SQL:
BEGIN;
-- 刪除現有發布(如果存在)
DROP PUBLICATION IF EXISTS supabase_realtime;
-- 使用兩個表創建發布
CREATE PUBLICATION supabase_realtime FOR TABLE auctions, bids;
COMMIT;
行級安全性
不要跳過這個。RLS 是你的服務器端驗證層。
ALTER TABLE auctions ENABLE ROW LEVEL SECURITY;
ALTER TABLE bids ENABLE ROW LEVEL SECURITY;
-- 任何人都可以查看活躍競價
CREATE POLICY "Public auction viewing" ON auctions
FOR SELECT USING (status IN ('active', 'ended', 'sold'));
-- 經過身份驗證的用戶可以查看活躍競價上的所有出價
CREATE POLICY "View bids on active auctions" ON bids
FOR SELECT USING (
EXISTS (
SELECT 1 FROM auctions
WHERE auctions.id = bids.auction_id
AND auctions.status = 'active'
)
);
-- 只有經過身份驗證的用戶才能出價
CREATE POLICY "Place bids" ON bids
FOR INSERT WITH CHECK (
auth.uid() = user_id
AND EXISTS (
SELECT 1 FROM auctions
WHERE auctions.id = auction_id
AND auctions.status = 'active'
AND auctions.ends_at > NOW()
)
);
PostgreSQL 觸發器用於競價邏輯
這是真正的魔法發生的地方。數據庫在服務器端強制執行所有競價邏輯——客戶端無法作弊。
出價驗證和競價更新觸發器
CREATE OR REPLACE FUNCTION process_new_bid()
RETURNS TRIGGER AS $$
DECLARE
v_auction auctions%ROWTYPE;
BEGIN
-- 鎖定競價行以防止競態條件
SELECT * INTO v_auction
FROM auctions
WHERE id = NEW.auction_id
FOR UPDATE;
-- 驗證競價是否活躍
IF v_auction.status != 'active' THEN
RAISE EXCEPTION 'Auction is not active';
END IF;
-- 驗證競價未結束
IF v_auction.ends_at < NOW() THEN
RAISE EXCEPTION 'Auction has ended';
END IF;
-- 驗證出價金額超過當前高 + 最小增量
IF NEW.amount < v_auction.current_high_bid + v_auction.min_increment THEN
RAISE EXCEPTION 'Bid must be at least % higher than current high bid of %',
v_auction.min_increment, v_auction.current_high_bid;
END IF;
-- 防止自我超出
IF v_auction.highest_bidder_id = NEW.user_id THEN
RAISE EXCEPTION 'You are already the highest bidder';
END IF;
-- 使用新高出價更新競價
UPDATE auctions
SET
current_high_bid = NEW.amount,
highest_bidder_id = NEW.user_id,
updated_at = NOW()
WHERE id = NEW.auction_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER validate_and_process_bid
BEFORE INSERT ON bids
FOR EACH ROW
EXECUTE FUNCTION process_new_bid();
那個 FOR UPDATE 鎖在競價行上是關鍵的。沒有它,兩個同時到達的出價都可以讀取相同的 current_high_bid,都通過驗證,並且都被插入。鎖序列化訪問。
Broadcast 被超出通知
此觸發器在成功出價後觸發,並向競價頻道發送臨時通知:
CREATE OR REPLACE FUNCTION notify_outbid()
RETURNS TRIGGER AS $$
DECLARE
v_previous_bidder UUID;
BEGIN
-- 找出誰剛被超出
SELECT user_id INTO v_previous_bidder
FROM bids
WHERE auction_id = NEW.auction_id
AND id != NEW.id
ORDER BY amount DESC
LIMIT 1;
-- 如果有前出價者,則廣播被超出通知
IF v_previous_bidder IS NOT NULL THEN
PERFORM realtime.send(
jsonb_build_object(
'auction_id', NEW.auction_id,
'new_high', NEW.amount,
'outbid_user', v_previous_bidder,
'new_leader', NEW.user_id
),
'outbid',
'auction:' || NEW.auction_id::text,
true
);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER after_bid_notify
AFTER INSERT ON bids
FOR EACH ROW
EXECUTE FUNCTION notify_outbid();
JavaScript 客戶端訂閱
現在讓我們連接前端。我將使用原生 JavaScript/React 模式展示這個——如果你使用 Next.js 或任何其他框架,同樣的方法也有效。
初始化客戶端
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
realtime: {
params: {
eventsPerSecond: 20 // 為競價流量節流
}
}
}
);
那個 eventsPerSecond 參數很重要。在熱競價中每秒有幾十個出價,你不希望重新渲染 50 次。每秒 20 次更新足以提供流暢的 UI。
訂閱競價頻道
function subscribeToAuction(auctionId, callbacks) {
const channel = supabase.channel(`auction:${auctionId}`);
channel
// 通過 Postgres Changes 監聽新出價
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'bids',
filter: `auction_id=eq.${auctionId}`
}, (payload) => {
callbacks.onNewBid(payload.new);
})
// 監聽競價狀態變化
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'auctions',
filter: `id=eq.${auctionId}`
}, (payload) => {
callbacks.onAuctionUpdate(payload.new);
})
// 監聽被超出廣播通知
.on('broadcast', { event: 'outbid' }, ({ payload }) => {
callbacks.onOutbid(payload);
})
// 通過在線狀態跟蹤活躍出價者
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const bidderCount = Object.keys(state).length;
callbacks.onPresenceUpdate(bidderCount, state);
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// 跟蹤此用戶的在線狀態
await channel.track({
user_id: supabase.auth.getUser()?.data?.user?.id,
status: 'watching',
joined_at: new Date().toISOString()
});
}
});
return channel;
}
競價訂閱的 React Hook
import { useState, useEffect, useCallback } from 'react';
function useAuction(auctionId) {
const [auction, setAuction] = useState(null);
const [bids, setBids] = useState([]);
const [bidderCount, setBidderCount] = useState(0);
const [isOutbid, setIsOutbid] = useState(false);
useEffect(() => {
// 獲取初始狀態
async function loadAuction() {
const { data: auctionData } = await supabase
.from('auctions')
.select('*')
.eq('id', auctionId)
.single();
setAuction(auctionData);
const { data: bidData } = await supabase
.from('bids')
.select('*')
.eq('auction_id', auctionId)
.order('amount', { ascending: false })
.limit(20);
setBids(bidData || []);
}
loadAuction();
// 訂閱實時更新
const channel = subscribeToAuction(auctionId, {
onNewBid: (bid) => {
setBids(prev => [bid, ...prev].slice(0, 20));
setIsOutbid(false);
},
onAuctionUpdate: (updated) => setAuction(updated),
onOutbid: (payload) => {
const currentUser = supabase.auth.getUser()?.data?.user;
if (payload.outbid_user === currentUser?.id) {
setIsOutbid(true);
}
},
onPresenceUpdate: (count) => setBidderCount(count)
});
return () => {
supabase.removeChannel(channel);
};
}, [auctionId]);
const placeBid = useCallback(async (amount) => {
const user = (await supabase.auth.getUser()).data.user;
const { data, error } = await supabase
.from('bids')
.insert({
auction_id: auctionId,
amount: parseFloat(amount),
user_id: user.id
})
.select()
.single();
if (error) throw new Error(error.message);
return data;
}, [auctionId]);
return { auction, bids, bidderCount, isOutbid, placeBid };
}
處理競態條件和出價驗證
競態條件是競價系統中最大的 bug 來源。以下是我的處理方式。
服務器端:PostgreSQL 做繁重工作
觸發器函數中的 SELECT ... FOR UPDATE 是第一道防線。但我開始使用另一個模式——針對高爭用競價的諮詢鎖:
CREATE OR REPLACE FUNCTION place_bid_safe(
p_auction_id UUID,
p_user_id UUID,
p_amount DECIMAL
)
RETURNS TABLE(bid_id UUID, new_high DECIMAL) AS $$
DECLARE
v_lock_key BIGINT;
v_bid_id UUID;
BEGIN
-- 從競價 UUID 生成確定性鎖鍵
v_lock_key := ('x' || left(p_auction_id::text, 15))::bit(64)::bigint;
-- 獲取諮詢鎖(阻止同一競價的並發出價)
PERFORM pg_advisory_xact_lock(v_lock_key);
-- 現在安全插入(觸發器處理驗證)
INSERT INTO bids (auction_id, user_id, amount)
VALUES (p_auction_id, p_user_id, p_amount)
RETURNING id INTO v_bid_id;
RETURN QUERY
SELECT v_bid_id, p_amount;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
使用 Supabase 的 RPC 從客戶端調用:
const { data, error } = await supabase.rpc('place_bid_safe', {
p_auction_id: auctionId,
p_user_id: user.id,
p_amount: bidAmount
});
客戶端:樂觀 UI 帶回滾
立即在 UI 中顯示出價,但準備好在服務器拒絕時回滾:
async function handleBidSubmit(amount) {
const optimisticBid = {
id: crypto.randomUUID(),
amount,
user_id: user.id,
placed_at: new Date().toISOString(),
_optimistic: true
};
// 立即顯示
setBids(prev => [optimisticBid, ...prev]);
try {
await placeBid(amount);
// 真實出價將通過 Realtime 到達並替換樂觀出價
} catch (err) {
// 失敗時移除樂觀出價
setBids(prev => prev.filter(b => b.id !== optimisticBid.id));
showError(err.message);
}
}
活躍出價者的在線狀態跟蹤
顯示有多少人在觀看競價會產生緊迫感。Supabase 的在線狀態跟蹤非常簡單:
// 當他們開始出價時更新用戶狀態
async function updatePresenceStatus(channel, status) {
await channel.track({
user_id: user.id,
status, // 'watching', 'bidding', 'won'
last_active: new Date().toISOString()
});
}
在顯示方面,你可以分解在線狀態以顯示多少人正在主動出價與只是在觀看:
function parseBidderStats(presenceState) {
const users = Object.values(presenceState).flat();
return {
total: users.length,
bidding: users.filter(u => u.status === 'bidding').length,
watching: users.filter(u => u.status === 'watching').length
};
}
性能調優和生產考慮因素
節流和去抖
競價戰可以每秒生成數十個事件。以下是我配置的內容:
- 服務器端:Supabase 客戶端配置上的
eventsPerSecond: 20 - 客戶端:出價按鈕去抖 300 毫秒以防止雙擊
- UI 更新:為出價列表動畫使用
requestAnimationFrame
競價結束時機
不要信任客戶端時鐘。使用通過 pg_cron 的 PostgreSQL cron 作業:
-- 每 10 秒運行一次以關閉過期競價
SELECT cron.schedule(
'close-expired-auctions',
'*/10 * * * * *',
$$
UPDATE auctions
SET status = CASE
WHEN highest_bidder_id IS NOT NULL THEN 'sold'
ELSE 'ended'
END
WHERE status = 'active'
AND ends_at <= NOW();
$$
);
防止狙擊延期
大多數競價平台在最後幾秒出現出價時延期截止時間:
-- 添加到 process_new_bid 觸發器
IF v_auction.ends_at - NOW() < INTERVAL '30 seconds' THEN
UPDATE auctions
SET ends_at = ends_at + INTERVAL '30 seconds'
WHERE id = NEW.auction_id;
END IF;
Supabase Realtime 對比替代方案
我在生產中使用過大多數方案。這是一個誠實的比較:
| 功能 | Supabase Realtime | Pusher | Ably | Firebase RTDB | Socket.io (自行託管) |
|---|---|---|---|---|---|
| 原生 DB 同步 | ✅ PostgreSQL WAL | ❌ 單獨服務 | ❌ 單獨服務 | ✅ JSON 樹 | ❌ 手動 |
| 延遲 (p99) | ~80-100ms | ~60ms | ~50ms | ~100ms | ~40ms (取決於基礎設施) |
| 最大事件/秒 | 200k+ | 10k (Pro) | 50k | 100k | 無限 (你擴展它) |
| 認證整合 | 內置 (RLS + JWT) | 自定義 | 基於令牌 | Firebase Auth | 自定義 |
| 在線狀態 | ✅ 內置 | ✅ 內置 | ✅ 內置 | ✅ 內置 | ✅ 內置 |
| 免費層 | 500K MAU, 200 並發 | 100 連接 | 600 萬消息/月 | 1GB 儲存 | $0 (託管成本) |
| Pro 價格 | $25/月 | $49/月 | $29/月 | 按使用付費 | ~$100-500/月 (AWS) |
| 最適合 | DB 中心實時應用 | 簡單 pub/sub | 高可靠性 | 行動應用 | 完全控制 |
對於競價系統特別是,Supabase 獲勝,因為你的出價已經在 PostgreSQL 中。你不需要在數據庫和單獨的 pub/sub 系統之間同步。出價命中 DB,DB 觸發 WebSocket 推送。一個真實來源。
如果你在 無頭 CMS 架構 上構建,Supabase 自然地與內容傳遞相適應,而無需管理另一個服務。
部署和擴展競價系統
對於大多數項目,Supabase 的託管 Pro 層 $25/月 可以舒適地處理多達 10,000 個日競價。以下是要注意的事項:
- 連接限制:Pro 層為你提供 500 個並發 Realtime 連接。如果你需要更多,你需要升級或在客戶端實現連接池。
- WAL 大小:高容量競價生成大量 WAL 流量。監控你的複製槽以避免磁盤膨脹。
- 頻道計數:每個競價都獲得它自己的頻道。有數千個活躍競價時,測試你的客戶端是否正確地從已結束的競價取消訂閱。
對於使用 Astro 或 Next.js 構建的前端,Supabase JS 客戶端工作完全相同——只需確保你在客戶端初始化它用於 Realtime 訂閱。
如果你正在構建需要認真擴展的東西——數十萬個並發出價者——聯繫我們。我們在規模上設計了這些系統,可以幫助你避免陷阱。你也可以查看我們的 定價頁面 了解基於項目的參與。
常見問題
Supabase Realtime 可以處理多少併發出價者? Supabase Realtime 可以在其託管平台的分佈式服務器上處理每秒超過 200,000 個事件。$25/月 的 Pro 層支持每個項目多達 500 個並發連接。對於較大的競價,企業層提供自定義限制,或者你可以自行託管 Realtime 服務器(它是開源的)在你自己的基礎設施上。
Supabase Realtime 對於實時競價是否足夠快? 是的。在我的測試中,從出價插入到客戶端通知的端到端延遲平均約為 50-80 毫秒,p99 低於 100 毫秒。作為背景,人類反應時間約為 200-300 毫秒,所以出價看起來實際上是瞬時的。瓶頸很少是 Supabase——通常是客戶端的網絡連接。
我如何在兩個人同時出價時防止競態條件?
在觸發器函數內使用 PostgreSQL 的 SELECT ... FOR UPDATE 行級鎖,或通過 pg_advisory_xact_lock() 使用諮詢鎖。這序列化每個競價的出價處理,所以只有一個出價被驗證。「失敗」出價仍被驗證——它只是看到來自贏家的更新的高出價,並要麼成功(如果它仍然更高)要麼失敗並出現適當的錯誤。
我可以將 Supabase Realtime 與 Next.js 或 Astro 一起使用嗎?
當然可以。@supabase/supabase-js 客戶端在任何 JavaScript 環境中工作。對於 Next.js,在客戶端組件中初始化 Supabase 客戶端(因為 Realtime 需要瀏覽器 WebSocket)並在 useEffect 鉤子內使用它。對於 Astro,在客戶端交互島中使用它。訂閱代碼在你選擇的框架上是相同的。
如果用戶連接在競價中期中斷會發生什麼? Supabase Realtime 自動嘗試重新連接。當客戶端重新連接並重新訂閱時,它接收當前狀態。對於關鍵競價,我建議在重新連接時也通過標準查詢獲取最新的競價狀態,以確保在斷開連接窗口期間沒有任何遺漏。在線狀態系統將在超時後自動移除斷開連接的用戶。
我如何準確處理競價結束時間?
永遠不要依賴客戶端計時器來表示競價結束時間——它們可以被操縱。使用 PostgreSQL 的 pg_cron 擴展每 10 秒檢查並關閉過期競價的服務器端。向客戶端發送服務器時間戳以便他們可以顯示倒計時,但實際結束確定總是在數據庫中發生。
Supabase Realtime 對於小型項目是否免費? Supabase 的免費層包括 Realtime,最多 200 個並發連接和 500,000 個月活躍用戶。這對於一個業餘競價網站或 MVP 足夠。如果你正在運行帶有有意義流量的生產競價平台,$25/月 的 Pro 層加上 $0.09/GB 出站是你想開始的地方。它遠低於運行你自己的 WebSocket 基礎設施的成本。
我如何在本地測試實時競價系統?
使用 Supabase CLI (supabase start) 運行啟用 Realtime 的本地 Supabase 實例。打開多個瀏覽器選項卡以模擬多個出價者。對於負載測試,我使用一個簡單的 Node.js 腳本,創建 100 多個 Supabase 客戶端,並在計時器上相互出價。這捕獲競態條件並幫助你在投入生產前調優你的 eventsPerSecond 參數。