realtime-auction-nextjs-supabase.html
< BACK "使用 Next.js 和 Supabase 构建实时竞拍网站"的英雄图像

使用 Next.js 和 Supabase 构建实时拍卖网站

2021年一个周四下午,一个客户给我打电话——一个住在巴斯的古董商,想把他的月度线下拍卖搬到线上。我当时觉得很简单。然后他说"竞价需要实时为所有观看的人更新,直播,无需刷新页面"。好的。那时候一个"简单的WordPress工作"变成了为期两周的架构讨论。WordPress job" turned into a two-week architecture conversation.

关键要点:Next.js加Supabase的直播竞价取决于实时通道、行级安全和服务端竞价验证;在做UI之前要先把状态机搞对。Live bidding on Next.js plus Supabase hinges on realtime channels, row-level security, and server-side bid validation; get the state machine right before the UI.

我在Seahawk Media已经建过超过12,000个网站,实时功能是那种如果从一开始没有好好规划就会咬你的东西。每五秒轮询听起来不错,直到你有200个竞价者同时对单个端点发送请求,你的托管费用在一夜间翻倍。所以让我给你展示一下我今天会怎样构建一个适当的直播拍卖平台——使用Next.js和Supabase——基于我真正交付过的东西。Seahawk Media, and real-time features are the ones that bite you if you don't plan them properly from the start. Polling every five seconds sounds fine until you have 200 bidders hammering a single endpoint simultaneously and your hosting bill doubles overnight. So let me show you exactly how I'd build a proper live auction platform today -- using Next.js and Supabase -- based on what I've actually shipped.

---

为什么为此选择 Next.js 和 Supabase

看吧,实现实时的方式有十几种。Node服务器上的Socket.io、Ably、Pusher、Firebase——我在不同阶段都用过所有的。但Next.js加Supabase这个组合之所以在这里占有一席之地是有特定原因的:Supabase Realtime是建立在PostgreSQL的逻辑复制之上的,这意味着你的直播竞价更新和你的持久数据层是同一个系统。不需要同步两个信息源。不用担心一个进入WebSocket的竞价是否也进入了数据库。same system. No syncing two sources of truth. No wondering if a bid that went into the WebSocket also made it into the database.

Supabase 还为你提供开箱即用的 Auth、行级安全 (RLS) 和存储。对于拍卖网站,"只有拍卖所有者可以结束一个拍品" 和 "用户不能对自己的物品出价" 这些都是真实的业务规则,Postgres 中的 RLS 策略确实是正确的工具。

还有Next.js是因为——说实话——App Router配合Server Components意味着你可以静态渲染拍卖目录,让SEO满意,只在客户端对实时竞价组件进行激活。这个分离很重要。你不想为一个90%是静态内容的页面付动态渲染的钱。

---

先设计数据库模式(不要跳过这一步)

大多数人在这里匆匆忙忙,后来都后悔了。我曾为浴室古董客户花了令人尴尬的三天时间重构模式,原因是我没有充分考虑出价历史模型。

这是我现在使用的核心结构:

  • `profiles` —— 扩展Supabase的auth.users,存储显示名称、认证竞价者标记以及如果你进行基于存款的竞价的credit_balance -- extends Supabase's auth.users, stores display name, verified bidder flag, and a credit_balance if you're doing deposit-based bidding
  • `auctions` —— 事件本身;starts_at、ends_at、status(draft | live | closed)以及created_by -- the event itself;starts_at,ends_at,status(draft | live | closed), and created_by
  • `lots` —— 拍卖中的单个项目;reserve_price、current_bid、current_bidder_id、lot_number、ends_at(拍品可以有各自的倒计时) -- individual items within an auction;reserve_price,current_bid,current_bidder_id,lot_number,ends_at(lots can have individual countdowns)
  • `bids` —— 不可变的只追加日志;lot_id、bidder_id、amount、placed_at。永远不要更新这个表。绝对不要。 -- immutable append-only log;lot_id,bidder_id,amount,placed_at. Never update this table. Ever.
  • `auction_participants` -- 一个联接表,用于跟踪谁注册了哪个拍卖(对保证金冻结和通知定位很有用) -- a join table tracking who has registered for which auction (useful for deposit holds and notification targeting)

