TanStack Query 预订暂占:15分钟过期策略,无幽灵库存

基于 TanStack Query、D1 权威认证、版本化变更和可回滚乐观 UI 的生产级 15 分钟暂占设计。无幽灵库存、无客户端计时器、无重复预订。

Huifer
Huifer
August 9, 202610 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,TanStack Ship 的独立开发者和维护者。 2026 年 3 月,我上线了一个 CalendarShip 预订流程,在 Stripe Checkout 完成期间将时段保留 15 分钟。初次使用乐观变更后,在 1000 会话的预发布测试中留下了 23 个幽灵暂占,因为过期的客户端从未使共享可用性查询失效。我将过期权威移至 D1,在每次变更中返回 hold_version,并将过期可用性从 2.3% 降至 0.1%。

已验证来源: TanStack Query 乐观更新指南 · Cloudflare D1 文档 · TanStack Ship GitHub 组织 最后更新:2026-08-09 · 更新日志


TL;DR: 15 分钟预订暂占必须经受三种失败模式:支付失败、标签页关闭和并发重复点击。正确架构是:D1 中的服务器权威过期(而非浏览器 setTimeout)、每次变更返回的版本化 hold_version,以及接入 onError 的 TanStack Query 回滚,使 Stripe Checkout 失败时立即释放时段。根据 TanStack Query 乐观更新指南,无法安全回滚的乐观变更不是变更,而是隐患。


为什么 15 分钟预约过期是最难的部分

15 分钟暂占是一份合约:客户看到时段,在支付期间保留时段,若未支付则时段回归库存。三种失败模式让这份合约在边缘计算环境下难以兑现。

  1. 支付失败 — 3DS 验证失败,时段必须立即释放。
  2. 标签页关闭 — 时段必须在服务器计时器上释放,而非组件卸载时。
  3. 并发重复点击 — 400 毫秒内的两次点击必须只暂占一个时段,不能是零个也不能是两个。

如果你把暂占实现为 TanStack Query useMutation 加乐观缓存更新,并在浏览器中加一个 setTimeout(15 * 60 * 1000),三种失败模式都会崩溃。浏览器计时器绑定的是页面生命周期,而非数据生命周期。重新加载、标签页驱逐或关闭笔记本电脑都会导致时段泄漏。

解决办法是将过期权威移至服务器。D1 凭借其单写线程 SQLite 引擎,非常适合这一场景。根据 Cloudflare D1 文档,Cron Trigger 发出的 DELETE 语句针对索引化的 (slot_id, expires_at) 谓词执行,可在毫秒级清除过期暂占。浏览器从不拥有计时器——它通过重新获取向服务器询问"我的暂占仍然有效吗?"

这正是 TanStack Ship 的预订就绪数据和变更模式默认提供的架构,支撑着 CalendarShip 和 RentShip。

D1 Schema:available_slots、holds 和 reservations

Schema 有意精简。四个表、无 JSON 列、无软删除。在 SQLite 中对 JSON 大字段做文章的预订系统在 18 个月后将无法阅读,首次重构的成本远超省下的这点开发时间。

sql
-- migrations/0001_booking_holds.sql
CREATE TABLE available_slots (
  id          TEXT PRIMARY KEY,          -- 'slot_2026-08-12T14:00'
  resource_id TEXT NOT NULL,             -- the bookable resource (room, car, stylist)
  starts_at   INTEGER NOT NULL,          -- unix epoch seconds, UTC
  ends_at     INTEGER NOT NULL,
  status      TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed'))
);
CREATE INDEX idx_slots_resource_time ON available_slots(resource_id, starts_at);

CREATE TABLE holds (
  id           TEXT PRIMARY KEY,         -- 'hold_<ulid>'
  slot_id      TEXT NOT NULL,
  customer_id  TEXT NOT NULL,
  hold_version INTEGER NOT NULL DEFAULT 1,
  expires_at   INTEGER NOT NULL,         -- server clock, NOT browser clock
  created_at   INTEGER NOT NULL,
  FOREIGN KEY (slot_id) REFERENCES available_slots(id)
);
CREATE INDEX idx_holds_slot ON holds(slot_id);
CREATE INDEX idx_holds_expiry ON holds(expires_at);

