我在過去兩年內構建了三個拍賣系統。第一個是個爛攤子——每秒輪詢數據庫,到處都是競爭條件,出價消失得無影無蹤。第二個好一些,但需要在主 API 旁邊管理一個單獨的 WebSocket 伺服器。第三個?那就是我要帶你瞭解的。它使用 Supabase Realtime,這是我第一次感覺構建競價引擎真正正確

Supabase Realtime 構建在 PostgreSQL 的預寫日誌(WAL)之上,使用基於 Elixir 的伺服器透過 WebSockets 將數據庫更改推送到已連接的用戶端。對於拍賣系統,這意味著每次命中你的數據庫的出價都會立即傳播到每個觀看該拍賣的競價者。沒有輪詢。沒有單獨的 pub/sub 基礎設施。你的數據庫就是你的事件系統。

讓我們從頭開始構建一個。

目錄

架構概述

在編寫任何代碼之前,讓我們理解我們正在構建什麼以及各部分如何組合在一起。

Supabase Realtime 為你提供三個原始物件,完美對應拍賣需求:

  • Postgres Changes:訂閱你的出價和拍賣表上的 INSERT、UPDATE 和 DELETE 事件。當有人出價時,每個訂閱者都在毫秒內獲得新行數據。
  • Broadcast:向頻道參與者發送短暫消息。非常適合不需要持久化的「你被超價」通知。
  • Presence:追蹤目前誰在觀看拍賣。這讓你在 UI 中顯示「14 個競價者正在觀看」並檢測幽靈會話。

數據流看起來像這樣:

  1. 競價者透過你的前端提交出價
  2. RPC 調用或直接插入命中你的 bids
  3. PostgreSQL 觸發器驗證出價金額並更新 auctions.current_high_bid
  4. Supabase Realtime 拾取 WAL 更改並將其推送給該拍賣頻道上的所有訂閱者
  5. 第二個觸發器發送 Broadcast 事件以通知前一個最高競價者他們已被超價
  6. 每個連接的客户端即時更新其 UI

從出價放置到 UI 更新遍及所有客户端的延遲通常在 100 毫秒以下。我在 Supabase Pro 層的生產環境中測量 p99 約在 80-90 毫秒。

為什麼不使用輪詢?

我知道你們中的一些人在想「我不能每 500 毫秒輪詢一次嗎?」你可以。但在單個拍賣上有 200 個併發競價者的情況下,那是每秒 400 個請求命中你的數據庫用於一個拍賣。將其乘以 50 個活躍拍賣,你將達到每秒 20,000 次查詢——其中大多數返回什麼都沒有新的。WebSocket 翻轉了這個模型:當沒有變化時零查詢,當有變化時立即更新。

數據庫模式和設置

這是我使用的模式。它故意簡單——你可以擴展它,但核心結構處理大多數拍賣類型。

-- Auctions table
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()
);

-- Bids table
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)
);

-- Index for fast bid lookups per auction
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);

-- Critical: enable replica identity for Realtime
ALTER TABLE auctions REPLICA IDENTITY FULL;
ALTER TABLE bids REPLICA IDENTITY FULL;

REPLICA IDENTITY FULL 設置至關重要。沒有它,Supabase Realtime 只在 UPDATE 和 DELETE 事件上獲取主鍵——而不是完整的行數據。對於拍賣系統,你需要完整的有效負載,以便客户端可以更新出價金額而無需進行單獨的查詢。

啟用 Realtime 複製

在 Supabase 儀表板中,進入 Database → Replication,並為 auctionsbids 表切換複製開啟。或者,你可以使用 SQL 執行此操作:

BEGIN;
  -- Remove existing publication if it exists
  DROP PUBLICATION IF EXISTS supabase_realtime;
  
  -- Create publication with both tables
  CREATE PUBLICATION supabase_realtime FOR TABLE auctions, bids;
COMMIT;

行級安全性

不要跳過這個。RLS 是你的伺服器端驗證層。

ALTER TABLE auctions ENABLE ROW LEVEL SECURITY;
ALTER TABLE bids ENABLE ROW LEVEL SECURITY;

-- Anyone can view active auctions
CREATE POLICY "Public auction viewing" ON auctions
  FOR SELECT USING (status IN ('active', 'ended', 'sold'));

-- Authenticated users can view all bids on active auctions
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'
    )
  );