lots 表上的 current_bid 和 current_bidder_id 列是有意反范式化的。是的,你可以在每次读取时从 bids 表派生它们,但在并发负载下那个查询会变得很昂贵。反范式化它,保持 bids 表作为你的审计日志,然后用一个 Postgres 函数在出价被接受时原子性地更新 lots。current_bid and current_bidder_id columns on lots are denormalised intentionally. Yes, you could derive them from the bids table on every read, but under concurrent load that query gets expensive fast. Denormalise it, keep the bids table as your audit log, and use a Postgres function to update lots atomically when a bid is accepted.

原子出价函数

这是大多数教程跳过的部分。拍卖中的竞态条件是真实存在的。两个用户在同一毫秒内提交 £520 -- 会发生什么?real. Two users submitting £520 at the same millisecond -- what happens?

答案是一个带有对 lot 行 FOR UPDATE 锁定的 Postgres 函数:FOR UPDATE locking on the lot row:

``` create or replace function place_bid(p_lot_id uuid, p_bidder_id uuid, p_amount numeric) returns json as $$ declare v_lot lots%rowtype; begin select * into v_lot from lots where id = p_lot_id for update;

if v_lot.status!= 'live' then return json_build_object('success', false, 'error', 'Lot is not live'); end if;

if p_amount <= v_lot.current_bid then return json_build_object('success', false, 'error', 'Bid too low'); end if;

if p_bidder_id = v_lot.current_bidder_id then return json_build_object('success', false, 'error', 'You are already the highest bidder'); end if;

insert into bids (lot_id, bidder_id, amount) values (p_lot_id, p_bidder_id, p_amount);

update lots set current_bid = p_amount, current_bidder_id = p_bidder_id where id = p_lot_id;

return json_build_object('success', true, 'new_bid', p_amount); end; $$ language plpgsql security definer; ```

通过 supabase.rpc('place_bid', {...}) 从你的 Next.js API 路由调用这个。FOR UPDATE 锁意味着在任何给定时刻只有一个事务能赢得该 lot。另一个会得到序列化错误,你在客户端返回一条友好的"有人刚刚出价更高"的消息。supabase.rpc('place_bid', {...}). The FOR UPDATE lock means only one transaction wins per lot at any given moment. The other one gets a serialisation error and you return a friendly "someone just outbid you" message on the client.

---

行级安全 -- 拍卖规则层

RLS 是那些开发者要么立刻喜欢,要么因为感觉不透明而回避的东西之一。在我参与的 Seahawk 金融科技项目之前,我属于回避派,直到那个项目用惨痛的教训告诉我,只在应用代码中强制执行访问控制距离灾难只有一个配置错误的 API 路由之遥。

对于拍卖网站,这些策略很重要:

  1. 任何人都可以查看活跃拍品 -- 对 auctions.status = 'live' 的拍品执行 SELECT -- SELECT on lots where auctions.status = 'live'
  2. 只有经过身份验证和审核的出价者才能插入出价 -- 在策略中检查 profiles.verified_bidder = true -- check profiles.verified_bidder = true in the policy
  3. 只有拍卖创建者可以更新拍品状态 -- 对 auctions.created_by = auth.uid() 的拍品执行 UPDATE -- UPDATE on lots where auctions.created_by = auth.uid()
  4. 出价历史可被拍品的拍卖创建者和出价者本人阅读 -- 其他人不需要实时查看完整的出价历史 -- no one else needs to see full bid history in real time

Supabase RLS 文档在这方面确实很好 -- 值得阅读关于安全定义函数的部分,因为它与 place_bid 之类的 RPC 调用如何交互有关。Supabase RLS documentation is genuinely good here -- worth reading the section on security definer functions, because it interacts with how RPC calls like place_bid work.

