Supabaseを使った3番目のオークションシステムを構築した

過去2年間で3つのオークションシステムを構築してきた。最初のシステムはひどかった — データベースを毎秒ポーリングしており、レースコンディションがいたるところに存在し、入札がどこかに消えてしまっていた。2番目のシステムはましだったが、メインAPIに加えて別のWebSocketサーバーを管理する必要があった。3番目のシステム?これがあなたに説明しようとしているやつだ。Supabase Realtimeを使っており、入札エンジンを構築するのが初めてまともに感じられた。

Supabase RealtimeはPostgreSQLのWrite-Ahead Log(WAL)の上に構築されており、Elixirベースのサーバーを使ってデータベースの変更をWebSocket経由で接続しているクライアントにプッシュする。オークションシステムの場合、これはデータベースに入った各入札が、そのオークションを見ている全ての入札者に即座に伝播することを意味する。ポーリングなし。別々のpub/subインフラなし。あなたのデータベースあなたのイベントシステムなのだ。

ゼロから構築してみよう。

目次

アーキテクチャの概要

コードを書く前に、何を構築しているのかと、各部分がどのように組み合わさるのかを理解しよう。

Supabase Realtimeは、オークション要件にぴったりマッピングされる3つのプリミティブを提供する:

  • Postgres Changes: INSERT、UPDATE、DELETEイベントの入札とオークションテーブルへのサブスクリプション。誰かが入札を行うと、全てのサブスクライバーは数ミリ秒以内に新しい行データを取得する。
  • Broadcast: チャネル参加者へのはかないメッセージを送信。「あなたはアウトビッドされました」という通知に完璧で、永続化される必要はない。
  • Presence: 現在オークションを見ている誰がいるかを追跡。これにより、「14人の入札者が見ています」をUIに表示でき、ゴーストセッションを検出できる。

データフローはこのように見える:

  1. 入札者がフロントエンドを通じて入札を送信する
  2. RPCコールまたは直接挿入がbidsテーブルにヒットする
  3. PostgreSQLトリガーが入札額を検証し、auctions.current_high_bidを更新する
  4. Supabase RealtimeがWAL変更をピックアップし、そのオークションのチャネルの全てのサブスクライバーにプッシュする
  5. 2番目のトリガーがBroadcastイベントを発火して、前の高入札者にアウトビッドされたことを通知する
  6. 接続している全てのクライアントがUIをリアルタイムで更新する

入札配置から全てのクライアントのUI更新までのレイテンシは通常100ms未満である。Supabaseのプロ層での本番環境ではp99を約80-90msで測定している。

ポーリングを使わない理由?

あなたの中には「500msごとにポーリングできないか?」と思っている人もいるだろう。できる。しかし、単一のオークションで200人の同時入札者の場合、それは1つのオークションのためにあなたのデータベースに毎秒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に行き、auctionsテーブルとbidsテーブルの両方のレプリケーションをトグルオンする。あるいは、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ロックは重大である。これがないと、同時に到着した2つの入札は同じ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フック

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が第1の防御線である。しかし、私が使い始めた別のパターンがある — 高競合オークション用のアドバイザリロック:

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のを使ってクライアントから呼び出す:

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
  • クライアント側: ダブルクリックを防ぐために入札ボタンを300msでデバウンス
  • UI更新: 入札リストアニメーション用にrequestAnimationFrameを使用

オークション終了タイミング

クライアント時計を信用しないこと。pg_cronを使ったPostgreSQLクロンジョブを使用:

-- 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と代替案

本番環境でこれらのほとんどを使用している。ここで正直な比較である:

機能 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接続 6M msgs/mo 1GB保存 $0(ホスティングコスト)
Pro価格 $25/mo $49/mo $29/mo 従量課金 ~$100-500/mo(AWS)
ベストな用途 DB中心のリアルタイムアプリ シンプルなpub/sub 高い信頼性 モバイルアプリ 完全なコントロール

特にオークションシステムについて、Supabaseは勝つ。入札は既にPostgreSQLにあるため。入札とdキャストした別のpub/subシステムの間で同期する必要はない。入札がDBにヒット、DBがWebSocketプッシュをトリガーする。1つの真実のソース。

ヘッドレスCMSアーキテクチャで構築している場合、Supabaseはコンテンツ配信と一緒に自然にフィットするため、別のサービスを管理する追加なしに。

オークションシステムのデプロイとスケーリング

ほとんどのプロジェクトでは、$25/月のSupabaseの管理されたProレベルは毎日最大10,000のオークションを快適に処理する。ここで監視すべきもの:

  • 接続制限: Pro層は500同時Realtime接続を提供する。もっと必要な場合、アップグレードするか、クライアント上で接続プーリングを実装する必要があります。
  • WALサイズ: 高量の入札はかなりのWALトラフィックを生成する。ディスク膨張を避けるためにレプリケーションスロットを監視する。
  • チャネル数: 各オークションは独自のチャネルを取得する。数千のアクティブなオークションで、クライアントが終了したオークションから適切にサブスクライブ解除することをテストする。

AstroまたはNext.jsで構築されたフロントエンドについて、Supabase JSクライアントは同じに機能する — Realtime購読用にクライアント側で初期化していることだけを確認する。

本当にスケールを処理する必要があるものを構築している場合 — 数十万の同時入札者 — お問い合わせください。私たちはこれらのシステムを規模で設計してきた、落とし穴を避けるのを助けることができます。価格設定ページもチェックして、プロジェクトベースのエンゲージメントを得ることができます。

FAQ

Supabase Realtimeはどの程度の同時入札者を処理できますか? Supabase Realtimeは、管理されたプラットフォーム上の分散サーバー全体で毎秒200,000以上のイベントを処理できる。$25/月のProレベルはプロジェクトあたり最大500同時接続をサポートする。より大きなオークションについて、Enterpriseレベルはカスタム制限を提供するか、あなたの独自のインフラでRealtimeサーバーを自社ホストできる(オープンソースである)。

Supabase Realtimeはライブオークション用に十分高速ですか? はい。私のテストでは、入札挿入からクライアント通知への端末間レイテンシは平均50-80ms、p99は100ms未満。参考のため、人間の反応時間は約200-300msなので、入札は効果的に瞬間的に表示される。ボトルネックはめったにSupabase — 通常はクライアントのネットワーク接続。

2人が同時に入札したときのレースコンディションを防ぐにはどうすればいい? PostgreSQLのSELECT ... FOR UPDATE行レベルロックをトリガー関数内で使用するか、pg_advisory_xact_lock()経由でアドバイザリロックを使用する。これはオークションごとの入札処理をシリアル化するため、一度に1つの入札だけが検証される。「負ける」入札はまだ検証される — 勝者からの更新された高入札を見るだけで、それが依然として高い場合は成功するか、または適切なエラーで失敗する。

Next.jsやAstroでSupabase Realtimeを使用できますか? 絶対。@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インスタンスを実行する。複数のブラウザタブを開いて、複数の入札者をシミュレートする。ロードテストについて、100+のSupabaseクライアントを作成し、タイマーでオークションに対して互いに入札させる単純なNode.jsスクリプトを使用する。これはレースコンディションをキャッチし、本番環境に行く前にeventsPerSecondパラメータを調整するのに役立つ。