-- Only authenticated users can place bids
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
  -- Lock the auction row to prevent race conditions
  SELECT * INTO v_auction
  FROM auctions
  WHERE id = NEW.auction_id
  FOR UPDATE;

  -- Validate auction is active
  IF v_auction.status != 'active' THEN
    RAISE EXCEPTION 'Auction is not active';
  END IF;

  -- Validate auction hasn't ended
  IF v_auction.ends_at < NOW() THEN
    RAISE EXCEPTION 'Auction has ended';
  END IF;

  -- Validate bid amount exceeds current high + minimum increment
  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;

  -- Prevent self-outbidding
  IF v_auction.highest_bidder_id = NEW.user_id THEN
    RAISE EXCEPTION 'You are already the highest bidder';
  END IF;

  -- Update auction with new high bid
  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,都通過驗證,並且都被插入。鎖序列化訪問。

廣播被超價通知

此觸發器在成功出價之後觸發並向拍賣頻道發送短暫通知:

CREATE OR REPLACE FUNCTION notify_outbid()
RETURNS TRIGGER AS $$
DECLARE
  v_previous_bidder UUID;
BEGIN
  -- Find who just got outbid
  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;

  -- Broadcast outbid notification if there was a previous bidder
  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 // Throttle for auction traffic
      }
    }
  }
);

eventsPerSecond 參數很重要。在一個熱拍賣上,每秒有數十次出價,你不想每秒重新渲染 50 次。每秒 20 次更新對於流暢的 UI 來說綽綽有餘。

訂閱拍賣頻道

function subscribeToAuction(auctionId, callbacks) {
  const channel = supabase.channel(`auction:${auctionId}`);

  channel
    // Listen for new bids via Postgres Changes
    .on('postgres_changes', {
      event: 'INSERT',
      schema: 'public',
      table: 'bids',
      filter: `auction_id=eq.${auctionId}`
    }, (payload) => {
      callbacks.onNewBid(payload.new);
    })

    // Listen for auction status changes
    .on('postgres_changes', {
      event: 'UPDATE',
      schema: 'public',
      table: 'auctions',
      filter: `id=eq.${auctionId}`
    }, (payload) => {
      callbacks.onAuctionUpdate(payload.new);
    })

    // Listen for outbid broadcast notifications
    .on('broadcast', { event: 'outbid' }, ({ payload }) => {
      callbacks.onOutbid(payload);
    })

    // Track active bidders via Presence
    .on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState();
      const bidderCount = Object.keys(state).length;
      callbacks.onPresenceUpdate(bidderCount, state);
    })

    .subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        // Track this user's presence
        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(() => {
    // Fetch initial state
    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();

    // Subscribe to real-time updates
    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 };
}

處理競爭條件和出價驗證

競爭條件是拍賣系統中最大的錯誤來源。以下是我處理它們的方式。

伺服器端: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
  -- Generate a deterministic lock key from auction UUID
  v_lock_key := ('x' || left(p_auction_id::text, 15))::bit(64)::bigint;
  
  -- Acquire advisory lock (blocks concurrent bids on same auction)
  PERFORM pg_advisory_xact_lock(v_lock_key);

  -- Now safe to insert (trigger handles validation)
  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
  };

  // Show immediately
  setBids(prev => [optimisticBid, ...prev]);

  try {
    await placeBid(amount);
    // Real bid will arrive via Realtime and replace optimistic one
  } catch (err) {
    // Remove optimistic bid on failure
    setBids(prev => prev.filter(b => b.id !== optimisticBid.id));
    showError(err.message);
  }
}

活躍競價者的狀態跟蹤

顯示有多少人正在觀看拍賣會產生緊迫感。狀態跟蹤對 Supabase 來說非常簡單:

// Update user status when they start bidding
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 作業:

-- Run every 10 seconds to close expired auctions
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();
  $$
);

反搶購延長

大多數拍賣平台在最後幾秒內出現出價時延長截止日期:

-- Add to the process_new_bid trigger
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 vs 替代方案

我已在生產中使用了大部分。這是誠實的比較:

功能 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) 自訂 基於 Token Firebase Auth 自訂
狀態 ✅ 內建 ✅ 內建 ✅ 內建 ✅ 內建 ✅ 內建
免費層 500K MAU, 200 併發 100 連接 6M 消息/月 1GB 儲存 $0(託管成本)
Pro 定價 $25/月 $49/月 $29/月 按使用付費 ~$100-500/月 (AWS)
最適合 以 DB 為中心的實時應用 簡單 pub/sub 高可靠性 行動應用 完全控制

對於拍賣系統特別是,Supabase 勝出,因為你的出價已經在 PostgreSQL 中。你不需要在數據庫和單獨的 pub/sub 系統之間同步。出價命中數據庫,數據庫觸發 WebSocket 推送。一個真實的來源。