一个需要注意的地方:如果你在 Postgres 函数中使用安全定义(如上所述),它会以函数所有者的权限运行,绕过 RLS。这是有意为之的 -- 你希望出价请求绕过出价者的 RLS,以便能够锁定和更新拍品行。但这意味着你必须在函数内部强制执行自己的业务逻辑检查,上面的代码就是这样做的。security definer on your Postgres function (as above), it runs with the function owner's privileges, bypassing RLS. That's intentional -- you want the bid placement to bypass the bidder's RLS so it can lock and update the lot row. But it means you must enforce your own business logic checks inside the function, which the code above does.

---

在 Next.js 中设置 Supabase Realtime

这就是真正令人满意的地方。Supabase Realtime 让你可以通过 WebSockets 订阅 Postgres 表上的更改,客户端 SDK 让这变得简单得不可思议。WebSockets underneath, and the client SDK makes it almost embarrassingly simple.

在你的拍卖拍品页面 -- Next.js App Router 中的客户端组件 -- 你会做类似这样的事情:

``` 'use client'

import { useEffect, useState } from 'react' import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'

export default function LotBidDisplay({ lotId, initialBid }) { const [currentBid, setCurrentBid] = useState(initialBid) const supabase = createClientComponentClient()

useEffect(() => { const channel = supabase.channel(lot-${lotId}).on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'lots', filter:id=eq.${lotId}}, (payload) => { setCurrentBid(payload.new.current_bid) } ).subscribe()lot-${lotId}).on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'lots', filter:id=eq.${lotId}}, (payload) => { setCurrentBid(payload.new.current_bid) } ).subscribe()

return () => { supabase.removeChannel(channel) } }, [lotId])

return <div>Current bid: £{currentBid.toLocaleString()}</div> } ```

从在服务器组件中传递 initialBid,该组件在请求时获取新鲜数据。客户端接管后,监听该特定拍卖品行的 UPDATE 事件。每次 place_bid 成功运行时,Supabase 广播更改,每个连接的竞价者的 UI 通常在 100-300ms 内更新。initialBid from a Server Component that fetches fresh data at request time. The client then takes over, listening for UPDATE events on that specific lot row. Every time place_bid runs successfully, Supabase broadcasts the change and every connected bidder's UI updates within about 100-300ms typically.

处理倒计时计时器

拍卖品通常有倒计时——"3 分 42 秒后结束"。不要信任客户端时钟。从 lots.ends_at(存储在 Postgres 的 UTC 中)推导结束时间,并在客户端使用 Date.now() 计算剩余秒数。每 60 秒重新同步一次,进行一次新的获取以防止时间偏差。并添加"软关闭"逻辑:如果竞价在最后 60 秒内到达,将 ends_at 延长两分钟。这是标准拍卖行为,竞价者期望如此。not trust the client clock for this. Derive the end time from lots.ends_at(stored in UTC in Postgres) and calculate remaining seconds on the client using Date.now(). Re-sync it every 60 seconds with a fresh fetch in case of drift. And add "soft close" logic: if a bid arrives within the last 60 seconds, extend ends_at by two minutes. That's standard auction behaviour and bidders expect it.

---

Next.js App Router 竞拍 UI 架构

我使用的页面结构:

``app/ auctions/ page.tsx ← 服务器组件,列出实时拍卖(ISR,revalidate: 60) [auctionId]/ page.tsx ← 服务器组件,服务器端获取lots列表 LotGrid.tsx ← 客户端组件,订阅lot状态变化 [lotId]/ page.tsx ← 服务器组件,初始lot数据 + SEO元数据 BidPanel.tsx ← 客户端组件,实时竞价显示 + 竞价表单``app/ auctions/ page.tsx ← Server Component, lists live auctions (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, fetches lots list server-side LotGrid.tsx ← Client Component, subscribes to lot status changes [lotId]/ page.tsx ← Server Component, initial lot data + metadata for SEO BidPanel.tsx ← Client Component, real-time bid display + bid form``

目录(/auctions)使用增量静态再生成,重新验证时间为60秒。单个拍品页面在首次加载时进行服务器端渲染(用于分享、预览、og:image生成),然后交由客户端组件处理实时内容。/auctions) uses Incremental Static Regeneration with a 60-second revalidation. Individual lot pages render server-side on first load (for sharing, previewing, og:image generation), then hand off to client components for the live stuff.

