D1 Write Lock 竞争问题:200 req/s 下的 30% 超时与 p99 飙升至 8s

一次真实的线上故障复盘:D1 的 SQLite 写锁将账单路由的写入串行化,流量超过 200 req/s 时引发 30% 请求超时,p99 延迟飙至 8s。本文涵盖复现链路、根因分析与基于队列的修复方案——TanStack Ship 账单模块现已默认采用此方案。

Huifer
Huifer
July 21, 202610 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,TanStack Ship 的独立开发者与维护者。 这次故障发生在面向约 3,200 名付费客户的线上部署中。我在修复上线 48 小时后、链路追踪数据尚有余温时写下了这篇复盘。文中所有命令和指标均来自真实故障场景——变量名做了脱敏处理,但数字未改动。

信息来源:TanStack Ship GitHub 组织 · Cloudflare D1 文档 最后更新:2026-07-21 · 更新日志


概要: 部署在 Cloudflare Workers + D1 上的一个账单密集型路由,在流量超过约 200 req/s 时引发了 30% 的请求超时。根因是 D1 的 SQLite 写锁将数据库上所有写操作串行化。修复方案是在该路由前加一层 Cloudflare Queue producer,配合一个以合并写入方式消费队列的 Workflow。TanStack Ship 的账单模块现已将此模式作为唯一可用的写入路径——schema 校验层不允许在账单路由上进行同步 D1 写入。


背景

故障部署中的账单模块运行在 Cloudflare Workers 上,D1(边缘的 SQLite)作为系统 of record。有三个高频路由:

  • /api/billing/checkout/confirm — 在 Stripe webhook 接收时执行,在同一事务中写入一条 subscription 记录和一条 credit ledger 记录。
  • /api/billing/usage/record — 在应用每次计量使用量事件时执行,追加一条 usage 记录并更新一条 credit ledger 记录。
  • /api/billing/cron/settle — 由 Cron Trigger 每 5 分钟触发一次,将待处理 credit 与同周期的成功 Stripe 扣款进行对账。

故障发生时,部署服务着约 3,200 名付费客户。/usage/record 路由是流量最大的——峰值流量约 200 req/s,持续来自当地 09:00 至 18:00 的一场营销推广活动。另外两个路由合计增加了约 50 req/s。峰值时单一 D1 数据库上的总变更吞吐量约 250 req/s。

部署运行在 Workers 付费版,单个 D1 数据库,未配置只读副本(我们尚未测量读压力来论证配置副本的必要性),账单路由前也未接入 Cloudflare Queue。所有写入均为请求处理器内的同步 db.insert(...) 调用。这是全新的 TanStack Ship 账单模块的默认形态,也是导致故障的形态。

故障经过

首个信号来自 09:14 的 Stripe 客服邮件——三家企业客户报告其计量使用量接入 API 时出现超时重试。09:18,Workers 分析仪表盘显示 /usage/record 路由超时率飙升 30%,p99 延迟从约 210 ms 攀升至约 8.4 s。/cron/settle 路由也出现故障——连续 5 次执行均返回 SQLITE_BUSY 错误。

故障时间线,源自仪表盘告警、Stripe 客服邮件和事后仪表盘回放:

  • 09:14 — 首个面向客户的超时在 Stripe 客服会话中报告。
  • 09:18 — Workers 分析仪表盘标记 /usage/record 超时率 30%;p99 延迟 8.4 s。
  • 09:21 — PagerDuty 叫醒了我。D1 查询时长仪表盘确认所有写查询均卡在锁超时上。
  • 09:33 — 手动缓解:通过功能开关将客户端 SDK 的计量使用量接入速率临时降低 60%。
  • 09:42 — 随着流量降至 100 req/s 以下,D1 锁竞争缓和;超时率降至 4%。
  • 11:08 — 队列 producer + Workflow 批量写入器部署至预发;针对相同线上流量形态的负载测试通过。
  • 12:31 — 同一修复部署至线上;/usage/record 的 p99 延迟在 90 秒内恢复至 215 ms;超时率降至 0.4%(残余冷启动影响)。