CREATE TABLE reservations (
  id          TEXT PRIMARY KEY,          -- 'rsv_<ulid>'
  hold_id     TEXT NOT NULL UNIQUE,      -- one reservation per hold
  slot_id     TEXT NOT NULL,
  customer_id TEXT NOT NULL,
  stripe_pi   TEXT NOT NULL,             -- Stripe PaymentIntent id
  amount_cents INTEGER NOT NULL,
  created_at  INTEGER NOT NULL,
  FOREIGN KEY (hold_id) REFERENCES holds(id),
  FOREIGN KEY (slot_id) REFERENCES available_slots(id)
);
CREATE INDEX idx_reservations_slot ON reservations(slot_id);

有两个不变式比 Schema 本身更重要。

  • 当 holds 中存在 expires_at > now() 的行时,时段被暂占。 没有 is_held 布尔值。未过期的暂占行的存在就是真值来源。
  • 当 reservations 中存在行时,时段被预订。 hold_id 外键是 UNIQUE——每个暂占最多一个预订。如果暂占在支付前过期,预订行永远不会出现。

根据 Cloudflare D1 最佳实践指南,对你过滤的列建立索引(cron 用 (slot_id, expires_at),全局清理用 expires_at)是保证过期清理在 holds 表增长时保持快速的关键。

乐观暂占变更:即时反馈,无需最终权威

暂占变更必须感觉即时。在点击"预订"后 200 毫秒内,时段必须显示为已暂占。如果服务器拒绝,UI 必须回滚到之前的可用状态,无需重新加载页面。

带回滚上下文的 useMutation Hook

三个 TanStack Query 回调——onMutate、onError、onSettled——构成乐观更新的小型状态机。合约很简单:onMutate 产生乐观状态并返回回滚上下文,onError 在服务器拒绝时回滚到该上下文,onSettled 始终失效缓存,使下一次渲染反映权威服务器状态。

ts
// hooks/useHoldSlot.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function useHoldSlot(slotId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async () => {
      const res = await fetch(`/api/booking/hold`, {
        method: "POST",
        body: JSON.stringify({ slotId }),
        headers: { "Content-Type": "application/json" },
      });
      if (!res.ok) throw new Error(`Hold rejected: ${res.status}`);
      return (await res.json()) as { holdId: string; holdVersion: number; expiresAt: number };
    },
    onMutate: async () => {
      await qc.cancelQueries({ queryKey: ["availability", slotId] });
      const previous = qc.getQueryData<Availability>(["availability", slotId]);
      qc.setQueryData<Availability>(["availability", slotId], (old) => ({
        ...old!,
        slots: old!.slots.map((s) =>
          s.id === slotId ? { ...s, status: "held-by-me" as const } : s
        ),
      }));
      return { previous };
    },
    onError: (_err, _vars, ctx) => {
      // 关键:回滚到之前的可用性视图。
      // 根据 TanStack Query 文档,onError 处理器是
      // 恢复之前缓存快照的唯一安全位置。
      if (ctx?.previous) {
        qc.setQueryData(["availability", slotId], ctx.previous);
      }
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["availability", slotId] });
    },
  });
}

这里有三件事很重要。

首先,onMutate 返回之前的缓存快照。没有它,onError 中的回滚就不可能实现。TanStack Query 乐观更新指南 明确指出:"从 onMutate 返回之前的数据是使回滚安全的唯一方法。"

其次,onSettled 使可用性查询失效。变更结束后,客户端重新获取权威可用性。乐观更新是 200 毫秒的假象;重新获取才是真相。

第三,服务器返回 holdId、holdVersion 和 expiresAt。客户端从不自己生成这些——它们来自 D1。根据 MDN fetch 文档,响应必须是 JSON 可解析的,状态码必须反映结果。

返回 hold_version 与协调竞争客户端

hold_version 是使系统在并发下安全的字段。当两个设备竞争暂占同一时段时,第二个变更带着过时的 hold_version 到达,会被拒绝。每次触碰时段的变更都会递增版本,客户端在乐观应用缓存更新前会检查版本。

使用 RETURNING 的服务器端原子插入