我一直做的一件事:将 BidPanel 组件延迟加载到 dynamic(() => import('./BidPanel'), { ssr: false }) 后面。它本来就只有在客户端才有意义,而且可以保持初始 HTML 负载对慢速连接用户的精简——如果你的拍卖观众倾向于年长用户(古董拍卖往往这样),这比你预期的更重要。BidPanel component lazy-loaded behind dynamic(() => import('./BidPanel'), { ssr: false }). It only makes sense client-side anyway, and it keeps your initial HTML payload lean for users on slow connections -- which, if your auction audience skews older (as antique auctions tend to), matters more than you'd expect.

---

身份验证和"已验证竞拍者"流程

标准的Supabase身份验证,使用邮箱/密码或魔法链接,适用于注册。但拍卖通常需要额外一步:竞拍者验证。你可能需要信用卡预授权、身份验证,或者只是管理员批准,才能让某人实际出价。

我使用的模式:在 profiles 表上有一个 verified_bidder 布尔值,默认为 false。注册后,用户会看到一个"完成你的注册"屏幕。一旦获得批准(由管理员手动操作,或在 Stripe 支付授权后自动),你就翻转这个标志。bids 表上的 RLS 策略会检查它。他们可以浏览和关注,但在验证前无法出价。verified_bidder boolean on the profiles table, defaulting to false. After sign-up, the user sees a "Complete your registration" screen. Once approved (manually by admin, or automatically after a Stripe payment authorisation), you flip the flag. The RLS policy on bids checks it. They can browse, watch, but not bid until verified.

对于 Stripe 支付授权冻结,Stripe 的带有 capture_method: manual 的 payment intents 是正确的方法——你授权 £50 的冻结,如果他们赢得拍卖就捕获,如果没赢就释放。这大大减少了拖欠情况,相信我,这是每个在线拍卖运营者的噩梦。Stripe's payment intents with capture_method: manual is the right approach -- you authorise a £50 hold, capture it if they win, release it if they don't. This dramatically reduces no-pay situations which, trust me, are the bane of every online auction operator's existence.

---

部署、性能和会让你吃亏的细节

部署到 Vercel——这是 Next.js 的明显选择,边缘网络与 Supabase 的全球基础设施配合良好。确保你的 Supabase 项目在最接近你的 Vercel 部署区域的 AWS 区域。我见过有人部署 Vercel 在 us-east-1、Supabase 在 eu-west-2,结果产生了 40-60ms 的完全不必要的延迟。选择一个区域,把两者都放在那里。Vercel -- it's the obvious choice for Next.js and the edge network plays well with Supabase's global infrastructure. Make sure your Supabase project is in the AWS region closest to your Vercel deployment region. I've seen 40-60ms of completely unnecessary latency because someone deployed Vercel in us-east-1 and Supabase in eu-west-2. Pick one region, put both there.

如果你不提前处理这些问题,会给你带来痛苦:

  • WebSocket连接限制。Supabase的免费层允许大约200个并发Realtime连接。如果你的拍卖走红,那个上限很重要。检查你的计划。Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
  • 竞价的乐观 UI。在服务器确认之前立即在竞价者的屏幕上显示竞价。如果失败(被超价、竞态条件),用错误恢复。200-300ms 的服务器往返时间除非 UI 等待它,否则是察觉不到的。Show the bid immediately on the bidder's screen before the server confirms. If it fails (outbid, race condition), revert with an error. The 200-300ms server round-trip is imperceptible unless the UI waits for it.
  • 拍卖品结束宽限期。永远不要在 ends_at 时刻关闭拍卖品。给它一个 2-3 秒的服务器端缓冲区,以允许在截止时间前刚刚提交的飞行中竞价处理。在你的 close_lot 定时函数中处理这个问题。Never close a lot exactly at ends_at. Give it a 2-3 second server-side buffer to allow in-flight bids that were submitted just before the deadline to process. Handle this in your close_lot scheduled function.
  • 电子邮件通知。使用 Supabase Edge Functions 配合 Resend 或 Postmark 发送"你被超价"和"你赢了!"的电子邮件。不要尝试从你的 Next.js API 路由做这个——它们会超时,拍卖参与者会因为通知不可靠而真正生气。Use Supabase Edge Functions with Resend or Postmark to send "You've been outbid" and "You won!" emails. Don't try to do this from your Next.js API routes -- they can time out, and auction participants get genuinely annoyed if notifications are unreliable.

