1866 万行的谜:一次 Cloudflare D1 免费额度被打穿的排查全过程

三个代码坑、三个诊断脚本自己的坑、两次自我推翻,最后在第三次复发时挖到了那条元凶查询

从"打不开题库"到"账户一天读了 1866 万行",中间踩了三个代码坑、三个诊断脚本自己的坑,两次亲手推翻自己深信不疑的结论 —— 最后在第三次复发时,真的挖到了那条元凶查询。

项目内容
事故日期2026-09-03 ~ 09-05(UTC)
影响全站接口不可用
直接原因Cloudflare D1 免费额度超限(错误码 7500)
根本原因学习模式接口的相关子查询没有合适的索引

这篇文章记录的是一个自用的小型刷题网站(Cloudflare Pages + Functions + D1)在上线新题库功能后遇到的一次生产事故。之所以值得写下来,不是因为最后完美解决了,而是因为中间每一步"以为找到了"后来都被更真实的数据推翻,包括排查工具本身也接连翻车。下面的报错和用量数据都来自真实日志(项目名、账户与数据库标识已隐去)。

时间线(北京时间):

时间事件
9/1新题库功能上线,部署成功
9/3 08:00读取暴涨约 495 万行
9/3 10:00读取暴涨约 912 万行
9/4 07:00读取暴涨约 458 万行,随后用户撞见故障
9/4 07:45 / 07:50两次部署秒失败
9/4 08:00配额重置(UTC 零点)
9/4 上午推翻"迁移导致"的猜测
9/5第三次复发(约 865 万行),锁定真正根因

01 症状:题库突然打不开了

反馈很简单:“怎么进去以后又加载不出题库了”,紧接着是"首页能打开,但学习模式一直显示加载中"。这类反馈信息量很少 —— 手机上看不了控制台,唯一能确认的就是"坏了"。真正的排查是从代码和日志里一步步挖出来的。

02 坑一:一个中文请求头,崩了全站接口

刚上线"题库切换"功能时,前端用一个自定义请求头 X-Question-Set 把当前选中的题库名透传给后端,图省事直接塞了中文:

new Headers().set('X-Question-Set', '1号题库')
// Uncaught TypeError: Cannot convert argument to a ByteString
// because the character at index 2 has a value of 39064
// which is greater than 255.

HTTP 请求头的值按规范只能是 ByteString(Latin-1 范围),中文字符直接塞进去必炸。更麻烦的是,前端几乎所有请求都走同一个 apiFetch 封装函数,很多调用方又写了 .catch(() => {}) 静默吞掉错误 —— 于是"一个请求头炸了"变成了"全站看不到任何数据",且没有任何报错提示。

修复:前端编码、后端容错解码。

// src/lib/api.ts
headers.set("X-Question-Set", encodeURIComponent(getQuestionSet()));
// functions/lib/questionSet.ts
export function getRequestQuestionSet(request: Request): string {
  const raw = request.headers.get("X-Question-Set");
  if (!raw) return DEFAULT_QUESTION_SET;
  try {
    return decodeURIComponent(raw);
  } catch {
    return DEFAULT_QUESTION_SET;
  }
}

03 坑二:“加载中"可以转到天荒地老

修完编码问题后,又暴露出第二层问题:只要任何一次 apiFetch 请求失败,页面的 loading 状态就再也没有机会被置回 false —— 看到的就是一个永远转圈的"加载中…",没有任何错误信息。

 async function load(restart = false) {
   setLoading(true);
+  setLoadError(null);
+  try {
     const res = await apiFetch<...>(`/study?bank=${bank}`);
     ...
+  } catch (e) {
+    setLoadError(e instanceof Error ? e.message : String(e));
+  } finally {
     setLoading(false);
+  }
 }

把 loadError 直接渲染在页面上,配一个"重试"按钮 —— 这个改动本身是对的,但后面会看到:它不是这次事故的根因,只是让下一次同类问题更容易被发现。

04 真正的元凶:D1 免费额度被打穿

顺着部署流水线往回查,发现"应用数据库迁移"这一步直接报错退出:

✘ [ERROR] A request to the Cloudflare API
(/accounts/***/d1/database/***/query) failed.

  Your account has exceeded D1's free tier daily row
  read limit. Upgrade to a paid plan or wait until
  tomorrow (midnight UTC) to continue. [code: 7500]

关键事实: Cloudflare D1 免费套餐每天 500 万行读取上限。这一天账户实际读取了 18,659,736 行 —— 超了 3.7 倍。迁移这一步失败会导致工作流里后面"部署到 Pages"这一步被整体跳过,也就是说:那天代码其实从来没有真正部署成功过,加载失败是数据库层面全面拒绝服务导致的,不是新代码本身有 bug。

05 配额到底怎么"判"的:一个反直觉的发现

当时被问了一个很尖锐的问题:“前两次大查询发生后配额不就用完了吗,怎么后面又可以发生大查询?" 把 GraphQL Analytics 接口的数据拆到小时级别之后,答案浮出水面:

