你的第一個競價引擎每秒輪詢數據庫。出價亂序到達。有些完全消失。第二個版本添加了單獨的 WebSocket 服務器——更好,但現在你要管理兩個基礎設施。第三次嘗試使用了 Supabase Realtime,競態條件停止了。沒有輪詢循環。沒有獨立的套接字服務器。PostgreSQL 觸發器在出價提交時廣播它們,每個連接的客戶端在 50 毫秒內看到相同的狀態。我在兩年內推出了三個競價系統。Supabase 版本是唯一一個我不必在啟動後重建的。以下是使其工作的架構——以及幾乎破壞它的那個函數。

Supabase Realtime 構建在 PostgreSQL 的預寫日誌 (WAL) 之上,使用基於 Elixir 的服務器通過 WebSocket 將數據庫更改推送到連接的客戶端。對於競價系統,這意味著每次出價都會立即傳播到觀看該競價的每個出價者。無需輪詢。無需單獨的 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 翻轉了這個模型:什麼都沒變時零查詢,有變時立即更新。

數據庫模式和設置

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

-- 競價表
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 並為 auctionsbids 表切換複製。或者,你可以使用 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 流量。監控你的複製槽以避免磁盤膨脹。
  • 頻道計數:每個競價都獲得它自己的頻道。有數千個活躍競價時,測試你的客戶端是否正確地從已結束的競價取消訂閱。

對於使用 AstroNext.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 參數。