---

常见问题

Supabase Realtime 能处理多少个并发竞价人?

Supabase 的 Pro 计划默认支持最多 500 个并发 Realtime 连接,更高的限制可用。对于大多数拍卖网站——除非你运行 Sotheby's 在线这样规模的东西——这已经足够了。如果你期望数千个同时查看者,考虑通过单一服务器端频道而不是按用户订阅广播拍卖品更新,并查看 Supabase 的 Realtime Broadcast 功能,这对高扇出场景更有效率。Realtime Broadcast feature which is more efficient for high fan-out scenarios.

我应该使用 Supabase Realtime 还是 Ably 这样的专用服务?

对于大多数项目,Supabase Realtime 完全足够,而且集成简单得多,因为你的数据已经在 Supabase 中。只有在你需要全球低于 50ms 的延迟时,或者你正在构建有数百万个并发连接的东西时,我才会选择 Ably 或 Pusher。一场古董拍卖、一场慈善筹款、一个小艺术画廊的在线销售——Supabase 都能很好地处理。

如果用户的 WebSocket 连接在拍卖中途断开会怎样?

Supabase 的客户端 SDK 将自动尝试重新连接。但在重新连接时,您应始终重新获取当前拍品状态(current_bid、ends_at),而不是信任连接中断前本地状态中的内容。在客户端组件中添加在线/离线事件监听器,并在连接恢复时触发新的服务器获取。current_bid,ends_at) on reconnection rather than trusting whatever was in local state before the drop. Add an online/offline event listener in your Client Component and trigger a fresh server fetch when the connection restores.

我可以用 Next.js Server Actions 代替 API 路由来出价吗?

是的,我做过。Next.js 14 中的 Server Actions 很便捷——它们消除了dedicated/api/bid路由的样板代码。权衡之处在于 Server Actions 单个限流比较困难(你需要在中间件级别应用限流,而不是按操作)。对于生产级拍卖网站,我会在中间件中添加 Upstash Redis 限流,防止单个用户无论使用 Actions 还是 API 路由都会导致出价请求被滥发。/api/bid route. The tradeoff is that Server Actions are a bit harder to rate-limit individually (you'd apply rate limiting at the middleware level rather than per-action). For a production auction site, I'd add Upstash Redis rate limiting in middleware to prevent a single user from spamming bid requests regardless of whether you use Actions or API routes.

我如何处理平局——两个相同出价在同一时间发出?

place_bid Postgres 函数中的 FOR UPDATE 锁序列化并发出价,因此从技术上讲,数据库级别无法出现平局。一个会成功,另一个会因"出价过低"响应失败(因为两者都等于 current_bid,且检查是 p_amount <= v_lot.current_bid)。先到先得。这是标准拍卖实践,大多数竞拍者都能理解。FOR UPDATE lock in the place_bid Postgres function serialises concurrent bids, so technically ties can't happen at the database level. One will succeed, the other will fail with a "bid too low" response (since both are equal to current_bid and the check is p_amount <= v_lot.current_bid). First in, first served. That's standard auction practice and most bidders understand it.

---

那位巴斯古董商——说实话——已经在线运营他的月度拍卖两年多了。某个周六晚上的并发竞拍者峰值达到84人——他整个村子的人仿佛都在线观看一批有争议的乔治亚银器以三倍底价成交。Supabase 没有皱眉。Next.js 没有皱眉。唯一崩溃的是他的 Wi-Fi,因为他从店铺地板上运行它。

实时处理一开始很难想清楚,但一旦架构定下来,原子出价函数就位,剩下的就主要是管道工作了。把基础打好,你就能把时间花在有趣的部分——倒计时动画、"第一次、第二次"的用户体验——而不是在午夜调试竞态条件。

< BACK