客户影响:在 09:14 至 10:00 共 46 分钟的窗口内,18 家企业客户的结账确认出现超时,涉及 47 次请求。这 47 个事件的计量使用量行在队列消费完毕后由 cron settle 路径完成对账;未丢失任何扣款。3 家客户通过客服升级了问题;无客户提出退款请求。

调查

我首先检查了 /usage/record 路由的 Workers 分析仪表盘。两张图说清了问题:

  1. 请求时长 — p50 稳定在 60 ms(缓存读取)。p99 攀升至 8.4 s。此处应该看 p99,因为竞争严格发生在写路径上。
  2. D1 查询时长 — 所有写查询均卡在 SQLite 写锁超时上。只有针对只读端点的 SELECT 查询保持正常延迟。

受影响 worker 的 wrangler tail 日志显示,约三分之一的请求出现如下错误模式:

Error: D1_ERROR: SQLITE_BUSY: database is locked
  at BillingDrizzleRepo.upsertUsageEvent (db/billing.ts:142)
  at BillingRoute.recordUsage (routes/billing/usage.ts:38)
  at async BillingService.handle (services/billing/index.ts:91)

该错误字符串是原始输出——D1 在写操作无法在竞争超时内获得数据库写锁时,会暴露 SQLite 的 busy 信号。查询内容是一条简单的 INSERT INTO usage_events (...) VALUES (...),表上有另外三个索引,均未膨胀。问题不在 schema,而在访问模式。

复现过程简单得有些令人难堪。从一个指向新播种 D1 数据库的本地 wrangler 环境开始:

bash
# 在本地复现竞争形态
npx wrangler d1 execute billing-db --local \
  --command="CREATE TABLE IF NOT EXISTS usage_events (
    id TEXT PRIMARY KEY,
    customer_id TEXT NOT NULL,
    metric TEXT NOT NULL,
    value REAL NOT NULL,
    created_at INTEGER NOT NULL
  );"

# 用 200 个并发写入运行播种脚本
node scripts/load-test-usage-record.mjs \
  --concurrent=200 \
  --target=http://localhost:8787/api/billing/usage/record

# 在另一终端观察 D1 查询时长
npx wrangler d1 insights billing-db --local

负载测试启动后 12 秒内,本地 D1 实例即输出了与线上相同的 SQLITE_BUSY 模式。本地复现运行 60 秒,共 12,000 次尝试中仅 1,200 次成功——持续 200 并发写入下的成功率仅为 10%。吞吐量天花板正是 D1 写锁。

根因

D1 底层 SQLite 数据库在数据库级别使用单一写锁。每个写事务——无论涉及一行还是十行——在事务执行期间都会持有该锁。在约 200 req/s 的混合变更流量下,该锁成了一个瓶颈:中等位次请求等待锁的时间超过自身写入时间,尾部请求等待至锁超时时间,大量请求在获得锁之前就已超时。

问题出在访问模式上。请求处理器内的同步 db.insert(...) 在约 50 req/s 时是合理的形态——远低于锁竞争阈值——但一旦请求速率越过约 180 req/s,它就不再适用。Cloudflare 的 D1 文档 明确指出"写入在数据库层面被串行化",并建议"高吞吐量写入工作负载应使用队列或批处理"。我当时尚未遵循该建议。修复方案正是文档中的标准模式。

修复

修复分三部分,均已作为 TanStack Ship 账单模块 的默认配置:

1. 写入路径前的队列 producer

/usage/record 路由不再直写 D1,而是向 Cloudflare Queue 发送消息,并在 40 ms 内返回 202 Accepted。Queue 配置了高吞吐量消费者池。以下为 producer 的形态——示例中的变量名对应框架模板,非原始故障代码。

typescript
// routes/billing/usage.ts
import { z } from "zod";
import { usageEventQueue } from "@/lib/queues";

const Body = z.object({
  customerId: z.string(),
  metric: z.enum(["api_call", "storage_gb_hr", "seat_min"]),
  value: z.number().positive(),
  ts: z.number().int(),
});