UTC 时间读查询数读行数写行数
00:00–01:001824,945,555443
01:00–02:006212581,806
02:00–03:001009,121,777196
08:00 / 09:00(各 1 小时)各 3各 8,3140
23:00–24:00514,575,51849

三段加起来正好是 18,659,736,跟 Cloudflare 控制台的数字完全对上。但更有意思的是:03:00 那次暴涨之后账户早就该被判定超额了,为什么 23:00 那段还能继续读几百万行? 直到部署尝试在 23:47 才第一次真正被拒绝。

反直觉的结论: 500 万行不是实时生效的硬闸门。查询该跑就跑,账户是否"超额"是靠后台异步汇总当天用量后才判定的 —— 从数据看,这个判定延迟长达近 20 个小时。所以会出现"早就超额了,但表面上一切正常,还能继续超支"的诡异窗口期,直到判定终于追上来,才会突然把所有新查询一并拦下。

06 走错的方向:一开始以为是迁移干的

最初的直觉判断:当天刚好上线了新题库功能,两个迁移文件一共 2000 多条 SQL 语句(去重导入 + 题干加序号),“批量导入烧掉配额"这个解释听起来很合理。

但被追问"这个时间点你确定吗"之后,去翻了 git 提交记录:

2026-09-01T02:08:55Z  新增题库切换功能:两个题库相互独立
2026-09-01T02:59:49Z  紧急修复:X-Question-Set 请求头塞中文导致全站失败
2026-09-01T03:15:23Z  新题库:题干前追加原始 Excel 序号

推翻: 这两个迁移是 9 月 1 日 提交并成功部署的,比事故发生的 9 月 3 日早了整整两天。事故那天的三次读取暴涨,跟这两个迁移完全没有关系。这是一次被当场纠正的判断失误。

07 顺手挖出的一个真问题:一条会越用越慢的查询

代码审查过程中确实挖出一个设计上有缺陷的查询 —— 首页"曾经掌握后遗忘"这个统计:

-- functions/api/review/stats.ts(修复前)
SELECT COUNT(*) AS n
FROM review_records rr
JOIN questions q ON q.id = rr.question_id
WHERE rr.user_id = ? AND rr.prev_status = 'mastered'
  AND rr.is_correct = 0 AND q.question_set = ?

review_records 是一张只增不减的答题日志表,这条查询没有任何时间范围限制,每次打开首页都要重新数一遍。用 EXPLAIN QUERY PLAN 实测:

SEARCH rr USING INDEX idx_review_records_user_time (user_id=?)
SEARCH q USING INDEX sqlite_autoindex_questions_1 (id=?)

索引确实用上了 —— 但只能定位到"这个用户的全部历史记录”,prev_status / is_correct 这两个真正的筛选条件完全没有索引覆盖。而这个应用用户很少,user_id=? 这个条件在实践中筛不掉什么,本质上跟全表扫描没有太大区别。

最初的修复思路是"加个索引”,但被追问"这个索引列区分度够吗"当场问住 —— 一个几乎没有区分度的 user_id 打头,索引救不了这条查询。真正的修复是把"读时统计"改成"写时计数”:只在真正发生"曾掌握又答错"这一刻才 +1(本来就是低频事件),读的时候变成主键点查一行:

SEARCH user_relapse_stats USING INDEX
  sqlite_autoindex_user_relapse_stats_1 (user_id=? AND question_set=?)

用模拟数据验证:旧查询要检查 5,000 行候选,实际符合条件的是 50 行;新查询只读 1 行,结果一样是 50。

08 诊断脚本本身也翻了三次车

坑 A · GraphQL 字段名写错。 查 Cloudflare 用量的诊断脚本,一开始查了个不存在的字段 pagesFunctionsInvocationsAdaptiveGroups { count },GraphQL 的行为是整个查询直接报错、什么数据都不返回(不是部分成功):"unknown field \"count\"", "data": null。

坑 B · API Token 权限不够。 字段名修好之后,又撞上 "not authorized for that account" —— 部署用的 Token 压根没开 Account Analytics 的读权限,得去 Cloudflare 后台手动加。