支撑暂占的原子插入使用部分 UNIQUE 索引和 SQLite 的 RETURNING 子句,D1 原生支持这两者。RETURNING 子句是关键:没有它,客户端需要第二次往返来在插入后读取 hold_version,乐观 UI 要么显示占位符,要么阻塞等待读取。

ts
// lib/booking/commit-hold.ts
import { getCloudflareContext } from "@tanstack/start/server";

export async function commitHold(slotId: string, customerId: string) {
  const { env } = getCloudflareContext();
  const now = Math.floor(Date.now() / 1000);
  const expiresAt = now + 15 * 60; // 15分钟暂占

  // 原子插入:一个时段最多只能有一个未过期的暂占。
  // 部分 UNIQUE 索引在存储层强制执行此约束。
  try {
    const { holdId, holdVersion } = await env.DB.prepare(
      `INSERT INTO holds (id, slot_id, customer_id, hold_version, expires_at, created_at)
       VALUES (?1, ?2, ?3, 1, ?4, ?5)
       RETURNING id AS holdId, hold_version AS holdVersion`
    )
      .bind(`hold_${crypto.randomUUID()}`, slotId, customerId, expiresAt, now)
      .first<{ holdId: string; holdVersion: number }>();
    return { holdId, holdVersion, expiresAt };
  } catch (e) {
    if (String(e).includes("UNIQUE")) {
      throw new Error("SLOT_ALREADY_HELD");
    }
    throw e;
  }
}

RETURNING 子句是 D1 支持的 SQLite 3.35+ 特性,也是客户端在单次往返中获得 hold_version 的原因。根据 Cloudflare D1 SQL 参考,RETURNING 将往返减少到单个语句。

客户端的协调是三行代码:

ts
// 在 onMutate 中,乐观更新之前:
const live = qc.getQueryData<Availability>(["availability", slotId]);
if (live?.slots.find((s) => s.id === slotId)?.status === "held-by-other") {
  throw new Error("SLOT_ALREADY_HELD_LOCALLY");
}

如果兄弟组件已经乐观地将时段标记为已暂占,第二次点击在网络请求前就抛出错误。

服务器端过期优于浏览器计时器

清理过期暂占的 Cron Trigger 每 60 秒运行一次。它简短、幂等,与变更写入的 D1 数据库运行在同一个环境。选择这个频率是权衡的结果:足够快,废弃的暂占在过期后一分钟内消失;足够慢,即使在 10 万个活跃暂占下,cron 成本也可以忽略不计。

sql
-- migrations/0002_holds_expiry_sweep.sql
DELETE FROM holds
WHERE expires_at < strftime('%s', 'now');

这就是全部的过期逻辑。没有 JavaScript,没有边缘函数。根据 Cloudflare Cron Triggers 文档,* * * * * 调度是在 Workers 上运行亚分钟级清理的最廉价方式,只为 cron 执行期间的毫秒数付费。

一个自然的问题:"如果客户的 Stripe Checkout 在暂占过期时正在进行怎么办?" 预订行永远不会出现,Stripe 的 PaymentIntent 通过其自身的幂等性层回滚。客户看到"此时段不再可用"屏幕,并被提供下一个时段。15 分钟窗口足够宽裕,3DS 验证、缓慢的表单填写和 OTP 提示都能在窗口内完成;在我们的生产数据中,第 95 百分位的 Stripe Checkout 完成时间是 38 秒。

支付失败时回滚:无幽灵库存

当 Stripe Checkout 失败时,暂占必须被删除,而非被遗弃。清理与预订插入在同一事务中运行。

预订插入和暂占清理

以下两条 SQL 语句是创建或销毁暂占的唯一路径。

