本文由 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 分钟暂占是一份合约:客户看到时段,在支付期间保留时段,若未支付则时段回归库存。三种失败模式让这份合约在边缘计算环境下难以兑现。
- 支付失败 — 3DS 验证失败,时段必须立即释放。
- 标签页关闭 — 时段必须在服务器计时器上释放,而非组件卸载时。
- 并发重复点击 — 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 个月后将无法阅读,首次重构的成本远超省下的这点开发时间。
-- 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 始终失效缓存,使下一次渲染反映权威服务器状态。
// 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 要么显示占位符,要么阻塞等待读取。
// 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 将往返减少到单个语句。
客户端的协调是三行代码:
// 在 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 成本也可以忽略不计。
-- 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 语句是创建或销毁暂占的唯一路径。
// 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 会话的预发布测试是系统首次在真实并发下运行。三个问题按以下顺序暴露。
- 乐观缓存更新没有以
hold_version为界限。 两个设备暂占同一时段,都乐观地将其标记为held-by-me。修复方案是onMutate中的本地预检查。 - cron 清理每 5 分钟运行一次,而非每 60 秒。 在 5 分钟窗口期间,仍有 47 个过期暂占出现。将 cron 缩短至 60 秒后,过期可用性从 2.3% 降至 0.1%。
- 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 万用户在 10 秒内点击"预订"时,D1 单写锁成为瓶颈。对于这类工作负载,将暂占推入 Cloudflare Queue 并通过 Workflow 批次合并写入——与 D1 写锁事后分析 使用的模式相同。
- 长表单预订(多日租赁、度假暂占)。 当客户需要上传文件、签署租约或等待担保人时,15 分钟窗口太短。暂占窗口应为 24–72 小时,cron 每小时运行一次。Schema 保持不变;只有
expires_at改变。 - 多资源预订(带附加项的酒店客房、带餐饮的会议室)。 单一时段模型无法干净地扩展到依赖资源的组合。我在生产环境中尚未交付这种形态,所以设计笔记还在纸上,不在代码里。
我没有在 1 万并发暂占下测试过该模式。1000 会话的预发布测试是我的证据上限。把数字当作工作基线,而非保证。
想要预订就绪的数据和变更模式开箱即用? TanStack Ship 交付完整技术栈——Schema、变更、cron、Stripe 接线和 TanStack Query hooks——作为单一的 tanstack-ship booking 模板。14 个模块位于 tanstackship.com/features,与其他 SaaS 启动器的比较位于 tanstackship.com/compare,定价层级位于 tanstackship.com/pricing。