坑 C · 一个方括号引发的 YAML 解析失败。 给诊断步骤起名字时手滑写了 name: [临时诊断] 用真实数据对比... —— YAML 里以 [ 开头的值会被解析成"流式数组",导致整个 workflow 文件解析失败。GitHub 的报错完全不指向真正原因,而是一句令人摸不着头脑的 "Workflow does not have 'workflow_dispatch' trigger",靠肉眼重新审查 YAML 才发现问题。

09 用真实数据验证 —— 以及又一次自我推翻

三个坑修完,诊断脚本终于能跑了。用一条只读 SQL 直接查生产库:

wrangler d1 execute <db-name> --remote
{
  "total_review_records": "约 1.7k",
  "actual_relapse_rows": 0
}
// meta.rows_read 约为总行数 × 3 —— 三个 COUNT 子查询各扫了一遍全表

再一次推翻: review_records 全表只有一两千行,即便旧查询真的扫了全表,也只是千行的量级,跟单小时读取几百万甚至近千万行的规模差了三四个数量级。“relapse-count 查询就是元凶"这个判断,同样站不住脚 —— 它是一个真实存在、值得修的问题,但不是这次事故的真正原因。

10 阶段性现状

已经确认并修复的:

  • 请求头中文编码崩溃 → 前端 encodeURIComponent,后端容错解码
  • 接口失败导致页面卡死在"加载中” → 全部关键页面补上 try/catch 和错误提示
  • 无边界的 relapse-count 全表扫描 → 改成写时维护的计数器
  • 诊断脚本自身的三个 bug(字段名、权限、YAML 语法)

当时还没查清楚的: 那三次单小时读取几百万到近千万行的真正来源。已排除的嫌疑:两个迁移文件(时间对不上)、relapse-count 查询(表太小)。为了不让下一次复发时继续两眼一抹黑,先给 env.DB 包了一层轻量埋点 —— 每次查询读取的行数只要超过阈值,就记一条日志,带上接口路径和 SQL 文本。没想到这条悬案很快就有了后续。

11 第三次复发:这次真的抓到了

埋点刚推上去、还没来得及部署,配额当天就在没有任何部署、没有任何迁移的情况下第三次复发了 —— 只是正常点了几次网页。查了一下当天的数据,比之前任何一次都更极端:

日期读查询数读行数平均每次
9 月 2 日(健康基线)45189,873≈199
9 月 4 日(全天)30521,705,253≈71,165
9 月 5 日(仅"点了几次")438,648,954≈201,150

只有 43 次读查询,平均每次读了 20 万行 —— 说明问题不在"查询发生了多少次",而在某一次查询内部本身。这个线索第一次指向了具体某个接口,而不是"某种模式"。

把之前没细看过的接口挨个翻了一遍,翻到 functions/api/study.ts(学习模式接口)时找到了:它为了在题目看板上显示"这道题上次做的结果",每一道题都要跑 4 个相关子查询:

-- functions/api/study.ts(修复前)
SELECT q.id, ...,
  (SELECT rr.is_correct FROM review_records rr
   WHERE rr.question_id = q.id AND rr.user_id = ?1
   ORDER BY rr.answered_at DESC LIMIT 1) AS last_is_correct,
  -- 另外 3 个几乎一样的子查询:last_uncertain / last_selected / last_answered_at
FROM questions q
WHERE q.source_file = ?2 AND q.question_set = ?3

EXPLAIN QUERY PLAN 一测,发现了跟 relapse-count 一模一样的病根:

CORRELATED SCALAR SUBQUERY 1
SEARCH rr USING INDEX idx_review_records_user_time (user_id=?)
CORRELATED SCALAR SUBQUERY 2
SEARCH rr USING INDEX idx_review_records_user_time (user_id=?)
-- 子查询 3、4 同上

找到了: 4 个子查询的筛选条件其实是 question_id,但 review_records 上能用的索引全部是 user_id 打头的 —— 又是同一个"几乎没有区分度的列打头"的问题,这次的爆炸半径却大得多:relapse-count 只在打开首页时触发一次,这条查询是"题库里有多少道题就触发多少次,还要 ×4"。一道题只要这个用户没做过,子查询就得把这个用户全部历史记录(按时间倒序)扫穿才能确认"没有"。题库越大、历史记录越多,一次页面加载能扫的行数就越夸张。

按接近真实的规模模拟(题库约 1100 道题、用户历史约 1700 条、这个题库刚做过 100 道):

旧版单次页面加载扫描行数加对索引之后差距
7,153,4004,5961556×

715 万,跟真实观测到的 495 万 / 912 万 / 865 万完全是同一个数量级 —— 这不是巧合,是同一个机制在不同的历史记录量、不同的题库大小下反复复现。修复只加了一行索引,没动任何查询逻辑:

-- migrations/0010_review_records_question_index.sql
CREATE INDEX IF NOT EXISTS idx_review_records_question_time
  ON review_records(question_id, user_id, answered_at DESC);

question_id 本身有几千个不同取值,区分度极高 —— 这次终于是"选对了打头的列"。加上索引后,4 个子查询全部变成精确的索引点查:

SEARCH rr USING INDEX idx_review_records_question_time
  (question_id=? AND user_id=?)
-- 子查询 4:SEARCH rr USING COVERING INDEX ...(连表都不用回查了)

顺带一提,“返回上一题标记蒙的"那个功能用的是一模一样的查询写法 —— 不用改一行代码,这次也跟着沾光修好了。


这篇复盘想说明的其实是一件很朴素的事:排查过程里"听起来合理"的解释,跟"有真实数据支撑"的结论,是两件不同的事 —— 这次一共有过两次被质疑后自我推翻,直到第三次真正复发,靠着回去挨个重新审查代码、而不是继续在"这次又是为什么"上做数据分析,才真正找到了根因。埋点已经加上了,如果这次的修复不是全部答案,下一次至少能直接看日志,不用再靠猜。