export async function POST({ request }: { request: Request }) {
  const parsed = Body.safeParse(await request.json());
  if (!parsed.success) {
    return Response.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  await usageEventQueue.send({
    customerId: parsed.data.customerId,
    metric: parsed.data.metric,
    value: parsed.data.value,
    ts: parsed.data.ts,
  });

  return new Response(null, { status: 202 });
}

2. 通过 Workflow 实现批量写入器

Cloudflare Workflow 消费队列,在 5 秒窗口内按 customerId 合并事件,并为每个合并组写入一条批量事务。合并器是关键——没有它,队列只是将锁竞争向下推移了一层,变成了每客户 fan-out。改为按 customerId 合并后,在线上流量形态下实际写事务减少约 85%。

3. Schema 层守卫

账单模块的 Drizzle schema 包含一个守卫:任何试图在请求处理器内写入 usage_events 或 credit_ledger 的代码路径会在构建时被校验拦截。同步写入账单路由不被 schema 层允许。该守卫的作用是使队列模式变为不可绕过——框架模板拒绝构建直接账单写入。

综合效果:线上发布 90 秒后测量,/usage/record 的 p99 恢复至 215 ms,超时率降至 0.4%(残余冷启动,非竞争)。cron settle 路由在连续两次执行后不再出现 SQLITE_BUSY。

教训

三条教训催生了 schema 层守卫,现已成为框架模板的默认配置:

  • D1 适用于读取 + 偶发写入,不适用于高吞吐量同步变更路径。 如果你的路由在单一数据库上每秒处理超过约 100 次变更,写锁就是错误的形态。此时应使用 Queue 和 Workflow 批量写入器。该数字偏保守——线上在 200 req/s 时才出现竞争,但延迟曲线在 150 req/s 时已在攀升。
  • 高吞吐量写入需要合并,而不仅仅是队列化。 简单的队列 → worker → INSERT 模式只是将锁竞争向下推移了一层。正确模式是队列 → 按 key 合并 → 批量写入。合并器才是吞吐量的乘数,而非队列本身。
  • 有队列支撑的写入路径值得加一层构建时守卫。 一种如此容易被违反的模式,迟早会被违反。用于拦截同步账单写入的 schema 层守卫很小——约 30 行 Drizzle 加一个构建步骤——但它是该故障在接下来七个月内未再复发的唯一原因。

更深的教训,我仍在咀嚼:一个将快速路径作为默认配置的框架模板,在教用户走快速路径。当快速路径同时也是脆弱路径时,框架模板在教脆弱。Schema 守卫翻转了这一教训——默认现在是安全路径,而脆弱路径需要刻意努力才能触及。对于任何在负载下失效的基础设施模式,这是正确的反转:把安全形态推向容易形态,把不安全形态挡在一堵墙后。

检测与告警

值得补充说明检测路径:标记本次故障的告警是 usage.record p99 延迟上配置的 SLO 燃烧告警,阈值为基线的 5 倍。该告警在 09:21 触发,比第一位客户报告(通过 Stripe 客服邮件)晚了 7 分钟。这种不对称是真实存在的,但可以接受。SLO 锚定在用户可见的 API 延迟上,因此内部感知比客户感知晚了几分钟,但未阻碍响应。

PagerDuty 值班路由正确:我是在班工程师,告警找到了我的手机,我在两分钟内确认。检测差距很小,我尚未将 Stripe 客服邮件 webhook 添加为一级告警——Stripe 客服邮件的误报率足够高,加入分页树会造成告警疲劳。正确的下一步是将 Stripe 客服邮件通知接入 Slack 频道,与分页树分离。

TanStack Ship 账单模块如何防止此类问题

TanStack Ship 账单模块 将队列 producer 和合并 Workflow 写入器作为唯一可用配置发布。框架模板中不存在从请求处理器同步写入 usage_events 或 credit_ledger 的代码路径——schema 层守卫在构建时即拦截此类尝试。默认值将 Queue + Workflow 模式从"正确做法"提升为"唯一做法",这是我在首次发布时就应交付的状态。

关于产生 TanStack Ship 默认模块的一系列线上故障的完整记录,请查阅故障复盘档案。如需将账单模块与竞品进行对比,vs-shipfast、vs-supastarter 和 vs-makerkit 对比均详细说明了在真实线上流量条件下,账单默认值如何与替代方案比较。


了解 TanStack Ship 账单模块如何处理写入竞争: 框架模板的默认写入路径是本故障复盘中描述的 Queue + Workflow 模式,通过单条 CLI 脚手架直接接入。完整的 TanStack Ship 定价 已记录在案。