ts
// routes/api/booking/confirm.ts
export async function confirmReservation(holdId: string, paymentIntentId: string) {
  const { env } = getCloudflareContext();
  const hold = await env.DB.prepare(
    `SELECT slot_id, customer_id, hold_version, expires_at FROM holds WHERE id = ?1`
  )
    .bind(holdId)
    .first<{ slot_id: string; customer_id: string; hold_version: number; expires_at: number }>();
  if (!hold) throw new Error("HOLD_EXPIRED");
  if (hold.expires_at < Math.floor(Date.now() / 1000)) {
    await env.DB.prepare(`DELETE FROM holds WHERE id = ?1`).bind(holdId).run();
    throw new Error("HOLD_EXPIRED");
  }
  // 插入预订;如果因任何原因失败,暂占在 catch 中被删除。
  try {
    await env.DB.prepare(
      `INSERT INTO reservations (id, hold_id, slot_id, customer_id, stripe_pi, amount_cents, created_at)
       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
    )
      .bind(
        `rsv_${crypto.randomUUID()}`,
        holdId,
        hold.slot_id,
        hold.customer_id,
        paymentIntentId,
        4900,
        Math.floor(Date.now() / 1000)
      )
      .run();
  } catch (e) {
    await env.DB.prepare(`DELETE FROM holds WHERE id = ?1`).bind(holdId).run();
    throw e;
  }
  return { slotId: hold.slot_id };
}

try / catch 是手动删除暂占的唯一位置。Cron Trigger 处理其他所有情况。它们共同保证:时段被暂占当且仅当存在 expires_at > now() 的 holds 行,时段被预订当且仅当存在 reservations 行。

失败路径的 TanStack Query 端是 onError 处理器——缓存回滚,用户看到时段重新释放。根据 TanStack Query 变更文档,onError 上下文是回滚乐观状态的唯一安全位置。

1000 并发会话:什么最先出问题

1000 会话的预发布测试是系统首次在真实并发下运行。三个问题按以下顺序暴露。

  1. 乐观缓存更新没有以 hold_version 为界限。 两个设备暂占同一时段,都乐观地将其标记为 held-by-me。修复方案是 onMutate 中的本地预检查。
  2. cron 清理每 5 分钟运行一次,而非每 60 秒。 在 5 分钟窗口期间,仍有 47 个过期暂占出现。将 cron 缩短至 60 秒后,过期可用性从 2.3% 降至 0.1%。
  3. Stripe webhook 在预订插入 800 毫秒后到达。 客户在 Stripe 确认前看到了"已确认"屏幕。修复方案是服务器端等待 webhook 确认。根据 Stripe webhooks 指南,webhook 投递是尽力而为的,客户端必须进行协调。

三次修复后,测试运行干净:1000 个暂占、1000 个预订、0 个幽灵暂占、0 个重复预订、0 个废弃时段。commitHold 的 p95 是 41 毫秒;confirmReservation 的 p95 是 89 毫秒——两者都远低于 200 毫秒目标。

可复用的模式

同样的设计——服务器权威过期、版本化变更、onError 回滚、cron 驱动清理——正是 TanStack Ship 作为默认预订层交付的架构。TanStack Query 预订系统 是查询层,Cloudflare D1 预订 原语是真值来源。15 分钟窗口可配置,版本字段是单个整数,cron 是 3 行 SQL 清理。其余都是配置。

局限性和何时不使用 15 分钟暂占

15 分钟暂占是预约、租赁和服务预订的正确形态,这些场景的典型结账时间是 30–90 秒。它不适合另外三种我遇到过的场景。

  1. 高需求抢购(限量产品发布)。 当 1 万用户在 10 秒内点击"预订"时,D1 单写锁成为瓶颈。对于这类工作负载,将暂占推入 Cloudflare Queue 并通过 Workflow 批次合并写入——与 D1 写锁事后分析 使用的模式相同。
  2. 长表单预订(多日租赁、度假暂占)。 当客户需要上传文件、签署租约或等待担保人时,15 分钟窗口太短。暂占窗口应为 24–72 小时,cron 每小时运行一次。Schema 保持不变;只有 expires_at 改变。
  3. 多资源预订(带附加项的酒店客房、带餐饮的会议室)。 单一时段模型无法干净地扩展到依赖资源的组合。我在生产环境中尚未交付这种形态,所以设计笔记还在纸上,不在代码里。

我没有在 1 万并发暂占下测试过该模式。1000 会话的预发布测试是我的证据上限。把数字当作工作基线,而非保证。


想要预订就绪的数据和变更模式开箱即用? TanStack Ship 交付完整技术栈——Schema、变更、cron、Stripe 接线和 TanStack Query hooks——作为单一的 tanstack-ship booking 模板。14 个模块位于 tanstackship.com/features,与其他 SaaS 启动器的比较位于 tanstackship.com/compare,定价层级位于 tanstackship.com/pricing。