如果你在無頭 CMS 架構上構建,Supabase 自然適合在內容交付旁邊,無需添加另一個要管理的服務。

部署和擴展拍賣系統

對於大多數項目,Supabase 的託管 Pro 層每月 $25 舒適地處理多達 10,000 個日常拍賣。以下是要注意的事項:

  • 連接限制:Pro 層為你提供 500 個併發 Realtime 連接。如果你需要更多,你需要升級或在客户端上實施連接池。
  • WAL 大小:高容量競價生成顯著的 WAL 流量。監控你的複製槽以避免磁盤膨脹。
  • 頻道計數:每個拍賣都獲得自己的頻道。有成千上萬個活躍拍賣,測試你的客户端是否正確從已結束的拍賣中取消訂閱。

對於使用 AstroNext.js 構建的前端,Supabase JS 客户端工作方式相同——只需確保你在客户端初始化它以進行 Realtime 訂閱。

如果你正在構建需要處理嚴肅規模的東西——成百上千的併發競價者——與我們聯繫。我們已經在規模上架構了這些系統,可以幫助你避免陷阱。你也可以檢查我們的定價頁面以了解基於項目的承諾。

常見問題

Supabase Realtime 可以處理多少個併發競價者? Supabase Realtime 在其託管平台上的分佈式伺服器上可以處理超過 200,000 個事件每秒。Pro 層每月 $25 支持每個項目多達 500 個併發連接。對於更大的拍賣,Enterprise 層提供自訂限制,或你可以在自己的基礎設施上自託管 Realtime 伺服器(它是開源的)。

Supabase Realtime 對於實時拍賣足夠快嗎? 是的。在我的測試中,從出價插入到客户端通知的端到端延遲平均約 50-80 毫秒,p99 在 100 毫秒以下。為了上下文,人類反應時間約為 200-300 毫秒,所以出價顯示時有效地立即。瓶頸很少是 Supabase——通常是客户端的網絡連接。

當兩個人同時出價時,我如何防止競爭條件? 在觸發器函數內使用 PostgreSQL 的 SELECT ... FOR UPDATE 行級鎖定,或透過 pg_advisory_xact_lock() 使用顧問鎖。這序列化每個拍賣的出價處理,以便一次只驗證一個出價。「失敗」的出價仍然得到驗證——它只是看到贏家的更新高出價,然後要麼成功(如果它仍然更高)要麼失敗並出現適當的錯誤。

我可以在 Next.js 或 Astro 中使用 Supabase Realtime 嗎? 絕對可以。@supabase/supabase-js 客户端適用於任何 JavaScript 環境。對於 Next.js,在客户端元件中初始化 Supabase 客户端(因為 Realtime 需要瀏覽器 WebSockets)並在 useEffect 鈎子內使用它。對於 Astro,在客户端互動島嶼中使用它。無論你的框架選擇如何,訂閱代碼都是相同的。

如果用戶的連接在拍賣過程中斷開會發生什麼? Supabase Realtime 自動嘗試重新連接。當客户端重新連接並重新訂閱時,它會接收當前狀態。對於關鍵拍賣,我建議在重新連接時也透過標準查詢獲取最新拍賣狀態,以確保在斷開連接窗口期間沒有錯過任何內容。狀態系統將在超時後自動刪除斷開連接的用戶。

我如何準確處理拍賣結束時間? 永遠不要依賴客户端計時器來確定拍賣結束時間——它們可以被操縱。使用 PostgreSQL 的 pg_cron 擴展每 10 秒檢查並關閉過期拍賣伺服器端。將伺服器時間戳發送給客户端,以便他們可以顯示倒計時,但實際的結束決定總是在數據庫中發生。

Supabase Realtime 對小項目免費嗎? Supabase 的免費層包括 Realtime,最多 200 個併發連接和 500,000 個月活躍用戶。那足以用於愛好拍賣站點或 MVP。如果你運行有意義流量的生產拍賣平台,每月 $25 的 Pro 層,每 GB 出口 $0.09,是你開始的地方。這比運行自己的 WebSocket 基礎設施便宜得多。

我如何在本地測試實時競價系統? 使用 Supabase CLI(supabase start)運行啟用 Realtime 的本地 Supabase 實例。打開多個瀏覽器標籤以模擬多個競價者。對於負載測試,我使用一個簡單的 Node.js 腳本,創建 100+ 個 Supabase 客户端,並讓他們根據計時器彼此互相出價。這會捕捉競爭條件,並幫助你在生產之前調優 eventsPerSecond 參數。